Skip to content
Merged
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
112 changes: 80 additions & 32 deletions backend/src/CCE.Api.Common/Auth/DevAuthHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,15 @@ public DevAuthHandler(

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var roles = ReadRoles();
// PRIORITY 1: If the request carries a real JWT (e.g. from /api/auth/login),
// authenticate as the real user and skip dev-mode entirely.
var realJwtResult = TryAuthenticateRealJwt();
if (realJwtResult is not null)
return Task.FromResult(realJwtResult);

// PRIORITY 2: Dev-mode auth — cookie or dev-prefixed bearer header.
// Only reached when no valid real JWT is present.
var roles = ReadDevRoles();
if (roles is null || roles.Count == 0)
{
return Task.FromResult(AuthenticateResult.NoResult());
Expand Down Expand Up @@ -101,38 +109,29 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
return Task.FromResult(AuthenticateResult.Success(ticket));
}

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

if (Request.Headers.TryGetValue("Authorization", out var auth))
{
var raw = auth.ToString();
var raw = auth.ToString();

const string devPrefix = "Bearer dev:";
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
{
return new List<string> { raw.Substring(devPrefix.Length).Trim() };
}
// Skip dev-prefixed tokens — they are handled by the dev-mode path.
const string devPrefix = "Bearer dev:";
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
return null;

// Fallback: try to decode as a real JWT (e.g. issued by /api/auth/login)
const string bearerPrefix = "Bearer ";
if (raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
{
var token = raw.Substring(bearerPrefix.Length).Trim();
return TryReadRolesFromJwt(token);
}
}
const string bearerPrefix = "Bearer ";
if (!raw.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
return null;

return null;
}
var token = raw.Substring(bearerPrefix.Length).Trim();

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

ClaimsPrincipal? principal;
ClaimsPrincipal principal;
try
{
principal = handler.ValidateToken(token, parameters, out _);
Expand All @@ -166,12 +165,61 @@ protected override Task<AuthenticateResult> HandleAuthenticateAsync()
continue;
}

var roles = principal.FindAll("roles").Select(c => c.Value).ToList();
if (roles.Count > 0)
return roles;
// Extract claims directly from the validated JWT — do NOT remap to dev users.
var sub = principal.FindFirstValue("sub")
?? principal.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrEmpty(sub))
continue;

var email = principal.FindFirstValue("email") ?? string.Empty;
var preferredUsername = principal.FindFirstValue("preferred_username") ?? email;
var name = principal.FindFirstValue("name")
?? principal.FindFirstValue(ClaimTypes.Name)
?? preferredUsername;

var claims = new List<Claim>
{
new("sub", sub),
new("oid", sub),
new("preferred_username", preferredUsername),
new("name", name),
new("email", email),
};
claims.AddRange(principal.FindAll("roles").Select(c => new Claim("roles", c.Value)));

var identity = new ClaimsIdentity(claims, SchemeName, "preferred_username", "roles");
var realPrincipal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(realPrincipal, SchemeName);
return AuthenticateResult.Success(ticket);
}

Logger.LogDebug("No valid real JWT found in DevAuthHandler; falling back to dev-mode auth");
return null;
}

/// <summary>
/// Reads dev-mode credentials from cookie or the <c>Bearer dev:&lt;role&gt;</c> header.
/// Returns <c>null</c> when neither is present.
/// </summary>
private List<string>? ReadDevRoles()
{
// Prefer bearer header (curl / Postman) over cookie.
if (Request.Headers.TryGetValue("Authorization", out var auth))
{
var raw = auth.ToString();
const string devPrefix = "Bearer dev:";
if (raw.StartsWith(devPrefix, StringComparison.OrdinalIgnoreCase))
{
return new List<string> { raw.Substring(devPrefix.Length).Trim() };
}
}

// Fall back to cookie (browser path).
if (Request.Cookies.TryGetValue(DevCookieName, out var cookieValue) && !string.IsNullOrEmpty(cookieValue))
{
return new List<string> { cookieValue.Trim() };
}

Logger.LogWarning("JWT validation failed for all profiles in DevAuthHandler");
return null;
}
}
56 changes: 56 additions & 0 deletions backend/src/CCE.Api.External/Endpoints/ProfileEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
using CCE.Api.Common.Extensions;
using CCE.Application.Common.Interfaces;
using CCE.Application.Identity.Auth.Register;
using CCE.Application.Identity.Public.Commands.ConfirmEmailChange;
using CCE.Application.Identity.Public.Commands.ConfirmPhoneChange;
using CCE.Application.Identity.Public.Commands.RequestEmailChange;
using CCE.Application.Identity.Public.Commands.RequestPhoneChange;
using CCE.Application.Identity.Public.Commands.SubmitExpertRequest;
using CCE.Application.Identity.Public.Commands.UpdateMyProfile;
using CCE.Application.Identity.Public.Queries.GetMyExpertStatus;
Expand Down Expand Up @@ -100,6 +104,58 @@ public static IEndpointRouteBuilder MapProfileEndpoints(this IEndpointRouteBuild
})
.WithName("GetMyExpertStatus");

me.MapPost("/email/request-change", async (
RequestEmailChangeRequest body,
ICurrentUserAccessor currentUser,
IMediator mediator, CancellationToken ct) =>
{
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
if (userId == System.Guid.Empty) return Results.Unauthorized();
var result = await mediator.Send(
new RequestEmailChangeCommand(userId, body.NewEmail), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("RequestEmailChange");

me.MapPost("/email/confirm-change", async (
ConfirmEmailChangeRequest body,
ICurrentUserAccessor currentUser,
IMediator mediator, CancellationToken ct) =>
{
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
if (userId == System.Guid.Empty) return Results.Unauthorized();
var result = await mediator.Send(
new ConfirmEmailChangeCommand(userId, body.VerificationId, body.Code), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("ConfirmEmailChange");

me.MapPost("/phone/request-change", async (
RequestPhoneChangeRequest body,
ICurrentUserAccessor currentUser,
IMediator mediator, CancellationToken ct) =>
{
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
if (userId == System.Guid.Empty) return Results.Unauthorized();
var result = await mediator.Send(
new RequestPhoneChangeCommand(userId, body.NewPhone, body.CountryCodeId), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("RequestPhoneChange");

me.MapPost("/phone/confirm-change", async (
ConfirmPhoneChangeRequest body,
ICurrentUserAccessor currentUser,
IMediator mediator, CancellationToken ct) =>
{
var userId = currentUser.GetUserId() ?? System.Guid.Empty;
if (userId == System.Guid.Empty) return Results.Unauthorized();
var result = await mediator.Send(
new ConfirmPhoneChangeCommand(userId, body.VerificationId, body.Code), ct).ConfigureAwait(false);
return result.ToHttpResult();
})
.WithName("ConfirmPhoneChange");

return app;
}
}
1 change: 1 addition & 0 deletions backend/src/CCE.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ public static IServiceCollection AddApplication(this IServiceCollection services

services.AddScoped<CCE.Application.Common.Errors>();
services.AddScoped<MessageFactory>();
services.AddScoped<CCE.Application.Identity.Public.Commands.ContactChangeOtpService>();

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

Expand Down
1 change: 1 addition & 0 deletions backend/src/CCE.Application/Identity/IUserRepository.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ public interface IUserRepository
{
Task<Guid?> FindUserIdByContactAsync(string contact, OtpVerificationType type, CancellationToken ct = default);
Task StampConfirmedAsync(Guid userId, OtpVerificationType type, CancellationToken ct = default);
Task<bool> IsContactTakenAsync(string contact, OtpVerificationType type, Guid excludeUserId, CancellationToken ct = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using CCE.Application.Common;
using MediatR;

namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;

public sealed record ConfirmEmailChangeCommand(
System.Guid UserId,
System.Guid VerificationId,
string Code) : IRequest<Response<VoidData>>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
using CCE.Application.Common;
using CCE.Application.Common.Interfaces;
using CCE.Application.Messages;
using CCE.Application.Verification;
using CCE.Domain.Identity;
using MediatR;
using Microsoft.AspNetCore.Identity;

namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;

internal sealed class ConfirmEmailChangeCommandHandler
: IRequestHandler<ConfirmEmailChangeCommand, Response<VoidData>>
{
private readonly IOtpVerificationRepository _otpRepo;
private readonly IUserProfileRepository _userRepo;
private readonly UserManager<User> _userManager;
private readonly ICceDbContext _db;
private readonly MessageFactory _msg;
private readonly IOtpCodeGenerator _codeGenerator;

public ConfirmEmailChangeCommandHandler(
IOtpVerificationRepository otpRepo,
IUserProfileRepository userRepo,
UserManager<User> userManager,
ICceDbContext db,
MessageFactory msg,
IOtpCodeGenerator codeGenerator)
{
_otpRepo = otpRepo;
_userRepo = userRepo;
_userManager = userManager;
_db = db;
_msg = msg;
_codeGenerator = codeGenerator;
}

public async Task<Response<VoidData>> Handle(
ConfirmEmailChangeCommand request, CancellationToken ct)
{
var now = DateTimeOffset.UtcNow;

// WRITE — fetch OTP via repository
var otp = await _otpRepo
.GetByIdAsync(request.VerificationId, ct)
.ConfigureAwait(false);

if (otp is null)
return _msg.OtpNotFound<VoidData>();

if (otp.IsInvalidated)
return _msg.OtpInvalidated<VoidData>();

if (otp.IsExpired(now))
return _msg.OtpExpired<VoidData>();

if (otp.HasExceededMaxAttempts())
return _msg.OtpMaxAttempts<VoidData>();

// Ownership validation — OTP must belong to the authenticated user
if (otp.UserId.HasValue && otp.UserId.Value != request.UserId)
return _msg.Unauthorized<VoidData>("OTP_UNAUTHORIZED");

otp.IncrementAttempt();

if (!_codeGenerator.Verify(request.Code, otp.CodeHash))
{
_otpRepo.Update(otp);
await _db.SaveChangesAsync(ct).ConfigureAwait(false);
return _msg.OtpInvalidCode<VoidData>();
}

// WRITE — fetch user via repository
var user = await _userRepo
.FindAsync(request.UserId, ct)
.ConfigureAwait(false);

if (user is null)
return _msg.UserNotFound<VoidData>();

// Use UserManager to ensure NormalizedEmail and SecurityStamp are properly updated
var setEmailResult = await _userManager.SetEmailAsync(user, otp.Contact).ConfigureAwait(false);
if (!setEmailResult.Succeeded)
return _msg.BusinessRule<VoidData>("EMAIL_CHANGE_FAILED");

// Update UserName to match the new email
var setUserNameResult = await _userManager.SetUserNameAsync(user, otp.Contact).ConfigureAwait(false);
if (!setUserNameResult.Succeeded)
return _msg.BusinessRule<VoidData>("EMAIL_CHANGE_FAILED");

// domain methods
otp.MarkVerified();
otp.Invalidate();

_otpRepo.Update(otp);

// ICceDbContext as unit of work
await _db.SaveChangesAsync(ct).ConfigureAwait(false);

return _msg.EmailUpdated();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
using FluentValidation;

namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;

public sealed class ConfirmEmailChangeCommandValidator : AbstractValidator<ConfirmEmailChangeCommand>
{
public ConfirmEmailChangeCommandValidator()
{
RuleFor(x => x.UserId).NotEmpty();
RuleFor(x => x.VerificationId).NotEmpty();
RuleFor(x => x.Code).NotEmpty().Length(6).Matches(@"^\d{6}$");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace CCE.Application.Identity.Public.Commands.ConfirmEmailChange;

public sealed record ConfirmEmailChangeRequest(System.Guid VerificationId, string Code);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using CCE.Application.Common;
using MediatR;

namespace CCE.Application.Identity.Public.Commands.ConfirmPhoneChange;

public sealed record ConfirmPhoneChangeCommand(
System.Guid UserId,
System.Guid VerificationId,
string Code) : IRequest<Response<VoidData>>;
Loading