Skip to content

Commit cdd3a6e

Browse files
authored
Feature/236112 auto register user (#56)
* Implemented the user auto registration logic in the API's strongly typed Client
1 parent 60e552d commit cdd3a6e

File tree

21 files changed

+1816
-15
lines changed

21 files changed

+1816
-15
lines changed

src/DfE.ExternalApplications.Api/Controllers/UsersController.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
using Asp.Versioning;
2+
using DfE.ExternalApplications.Application.Users.Commands;
23
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
34
using DfE.ExternalApplications.Application.Users.Queries;
45
using MediatR;
56
using Microsoft.AspNetCore.Authorization;
67
using Microsoft.AspNetCore.Mvc;
78
using Swashbuckle.AspNetCore.Annotations;
89
using GovUK.Dfe.CoreLibs.Http.Models;
10+
using DfE.ExternalApplications.Infrastructure.Security;
11+
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Request;
912

1013
namespace DfE.ExternalApplications.Api.Controllers;
1114

@@ -35,4 +38,27 @@ public async Task<IActionResult> GetMyPermissionsAsync(
3538
StatusCode = StatusCodes.Status200OK
3639
};
3740
}
41+
42+
/// <summary>
43+
/// Create and registers a new user using the data in the provided External-IDP token.
44+
/// </summary>
45+
[HttpPost("register")]
46+
[SwaggerResponse(200, "User registered successfully.", typeof(UserDto))]
47+
[SwaggerResponse(400, "Invalid request data.", typeof(ExceptionResponse))]
48+
[SwaggerResponse(401, "Unauthorized - no valid user token", typeof(ExceptionResponse))]
49+
[SwaggerResponse(500, "Internal server error.", typeof(ExceptionResponse))]
50+
[SwaggerResponse(429, "Too Many Requests.", typeof(ExceptionResponse))]
51+
[Authorize(AuthenticationSchemes = AuthConstants.AzureAdScheme, Policy = "SvcCanReadWrite")]
52+
public async Task<ActionResult<UserDto>> RegisterUserAsync(
53+
[FromBody] RegisterUserRequest request,
54+
CancellationToken ct)
55+
{
56+
var result = await sender.Send(
57+
new RegisterUserCommand(request.AccessToken, request.TemplateId), ct);
58+
59+
if (!result.IsSuccess)
60+
return BadRequest(new ExceptionResponse { Message = result.Error });
61+
62+
return Ok(result.Value);
63+
}
3864
}

src/DfE.ExternalApplications.Api/DfE.ExternalApplications.Api.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
<ItemGroup>
1111
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.0" />
1212
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
13-
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.45" />
13+
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.46" />
1414
<PackageReference Include="GovUK.Dfe.CoreLibs.Http" Version="1.0.10" />
1515
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.23.0" />
1616
<PackageReference Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="8.0.18" />

src/DfE.ExternalApplications.Application/DfE.ExternalApplications.Application.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<PackageReference Include="AutoMapper" Version="14.0.0" />
1818
<PackageReference Include="GovUK.Dfe.CoreLibs.AsyncProcessing" Version="1.0.12" />
1919
<PackageReference Include="GovUK.Dfe.CoreLibs.Caching" Version="1.0.10" />
20-
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.45" />
20+
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.46" />
2121
<PackageReference Include="GovUK.Dfe.CoreLibs.Email" Version="0.1.0-prerelease-146" />
2222
<PackageReference Include="GovUK.Dfe.CoreLibs.FileStorage" Version="0.1.3" />
2323
<PackageReference Include="GovUK.Dfe.CoreLibs.Notifications" Version="0.1.5" />
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System.Net;
2+
using System.Security.Claims;
3+
using DfE.ExternalApplications.Application.Common.Attributes;
4+
using DfE.ExternalApplications.Application.Common.Behaviours;
5+
using DfE.ExternalApplications.Application.Users.QueryObjects;
6+
using DfE.ExternalApplications.Domain.Common;
7+
using DfE.ExternalApplications.Domain.Entities;
8+
using DfE.ExternalApplications.Domain.Factories;
9+
using DfE.ExternalApplications.Domain.Interfaces;
10+
using DfE.ExternalApplications.Domain.Interfaces.Repositories;
11+
using DfE.ExternalApplications.Domain.Services;
12+
using DfE.ExternalApplications.Domain.ValueObjects;
13+
using GovUK.Dfe.CoreLibs.Contracts.ExternalApplications.Models.Response;
14+
using GovUK.Dfe.CoreLibs.Security.Interfaces;
15+
using MediatR;
16+
using Microsoft.AspNetCore.Http;
17+
using Microsoft.EntityFrameworkCore;
18+
using Microsoft.IdentityModel.Tokens;
19+
20+
namespace DfE.ExternalApplications.Application.Users.Commands;
21+
22+
[RateLimit(5, 30)]
23+
public sealed record RegisterUserCommand(string SubjectToken, Guid? TemplateId = null) : IRequest<Result<UserDto>>, IRateLimitedRequest;
24+
25+
public sealed class RegisterUserCommandHandler(
26+
IEaRepository<User> userRepo,
27+
IExternalIdentityValidator externalValidator,
28+
IHttpContextAccessor httpContextAccessor,
29+
IUserFactory userFactory,
30+
IUnitOfWork unitOfWork) : IRequestHandler<RegisterUserCommand, Result<UserDto>>
31+
{
32+
public async Task<Result<UserDto>> Handle(
33+
RegisterUserCommand request,
34+
CancellationToken cancellationToken)
35+
{
36+
try
37+
{
38+
// Validate external token and extract claims
39+
var externalUser = await externalValidator
40+
.ValidateIdTokenAsync(request.SubjectToken, cancellationToken);
41+
42+
var email = externalUser.FindFirst(ClaimTypes.Email)?.Value
43+
?? throw new SecurityTokenException("RegisterUserCommandHandler > Missing email");
44+
45+
var name = externalUser.FindFirst(ClaimTypes.Name)?.Value
46+
?? externalUser.FindFirst("name")?.Value
47+
?? externalUser.FindFirst("given_name")?.Value
48+
?? email; // Fallback to email if name not available
49+
50+
// Check if user already exists
51+
var dbUser = await (new GetUserByEmailQueryObject(email))
52+
.Apply(userRepo.Query().AsNoTracking())
53+
.FirstOrDefaultAsync(cancellationToken: cancellationToken);
54+
55+
if (dbUser is not null)
56+
{
57+
// User already exists, return their information
58+
return Result<UserDto>.Success(new UserDto
59+
{
60+
UserId = dbUser.Id!.Value,
61+
Name = dbUser.Name,
62+
Email = dbUser.Email,
63+
RoleId = dbUser.RoleId.Value,
64+
Authorization = CreateAuthorizationFromUser(dbUser)
65+
});
66+
}
67+
68+
// Create new user with User role
69+
var userId = new UserId(Guid.NewGuid());
70+
var now = DateTime.UtcNow;
71+
72+
// TemplateId is required for user registration
73+
if (!request.TemplateId.HasValue)
74+
{
75+
return Result<UserDto>.Failure("Template ID is required for user registration");
76+
}
77+
78+
var templateId = new TemplateId(request.TemplateId.Value);
79+
80+
var newUser = userFactory.CreateUser(
81+
userId,
82+
new RoleId(RoleConstants.UserRoleId),
83+
name,
84+
email,
85+
templateId,
86+
now);
87+
88+
await userRepo.AddAsync(newUser, cancellationToken);
89+
await unitOfWork.CommitAsync(cancellationToken);
90+
91+
// Create authorization data directly from the new user
92+
var authorization = CreateAuthorizationFromUser(newUser);
93+
94+
return Result<UserDto>.Success(new UserDto
95+
{
96+
UserId = newUser.Id!.Value,
97+
Name = newUser.Name,
98+
Email = newUser.Email,
99+
RoleId = newUser.RoleId.Value,
100+
Authorization = authorization
101+
});
102+
}
103+
catch (SecurityTokenException ex)
104+
{
105+
return Result<UserDto>.Failure($"Invalid token: {ex.Message}");
106+
}
107+
catch (Exception ex)
108+
{
109+
return Result<UserDto>.Failure(ex.Message);
110+
}
111+
}
112+
113+
private UserAuthorizationDto? CreateAuthorizationFromUser(User user)
114+
{
115+
if (user.Permissions == null || !user.Permissions.Any())
116+
return null;
117+
118+
return new UserAuthorizationDto
119+
{
120+
Permissions = user.Permissions
121+
.Select(p => new UserPermissionDto
122+
{
123+
ApplicationId = p.ApplicationId?.Value,
124+
ResourceType = p.ResourceType,
125+
ResourceKey = p.ResourceKey,
126+
AccessType = p.AccessType
127+
})
128+
.ToArray(),
129+
Roles = new List<string> { user.Role?.Name ?? "User" }
130+
};
131+
}
132+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using FluentValidation;
2+
using System.Runtime.CompilerServices;
3+
4+
[assembly: InternalsVisibleTo("DfE.ExternalApplications.Application.Tests")]
5+
namespace DfE.ExternalApplications.Application.Users.Commands;
6+
7+
internal class RegisterUserCommandValidator : AbstractValidator<RegisterUserCommand>
8+
{
9+
public RegisterUserCommandValidator()
10+
{
11+
RuleFor(x => x.SubjectToken)
12+
.NotEmpty()
13+
.WithMessage("Subject token is required");
14+
15+
RuleFor(x => x.TemplateId)
16+
.NotNull()
17+
.NotEmpty()
18+
.WithMessage("Template ID is required");
19+
}
20+
}
21+
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using DfE.ExternalApplications.Application.Common.EventHandlers;
2+
using DfE.ExternalApplications.Domain.Events;
3+
using Microsoft.Extensions.Logging;
4+
5+
namespace DfE.ExternalApplications.Application.Users.EventHandlers;
6+
7+
public sealed class UserCreatedEventHandler(
8+
ILogger<UserCreatedEventHandler> logger) : BaseEventHandler<UserCreatedEvent>(logger)
9+
{
10+
protected override async Task HandleEvent(UserCreatedEvent notification, CancellationToken cancellationToken)
11+
{
12+
logger.LogInformation("User created: {UserId} - {Email} at {CreatedOn}",
13+
notification.User.Id!.Value,
14+
notification.User.Email,
15+
notification.CreatedOn);
16+
17+
// Future: Add welcome email or other side effects here
18+
19+
await Task.CompletedTask;
20+
}
21+
}
22+

src/DfE.ExternalApplications.Domain/DfE.ExternalApplications.Domain.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
<ItemGroup>
88
<PackageReference Include="GovUK.Dfe.CoreLibs.Caching" Version="1.0.10" />
9-
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.45" />
9+
<PackageReference Include="GovUK.Dfe.CoreLibs.Contracts" Version="1.0.46" />
1010
<PackageReference Include="FluentValidation" Version="12.0.0" />
1111
<PackageReference Include="MediatR" Version="12.5.0" />
1212
</ItemGroup>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using DfE.ExternalApplications.Domain.Common;
2+
using DfE.ExternalApplications.Domain.Entities;
3+
using DfE.ExternalApplications.Domain.ValueObjects;
4+
5+
namespace DfE.ExternalApplications.Domain.Events;
6+
7+
public sealed record UserCreatedEvent(
8+
User User,
9+
DateTime CreatedOn) : IDomainEvent
10+
{
11+
public DateTime OccurredOn => CreatedOn;
12+
}
13+

src/DfE.ExternalApplications.Domain/Factories/IUserFactory.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ User CreateContributor(
1818
TemplateId templateId,
1919
DateTime? createdOn = null);
2020

21+
User CreateUser(
22+
UserId id,
23+
RoleId roleId,
24+
string name,
25+
string email,
26+
TemplateId templateId,
27+
DateTime? createdOn = null);
2128

2229
void AddPermissionToUser(
2330
User user,

src/DfE.ExternalApplications.Domain/Factories/UserFactory.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,83 @@ public User CreateContributor(
108108
return contributor;
109109
}
110110

111+
public User CreateUser(
112+
UserId id,
113+
RoleId roleId,
114+
string name,
115+
string email,
116+
TemplateId templateId ,
117+
DateTime? createdOn = null)
118+
{
119+
if (id == null)
120+
throw new ArgumentException("Id cannot be null", nameof(id));
121+
122+
if (roleId == null)
123+
throw new ArgumentException("RoleId cannot be null", nameof(roleId));
124+
125+
if (string.IsNullOrWhiteSpace(name))
126+
throw new ArgumentException("Name cannot be null or empty", nameof(name));
127+
128+
if (string.IsNullOrWhiteSpace(email))
129+
throw new ArgumentException("Email cannot be null or empty", nameof(email));
130+
131+
var when = createdOn ?? DateTime.UtcNow;
132+
133+
var user = new User(
134+
id,
135+
roleId,
136+
name,
137+
email,
138+
when,
139+
null, // CreatedBy is null for self-registered users
140+
null,
141+
null);
142+
143+
// Add self permission to the user to read their own user record
144+
AddPermissionToUser(
145+
user,
146+
email,
147+
ResourceType.User,
148+
new[] { AccessType.Read },
149+
id, // User grants permission to themselves
150+
null, // No application context
151+
when);
152+
153+
// Add notification permissions for the user's own email
154+
AddPermissionToUser(
155+
user,
156+
email,
157+
ResourceType.Notifications,
158+
new[] { AccessType.Read, AccessType.Write, AccessType.Delete },
159+
id, // User grants permission to themselves
160+
null, // No application context
161+
when);
162+
163+
// Add template permissions if TemplateId is provided
164+
AddTemplatePermissionToUser(
165+
user,
166+
templateId.Value.ToString(),
167+
new[] { AccessType.Read, AccessType.Write },
168+
id, // User grants permission to themselves
169+
when);
170+
171+
// Add application permissions for "Any" application
172+
AddPermissionToUser(
173+
user,
174+
"Any",
175+
ResourceType.Application,
176+
new[] { AccessType.Read, AccessType.Write },
177+
id,
178+
null,
179+
when);
180+
181+
// Raise domain event for user creation (side effects like email)
182+
user.AddDomainEvent(new UserCreatedEvent(
183+
user,
184+
when));
185+
186+
return user;
187+
}
111188

112189
public void AddPermissionToUser(
113190
User user,

0 commit comments

Comments
 (0)