Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@
{
"name": ".NET Core Attach",
"type": "coreclr",
"request": "attach"
"request": "attach",
"processName": "WebApi.exe"
}
]
}
1 change: 1 addition & 0 deletions backend/UnitTests/UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Analyzer" Version="1.1.0" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="Shouldly" Version="4.1.0" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AuthorizationHandlerContext> 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;
}
}
45 changes: 45 additions & 0 deletions backend/UnitTests/WebApi/ContextHelpers.cs
Original file line number Diff line number Diff line change
@@ -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<string> projects)
{
var user = new LfUser("test@testeroolaboom.fun", LfId<User>.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<ILfWebContext>();
contextMock.SetupGet(c => c.User).Returns(user);
return contextMock.Object;
}

public static ILfProjectContext ProjectContext(string projectCode = null)
{
var contextMock = new Mock<ILfProjectContext>();
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<IFilterMetadata>(), new List<IValueProviderFactory>());
}
}
Original file line number Diff line number Diff line change
@@ -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<BadRequestObjectResult>();
}

[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();
}
}
5 changes: 5 additions & 0 deletions backend/WebApi/Auth/AuthSetup.cs
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;

Expand All @@ -21,11 +22,15 @@ 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.DefaultPolicy = AuthorizationPolicy.Combine(options.DefaultPolicy, options.GetPolicy(nameof(ProjectAuthorizationRequirement)));
options.FallbackPolicy = options.DefaultPolicy;
});
services.AddScoped<IAuthorizationHandler, ProjectCodeAuthorizationHandler>();
services.AddOptions<JwtOptions>()
.BindConfiguration("Authentication:Jwt")
.Validate(options => options.GoogleClientId != "==== replace ====",
Expand Down
4 changes: 2 additions & 2 deletions backend/WebApi/Auth/JwtService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<User>.Parse(idClaim);
Expand Down
48 changes: 48 additions & 0 deletions backend/WebApi/Auth/ProjectCodeAuthorizationHandler.cs
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 partial class ProjectCodeAuthorizationHandler : AuthorizationHandler<ProjectAuthorizationRequirement>
{
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;
}
}
6 changes: 6 additions & 0 deletions backend/WebApi/Constants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace LanguageForge.WebApi;

public static class Constants
{
public const string ProjectCodeHeader = "project-code";
}
4 changes: 3 additions & 1 deletion backend/WebApi/Controllers/CommentController.cs
Original file line number Diff line number Diff line change
@@ -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}")]
Expand Down
7 changes: 4 additions & 3 deletions backend/WebApi/Controllers/EntryController.cs
Original file line number Diff line number Diff line change
@@ -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<EntryDto> GetEntries(string projectCode)
public List<EntryDto> GetEntries()
{
return new() {
new() {
Expand Down
26 changes: 18 additions & 8 deletions backend/WebApi/Controllers/ProjectController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,7 +21,7 @@ public ProjectController(ProjectService projectService, ILfWebContext userContex
}

// GET: api/Project
[HttpGet]
[HttpGet("list")]
public async Task<List<ProjectDto>> GetProjects()
{
return await _projectService.ListProjects(_userContext.User.Projects.Select(p => p.ProjectCode));
Expand All @@ -35,27 +36,36 @@ public async Task<List<ProjectDto>> GetAllProjects()
}

// GET: api/Project/5
[HttpGet("{projectCode}")]
public string GetProject(string projectCode)
[HttpGet]
[RequireProjectCode]
public async Task<ActionResult<ProjectDto>> 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()
{
}
}
23 changes: 20 additions & 3 deletions backend/WebApi/LfWebContext.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
using LanguageForge.WebApi.Auth;
using static LanguageForge.WebApi.Constants;

namespace LanguageForge.WebApi;

public interface ILfWebContext
{
/// <summary>
/// Authenticated user details
/// Authenticated user details. Null if the current user is not authenticated.
/// </summary>
LfUser User { get; }
LfUser? User { get; }
}

public class LfWebContext : ILfWebContext
{
public LfUser User { get; }
public LfUser? User { get; }

public LfWebContext(IHttpContextAccessor httpContextAccessor)
{
Expand All @@ -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();
}
}
Loading