Skip to content

Commit 57b80c2

Browse files
authored
Merge pull request #57 from rubberduck-vba/webhook
Authenticate with GitHub OAuth
2 parents 0d00def + 7b38646 commit 57b80c2

17 files changed

+487
-165
lines changed
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,32 @@
1-
using Microsoft.AspNetCore.Authorization;
2-
using Microsoft.AspNetCore.Mvc;
1+
using Microsoft.AspNetCore.Mvc;
32
using Microsoft.Extensions.Options;
43
using Octokit;
54
using Octokit.Internal;
65
using System.Security.Claims;
7-
using System.Text;
86

97
namespace rubberduckvba.Server.Api.Auth;
108

119
public record class UserViewModel
1210
{
13-
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", HasOrgRole = false };
11+
public static UserViewModel Anonymous { get; } = new UserViewModel { Name = "(anonymous)", IsAuthenticated = false, IsAdmin = false };
1412

1513
public string Name { get; init; } = default!;
16-
public bool HasOrgRole { get; init; }
14+
public bool IsAuthenticated { get; init; }
15+
public bool IsAdmin { get; init; }
1716
}
1817

19-
18+
public record class SignInViewModel
19+
{
20+
public string? State { get; init; }
21+
public string? Code { get; init; }
22+
public string? Token { get; init; }
23+
}
2024

2125
[ApiController]
22-
[AllowAnonymous]
23-
public class AuthController(IOptions<GitHubSettings> configuration, IOptions<ApiSettings> api) : ControllerBase
26+
public class AuthController(IOptions<GitHubSettings> configuration, IOptions<ApiSettings> api, ILogger<AuthController> logger) : ControllerBase
2427
{
2528
[HttpGet("auth")]
26-
[AllowAnonymous]
27-
public ActionResult<UserViewModel> Index()
29+
public IActionResult Index()
2830
{
2931
var claims = HttpContext.User.Claims.ToDictionary(claim => claim.Type, claim => claim.Value);
3032
var hasName = claims.TryGetValue(ClaimTypes.Name, out var name);
@@ -37,10 +39,12 @@ public ActionResult<UserViewModel> Index()
3739
return BadRequest();
3840
}
3941

42+
var isAuthenticated = HttpContext.User.Identity?.IsAuthenticated ?? false;
4043
var model = new UserViewModel
4144
{
4245
Name = name,
43-
HasOrgRole = (HttpContext.User.Identity?.IsAuthenticated ?? false) && role == configuration.Value.OwnerOrg
46+
IsAuthenticated = isAuthenticated,
47+
IsAdmin = role == configuration.Value.OwnerOrg
4448
};
4549

4650
return Ok(model);
@@ -52,12 +56,13 @@ public ActionResult<UserViewModel> Index()
5256
}
5357

5458
[HttpPost("auth/signin")]
55-
[AllowAnonymous]
56-
public async Task<ActionResult> SignIn()
59+
public IActionResult SessionSignIn(SignInViewModel vm)
5760
{
58-
var xsrf = Guid.NewGuid().ToString();
59-
HttpContext.Session.SetString("xsrf:state", xsrf);
60-
await HttpContext.Session.CommitAsync();
61+
if (User.Identity?.IsAuthenticated ?? false)
62+
{
63+
logger.LogInformation("Signin was requested, but user is already authenticated. Redirecting to home page...");
64+
return Redirect("/");
65+
}
6166

6267
var clientId = configuration.Value.ClientId;
6368
var agent = configuration.Value.UserAgent;
@@ -67,53 +72,45 @@ public async Task<ActionResult> SignIn()
6772
{
6873
AllowSignup = false,
6974
Scopes = { "read:user", "read:org" },
70-
State = xsrf
75+
State = vm.State
7176
};
7277

78+
logger.LogInformation("Requesting OAuth app GitHub login url...");
7379
var url = github.Oauth.GetGitHubLoginUrl(request);
7480
if (url is null)
7581
{
82+
logger.LogInformation("OAuth login was cancelled by the user or did not return a url.");
7683
return Forbid();
7784
}
7885

79-
// TODO log url
80-
//return Redirect(url.ToString());
81-
return RedirectToAction("Index", "Home");
86+
logger.LogInformation("Returning the login url for the client to redirect. State: {xsrf}", vm.State);
87+
return Ok(url.ToString());
8288
}
8389

84-
[HttpGet("auth/github")]
85-
[AllowAnonymous]
86-
public async Task<ActionResult> GitHubCallback(string code, string state)
90+
[HttpPost("auth/github")]
91+
public async Task<IActionResult> OnGitHubCallback(SignInViewModel vm)
8792
{
88-
if (string.IsNullOrWhiteSpace(code))
89-
{
90-
return BadRequest();
91-
}
92-
93-
var expected = HttpContext.Session.GetString("xsrf:state");
94-
HttpContext.Session.Clear();
95-
await HttpContext.Session.CommitAsync();
96-
97-
if (state != expected)
98-
{
99-
return BadRequest();
100-
}
101-
93+
logger.LogInformation("OAuth token was received. State: {state}", vm.State);
10294
var clientId = configuration.Value.ClientId;
10395
var clientSecret = configuration.Value.ClientSecret;
10496
var agent = configuration.Value.UserAgent;
10597

10698
var github = new GitHubClient(new ProductHeaderValue(agent));
10799

108-
var request = new OauthTokenRequest(clientId, clientSecret, code);
100+
var request = new OauthTokenRequest(clientId, clientSecret, vm.Code);
109101
var token = await github.Oauth.CreateAccessToken(request);
102+
if (token is null)
103+
{
104+
logger.LogWarning("OAuth access token was not created.");
105+
return Unauthorized();
106+
}
110107

111-
await AuthorizeAsync(token.AccessToken);
112-
113-
return Ok();
108+
logger.LogInformation("OAuth access token was created. Authorizing...");
109+
var authorizedToken = await AuthorizeAsync(token.AccessToken);
110+
return authorizedToken is null ? Unauthorized() : Ok(vm with { Token = authorizedToken });
114111
}
115112

116-
private async Task AuthorizeAsync(string token)
113+
private async Task<string?> AuthorizeAsync(string token)
117114
{
118115
try
119116
{
@@ -122,42 +119,44 @@ private async Task AuthorizeAsync(string token)
122119
var githubUser = await github.User.Current();
123120
if (githubUser.Suspended)
124121
{
125-
throw new InvalidOperationException("User is suspended");
122+
logger.LogWarning("User {name} with login '{login}' ({url}) is a suspended GitHub account and will not be authorized.", githubUser.Name, githubUser.Login, githubUser.Url);
123+
return default;
126124
}
127125

128-
var emailClaim = new Claim(ClaimTypes.Email, githubUser.Email);
129-
130126
var identity = new ClaimsIdentity("github", ClaimTypes.Name, ClaimTypes.Role);
131-
if (identity != null)
132-
{
133-
identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login));
134-
135-
var orgs = await github.Organization.GetAllForUser(githubUser.Login);
136-
var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId);
127+
identity.AddClaim(new Claim(ClaimTypes.Name, githubUser.Login));
128+
logger.LogInformation("Creating claims identity for GitHub login '{login}'...", githubUser.Login);
137129

138-
if (rdOrg != null)
139-
{
140-
identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg));
141-
identity.AddClaim(new Claim(ClaimTypes.Authentication, token));
142-
identity.AddClaim(new Claim("access_token", token));
130+
var orgs = await github.Organization.GetAllForUser(githubUser.Login);
131+
var rdOrg = orgs.SingleOrDefault(org => org.Id == configuration.Value.RubberduckOrgId);
143132

144-
var principal = new ClaimsPrincipal(identity);
133+
if (rdOrg != null)
134+
{
135+
identity.AddClaim(new Claim(ClaimTypes.Role, configuration.Value.OwnerOrg));
136+
identity.AddClaim(new Claim(ClaimTypes.Authentication, token));
137+
identity.AddClaim(new Claim("access_token", token));
138+
logger.LogDebug("GitHub Organization claims were granted. Creating claims principal...");
145139

146-
var issued = DateTime.UtcNow;
147-
var expires = issued.Add(TimeSpan.FromMinutes(50));
148-
var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value));
140+
var principal = new ClaimsPrincipal(identity);
141+
var roles = string.Join(",", identity.Claims.Where(claim => claim.Type == ClaimTypes.Role).Select(claim => claim.Value));
149142

150-
HttpContext.User = principal;
151-
Thread.CurrentPrincipal = HttpContext.User;
143+
HttpContext.User = principal;
144+
Thread.CurrentPrincipal = HttpContext.User;
152145

153-
var jwt = principal.AsJWT(api.Value.SymetricKey, configuration.Value.JwtIssuer, configuration.Value.JwtAudience);
154-
HttpContext.Session.SetString("jwt", jwt);
155-
}
146+
logger.LogInformation("GitHub user with login {login} has signed in with role authorizations '{role}'.", githubUser.Login, configuration.Value.OwnerOrg);
147+
return token;
148+
}
149+
else
150+
{
151+
logger.LogWarning("User {name} ({email}) with login '{login}' is not a member of organization ID {org} and will not be authorized.", githubUser.Name, githubUser.Email, githubUser.Login, configuration.Value.RubberduckOrgId);
152+
return default;
156153
}
157154
}
158155
catch (Exception)
159156
{
160157
// just ignore: configuration needs the org (prod) client app id to avoid throwing this exception
158+
logger.LogWarning("An exception was thrown. Verify GitHub:ClientId and GitHub:ClientSecret configuration; authorization fails.");
159+
return default;
161160
}
162161
}
163162
}

rubberduckvba.Server/GitHubAuthenticationHandler.cs

+20-7
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,28 @@ public GitHubAuthenticationHandler(IGitHubClientService github,
2020

2121
protected async override Task<AuthenticateResult> HandleAuthenticateAsync()
2222
{
23-
var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault();
24-
if (token is null)
23+
try
2524
{
25+
var token = Context.Request.Headers["X-ACCESS-TOKEN"].SingleOrDefault();
26+
if (string.IsNullOrWhiteSpace(token))
27+
{
28+
return AuthenticateResult.NoResult();
29+
}
30+
31+
var principal = await _github.ValidateTokenAsync(token);
32+
if (principal is ClaimsPrincipal)
33+
{
34+
Context.User = principal;
35+
Thread.CurrentPrincipal = principal;
36+
return AuthenticateResult.Success(new AuthenticationTicket(principal, "github"));
37+
}
38+
39+
return AuthenticateResult.NoResult();
40+
}
41+
catch (InvalidOperationException e)
42+
{
43+
Logger.LogError(e, e.Message);
2644
return AuthenticateResult.NoResult();
2745
}
28-
29-
var principal = await _github.ValidateTokenAsync(token);
30-
return principal is ClaimsPrincipal
31-
? AuthenticateResult.Success(new AuthenticationTicket(principal, "github"))
32-
: AuthenticateResult.NoResult();
3346
}
3447
}

rubberduckvba.Server/Program.cs

+6-4
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,7 @@ public static void Main(string[] args)
4545
builder.Services.AddAuthentication(options =>
4646
{
4747
options.RequireAuthenticatedSignIn = false;
48-
4948
options.DefaultAuthenticateScheme = "github";
50-
options.DefaultScheme = "anonymous";
5149

5250
options.AddScheme("github", builder =>
5351
{
@@ -90,6 +88,7 @@ public static void Main(string[] args)
9088
app.UseHttpsRedirection();
9189

9290
app.UseRouting();
91+
app.UseSession();
9392
app.UseAuthentication();
9493
app.UseAuthorization();
9594

@@ -98,9 +97,12 @@ public static void Main(string[] args)
9897

9998
app.UseCors(policy =>
10099
{
101-
policy.SetIsOriginAllowed(origin => true);
100+
policy
101+
.AllowAnyMethod()
102+
.AllowAnyHeader()
103+
.AllowCredentials()
104+
.SetIsOriginAllowed(origin => true);
102105
});
103-
app.UseSession();
104106

105107
StartHangfire(app);
106108
app.Run();

rubberduckvba.Server/Services/GitHubClientService.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,11 @@ public class GitHubClientService(IOptions<GitHubSettings> configuration, ILogger
4444
var user = await client.User.Current();
4545
var identity = new ClaimsIdentity(new[]
4646
{
47-
new Claim(ClaimTypes.Name, user.Name),
48-
new Claim(ClaimTypes.Email, user.Email),
47+
new Claim(ClaimTypes.Name, user.Login),
4948
new Claim(ClaimTypes.Role, config.OwnerOrg),
49+
new Claim(ClaimTypes.Authentication, token),
5050
new Claim("access_token", token)
51-
});
51+
}, "github");
5252
return new ClaimsPrincipal(identity);
5353
}
5454

rubberduckvba.client/src/app/app.module.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
33
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
44

55
import { DataService } from './services/data.service';
6-
import { ApiClientService } from './services/api-client.service';
6+
import { AdminApiClientService, ApiClientService } from './services/api-client.service';
77
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';
88
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
99
import { BrowserModule } from '@angular/platform-browser';
@@ -33,6 +33,8 @@ import { AnnotationComponent } from './routes/annotation/annotation.component';
3333
import { QuickFixComponent } from './routes/quickfixes/quickfix.component';
3434

3535
import { DefaultUrlSerializer, UrlTree } from '@angular/router';
36+
import { AuthMenuComponent } from './components/auth-menu/auth-menu.component';
37+
import { AuthComponent } from './routes/auth/auth.component';
3638

3739
/**
3840
* https://stackoverflow.com/a/39560520
@@ -52,6 +54,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
5254
declarations: [
5355
AppComponent,
5456
HomeComponent,
57+
AuthComponent,
5558
FeaturesComponent,
5659
FeatureComponent,
5760
TagDownloadComponent,
@@ -70,7 +73,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
7073
InspectionComponent,
7174
AnnotationComponent,
7275
QuickFixComponent,
73-
AboutComponent
76+
AboutComponent,
77+
AuthMenuComponent
7478
],
7579
bootstrap: [AppComponent],
7680
imports: [
@@ -83,7 +87,8 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
8387
{ path: 'inspections/:name', component: InspectionComponent },
8488
{ path: 'annotations/:name', component: AnnotationComponent },
8589
{ path: 'quickfixes/:name', component: QuickFixComponent },
86-
{ path: 'about', component: AboutComponent},
90+
{ path: 'about', component: AboutComponent },
91+
{ path: 'auth/github', component: AuthComponent },
8792
// legacy routes:
8893
{ path: 'inspections/details/:name', redirectTo: 'inspections/:name' },
8994
]),
@@ -93,6 +98,7 @@ export class LowerCaseUrlSerializer extends DefaultUrlSerializer {
9398
providers: [
9499
DataService,
95100
ApiClientService,
101+
AdminApiClientService,
96102
provideHttpClient(withInterceptorsFromDi()),
97103
{
98104
provide: UrlSerializer,

0 commit comments

Comments
 (0)