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();
}
}