feat/Update_Controllers_to_Allow_for_Premium_Membership
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user