Skip to content

Commit 3265e32

Browse files
committed
fix(verification, identity): bind OTP to user, fix email/phone change persistence, add unique email constraint
OTP Ownership & Security: - Add UserId to OtpVerification entity (nullable, for anonymous registration flows) - Pass recipientUserId through ContactChangeOtpService into OTP create/refresh - Add user-scoped FindActiveAsync overload in OtpVerificationRepository to prevent contact-level collisions - Add OTP ownership validation in ConfirmEmailChange and ConfirmPhoneChange handlers - Use entity.UserId in VerifyOtpCommandHandler with fallback to contact lookup for anonymous flows Email/Phone Change Fixes: - Use UserManager.SetEmailAsync + SetUserNameAsync in ConfirmEmailChange instead of raw property mutation (fixes stale NormalizedEmail) - Use UserManager for phone change confirmation with ownership validation Duplicate Account Prevention: - Add filtered unique index on NormalizedEmail to prevent duplicate accounts at DB level - Create EF migration: AddOtpVerificationUserId (adds user_id column + composite index + unique email index)
1 parent e737cd2 commit 3265e32

39 files changed

Lines changed: 8586 additions & 54 deletions

File tree

backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs

Lines changed: 80 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,15 @@ public DevAuthHandler(
7171

7272
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
7373
{
74-
var roles = ReadRoles();
74+
// PRIORITY 1: If the request carries a real JWT (e.g. from /api/auth/login),
75+
// authenticate as the real user and skip dev-mode entirely.
76+
var realJwtResult = TryAuthenticateRealJwt();
77+
if (realJwtResult is not null)
78+
return Task.FromResult(realJwtResult);
79+
80+
// PRIORITY 2: Dev-mode auth — cookie or dev-prefixed bearer header.
81+
// Only reached when no valid real JWT is present.
82+
var roles = ReadDevRoles();
7583
if (roles is null || roles.Count == 0)
7684
{
7785
return Task.FromResult(AuthenticateResult.NoResult());
@@ -101,38 +109,29 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
101109
return Task.FromResult(AuthenticateResult.Success(ticket));
102110
}
103111

104-
private List<string>? ReadRoles()
112+
/// <summary>
113+
/// Attempts to validate the Authorization header as a real JWT issued by
114+
/// <c>/api/auth/login</c>. Returns <c>null</c> when no header is present,
115+
/// the token is invalid, or it is a dev-mode token.
116+
/// </summary>
117+
private AuthenticateResult? TryAuthenticateRealJwt()
105118
{
106-
// Prefer cookie (browser path); fall back to bearer header (curl / Postman).
107-
if (Request.Cookies.TryGetValue(DevCookieName, out var cookieValue) && !string.IsNullOrEmpty(cookieValue))
108-
{
109-
return new List<string> { cookieValue.Trim() };
110-
}
119+
if (!Request.Headers.TryGetValue("Authorization", out var auth))
120+
return null;
111121

112-
if (Request.Headers.TryGetValue("Authorization", out var auth))
113-
{
114-
var raw = auth.ToString();
122+
var raw = auth.ToString();
115123

116-
const string devPrefix = "Bearer dev:";
117-
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
118-
{
119-
return new List<string> { raw.Substring(devPrefix.Length).Trim() };
120-
}
124+
// Skip dev-prefixed tokens — they are handled by the dev-mode path.
125+
const string devPrefix = "Bearer dev:";
126+
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
127+
return null;
121128

122-
// Fallback: try to decode as a real JWT (e.g. issued by /api/auth/login)
123-
const string bearerPrefix = "Bearer ";
124-
if (raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
125-
{
126-
var token = raw.Substring(bearerPrefix.Length).Trim();
127-
return TryReadRolesFromJwt(token);
128-
}
129-
}
129+
const string bearerPrefix = "Bearer ";
130+
if (!raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
131+
return null;
130132

131-
return null;
132-
}
133+
var token = raw.Substring(bearerPrefix.Length).Trim();
133134

134-
private List<string>? TryReadRolesFromJwt(string token)
135-
{
136135
var opts = _localAuthOptions.Value;
137136
var profiles = new[] { opts.External, opts.Internal };
138137
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
@@ -155,7 +154,7 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
155154
ClockSkew = TimeSpan.FromMinutes(2),
156155
};
157156

158-
ClaimsPrincipal? principal;
157+
ClaimsPrincipal principal;
159158
try
160159
{
161160
principal = handler.ValidateToken(token, parameters, out _);
@@ -166,12 +165,61 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
166165
continue;
167166
}
168167

169-
var roles = principal.FindAll("roles").Select(c => c.Value).ToList();
170-
if (roles.Count > 0)
171-
return roles;
168+
// Extract claims directly from the validated JWT — do NOT remap to dev users.
169+
var sub = principal.FindFirstValue("sub")
170+
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
171+
if (string.IsNullOrEmpty(sub))
172+
continue;
173+
174+
var email = principal.FindFirstValue("email") ?? string.Empty;
175+
var preferredUsername = principal.FindFirstValue("preferred_username") ?? email;
176+
var name = principal.FindFirstValue("name")
177+
?? principal.FindFirstValue(ClaimTypes.Name)
178+
?? preferredUsername;
179+
180+
var claims = new List<Claim>
181+
{
182+
new("sub", sub),
183+
new("oid", sub),
184+
new("preferred_username", preferredUsername),
185+
new("name", name),
186+
new("email", email),
187+
};
188+
claims.AddRange(principal.FindAll("roles").Select(c => new Claim("roles", c.Value)));
189+
190+
var identity = new ClaimsIdentity(claims, SchemeName, "preferred_username", "roles");
191+
var realPrincipal = new ClaimsPrincipal(identity);
192+
var ticket = new AuthenticationTicket(realPrincipal, SchemeName);
193+
return AuthenticateResult.Success(ticket);
194+
}
195+
196+
Logger.LogDebug("No valid real JWT found in DevAuthHandler; falling back to dev-mode auth");
197+
return null;
198+
}
199+
200+
/// <summary>
201+
/// Reads dev-mode credentials from cookie or the <c>Bearer dev:&lt;role&gt;</c> header.
202+
/// Returns <c>null</c> when neither is present.
203+
/// </summary>
204+
private List<string>? ReadDevRoles()
205+
{
206+
// Prefer bearer header (curl / Postman) over cookie.
207+
if (Request.Headers.TryGetValue("Authorization", out var auth))
208+
{
209+
var raw = auth.ToString();
210+
const string devPrefix = "Bearer dev:";
211+
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
212+
{
213+
return new List<string> { raw.Substring(devPrefix.Length).Trim() };
214+
}
215+
}
216+
217+
// Fall back to cookie (browser path).
218+
if (Request.Cookies.TryGetValue(DevCookieName, out var cookieValue) && !string.IsNullOrEmpty(cookieValue))
219+
{
220+
return new List<string> { cookieValue.Trim() };
172221
}
173222

174-
Logger.LogWarning("JWT validation failed for all profiles in DevAuthHandler");
175223
return null;
176224
}
177225
}

backend/src/CCE.Api.External/Endpoints/ProfileEndpoints.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
using CCE.Api.Common.Extensions;
33
using CCE.Application.Common.Interfaces;
44
using CCE.Application.Identity.Auth.Register;
5+
using CCE.Application.Identity.Public.Commands.ConfirmEmailChange;
6+
using CCE.Application.Identity.Public.Commands.ConfirmPhoneChange;
7+
using CCE.Application.Identity.Public.Commands.RequestEmailChange;
8+
using CCE.Application.Identity.Public.Commands.RequestPhoneChange;
59
using CCE.Application.Identity.Public.Commands.SubmitExpertRequest;
610
using CCE.Application.Identity.Public.Commands.UpdateMyProfile;
711
using CCE.Application.Identity.Public.Queries.GetMyExpertStatus;
@@ -100,6 +104,58 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild
100104
})
101105
.WithName("GetMyExpertStatus");
102106

107+
me.MapPost("/email/request-change", async (
108+
RequestEmailChangeRequest body,
109+
ICurrentUserAccessor currentUser,
110+
IMediator mediator, CancellationToken ct) =>
111+
{
112+
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
113+
if (userId == System.Guid.Empty) return Results.Unauthorized();
114+
var result = await mediator.Send(
115+
new RequestEmailChangeCommand(userId, body.NewEmail), ct).ConfigureAwait(false);
116+
return result.ToHttpResult();
117+
})
118+
.WithName("RequestEmailChange");
119+
120+
me.MapPost("/email/confirm-change", async (
121+
ConfirmEmailChangeRequest body,
122+
ICurrentUserAccessor currentUser,
123+
IMediator mediator, CancellationToken ct) =>
124+
{
125+
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
126+
if (userId == System.Guid.Empty) return Results.Unauthorized();
127+
var result = await mediator.Send(
128+
new ConfirmEmailChangeCommand(userId, body.VerificationId, body.Code), ct).ConfigureAwait(false);
129+
return result.ToHttpResult();
130+
})
131+
.WithName("ConfirmEmailChange");
132+
133+
me.MapPost("/phone/request-change", async (
134+
RequestPhoneChangeRequest body,
135+
ICurrentUserAccessor currentUser,
136+
IMediator mediator, CancellationToken ct) =>
137+
{
138+
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
139+
if (userId == System.Guid.Empty) return Results.Unauthorized();
140+
var result = await mediator.Send(
141+
new RequestPhoneChangeCommand(userId, body.NewPhone, body.CountryCodeId), ct).ConfigureAwait(false);
142+
return result.ToHttpResult();
143+
})
144+
.WithName("RequestPhoneChange");
145+
146+
me.MapPost("/phone/confirm-change", async (
147+
ConfirmPhoneChangeRequest body,
148+
ICurrentUserAccessor currentUser,
149+
IMediator mediator, CancellationToken ct) =>
150+
{
151+
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
152+
if (userId == System.Guid.Empty) return Results.Unauthorized();
153+
var result = await mediator.Send(
154+
new ConfirmPhoneChangeCommand(userId, body.VerificationId, body.Code), ct).ConfigureAwait(false);
155+
return result.ToHttpResult();
156+
})
157+
.WithName("ConfirmPhoneChange");
158+
103159
return app;
104160
}
105161
}

backend/src/CCE.Application/DependencyInjection.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services
2626

2727
services.AddScoped<CCE.Application.Common.Errors>();
2828
services.AddScoped<MessageFactory>();
29+
services.AddScoped<CCE.Application.Identity.Public.Commands.ContactChangeOtpService>();
2930

3031
services.AddSingleton<Reports.ICsvStreamWriter, Reports.CsvStreamWriter>();
3132

backend/src/CCE.Application/Identity/IUserRepository.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ public interface IUserRepository
66
{
77
Task<Guid?> FindUserIdByContactAsync(string contact, OtpVerificationType type, CancellationToken ct = default);
88
Task StampConfirmedAsync(Guid userId, OtpVerificationType type, CancellationToken ct = default);
9+
Task<bool> IsContactTakenAsync(string contact, OtpVerificationType type, Guid excludeUserId, CancellationToken ct = default);
910
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using CCE.Application.Common;
2+
using MediatR;
3+
4+
namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;
5+
6+
public sealed record ConfirmEmailChangeCommand(
7+
System.Guid UserId,
8+
System.Guid VerificationId,
9+
string Code) : IRequest<Response<VoidData>>;
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using CCE.Application.Common;
2+
using CCE.Application.Common.Interfaces;
3+
using CCE.Application.Messages;
4+
using CCE.Application.Verification;
5+
using CCE.Domain.Identity;
6+
using MediatR;
7+
using Microsoft.AspNetCore.Identity;
8+
9+
namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;
10+
11+
internal sealed class ConfirmEmailChangeCommandHandler
12+
: IRequestHandler<ConfirmEmailChangeCommand, Response<VoidData>>
13+
{
14+
private readonly IOtpVerificationRepository _otpRepo;
15+
private readonly IUserProfileRepository _userRepo;
16+
private readonly UserManager<User> _userManager;
17+
private readonly ICceDbContext _db;
18+
private readonly MessageFactory _msg;
19+
private readonly IOtpCodeGenerator _codeGenerator;
20+
21+
public ConfirmEmailChangeCommandHandler(
22+
IOtpVerificationRepository otpRepo,
23+
IUserProfileRepository userRepo,
24+
UserManager<User> userManager,
25+
ICceDbContext db,
26+
MessageFactory msg,
27+
IOtpCodeGenerator codeGenerator)
28+
{
29+
_otpRepo = otpRepo;
30+
_userRepo = userRepo;
31+
_userManager = userManager;
32+
_db = db;
33+
_msg = msg;
34+
_codeGenerator = codeGenerator;
35+
}
36+
37+
public async Task<Response<VoidData>> Handle(
38+
ConfirmEmailChangeCommand request, CancellationToken ct)
39+
{
40+
var now = DateTimeOffset.UtcNow;
41+
42+
// WRITE — fetch OTP via repository
43+
var otp = await _otpRepo
44+
.GetByIdAsync(request.VerificationId, ct)
45+
.ConfigureAwait(false);
46+
47+
if (otp is null)
48+
return _msg.OtpNotFound<VoidData>();
49+
50+
if (otp.IsInvalidated)
51+
return _msg.OtpInvalidated<VoidData>();
52+
53+
if (otp.IsExpired(now))
54+
return _msg.OtpExpired<VoidData>();
55+
56+
if (otp.HasExceededMaxAttempts())
57+
return _msg.OtpMaxAttempts<VoidData>();
58+
59+
// Ownership validation — OTP must belong to the authenticated user
60+
if (otp.UserId.HasValue && otp.UserId.Value != request.UserId)
61+
return _msg.Unauthorized<VoidData>("OTP_UNAUTHORIZED");
62+
63+
otp.IncrementAttempt();
64+
65+
if (!_codeGenerator.Verify(request.Code, otp.CodeHash))
66+
{
67+
_otpRepo.Update(otp);
68+
await _db.SaveChangesAsync(ct).ConfigureAwait(false);
69+
return _msg.OtpInvalidCode<VoidData>();
70+
}
71+
72+
// WRITE — fetch user via repository
73+
var user = await _userRepo
74+
.FindAsync(request.UserId, ct)
75+
.ConfigureAwait(false);
76+
77+
if (user is null)
78+
return _msg.UserNotFound<VoidData>();
79+
80+
// Use UserManager to ensure NormalizedEmail and SecurityStamp are properly updated
81+
var setEmailResult = await _userManager.SetEmailAsync(user, otp.Contact).ConfigureAwait(false);
82+
if (!setEmailResult.Succeeded)
83+
return _msg.BusinessRule<VoidData>("EMAIL_CHANGE_FAILED");
84+
85+
// Update UserName to match the new email
86+
var setUserNameResult = await _userManager.SetUserNameAsync(user, otp.Contact).ConfigureAwait(false);
87+
if (!setUserNameResult.Succeeded)
88+
return _msg.BusinessRule<VoidData>("EMAIL_CHANGE_FAILED");
89+
90+
// domain methods
91+
otp.MarkVerified();
92+
otp.Invalidate();
93+
94+
_otpRepo.Update(otp);
95+
96+
// ICceDbContext as unit of work
97+
await _db.SaveChangesAsync(ct).ConfigureAwait(false);
98+
99+
return _msg.EmailUpdated();
100+
}
101+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using FluentValidation;
2+
3+
namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;
4+
5+
public sealed class ConfirmEmailChangeCommandValidator : AbstractValidator<ConfirmEmailChangeCommand>
6+
{
7+
public ConfirmEmailChangeCommandValidator()
8+
{
9+
RuleFor(x => x.UserId).NotEmpty();
10+
RuleFor(x => x.VerificationId).NotEmpty();
11+
RuleFor(x => x.Code).NotEmpty().Length(6).Matches(@"^\d{6}$");
12+
}
13+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;
2+
3+
public sealed record ConfirmEmailChangeRequest(System.Guid VerificationId, string Code);
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
using CCE.Application.Common;
2+
using MediatR;
3+
4+
namespace CCE.Application.Identity.Public.Commands.ConfirmPhoneChange;
5+
6+
public sealed record ConfirmPhoneChangeCommand(
7+
System.Guid UserId,
8+
System.Guid VerificationId,
9+
string Code) : IRequest<Response<VoidData>>;

0 commit comments

Comments
 (0)