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..64831bc --- /dev/null +++ b/backend/UnitTests/WebApi/ContextHelpers.cs @@ -0,0 +1,45 @@ +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(string? projectCode) + { + 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/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..02b5dd2 --- /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..acc0e6b 100644 --- a/backend/WebApi/Program.cs +++ b/backend/WebApi/Program.cs @@ -20,6 +20,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/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/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(); } }