feat/Update_Controllers_to_Allow_for_Premium_Membership

This commit is contained in:
cesnimda
2026-08-03 09:17:28 +02:00
parent de937d25dc
commit c3f4a57195
187 changed files with 26062 additions and 991 deletions
+319 -59
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Security.Claims;
using System.ComponentModel.DataAnnotations;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
@@ -26,8 +27,9 @@ public sealed class AuthController : ControllerBase
private readonly JobTrackerContext _db;
private readonly string _avatarDataRoot;
private readonly IHttpClientFactory? _httpClients;
private readonly ExternalOrigin _externalOrigin;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null)
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null, ExternalOrigin? externalOrigin = null)
{
_cfg = cfg;
_users = users;
@@ -39,6 +41,7 @@ public sealed class AuthController : ControllerBase
_twoFactorPending = twoFactorPending;
_db = db;
_httpClients = httpClients;
_externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg);
_avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
}
@@ -70,6 +73,7 @@ public sealed class AuthController : ControllerBase
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record RegistrationPendingResult(bool VerificationRequired);
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
public sealed record MicrosoftLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
@@ -89,6 +93,7 @@ public sealed class AuthController : ControllerBase
AccountEntitlements Entitlements,
GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink);
public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc);
private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase)
{
@@ -96,7 +101,10 @@ public sealed class AuthController : ControllerBase
};
public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson);
public sealed record GoogleTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true);
public sealed record MicrosoftTokenRequest(string Token, bool RememberMe = true, string? CurrentPassword = null);
public sealed record MicrosoftLegacyRelinkRequiredResult(bool LegacyRelinkRequired);
public sealed record ConfirmMicrosoftLegacyRelinkRequest(string UserId, string TenantId, string ObjectId, string RecoveryToken, string MicrosoftToken);
public sealed record MicrosoftUnlinkRequest(string CurrentPassword);
[HttpPost("login")]
[AllowAnonymous]
@@ -175,6 +183,8 @@ public sealed class AuthController : ControllerBase
// created either way, the user can request a fresh link via resend-verification-email.
_logger.LogError(ex, "Failed to send verification email to {Email}", user.Email);
}
return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
}
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
@@ -295,22 +305,50 @@ public sealed class AuthController : ControllerBase
return Unauthorized(ex.Message);
}
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
return Unauthorized("Microsoft token is missing its stable identity.");
var user = await _users.Users.FirstOrDefaultAsync(
x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email),
x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId,
cancellationToken);
if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email))
if (user is null)
{
user = await _users.FindByEmailAsync(microsoft.Email);
if (user is not null)
var legacyCandidates = await _users.Users
.Where(x => x.MicrosoftTenantId == null && x.MicrosoftObjectId == null)
.Where(x => x.MicrosoftSubject == objectId || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email))
.ToListAsync(cancellationToken);
if (legacyCandidates.Count > 1)
return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");
if (legacyCandidates.Count == 1)
{
_logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email);
var legacy = legacyCandidates[0];
if (!legacy.EmailConfirmed || string.IsNullOrWhiteSpace(legacy.Email))
return Conflict("This legacy Microsoft link requires administrator-assisted recovery.");
var purpose = MicrosoftLegacyRelinkPurpose(tenantId, objectId);
var recoveryToken = await _users.GenerateUserTokenAsync(legacy, TokenOptions.DefaultProvider, purpose);
var link = _externalOrigin.BuildPath($"/microsoft-legacy-relink?userId={Uri.EscapeDataString(legacy.Id)}&tenantId={Uri.EscapeDataString(tenantId)}&objectId={Uri.EscapeDataString(objectId)}&token={Uri.EscapeDataString(recoveryToken)}");
try
{
await _email.SendAsync(
legacy.Email,
"Confirm your Microsoft account relink",
$"A tenant-qualified Microsoft account requested access to your Jobbjakt account. If this was you, open the link and authenticate with the same Microsoft account:\n\n{link}\n\nIf this was not you, ignore this email.",
cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send Microsoft legacy-relink proof");
return EmailDeliveryUnavailable("Microsoft account recovery email could not be sent right now. Please try again later.");
}
return Accepted(new MicrosoftLegacyRelinkRequiredResult(true));
}
}
if (user is null)
{
if (!microsoft.EmailVerified || string.IsNullOrWhiteSpace(microsoft.Email))
if (string.IsNullOrWhiteSpace(microsoft.Email))
{
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
@@ -321,36 +359,95 @@ public sealed class AuthController : ControllerBase
return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet.");
}
user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.Email, EmailConfirmed = true };
if (await _users.FindByEmailAsync(microsoft.Email) is not null)
return Conflict("Sign in to the existing Jobbjakt account before linking Microsoft.");
user = new ApplicationUser
{
UserName = microsoft.Email,
Email = microsoft.Email,
EmailConfirmed = false,
MicrosoftTenantId = tenantId,
MicrosoftObjectId = objectId,
MicrosoftEmail = microsoft.Email,
MicrosoftLinkedAt = DateTimeOffset.UtcNow,
DisplayName = TrimOrNull(microsoft.Name),
FirstName = TrimOrNull(microsoft.GivenName),
LastName = TrimOrNull(microsoft.FamilyName),
};
var created = await _users.CreateAsync(user);
if (!created.Succeeded)
{
return BadRequest(string.Join("; ", created.Errors.Select(e => e.Description)));
}
_logger.LogInformation("Created new user via Microsoft sign-up for {Email}", microsoft.Email);
_logger.LogInformation("Created a new tenant-qualified Microsoft user");
if (_cfg.GetValue("Auth:RequireEmailVerification", false))
{
try { await SendVerificationEmailAsync(user, cancellationToken); }
catch (Exception ex) { _logger.LogError(ex, "Failed to send verification email for Microsoft registration"); }
return StatusCode(StatusCodes.Status202Accepted, new RegistrationPendingResult(true));
}
}
if (string.IsNullOrWhiteSpace(user.MicrosoftSubject) || !string.Equals(user.MicrosoftSubject, microsoft.Subject, StringComparison.Ordinal))
{
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
await _users.UpdateAsync(user);
}
if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed)
return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt ??= DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
user.FirstName ??= TrimOrNull(microsoft.GivenName);
user.LastName ??= TrimOrNull(microsoft.FamilyName);
var metadataUpdate = await _users.UpdateAsync(user);
if (!metadataUpdate.Succeeded) return BadRequest(string.Join("; ", metadataUpdate.Errors.Select(x => x.Description)));
return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken);
}
[HttpPost("microsoft/legacy-relink/confirm")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ConfirmMicrosoftLegacyRelink([FromBody] ConfirmMicrosoftLegacyRelinkRequest request, CancellationToken cancellationToken)
{
MicrosoftTokenPrincipal microsoft;
try { microsoft = await _microsoftTokens.ValidateAsync(request.MicrosoftToken, cancellationToken); }
catch (Exception ex) { return BadRequest(ex.Message); }
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId)
|| !string.Equals(tenantId, request.TenantId, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(objectId, request.ObjectId, StringComparison.OrdinalIgnoreCase))
return BadRequest("Microsoft identity does not match this recovery link.");
var user = await _users.FindByIdAsync(request.UserId);
if (user is null || user.MicrosoftTenantId is not null || user.MicrosoftObjectId is not null)
return BadRequest("Invalid or expired recovery link.");
if (!await _users.VerifyUserTokenAsync(user, TokenOptions.DefaultProvider, MicrosoftLegacyRelinkPurpose(tenantId, objectId), request.RecoveryToken))
return BadRequest("Invalid or expired recovery link.");
if (await _users.Users.AnyAsync(x => x.Id != user.Id && x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken))
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
user.MicrosoftTenantId = tenantId;
user.MicrosoftObjectId = objectId;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return NoContent();
}
[HttpPost("logout")]
// Anonymous on purpose, and now explicitly: this only clears the caller's own session cookies and
// leaks nothing. Requiring authentication would mean a user whose token has already expired gets a
// 401 when signing out and stays stuck in a half-signed-in state.
[AllowAnonymous]
public IActionResult Logout()
public async Task<IActionResult> Logout(CancellationToken cancellationToken)
{
var cookieToken = Request.Cookies[AuthSessionOptions.SessionCookieName];
if (SessionRevocation.TryReadIdentity(User, cookieToken, out var userId, out var sessionId))
await SessionRevocation.RevokeCurrentAsync(_db, userId, sessionId, cancellationToken);
ClearSessionCookies();
return NoContent();
}
@@ -417,12 +514,8 @@ public sealed class AuthController : ControllerBase
// - "value" -> set (trimmed)
// This lets /profile save identity fields and /career save the master-profile fields
// through the same endpoint without one wiping the other. Email and UserName are the
// login identifiers and are never cleared to empty.
if (request.Email is not null)
{
var v = request.Email.Trim();
if (v.Length > 0) user.Email = v;
}
// login identifiers and are never cleared to empty. Email ownership changes use the
// separate, token-confirmed email-change flow below.
if (request.UserName is not null)
{
var v = request.UserName.Trim();
@@ -441,6 +534,145 @@ public sealed class AuthController : ControllerBase
return NoContent();
}
public sealed record RequestEmailChangeRequest(string Email, string CurrentPassword);
public sealed record ConfirmEmailChangeRequest(string UserId, string Email, string Token);
public sealed record CancelEmailChangeRequest(string CurrentPassword);
[HttpGet("email-change")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<PendingEmailChangeResult>> GetEmailChange()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
return Ok(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
}
[HttpPost("email-change/request")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> RequestEmailChange([FromBody] RequestEmailChangeRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!user.EmailConfirmed) return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" });
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("Current password is incorrect.");
var newEmail = (request.Email ?? string.Empty).Trim();
if (newEmail.Length > 320 || !new EmailAddressAttribute().IsValid(newEmail)) return BadRequest("A valid email is required.");
if (string.Equals(_users.NormalizeEmail(newEmail), _users.NormalizeEmail(user.Email), StringComparison.Ordinal)) return BadRequest("The new email must be different.");
var existing = await _users.FindByEmailAsync(newEmail);
if (existing is not null && !string.Equals(existing.Id, user.Id, StringComparison.Ordinal)) return BadRequest("Email is already in use.");
user.PendingEmail = newEmail;
user.PendingEmailRequestedAtUtc = DateTimeOffset.UtcNow;
user.SecurityStamp = Guid.NewGuid().ToString();
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
var token = await _users.GenerateChangeEmailTokenAsync(user, newEmail);
var link = _externalOrigin.BuildPath($"/confirm-email-change?userId={Uri.EscapeDataString(user.Id)}&email={Uri.EscapeDataString(newEmail)}&token={Uri.EscapeDataString(token)}");
try
{
await _email.SendAsync(newEmail, "Confirm your new email", $"Confirm this email address for your Jobbjakt account:\n\n{link}\n\nIf you did not request this change, ignore this email.", cancellationToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send an email-change confirmation");
return EmailDeliveryUnavailable("The confirmation email could not be sent right now. Please try again later.");
}
if (!string.IsNullOrWhiteSpace(user.Email))
{
try
{
await _email.SendAsync(user.Email, "Email change requested", "A change to the email address on your Jobbjakt account was requested. Your current email remains active until the new address is confirmed.", cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send the current-address email-change notice");
}
}
return Accepted(new PendingEmailChangeResult(user.PendingEmail, user.PendingEmailRequestedAtUtc));
}
[HttpPost("email-change/confirm")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ConfirmEmailChange([FromBody] ConfirmEmailChangeRequest request, CancellationToken cancellationToken)
{
var userId = (request.UserId ?? string.Empty).Trim();
var newEmail = (request.Email ?? string.Empty).Trim();
var token = request.Token ?? string.Empty;
if (userId.Length == 0 || newEmail.Length == 0 || token.Length == 0) return BadRequest("Invalid or expired link.");
var user = await _users.FindByIdAsync(userId);
if (user is null || !string.Equals(_users.NormalizeEmail(user.PendingEmail), _users.NormalizeEmail(newEmail), StringComparison.Ordinal))
return BadRequest("Invalid or expired link.");
var oldEmail = user.Email;
var updateUserName = string.Equals(_users.NormalizeName(user.UserName), _users.NormalizeEmail(oldEmail), StringComparison.Ordinal);
var transaction = _db.Database.IsRelational() ? await _db.Database.BeginTransactionAsync(cancellationToken) : null;
try
{
var changed = await _users.ChangeEmailAsync(user, newEmail, token);
if (!changed.Succeeded) return BadRequest("Invalid or expired link.");
if (updateUserName)
{
var renamed = await _users.SetUserNameAsync(user, newEmail);
if (!renamed.Succeeded) return BadRequest(string.Join("; ", renamed.Errors.Select(x => x.Description)));
}
user.PendingEmail = null;
user.PendingEmailRequestedAtUtc = null;
var cleared = await _users.UpdateAsync(user);
if (!cleared.Succeeded) return BadRequest(string.Join("; ", cleared.Errors.Select(x => x.Description)));
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
finally
{
if (transaction is not null) await transaction.DisposeAsync();
}
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
if (!string.IsNullOrWhiteSpace(oldEmail))
{
try
{
await _email.SendAsync(oldEmail, "Your Jobbjakt email changed", "The email address on your Jobbjakt account was changed. If this was not you, reset your password and contact the administrator.", cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send the old-address email-change notice");
}
}
return NoContent();
}
[HttpPost("email-change/cancel")]
[Authorize(AuthenticationSchemes = "local")]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> CancelEmailChange([FromBody] CancelEmailChangeRequest request)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("Current password is incorrect.");
user.PendingEmail = null;
user.PendingEmailRequestedAtUtc = null;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded) return BadRequest(string.Join("; ", update.Errors.Select(x => x.Description)));
return NoContent();
}
[HttpPost("google/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<GoogleLinkDto>> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken)
@@ -534,15 +766,21 @@ public sealed class AuthController : ControllerBase
return BadRequest(ex.Message);
}
if (!TryGetMicrosoftKey(microsoft, out var tenantId, out var objectId))
return BadRequest("Microsoft token is missing its stable identity.");
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("Current password is required to link Microsoft.");
var conflict = await _users.Users
.Where(x => x.Id != user.Id)
.FirstOrDefaultAsync(x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken);
.FirstOrDefaultAsync(x => x.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken);
if (conflict is not null)
{
return Conflict("That Microsoft account is already linked to another Jobbjakt user.");
}
user.MicrosoftSubject = microsoft.Subject;
user.MicrosoftTenantId = tenantId;
user.MicrosoftObjectId = objectId;
user.MicrosoftEmail = microsoft.Email;
user.MicrosoftLinkedAt = DateTimeOffset.UtcNow;
user.DisplayName ??= TrimOrNull(microsoft.Name);
@@ -555,12 +793,16 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt));
}
[HttpDelete("microsoft/link")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> UnlinkMicrosoft()
public async Task<IActionResult> UnlinkMicrosoft([FromBody] MicrosoftUnlinkRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
@@ -568,7 +810,12 @@ public sealed class AuthController : ControllerBase
return Unauthorized();
}
if (!await _users.HasPasswordAsync(user) || !await _users.CheckPasswordAsync(user, request.CurrentPassword ?? string.Empty))
return BadRequest("A current password is required before unlinking Microsoft.");
user.MicrosoftSubject = null;
user.MicrosoftTenantId = null;
user.MicrosoftObjectId = null;
user.MicrosoftEmail = null;
user.MicrosoftLinkedAt = null;
@@ -578,6 +825,10 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
ClearSessionCookies();
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return NoContent();
}
@@ -656,7 +907,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("change-password")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request)
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null)
@@ -671,6 +922,10 @@ public sealed class AuthController : ControllerBase
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
var currentTrustedDevice = TrustedDeviceService.CurrentDeviceTokenHash(Request);
await SessionRevocation.RevokeAllAsync(_db, user.Id, currentTrustedDevice, cancellationToken);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, false, _externalOrigin.UsesHttps, cancellationToken);
return NoContent();
}
@@ -685,20 +940,14 @@ public sealed class AuthController : ControllerBase
if (email.Length == 0) return NoContent();
var user = await _users.FindByEmailAsync(email);
if (user is null || string.IsNullOrWhiteSpace(user.Email))
if (user is null || string.IsNullOrWhiteSpace(user.Email) || !user.EmailConfirmed || !await _users.HasPasswordAsync(user))
{
return NoContent();
}
var token = await _users.GeneratePasswordResetTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}";
var link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}");
try
{
@@ -723,7 +972,7 @@ public sealed class AuthController : ControllerBase
[HttpPost("reset-password")]
[AllowAnonymous]
[EnableRateLimiting("auth-email")]
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request)
public async Task<IActionResult> ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken cancellationToken)
{
var email = (request.Email ?? string.Empty).Trim();
var token = request.Token ?? string.Empty;
@@ -740,6 +989,14 @@ public sealed class AuthController : ControllerBase
if (!res.Succeeded)
return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description)));
await SessionRevocation.RevokeAllAsync(_db, user.Id, trustedDeviceHashToKeep: null, cancellationToken);
if (SessionRevocation.TryReadIdentity(User, Request.Cookies[AuthSessionOptions.SessionCookieName], out var currentUserId, out _)
&& string.Equals(currentUserId, user.Id, StringComparison.Ordinal))
{
ClearSessionCookies();
}
TrustedDeviceService.ClearCookie(Response, _externalOrigin.UsesHttps);
return NoContent();
}
@@ -803,13 +1060,7 @@ public sealed class AuthController : ControllerBase
{
var token = await _users.GenerateEmailConfirmationTokenAsync(user);
var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
{
baseUrl = $"{Request.Scheme}://{Request.Host}";
}
var link = $"{baseUrl}/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}";
var link = _externalOrigin.BuildPath($"/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}");
await _email.SendAsync(
user.Email!,
@@ -837,32 +1088,30 @@ public sealed class AuthController : ControllerBase
// (no cookie, wrong user, expired, revoked) -- never errors, just doesn't skip.
if (user.TwoFactorEnabled && await TrustedDeviceService.IsDeviceTrustedAsync(_db, Request, user.Id, cancellationToken))
{
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
return Ok(new AuthSessionResult(true, provider));
}
if (user.TwoFactorEnabled)
{
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe);
var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe, user.SecurityStamp);
return Ok(new TwoFactorRequiredResult(true, pendingToken));
}
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken);
await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, _externalOrigin.UsesHttps, cancellationToken);
return Ok(new AuthSessionResult(true, provider));
}
private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null)
private void EnsureCsrfCookie(bool persistent)
{
var secure = secureOverride ?? Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, secure));
Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, _externalOrigin.UsesHttps));
}
private void ClearSessionCookies()
{
var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase);
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(secure));
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(secure));
Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps));
Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(_externalOrigin.UsesHttps));
}
private static string? DetectAvatarContentType(byte[] bytes)
@@ -909,9 +1158,20 @@ public sealed class AuthController : ControllerBase
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
private static bool TryGetMicrosoftKey(MicrosoftTokenPrincipal principal, out string tenantId, out string objectId)
{
tenantId = Guid.TryParse(principal.TenantId, out var tenant) ? tenant.ToString("D") : string.Empty;
objectId = Guid.TryParse(principal.ObjectId, out var obj) ? obj.ToString("D") : string.Empty;
return tenantId.Length > 0 && objectId.Length > 0;
}
private static string MicrosoftLegacyRelinkPurpose(string tenantId, string objectId)
=> $"microsoft-legacy-relink:{tenantId}:{objectId}";
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
{
var entitlements = AccountPlans.ForRoles(roles);
var planEntitlements = AccountPlans.ForRoles(roles);
var entitlements = planEntitlements with { Ai = planEntitlements.Ai && user.AiEnabled };
return new MeResult(
Provider: "local",
Id: user.Id,
@@ -924,14 +1184,14 @@ public sealed class AuthController : ControllerBase
ProfileCvStructureJson: user.ProfileCvStructureJson,
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
Roles: roles,
Plan: entitlements.AdvancedAi ? "premium" : "free",
Plan: AccountPlans.Name(planEntitlements),
Entitlements: entitlements,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
LinkedAt: user.GoogleLinkedAt),
MicrosoftLink: new MicrosoftLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject),
Linked: !string.IsNullOrWhiteSpace(user.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId),
Email: user.MicrosoftEmail,
LinkedAt: user.MicrosoftLinkedAt));
}