-
Notifications
You must be signed in to change notification settings - Fork 0
Create project context from route and authorize based on user context #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| using LanguageForge.Api.Entities; | ||
| using LanguageForge.WebApi.Auth; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using static LanguageForge.UnitTests.WebApi.ContextHelpers; | ||
|
|
||
| namespace LanguageForge.UnitTests.WebApi.Auth; | ||
|
|
||
| public class ProjectAuthorizationHandlerTest | ||
| { | ||
| [Fact] | ||
| public async Task FailsIfUserIsNotAuthorized() | ||
| { | ||
| // GIVEN a user that is not authorized | ||
| var user = new LfUser("test@testeroolaboom.fun", LfId<User>.Parse("User:6359f8855e3dc273d4662f2a"), | ||
| UserRole.User, | ||
| new[] { | ||
| new UserProjectRole("fun-language", ProjectRole.Manager), | ||
| new UserProjectRole("spooky-language", ProjectRole.Contributor), | ||
| }); | ||
| var webContext = WebContext(user); | ||
| var projectContext = ProjectContext("missing-language"); | ||
|
|
||
| // WHEN the handler is invoked | ||
| var handler = new ProjectAuthorizationHandler(webContext, projectContext); | ||
| var req = new ProjectAuthorizationRequirement(); | ||
| var context = new AuthorizationHandlerContext(new IAuthorizationRequirement[] { req }, null, null); | ||
| await handler.HandleAsync(context); | ||
|
|
||
| // THEN the handler fails | ||
| context.HasFailed.ShouldBeTrue(); | ||
| } | ||
|
|
||
| [Fact] | ||
| public async Task SucceedsIfUserIsAdmin() | ||
| { | ||
| // GIVEN an admin user | ||
| var user = new LfUser("test@testeroolaboom.fun", LfId<User>.Parse("User:6359f8855e3dc273d4662f2a"), | ||
| UserRole.SystemAdmin, | ||
| new[] { | ||
| new UserProjectRole("fun-language", ProjectRole.Manager), | ||
| new UserProjectRole("spooky-language", ProjectRole.Contributor), | ||
| }); | ||
| var webContext = WebContext(user); | ||
| var projectContext = ProjectContext("missing-language"); | ||
|
|
||
| // WHEN the handler is invoked | ||
| var handler = new ProjectAuthorizationHandler(webContext, projectContext); | ||
| var req = new ProjectAuthorizationRequirement(); | ||
| var context = new AuthorizationHandlerContext(new IAuthorizationRequirement[] { req }, null, null); | ||
| await handler.HandleAsync(context); | ||
|
|
||
| // THEN the handler succeeds | ||
| context.HasSucceeded.ShouldBeTrue(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| using LanguageForge.WebApi; | ||
| using LanguageForge.WebApi.Auth; | ||
| using Moq; | ||
|
|
||
| namespace LanguageForge.UnitTests.WebApi; | ||
|
|
||
| public static class ContextHelpers | ||
| { | ||
| public static ILfWebContext WebContext(LfUser user) | ||
| { | ||
| var contextMock = new Mock<ILfWebContext>(); | ||
| contextMock.SetupGet(c => c.User).Returns(user); | ||
| return contextMock.Object; | ||
| } | ||
|
|
||
| public static ILfProjectContext ProjectContext(string projectCode) | ||
| { | ||
| var contextMock = new Mock<ILfProjectContext>(); | ||
| contextMock.SetupGet(c => c.ProjectCode).Returns(projectCode); | ||
| return contextMock.Object; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,5 @@ | ||
| using Microsoft.AspNetCore.Authentication.JwtBearer; | ||
| using Microsoft.AspNetCore.Authorization; | ||
| using Microsoft.IdentityModel.Logging; | ||
| using Microsoft.OpenApi.Models; | ||
|
|
||
|
|
@@ -21,11 +22,14 @@ public static void SetupLfAuth(IServiceCollection services, IConfiguration confi | |
| services.AddSingleton<JwtService>(); | ||
| services.AddAuthorization(options => | ||
| { | ||
| options.AddPolicy(nameof(ProjectAuthorizationRequirement), policy => policy.Requirements.Add(new ProjectAuthorizationRequirement())); | ||
|
|
||
| //fallback policy is used when there's no auth attribute. | ||
| //default policy is when there's no parameters specified on the auth attribute | ||
| //this will make sure that all endpoints require auth unless they have the AllowAnonymous attribute | ||
| options.FallbackPolicy = options.DefaultPolicy; | ||
| options.FallbackPolicy = AuthorizationPolicy.Combine(options.DefaultPolicy, options.GetPolicy(nameof(ProjectAuthorizationRequirement))); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So this modifies the fallback policy. But not the default, so if we were to put the authorize attribute somewhere then we would no longer be applying the project auth requirement without any trigger that this was happening.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. True. Then, I'll change the Default-Policy. |
||
| }); | ||
| services.AddScoped<IAuthorizationHandler, ProjectAuthorizationHandler>(); | ||
| services.AddOptions<JwtOptions>() | ||
| .BindConfiguration("Authentication:Jwt") | ||
| .Validate(options => options.GoogleClientId != "==== replace ====", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -105,7 +105,7 @@ public static TokenValidationParameters TokenValidationParameters(JwtOptions jwt | |
| }; | ||
| } | ||
|
|
||
| public static LfUser ExtractLfUser(ClaimsPrincipal user) | ||
| public static LfUser? ExtractLfUser(ClaimsPrincipal user) | ||
| { | ||
| var emailClaim = user.FindFirstValue(EmailClaimType); | ||
| var idClaim = user.FindFirstValue(JwtRegisteredClaimNames.Sub); | ||
|
|
@@ -115,7 +115,7 @@ public static LfUser ExtractLfUser(ClaimsPrincipal user) | |
| if (string.IsNullOrEmpty(emailClaim) || string.IsNullOrEmpty(idClaim) || | ||
| string.IsNullOrEmpty(roleClaim) || string.IsNullOrEmpty(projectRolesClaim)) | ||
| { | ||
| throw new ArgumentException($"User is missing required claims. Claims: {user.Claims}."); | ||
| return null; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @hahn-kev I decided this makes more sense in the end, because when we get requests from unauthenticated users there can still be a WebContext, it just doesn't include a user. Ultimately I made this change, because (to my surprise) my
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My first thought was to just lazily get the user - and leave this code as is. But that means we could miss bugs where we access the user from some code outside of an authorized context, in this case that code has to decide what to do if the user is null now. |
||
| } | ||
|
|
||
| var userId = LfId<User>.Parse(idClaim); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| using LanguageForge.Api.Entities; | ||
| using Microsoft.AspNetCore.Authorization; | ||
|
|
||
| namespace LanguageForge.WebApi.Auth; | ||
|
|
||
| public class ProjectAuthorizationRequirement : IAuthorizationRequirement { } | ||
|
|
||
| public class ProjectAuthorizationHandler : AuthorizationHandler<ProjectAuthorizationRequirement> | ||
| { | ||
| private readonly ILfWebContext _lfWebContext; | ||
| private readonly ILfProjectContext _lfProjectContext; | ||
|
|
||
| public ProjectAuthorizationHandler(ILfWebContext lfWebContext, ILfProjectContext lfProjectContext) | ||
| { | ||
| _lfWebContext = lfWebContext; | ||
| _lfProjectContext = lfProjectContext; | ||
| } | ||
|
|
||
| protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, ProjectAuthorizationRequirement requirement) | ||
| { | ||
| var projectCode = _lfProjectContext.ProjectCode; | ||
| if (string.IsNullOrWhiteSpace(projectCode)) | ||
| { | ||
| context.Succeed(requirement); | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| var lfUser = _lfWebContext.User; | ||
|
|
||
| if (lfUser == null) | ||
| { | ||
| context.Fail(new AuthorizationFailureReason(this, "User is not authenticated")); | ||
| return Task.CompletedTask; | ||
| } | ||
|
|
||
| if (lfUser.Role == UserRole.SystemAdmin | ||
| || lfUser.Projects.Any(p => p.ProjectCode == projectCode)) | ||
| { | ||
| context.Succeed(requirement); | ||
| } | ||
| else | ||
| { | ||
| context.Fail(new AuthorizationFailureReason(this, $"User is not authorized for project: {projectCode}")); | ||
| } | ||
|
|
||
| return Task.CompletedTask; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| namespace LanguageForge.WebApi.Controllers; | ||
|
|
||
| public static class PathConstants | ||
| { | ||
| public const string ProjectCode = "projectCode"; | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.