From 6b0c6142df07b5c80e963278e961aa60b612d2ae Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Fri, 27 Jan 2023 12:36:19 +0100 Subject: [PATCH 1/2] Create project context from header and authorize based on user context --- .vscode/launch.json | 3 +- backend/UnitTests/UnitTests.csproj | 1 + .../ProjectCodeAuthorizationHandlerTest.cs | 61 +++++++++++++++++++ backend/UnitTests/WebApi/ContextHelpers.cs | 44 +++++++++++++ .../RequireProjectCodeFilterTest.cs | 54 ++++++++++++++++ backend/WebApi/Auth/AuthSetup.cs | 5 ++ backend/WebApi/Auth/JwtService.cs | 4 +- .../Auth/ProjectCodeAuthorizationHandler.cs | 48 +++++++++++++++ backend/WebApi/Constants.cs | 6 ++ .../WebApi/Controllers/CommentController.cs | 4 +- backend/WebApi/Controllers/EntryController.cs | 7 ++- .../WebApi/Controllers/ProjectController.cs | 26 +++++--- backend/WebApi/LfWebContext.cs | 23 ++++++- backend/WebApi/Program.cs | 6 +- backend/WebApi/Services/ProjectService.cs | 12 +++- backend/WebApi/SwaggerConfig.cs | 27 ++++++++ .../Validation/RequireProjectCodeFilter.cs | 44 +++++++++++++ backend/WebApi/WebApiKernel.cs | 3 +- 18 files changed, 357 insertions(+), 21 deletions(-) create mode 100644 backend/UnitTests/WebApi/Auth/ProjectCodeAuthorizationHandlerTest.cs create mode 100644 backend/UnitTests/WebApi/ContextHelpers.cs create mode 100644 backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs create mode 100644 backend/WebApi/Auth/ProjectCodeAuthorizationHandler.cs create mode 100644 backend/WebApi/Constants.cs create mode 100644 backend/WebApi/SwaggerConfig.cs create mode 100644 backend/WebApi/Validation/RequireProjectCodeFilter.cs diff --git a/.vscode/launch.json b/.vscode/launch.json index 6419501..ca2f8fa 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -40,7 +40,8 @@ { "name": ".NET Core Attach", "type": "coreclr", - "request": "attach" + "request": "attach", + "processName": "WebApi.exe" } ] } \ No newline at end of file diff --git a/backend/UnitTests/UnitTests.csproj b/backend/UnitTests/UnitTests.csproj index fbba25a..c73a462 100644 --- a/backend/UnitTests/UnitTests.csproj +++ b/backend/UnitTests/UnitTests.csproj @@ -16,6 +16,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/backend/UnitTests/WebApi/Auth/ProjectCodeAuthorizationHandlerTest.cs b/backend/UnitTests/WebApi/Auth/ProjectCodeAuthorizationHandlerTest.cs new file mode 100644 index 0000000..7d6df43 --- /dev/null +++ b/backend/UnitTests/WebApi/Auth/ProjectCodeAuthorizationHandlerTest.cs @@ -0,0 +1,61 @@ +using LanguageForge.Api.Entities; +using LanguageForge.WebApi; +using LanguageForge.WebApi.Auth; +using Microsoft.AspNetCore.Authorization; +using static LanguageForge.UnitTests.WebApi.ContextHelpers; + +namespace LanguageForge.UnitTests.WebApi.Auth; + +public class ProjectCodeAuthorizationHandlerTest +{ + [Fact] + public async Task AuthFailsIfUserIsNotAuthorized() + { + // GIVEN a user that is not a member of the requested project + var webContext = WebContext(UserRole.User, new[] { "fun-language", "spooky-language" }); + var projectContext = ProjectContext("missing-language"); + + // WHEN the handler is invoked + var authContext = await InvokeAuthorizationHandler(webContext, projectContext); + + // THEN the handler fails + authContext.HasFailed.ShouldBeTrue(); + } + + [Fact] + public async Task AuthSucceedsIfUserIsAdmin() + { + // GIVEN an admin user that is not a member of the requested project + var webContext = WebContext(UserRole.SystemAdmin, new[] { "fun-language", "spooky-language" }); + var projectContext = ProjectContext("missing-language"); + + // WHEN the handler is invoked + var authContext = await InvokeAuthorizationHandler(webContext, projectContext); + + // THEN the handler succeeds + authContext.HasSucceeded.ShouldBeTrue(); + } + + [Fact] + public async Task AuthSucceedsIfUserIsProjectMember() + { + // GIVEN a user that is a member of the requested project + var webContext = WebContext(UserRole.User, new[] { "fun-language", "spooky-language" }); + var projectContext = ProjectContext("spooky-language"); + + // WHEN the handler is invoked + var authContext = await InvokeAuthorizationHandler(webContext, projectContext); + + // THEN the handler succeeds + authContext.HasSucceeded.ShouldBeTrue(); + } + + private async Task InvokeAuthorizationHandler(ILfWebContext webContext, ILfProjectContext projectContext) + { + var handler = new ProjectCodeAuthorizationHandler(webContext, projectContext); + var req = new ProjectAuthorizationRequirement(); + var context = new AuthorizationHandlerContext(new IAuthorizationRequirement[] { req }, null, null); + await handler.HandleAsync(context); + return context; + } +} diff --git a/backend/UnitTests/WebApi/ContextHelpers.cs b/backend/UnitTests/WebApi/ContextHelpers.cs new file mode 100644 index 0000000..cdc68d2 --- /dev/null +++ b/backend/UnitTests/WebApi/ContextHelpers.cs @@ -0,0 +1,44 @@ +using LanguageForge.Api.Entities; +using LanguageForge.WebApi; +using LanguageForge.WebApi.Auth; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Routing; +using Moq; + +namespace LanguageForge.UnitTests.WebApi; + +public static class ContextHelpers +{ + public static ILfWebContext WebContext(UserRole role, IReadOnlyList projects) + { + var user = new LfUser("test@testeroolaboom.fun", LfId.Parse("User:6359f8855e3dc273d4662f2a"), + UserRole.User, + projects.Select(p => new UserProjectRole(p, ProjectRole.Contributor)).ToArray()); + return WebContext(user); + } + + public static ILfWebContext WebContext(LfUser user = null) + { + var contextMock = new Mock(); + contextMock.SetupGet(c => c.User).Returns(user); + return contextMock.Object; + } + + public static ILfProjectContext ProjectContext(string projectCode = null) + { + var contextMock = new Mock(); + contextMock.SetupGet(c => c.ProjectCode).Returns(projectCode); + return contextMock.Object; + } + + public static ResourceExecutingContext ResourceExecutingContext(List filters) + { + var FilterDescriptors = filters.Select(filter => new FilterDescriptor(filter, 0)).ToList(); + var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor { FilterDescriptors = FilterDescriptors }); + return new ResourceExecutingContext(actionContext, filters, new List()); + } +} diff --git a/backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs b/backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs new file mode 100644 index 0000000..b0b7b89 --- /dev/null +++ b/backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs @@ -0,0 +1,54 @@ +using LanguageForge.WebApi.Validation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using static LanguageForge.UnitTests.WebApi.ContextHelpers; + +namespace LanguageForge.UnitTests.WebApi.Validation; + +public class RequireProjectCodeFilterTest +{ + [Fact] + public void FailsIfRequiredProjectCodeNotPresent() + { + // GIVEN - an empty project context and a context that requires a project code + var projectContext = ProjectContext(); + var resourceContext = ResourceExecutingContext(new List { new RequireProjectCodeAttribute() }); + + // WHEN - the project code is validated + var handler = new RequireProjectCodeFilter(projectContext); + handler.OnResourceExecuting(resourceContext); + + // THEN - Validation fails + resourceContext.Result.ShouldBeOfType(); + } + + [Fact] + public void PassesIfRequiredProjectCodeIsPresent() + { + // GIVEN - an empty project context and a context that requires a project code + var projectContext = ProjectContext("spicy-project"); + var resourceContext = ResourceExecutingContext(new List { new RequireProjectCodeAttribute() }); + + // WHEN - the project code is validated + var handler = new RequireProjectCodeFilter(projectContext); + handler.OnResourceExecuting(resourceContext); + + // THEN - Validation fails + resourceContext.Result.ShouldBeNull(); + } + + [Fact] + public void PassesIfNoProjectCodeIsRequired() + { + // GIVEN - an empty project context and a context that requires a project code + var projectContext = ProjectContext(); + var resourceContext = ResourceExecutingContext(new List { }); + + // WHEN - the project code is validated + var handler = new RequireProjectCodeFilter(projectContext); + handler.OnResourceExecuting(resourceContext); + + // THEN - Validation fails + resourceContext.Result.ShouldBeNull(); + } +} diff --git a/backend/WebApi/Auth/AuthSetup.cs b/backend/WebApi/Auth/AuthSetup.cs index 99b1a62..b6f0d4c 100644 --- a/backend/WebApi/Auth/AuthSetup.cs +++ b/backend/WebApi/Auth/AuthSetup.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.IdentityModel.Logging; using Microsoft.OpenApi.Models; @@ -21,11 +22,15 @@ public static void SetupLfAuth(IServiceCollection services, IConfiguration confi services.AddSingleton(); 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.DefaultPolicy = AuthorizationPolicy.Combine(options.DefaultPolicy, options.GetPolicy(nameof(ProjectAuthorizationRequirement))); options.FallbackPolicy = options.DefaultPolicy; }); + services.AddScoped(); services.AddOptions() .BindConfiguration("Authentication:Jwt") .Validate(options => options.GoogleClientId != "==== replace ====", diff --git a/backend/WebApi/Auth/JwtService.cs b/backend/WebApi/Auth/JwtService.cs index a3b8031..8f80f76 100644 --- a/backend/WebApi/Auth/JwtService.cs +++ b/backend/WebApi/Auth/JwtService.cs @@ -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; } var userId = LfId.Parse(idClaim); diff --git a/backend/WebApi/Auth/ProjectCodeAuthorizationHandler.cs b/backend/WebApi/Auth/ProjectCodeAuthorizationHandler.cs new file mode 100644 index 0000000..ad0f366 --- /dev/null +++ b/backend/WebApi/Auth/ProjectCodeAuthorizationHandler.cs @@ -0,0 +1,48 @@ +using LanguageForge.Api.Entities; +using Microsoft.AspNetCore.Authorization; + +namespace LanguageForge.WebApi.Auth; + +public class ProjectAuthorizationRequirement : IAuthorizationRequirement { } + +public partial class ProjectCodeAuthorizationHandler : AuthorizationHandler +{ + private readonly ILfWebContext _lfWebContext; + private readonly ILfProjectContext _lfProjectContext; + + public ProjectCodeAuthorizationHandler(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; + } +} diff --git a/backend/WebApi/Constants.cs b/backend/WebApi/Constants.cs new file mode 100644 index 0000000..8079728 --- /dev/null +++ b/backend/WebApi/Constants.cs @@ -0,0 +1,6 @@ +namespace LanguageForge.WebApi; + +public static class Constants +{ + public const string ProjectCodeHeader = "Project-Code"; +} diff --git a/backend/WebApi/Controllers/CommentController.cs b/backend/WebApi/Controllers/CommentController.cs index fea6dd3..248b426 100644 --- a/backend/WebApi/Controllers/CommentController.cs +++ b/backend/WebApi/Controllers/CommentController.cs @@ -1,9 +1,11 @@ +using LanguageForge.WebApi.Validation; using Microsoft.AspNetCore.Mvc; namespace LanguageForge.WebApi.Controllers; [ApiController] -[Route("api/[controller]/{projectCode}/{entryId}")] +[Route($"api/[controller]/{{entryId}}")] +[RequireProjectCode] public class CommentController : ControllerBase { [HttpGet("{fieldName}/{inputSystem}")] diff --git a/backend/WebApi/Controllers/EntryController.cs b/backend/WebApi/Controllers/EntryController.cs index 1062de9..60b04a6 100644 --- a/backend/WebApi/Controllers/EntryController.cs +++ b/backend/WebApi/Controllers/EntryController.cs @@ -1,15 +1,16 @@ using LanguageForge.WebApi.Dtos; +using LanguageForge.WebApi.Validation; using Microsoft.AspNetCore.Mvc; namespace LanguageForge.WebApi.Controllers; [ApiController] -[Route("api/[controller]/{projectCode}")] +[Route($"api/[controller]")] +[RequireProjectCode] public class EntryController : ControllerBase { - // GET: api/Entry/{projectCode} [HttpGet] - public List GetEntries(string projectCode) + public List GetEntries() { return new() { new() { diff --git a/backend/WebApi/Controllers/ProjectController.cs b/backend/WebApi/Controllers/ProjectController.cs index 099d3d2..eda8ceb 100644 --- a/backend/WebApi/Controllers/ProjectController.cs +++ b/backend/WebApi/Controllers/ProjectController.cs @@ -2,6 +2,7 @@ using LanguageForge.WebApi.Auth; using LanguageForge.WebApi.Dtos; using LanguageForge.WebApi.Services; +using LanguageForge.WebApi.Validation; using Microsoft.AspNetCore.Mvc; namespace LanguageForge.WebApi.Controllers; @@ -20,7 +21,7 @@ public ProjectController(ProjectService projectService, ILfWebContext userContex } // GET: api/Project - [HttpGet] + [HttpGet("list")] public async Task> GetProjects() { return await _projectService.ListProjects(_userContext.User.Projects.Select(p => p.ProjectCode)); @@ -35,27 +36,36 @@ public async Task> GetAllProjects() } // GET: api/Project/5 - [HttpGet("{projectCode}")] - public string GetProject(string projectCode) + [HttpGet] + [RequireProjectCode] + public async Task> GetProject() { - return "value"; + var project = await _projectService.GetProject(); + if (project == null) + { + return NotFound(); + } + return project; } // POST: api/Project [HttpPost] + [RequireProjectCode] public void PostProject([FromBody] string value) { } // PUT: api/Project/5 - [HttpPut("{projectCode}")] - public void PutProject(string projectCode, [FromBody] string value) + [HttpPut] + [RequireProjectCode] + public void PutProject([FromBody] string value) { } // DELETE: api/Project/5 - [HttpDelete("{projectCode}")] - public void DeleteProject(string projectCode) + [HttpDelete] + [RequireProjectCode] + public void DeleteProject() { } } diff --git a/backend/WebApi/LfWebContext.cs b/backend/WebApi/LfWebContext.cs index 6112006..8b29eb7 100644 --- a/backend/WebApi/LfWebContext.cs +++ b/backend/WebApi/LfWebContext.cs @@ -1,18 +1,19 @@ using LanguageForge.WebApi.Auth; +using static LanguageForge.WebApi.Constants; namespace LanguageForge.WebApi; public interface ILfWebContext { /// - /// Authenticated user details + /// Authenticated user details. Null if the current user is not authenticated. /// - LfUser User { get; } + LfUser? User { get; } } public class LfWebContext : ILfWebContext { - public LfUser User { get; } + public LfUser? User { get; } public LfWebContext(IHttpContextAccessor httpContextAccessor) { @@ -23,5 +24,21 @@ public LfWebContext(IHttpContextAccessor httpContextAccessor) } User = JwtService.ExtractLfUser(httpContext.User); } +} + +public interface ILfProjectContext +{ + public string? ProjectCode { get; } +} +public class LfProjectContext : ILfProjectContext +{ + public string? ProjectCode { get; } + + public LfProjectContext(IHttpContextAccessor httpContextAccessor) + { + ProjectCode = httpContextAccessor.HttpContext?.Request.Headers + .FirstOrDefault(header => header.Key.Equals(ProjectCodeHeader, StringComparison.OrdinalIgnoreCase)) + .Value.FirstOrDefault(); + } } diff --git a/backend/WebApi/Program.cs b/backend/WebApi/Program.cs index 8ed3379..c87a887 100644 --- a/backend/WebApi/Program.cs +++ b/backend/WebApi/Program.cs @@ -4,13 +4,16 @@ using LanguageForge.Api.Entities; using LanguageForge.WebApi; using LanguageForge.WebApi.Auth; +using LanguageForge.WebApi.Validation; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddControllers().AddJsonOptions(opts => +builder.Services.AddControllers() +.AddMvcOptions(options => options.Filters.Add()) +.AddJsonOptions(opts => { var enumConverter = new JsonStringEnumConverter(); opts.JsonSerializerOptions.Converters.Add(enumConverter); @@ -20,6 +23,7 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { + c.OperationFilter(); c.MapType(typeof(LfId<>), () => new OpenApiSchema { Type = "string" }); }); diff --git a/backend/WebApi/Services/ProjectService.cs b/backend/WebApi/Services/ProjectService.cs index f19d678..915a0d7 100644 --- a/backend/WebApi/Services/ProjectService.cs +++ b/backend/WebApi/Services/ProjectService.cs @@ -9,6 +9,7 @@ namespace LanguageForge.WebApi.Services; public class ProjectService { private readonly SystemDbContext _systemDbContext; + private readonly ILfProjectContext _projectContext; private readonly Expression> _projectToDto = p => new ProjectDto { @@ -19,9 +20,10 @@ public class ProjectService Users = p.Users.Select(pair => new ProjectUserDto(pair.Key, pair.Value.Role)).ToArray() }; - public ProjectService(SystemDbContext systemDbContext) + public ProjectService(SystemDbContext systemDbContext, ILfProjectContext projectContext) { _systemDbContext = systemDbContext; + _projectContext = projectContext; } public async Task> ListAllProjects() @@ -39,4 +41,12 @@ public async Task> ListProjects(IEnumerable projectCode .Project(_projectToDto) .ToListAsync(); } + + public async Task GetProject() + { + return await _systemDbContext.Projects + .Find(p => p.ProjectCode == _projectContext.ProjectCode) + .Project(_projectToDto) + .SingleOrDefaultAsync(); + } } diff --git a/backend/WebApi/SwaggerConfig.cs b/backend/WebApi/SwaggerConfig.cs new file mode 100644 index 0000000..cdc7a96 --- /dev/null +++ b/backend/WebApi/SwaggerConfig.cs @@ -0,0 +1,27 @@ +using LanguageForge.WebApi.Validation; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using static LanguageForge.WebApi.Constants; + +namespace LanguageForge.WebApi; + +public partial class AddRequiredProjectCodeHeader : IOperationFilter +{ + public void Apply(OpenApiOperation operation, OperationFilterContext context) + { + var filters = context.ApiDescription.ActionDescriptor.FilterDescriptors.Select(x => x.Filter); + + if (!filters.Any(filter => filter is RequireProjectCodeAttribute)) + { + return; + } + + operation.Parameters ??= new List(); + operation.Parameters.Add(new OpenApiParameter + { + Name = ProjectCodeHeader, + In = ParameterLocation.Header, + Required = true + }); + } +} diff --git a/backend/WebApi/Validation/RequireProjectCodeFilter.cs b/backend/WebApi/Validation/RequireProjectCodeFilter.cs new file mode 100644 index 0000000..a2f5582 --- /dev/null +++ b/backend/WebApi/Validation/RequireProjectCodeFilter.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.IdentityModel.Tokens; +using static LanguageForge.WebApi.Constants; + +namespace LanguageForge.WebApi.Validation; + +public class RequireProjectCodeFilter : IResourceFilter +{ + private readonly ILfProjectContext _lfProjectContext; + + public RequireProjectCodeFilter(ILfProjectContext lfProjectContext) + { + _lfProjectContext = lfProjectContext; + } + + public void OnResourceExecuted(ResourceExecutedContext context) + { + } + + public void OnResourceExecuting(ResourceExecutingContext context) + { + if (_lfProjectContext.ProjectCode.IsNullOrEmpty()) + { + var requiresProjectCode = context.ActionDescriptor.FilterDescriptors.Any(x => x.Filter is RequireProjectCodeAttribute); + if (requiresProjectCode) + { + context.Result = new BadRequestObjectResult($"{ProjectCodeHeader} header is required"); + } + } + } +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)] +public class RequireProjectCodeAttribute : Attribute, IResourceFilter +{ + public void OnResourceExecuted(ResourceExecutedContext context) + { + } + + public void OnResourceExecuting(ResourceExecutingContext context) + { + } +} diff --git a/backend/WebApi/WebApiKernel.cs b/backend/WebApi/WebApiKernel.cs index 9582eea..fa2d17e 100644 --- a/backend/WebApi/WebApiKernel.cs +++ b/backend/WebApi/WebApiKernel.cs @@ -7,9 +7,10 @@ public static class WebApiKernel public static void Setup(IServiceCollection services) { services.AddHttpClient(); - services.AddSingleton(); + services.AddScoped(); services.AddSingleton(); services.AddHttpContextAccessor(); services.AddScoped(); + services.AddScoped(); } } From 25622d7b38e43d7fd0fd02c52b2d964a8f07799d Mon Sep 17 00:00:00 2001 From: Tim Haasdyk Date: Tue, 31 Jan 2023 14:40:07 +0100 Subject: [PATCH 2/2] fixup! Create project context from header and authorize based on user context --- backend/UnitTests/WebApi/ContextHelpers.cs | 9 ++-- .../RequireProjectCodeAttributeTest.cs | 36 +++++++++++++ .../RequireProjectCodeFilterTest.cs | 54 ------------------- backend/WebApi/Constants.cs | 2 +- backend/WebApi/Program.cs | 5 +- .../Validation/RequireProjectCodeAttribute.cs | 22 ++++++++ .../Validation/RequireProjectCodeFilter.cs | 44 --------------- 7 files changed, 65 insertions(+), 107 deletions(-) create mode 100644 backend/UnitTests/WebApi/Validation/RequireProjectCodeAttributeTest.cs delete mode 100644 backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs create mode 100644 backend/WebApi/Validation/RequireProjectCodeAttribute.cs delete mode 100644 backend/WebApi/Validation/RequireProjectCodeFilter.cs diff --git a/backend/UnitTests/WebApi/ContextHelpers.cs b/backend/UnitTests/WebApi/ContextHelpers.cs index cdc68d2..64831bc 100644 --- a/backend/UnitTests/WebApi/ContextHelpers.cs +++ b/backend/UnitTests/WebApi/ContextHelpers.cs @@ -35,10 +35,11 @@ public static ILfProjectContext ProjectContext(string projectCode = null) return contextMock.Object; } - public static ResourceExecutingContext ResourceExecutingContext(List filters) + public static ResourceExecutingContext ResourceExecutingContext(string? projectCode) { - var FilterDescriptors = filters.Select(filter => new FilterDescriptor(filter, 0)).ToList(); - var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor { FilterDescriptors = FilterDescriptors }); - return new ResourceExecutingContext(actionContext, filters, new List()); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Add(Constants.ProjectCodeHeader, projectCode); + var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); + return new ResourceExecutingContext(actionContext, new List(), new List()); } } diff --git a/backend/UnitTests/WebApi/Validation/RequireProjectCodeAttributeTest.cs b/backend/UnitTests/WebApi/Validation/RequireProjectCodeAttributeTest.cs new file mode 100644 index 0000000..20523bf --- /dev/null +++ b/backend/UnitTests/WebApi/Validation/RequireProjectCodeAttributeTest.cs @@ -0,0 +1,36 @@ +using LanguageForge.WebApi.Validation; +using Microsoft.AspNetCore.Mvc; +using static LanguageForge.UnitTests.WebApi.ContextHelpers; + +namespace LanguageForge.UnitTests.WebApi.Validation; + +public class RequireProjectCodeAttributeTest +{ + [Fact] + public void FailsIfProjectCodeIsNotPresent() + { + // GIVEN - no project code + var resourceContext = ResourceExecutingContext(null); + + // WHEN - the project code is validated + var handler = new RequireProjectCodeAttribute(); + handler.OnResourceExecuting(resourceContext); + + // THEN - Validation fails + resourceContext.Result.ShouldBeOfType(); + } + + [Fact] + public void PassesIfProjectCodeIsPresent() + { + // GIVEN - a project code + var resourceContext = ResourceExecutingContext("spicy-project"); + + // WHEN - the project code is validated + var handler = new RequireProjectCodeAttribute(); + handler.OnResourceExecuting(resourceContext); + + // THEN - Validation passes + resourceContext.Result.ShouldBeNull(); + } +} diff --git a/backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs b/backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs deleted file mode 100644 index b0b7b89..0000000 --- a/backend/UnitTests/WebApi/Validation/RequireProjectCodeFilterTest.cs +++ /dev/null @@ -1,54 +0,0 @@ -using LanguageForge.WebApi.Validation; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using static LanguageForge.UnitTests.WebApi.ContextHelpers; - -namespace LanguageForge.UnitTests.WebApi.Validation; - -public class RequireProjectCodeFilterTest -{ - [Fact] - public void FailsIfRequiredProjectCodeNotPresent() - { - // GIVEN - an empty project context and a context that requires a project code - var projectContext = ProjectContext(); - var resourceContext = ResourceExecutingContext(new List { new RequireProjectCodeAttribute() }); - - // WHEN - the project code is validated - var handler = new RequireProjectCodeFilter(projectContext); - handler.OnResourceExecuting(resourceContext); - - // THEN - Validation fails - resourceContext.Result.ShouldBeOfType(); - } - - [Fact] - public void PassesIfRequiredProjectCodeIsPresent() - { - // GIVEN - an empty project context and a context that requires a project code - var projectContext = ProjectContext("spicy-project"); - var resourceContext = ResourceExecutingContext(new List { new RequireProjectCodeAttribute() }); - - // WHEN - the project code is validated - var handler = new RequireProjectCodeFilter(projectContext); - handler.OnResourceExecuting(resourceContext); - - // THEN - Validation fails - resourceContext.Result.ShouldBeNull(); - } - - [Fact] - public void PassesIfNoProjectCodeIsRequired() - { - // GIVEN - an empty project context and a context that requires a project code - var projectContext = ProjectContext(); - var resourceContext = ResourceExecutingContext(new List { }); - - // WHEN - the project code is validated - var handler = new RequireProjectCodeFilter(projectContext); - handler.OnResourceExecuting(resourceContext); - - // THEN - Validation fails - resourceContext.Result.ShouldBeNull(); - } -} diff --git a/backend/WebApi/Constants.cs b/backend/WebApi/Constants.cs index 8079728..02b5dd2 100644 --- a/backend/WebApi/Constants.cs +++ b/backend/WebApi/Constants.cs @@ -2,5 +2,5 @@ namespace LanguageForge.WebApi; public static class Constants { - public const string ProjectCodeHeader = "Project-Code"; + public const string ProjectCodeHeader = "project-code"; } diff --git a/backend/WebApi/Program.cs b/backend/WebApi/Program.cs index c87a887..acc0e6b 100644 --- a/backend/WebApi/Program.cs +++ b/backend/WebApi/Program.cs @@ -4,16 +4,13 @@ using LanguageForge.Api.Entities; using LanguageForge.WebApi; using LanguageForge.WebApi.Auth; -using LanguageForge.WebApi.Validation; using Microsoft.OpenApi.Models; var builder = WebApplication.CreateBuilder(args); // Add services to the container. -builder.Services.AddControllers() -.AddMvcOptions(options => options.Filters.Add()) -.AddJsonOptions(opts => +builder.Services.AddControllers().AddJsonOptions(opts => { var enumConverter = new JsonStringEnumConverter(); opts.JsonSerializerOptions.Converters.Add(enumConverter); diff --git a/backend/WebApi/Validation/RequireProjectCodeAttribute.cs b/backend/WebApi/Validation/RequireProjectCodeAttribute.cs new file mode 100644 index 0000000..f762c0d --- /dev/null +++ b/backend/WebApi/Validation/RequireProjectCodeAttribute.cs @@ -0,0 +1,22 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using static LanguageForge.WebApi.Constants; + +namespace LanguageForge.WebApi.Validation; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)] +public class RequireProjectCodeAttribute : Attribute, IResourceFilter +{ + public void OnResourceExecuted(ResourceExecutedContext context) + { + } + + public void OnResourceExecuting(ResourceExecutingContext context) + { + if (!context.HttpContext.Request.Headers.TryGetValue(ProjectCodeHeader, out var projectCode) + || string.IsNullOrEmpty(projectCode)) + { + context.Result = new BadRequestObjectResult($"{ProjectCodeHeader} header is required"); + } + } +} diff --git a/backend/WebApi/Validation/RequireProjectCodeFilter.cs b/backend/WebApi/Validation/RequireProjectCodeFilter.cs deleted file mode 100644 index a2f5582..0000000 --- a/backend/WebApi/Validation/RequireProjectCodeFilter.cs +++ /dev/null @@ -1,44 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.IdentityModel.Tokens; -using static LanguageForge.WebApi.Constants; - -namespace LanguageForge.WebApi.Validation; - -public class RequireProjectCodeFilter : IResourceFilter -{ - private readonly ILfProjectContext _lfProjectContext; - - public RequireProjectCodeFilter(ILfProjectContext lfProjectContext) - { - _lfProjectContext = lfProjectContext; - } - - public void OnResourceExecuted(ResourceExecutedContext context) - { - } - - public void OnResourceExecuting(ResourceExecutingContext context) - { - if (_lfProjectContext.ProjectCode.IsNullOrEmpty()) - { - var requiresProjectCode = context.ActionDescriptor.FilterDescriptors.Any(x => x.Filter is RequireProjectCodeAttribute); - if (requiresProjectCode) - { - context.Result = new BadRequestObjectResult($"{ProjectCodeHeader} header is required"); - } - } - } -} - -[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true)] -public class RequireProjectCodeAttribute : Attribute, IResourceFilter -{ - public void OnResourceExecuted(ResourceExecutedContext context) - { - } - - public void OnResourceExecuting(ResourceExecutingContext context) - { - } -}