Skip to content

Commit fda823d

Browse files
author
Nick Campanini
committed
Extract user resolution to ResolveUserFilter to remove PII from controller code
1 parent 2bb5654 commit fda823d

File tree

5 files changed

+189
-66
lines changed

5 files changed

+189
-66
lines changed

src/SEBT.Portal.Api/Controllers/IdProofing/ChallengesController.cs

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
using System.Security.Claims;
21
using Microsoft.AspNetCore.Authorization;
32
using Microsoft.AspNetCore.Mvc;
4-
using SEBT.Portal.Api.Models;
5-
using SEBT.Portal.Core.Repositories;
3+
using SEBT.Portal.Api.Filters;
64
using SEBT.Portal.Kernel;
75
using SEBT.Portal.Kernel.AspNetCore;
86
using SEBT.Portal.UseCases.IdProofing;
@@ -15,15 +13,15 @@ namespace SEBT.Portal.Api.Controllers.IdProofing;
1513
[ApiController]
1614
[Route("api/challenges")]
1715
[Authorize]
18-
public class ChallengesController(ILogger<ChallengesController> logger) : ControllerBase
16+
[ServiceFilter(typeof(ResolveUserFilter))]
17+
public class ChallengesController : ControllerBase
1918
{
2019
/// <summary>
2120
/// Starts a document verification challenge by generating a Socure DocV session token.
2221
/// Called when the user clicks "Continue" on the document verification interstitial.
2322
/// </summary>
2423
/// <param name="id">The challenge's public GUID.</param>
2524
/// <param name="handler">The command handler.</param>
26-
/// <param name="userRepository">User repository for resolving user ID.</param>
2725
/// <param name="cancellationToken">Cancellation token.</param>
2826
/// <response code="200">DocV session created. Returns token and URL for the frontend SDK.</response>
2927
/// <response code="401">User is not authenticated.</response>
@@ -37,30 +35,14 @@ public class ChallengesController(ILogger<ChallengesController> logger) : Contro
3735
public async Task<IActionResult> Start(
3836
Guid id,
3937
[FromServices] ICommandHandler<StartChallengeCommand, StartChallengeResponse> handler,
40-
[FromServices] IUserRepository userRepository,
4138
CancellationToken cancellationToken)
4239
{
43-
var email = User.FindFirst(ClaimTypes.Email)?.Value
44-
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
45-
?? User.Identity?.Name;
46-
47-
if (string.IsNullOrWhiteSpace(email))
48-
{
49-
logger.LogWarning("Challenge start request but email could not be extracted from claims");
50-
return Unauthorized(new ErrorResponse("Unable to identify user from token."));
51-
}
52-
53-
var user = await userRepository.GetUserByEmailAsync(email, cancellationToken);
54-
if (user == null)
55-
{
56-
logger.LogWarning("Challenge start request but authenticated user not found in database");
57-
return Unauthorized(new ErrorResponse("Unable to identify user from token."));
58-
}
40+
var userId = (int)HttpContext.Items[ResolveUserFilter.UserIdKey]!;
5941

6042
var command = new StartChallengeCommand
6143
{
6244
ChallengeId = id,
63-
UserId = user.Id
45+
UserId = userId
6446
};
6547

6648
var result = await handler.Handle(command, cancellationToken);
Lines changed: 7 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
using System.Security.Claims;
21
using Microsoft.AspNetCore.Authorization;
32
using Microsoft.AspNetCore.Mvc;
3+
using SEBT.Portal.Api.Filters;
44
using SEBT.Portal.Api.Models;
55
using SEBT.Portal.Api.Models.IdProofing;
6-
using SEBT.Portal.Core.Repositories;
76
using SEBT.Portal.Kernel;
87
using SEBT.Portal.Kernel.AspNetCore;
98
using SEBT.Portal.UseCases.IdProofing;
@@ -17,7 +16,8 @@ namespace SEBT.Portal.Api.Controllers.IdProofing;
1716
[ApiController]
1817
[Route("api/id-proofing")]
1918
[Authorize]
20-
public class IdProofingController(ILogger<IdProofingController> logger) : ControllerBase
19+
[ServiceFilter(typeof(ResolveUserFilter))]
20+
public class IdProofingController : ControllerBase
2121
{
2222
/// <summary>
2323
/// Submits ID proofing data for risk assessment.
@@ -35,18 +35,13 @@ public class IdProofingController(ILogger<IdProofingController> logger) : Contro
3535
public async Task<IActionResult> Submit(
3636
[FromBody] SubmitIdProofingRequest request,
3737
[FromServices] ICommandHandler<SubmitIdProofingCommand, SubmitIdProofingResponse> handler,
38-
[FromServices] IUserRepository userRepository,
3938
CancellationToken cancellationToken)
4039
{
41-
var userId = await ResolveUserId(userRepository, cancellationToken);
42-
if (userId == null)
43-
{
44-
return Unauthorized(new ErrorResponse("Unable to identify user from token."));
45-
}
40+
var userId = (int)HttpContext.Items[ResolveUserFilter.UserIdKey]!;
4641

4742
var command = new SubmitIdProofingCommand
4843
{
49-
UserId = userId.Value,
44+
UserId = userId,
5045
DateOfBirth = $"{request.DateOfBirth.Year}-{request.DateOfBirth.Month.PadLeft(2, '0')}-{request.DateOfBirth.Day.PadLeft(2, '0')}",
5146
IdType = request.IdType,
5247
IdValue = request.IdValue
@@ -62,7 +57,6 @@ public async Task<IActionResult> Submit(
6257
/// </summary>
6358
/// <param name="challengeId">The challenge's public GUID.</param>
6459
/// <param name="handler">The query handler.</param>
65-
/// <param name="userRepository">User repository for resolving user ID.</param>
6660
/// <param name="cancellationToken">Cancellation token.</param>
6761
/// <response code="200">Status retrieved.</response>
6862
/// <response code="401">User is not authenticated.</response>
@@ -74,47 +68,17 @@ public async Task<IActionResult> Submit(
7468
public async Task<IActionResult> GetStatus(
7569
[FromQuery] Guid challengeId,
7670
[FromServices] IQueryHandler<GetVerificationStatusQuery, VerificationStatusResponse> handler,
77-
[FromServices] IUserRepository userRepository,
7871
CancellationToken cancellationToken)
7972
{
80-
var userId = await ResolveUserId(userRepository, cancellationToken);
81-
if (userId == null)
82-
{
83-
return Unauthorized(new ErrorResponse("Unable to identify user from token."));
84-
}
73+
var userId = (int)HttpContext.Items[ResolveUserFilter.UserIdKey]!;
8574

8675
var query = new GetVerificationStatusQuery
8776
{
8877
ChallengeId = challengeId,
89-
UserId = userId.Value
78+
UserId = userId
9079
};
9180

9281
var result = await handler.Handle(query, cancellationToken);
9382
return result.ToActionResult();
9483
}
95-
96-
/// <summary>
97-
/// Resolves the authenticated user's numeric ID from their email claim.
98-
/// </summary>
99-
private async Task<int?> ResolveUserId(IUserRepository userRepository, CancellationToken cancellationToken)
100-
{
101-
var email = User.FindFirst(ClaimTypes.Email)?.Value
102-
?? User.FindFirst(ClaimTypes.NameIdentifier)?.Value
103-
?? User.Identity?.Name;
104-
105-
if (string.IsNullOrWhiteSpace(email))
106-
{
107-
logger.LogWarning("ID proofing request but email could not be extracted from claims");
108-
return null;
109-
}
110-
111-
var user = await userRepository.GetUserByEmailAsync(email, cancellationToken);
112-
if (user == null)
113-
{
114-
logger.LogWarning("ID proofing request but authenticated user not found in database");
115-
return null;
116-
}
117-
118-
return user.Id;
119-
}
12084
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.AspNetCore.Mvc.Filters;
4+
using SEBT.Portal.Api.Models;
5+
using SEBT.Portal.Core.Repositories;
6+
7+
namespace SEBT.Portal.Api.Filters;
8+
9+
/// <summary>
10+
/// Action filter that resolves the authenticated user's numeric ID from their email claim
11+
/// and stashes it on HttpContext.Items. Controllers read the pre-resolved ID, keeping PII
12+
/// (email) out of controller code entirely.
13+
/// </summary>
14+
public class ResolveUserFilter(
15+
IUserRepository userRepository,
16+
ILogger<ResolveUserFilter> logger) : IAsyncActionFilter
17+
{
18+
/// <summary>
19+
/// The HttpContext.Items key where the resolved user ID is stored.
20+
/// </summary>
21+
public const string UserIdKey = "ResolvedUserId";
22+
23+
/// <inheritdoc />
24+
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
25+
{
26+
var email = context.HttpContext.User.FindFirst(ClaimTypes.Email)?.Value
27+
?? context.HttpContext.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
28+
?? context.HttpContext.User.Identity?.Name;
29+
30+
if (string.IsNullOrWhiteSpace(email))
31+
{
32+
logger.LogWarning("Request to resolved-user endpoint but email could not be extracted from claims");
33+
context.Result = new UnauthorizedObjectResult(
34+
new ErrorResponse("Unable to identify user from token."));
35+
return;
36+
}
37+
38+
var user = await userRepository.GetUserByEmailAsync(email, context.HttpContext.RequestAborted);
39+
if (user == null)
40+
{
41+
logger.LogWarning("Request to resolved-user endpoint but authenticated user not found in database");
42+
context.Result = new UnauthorizedObjectResult(
43+
new ErrorResponse("Unable to identify user from token."));
44+
return;
45+
}
46+
47+
context.HttpContext.Items[UserIdKey] = user.Id;
48+
await next();
49+
}
50+
}

src/SEBT.Portal.Api/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
using Microsoft.Extensions.Options;
77
using Microsoft.IdentityModel.Tokens;
88
using SEBT.Portal.Api.Composition;
9+
using SEBT.Portal.Api.Filters;
910
using SEBT.Portal.Api.Models;
1011
using Serilog;
1112
using Microsoft.FeatureManagement;
@@ -102,6 +103,9 @@
102103
builder.Services.AddPortalInfrastructureRepositories(builder.Configuration);
103104
builder.Services.AddPortalInfrastructureAppSettings(builder.Configuration);
104105

106+
// Action filters
107+
builder.Services.AddScoped<ResolveUserFilter>();
108+
105109
// Register IDatabaseSeeder for development utilities (e.g., ClearSeededData script)
106110
builder.Services.AddScoped<IDatabaseSeeder>(sp =>
107111
{
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
using System.Security.Claims;
2+
using Microsoft.AspNetCore.Http;
3+
using Microsoft.AspNetCore.Mvc;
4+
using Microsoft.AspNetCore.Mvc.Abstractions;
5+
using Microsoft.AspNetCore.Mvc.Filters;
6+
using Microsoft.AspNetCore.Routing;
7+
using Microsoft.Extensions.Logging.Abstractions;
8+
using NSubstitute;
9+
using SEBT.Portal.Api.Filters;
10+
using SEBT.Portal.Core.Models.Auth;
11+
using SEBT.Portal.Core.Repositories;
12+
13+
namespace SEBT.Portal.Tests.Unit.Filters;
14+
15+
public class ResolveUserFilterTests
16+
{
17+
private readonly IUserRepository userRepository = Substitute.For<IUserRepository>();
18+
19+
private readonly NullLogger<ResolveUserFilter> logger =
20+
NullLogger<ResolveUserFilter>.Instance;
21+
22+
private ResolveUserFilter CreateFilter() => new(userRepository, logger);
23+
24+
private static ActionExecutingContext CreateContext(ClaimsPrincipal? user = null)
25+
{
26+
var httpContext = new DefaultHttpContext();
27+
if (user != null)
28+
{
29+
httpContext.User = user;
30+
}
31+
32+
var actionContext = new ActionContext(
33+
httpContext,
34+
new RouteData(),
35+
new ActionDescriptor());
36+
37+
return new ActionExecutingContext(
38+
actionContext,
39+
new List<IFilterMetadata>(),
40+
new Dictionary<string, object?>(),
41+
controller: null!);
42+
}
43+
44+
private static ClaimsPrincipal CreateAuthenticatedUser(string email, string claimType = ClaimTypes.Email)
45+
{
46+
var claims = new List<Claim> { new(claimType, email) };
47+
var identity = new ClaimsIdentity(claims, "Test");
48+
return new ClaimsPrincipal(identity);
49+
}
50+
51+
[Fact]
52+
public async Task OnActionExecutionAsync_ShouldSetUserId_WhenEmailClaimResolvesToUser()
53+
{
54+
var filter = CreateFilter();
55+
var user = new User { Id = 42, Email = "test@example.com" };
56+
userRepository.GetUserByEmailAsync("test@example.com", Arg.Any<CancellationToken>())
57+
.Returns(user);
58+
59+
var context = CreateContext(CreateAuthenticatedUser("test@example.com"));
60+
var nextCalled = false;
61+
62+
await filter.OnActionExecutionAsync(context, () =>
63+
{
64+
nextCalled = true;
65+
return Task.FromResult(new ActionExecutedContext(
66+
context, new List<IFilterMetadata>(), controller: null!));
67+
});
68+
69+
Assert.True(nextCalled);
70+
Assert.Equal(42, context.HttpContext.Items[ResolveUserFilter.UserIdKey]);
71+
}
72+
73+
[Fact]
74+
public async Task OnActionExecutionAsync_ShouldReturn401_WhenNoEmailClaim()
75+
{
76+
var filter = CreateFilter();
77+
var context = CreateContext(new ClaimsPrincipal(new ClaimsIdentity()));
78+
79+
await filter.OnActionExecutionAsync(context, () =>
80+
throw new InvalidOperationException("Next should not be called"));
81+
82+
var result = Assert.IsType<UnauthorizedObjectResult>(context.Result);
83+
Assert.NotNull(result);
84+
}
85+
86+
[Fact]
87+
public async Task OnActionExecutionAsync_ShouldReturn401_WhenUserNotFoundInDatabase()
88+
{
89+
var filter = CreateFilter();
90+
userRepository.GetUserByEmailAsync("unknown@example.com", Arg.Any<CancellationToken>())
91+
.Returns((User?)null);
92+
93+
var context = CreateContext(CreateAuthenticatedUser("unknown@example.com"));
94+
95+
await filter.OnActionExecutionAsync(context, () =>
96+
throw new InvalidOperationException("Next should not be called"));
97+
98+
var result = Assert.IsType<UnauthorizedObjectResult>(context.Result);
99+
Assert.NotNull(result);
100+
}
101+
102+
[Fact]
103+
public async Task OnActionExecutionAsync_ShouldFallBackToNameIdentifier_WhenEmailClaimMissing()
104+
{
105+
var filter = CreateFilter();
106+
var user = new User { Id = 7, Email = "fallback@example.com" };
107+
userRepository.GetUserByEmailAsync("fallback@example.com", Arg.Any<CancellationToken>())
108+
.Returns(user);
109+
110+
var context = CreateContext(CreateAuthenticatedUser("fallback@example.com", ClaimTypes.NameIdentifier));
111+
112+
var nextCalled = false;
113+
await filter.OnActionExecutionAsync(context, () =>
114+
{
115+
nextCalled = true;
116+
return Task.FromResult(new ActionExecutedContext(
117+
context, new List<IFilterMetadata>(), controller: null!));
118+
});
119+
120+
Assert.True(nextCalled);
121+
Assert.Equal(7, context.HttpContext.Items[ResolveUserFilter.UserIdKey]);
122+
}
123+
}

0 commit comments

Comments
 (0)