using System.Text.Json; using System.Security.Claims; using System.ComponentModel.DataAnnotations; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/auth")] public sealed class AuthController : ControllerBase { private readonly IConfiguration _cfg; private readonly UserManager _users; private readonly ITokenService _tokens; private readonly IAppEmailSender _email; private readonly IGoogleTokenValidator _googleTokens; private readonly IMicrosoftTokenValidator _microsoftTokens; private readonly ILogger _logger; private readonly ITwoFactorPendingTokenService _twoFactorPending; private readonly JobTrackerContext _db; private readonly string _avatarDataRoot; private readonly IHttpClientFactory? _httpClients; private readonly ExternalOrigin _externalOrigin; public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null, ExternalOrigin? externalOrigin = null) { _cfg = cfg; _users = users; _tokens = tokens; _email = email; _googleTokens = googleTokens; _microsoftTokens = microsoftTokens; _logger = logger; _twoFactorPending = twoFactorPending; _db = db; _httpClients = httpClients; _externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg); _avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim()); } [HttpGet("config")] [AllowAnonymous] public IActionResult Config() { var requireAuth = _cfg.GetValue("Auth:Require", false); var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()); var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim()); var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false); var turnstileSiteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim(); var turnstileEnabled = turnstileSiteKey.Length > 0 && !string.IsNullOrWhiteSpace(_cfg["Turnstile:SecretKey"]); return Ok(new { requireAuth, googleEnabled, microsoftEnabled, localEnabled = true, allowRegistration, requireEmailVerification, turnstileEnabled, turnstileSiteKey = turnstileEnabled ? turnstileSiteKey : null, }); } 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); public sealed record MeResult( string Provider, string? Id, string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson, string? AvatarImageDataUrl, IList Roles, string Plan, AccountEntitlements Entitlements, GoogleLinkDto? GoogleLink, MicrosoftLinkDto? MicrosoftLink) { public string AppVersion { get; init; } = "unknown"; public string? AppCommitSha { get; init; } } public sealed record PendingEmailChangeResult(string? PendingEmail, DateTimeOffset? RequestedAtUtc); private const int MaxAvatarBytes = 1_000_000; private static readonly HashSet AllowedAvatarExtensions = new(StringComparer.OrdinalIgnoreCase) { ".png", ".jpg", ".jpeg", ".webp" }; 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, 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] [EnableRateLimiting("auth-login")] public async Task Login([FromBody] LoginRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? string.Empty).Trim(); var password = request.Password ?? string.Empty; if (email.Length == 0) return BadRequest("Email is required."); if (password.Length == 0) return BadRequest("Password is required."); if (!await VerifyTurnstileAsync(request.TurnstileToken, "login", cancellationToken)) return BadRequest("Security verification failed. Please try again."); var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email); if (user is null) return Unauthorized(); // Same generic 401 whether the account doesn't exist, is locked out, or the password is // wrong -- don't let a client distinguish "locked" from "wrong password" (enumeration). if (await _users.IsLockedOutAsync(user)) return Unauthorized(); var ok = await _users.CheckPasswordAsync(user, password); if (!ok) { await _users.AccessFailedAsync(user); return Unauthorized(); } await _users.ResetAccessFailedCountAsync(user); // Same enumeration-avoidance discipline as the password-check branch above: this only // runs once the password is already confirmed correct, so it can never be used to probe // whether an email is registered. if (_cfg.GetValue("Auth:RequireEmailVerification", false) && !user.EmailConfirmed) { return StatusCode(StatusCodes.Status403Forbidden, new { error = "email_not_verified" }); } return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken); } [HttpPost("register")] [AllowAnonymous] [EnableRateLimiting("auth-login")] public async Task Register([FromBody] RegisterRequest request, CancellationToken cancellationToken) { var allow = _cfg.GetValue("Auth:AllowRegistration", false); if (!allow) return StatusCode(403, "Registration is disabled."); var email = (request.Email ?? string.Empty).Trim(); var password = request.Password ?? string.Empty; if (email.Length == 0) return BadRequest("Email is required."); if (password.Length == 0) return BadRequest("Password is required."); if (!await VerifyTurnstileAsync(request.TurnstileToken, "register", cancellationToken)) return BadRequest("Security verification failed. Please try again."); var existing = await _users.FindByEmailAsync(email); if (existing is not null) return BadRequest("User already exists."); var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false); var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = !requireEmailVerification }; var res = await _users.CreateAsync(user, password); if (!res.Succeeded) { return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); } if (requireEmailVerification) { try { await SendVerificationEmailAsync(user, cancellationToken); } catch (Exception ex) { // ponytail: don't fail registration over a flaky email send -- the account is // 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); } private async Task VerifyTurnstileAsync(string? token, string expectedAction, CancellationToken cancellationToken) { var secret = (_cfg["Turnstile:SecretKey"] ?? string.Empty).Trim(); var siteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim(); if (secret.Length == 0 && siteKey.Length == 0) return true; if (secret.Length == 0 || siteKey.Length == 0) return false; if (string.IsNullOrWhiteSpace(token) || token.Length > 2048 || _httpClients is null) return false; try { using var content = new FormUrlEncodedContent(new Dictionary { ["secret"] = secret, ["response"] = token.Trim(), ["remoteip"] = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty, }); using var response = await _httpClients.CreateClient().PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", content, cancellationToken); if (!response.IsSuccessStatusCode) return false; using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken)); return json.RootElement.TryGetProperty("success", out var success) && success.GetBoolean() && json.RootElement.TryGetProperty("action", out var action) && string.Equals(action.GetString(), expectedAction, StringComparison.Ordinal); } catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException) { _logger.LogWarning(ex, "Turnstile verification failed"); return false; } } [HttpPost("google/exchange")] [AllowAnonymous] [EnableRateLimiting("auth-login")] public async Task ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) { var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Google token is required."); GoogleTokenPrincipal google; try { google = await _googleTokens.ValidateAsync(token, cancellationToken); } catch (Exception ex) { return Unauthorized(ex.Message); } var user = await _users.Users.FirstOrDefaultAsync( x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken); if (user is null && google.EmailVerified && !string.IsNullOrWhiteSpace(google.Email)) { user = await _users.FindByEmailAsync(google.Email); if (user is not null) { _logger.LogInformation("Auto-linking Google sign-in for existing local account {Email}", google.Email); } } if (user is null) { if (!google.EmailVerified || string.IsNullOrWhiteSpace(google.Email)) { return Unauthorized("This Google account is not linked to a Jobbjakt user yet."); } var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); if (!allowRegistration) { return Unauthorized("This Google account is not linked to a Jobbjakt user yet."); } user = new ApplicationUser { UserName = google.Email, Email = google.Email, EmailConfirmed = true }; 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 Google sign-up for {Email}", google.Email); } if (string.IsNullOrWhiteSpace(user.GoogleSubject) || !string.Equals(user.GoogleSubject, google.Subject, StringComparison.Ordinal)) { user.GoogleSubject = google.Subject; user.GoogleEmail = google.Email; user.GoogleLinkedAt ??= DateTimeOffset.UtcNow; user.DisplayName ??= TrimOrNull(google.Name); user.FirstName ??= TrimOrNull(google.GivenName); user.LastName ??= TrimOrNull(google.FamilyName); await _users.UpdateAsync(user); } return await CompleteSignInAsync(user, request.RememberMe, "google", cancellationToken); } [HttpPost("microsoft/exchange")] [AllowAnonymous] [EnableRateLimiting("auth-login")] public async Task ExchangeMicrosoftToken([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken) { var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Microsoft token is required."); MicrosoftTokenPrincipal microsoft; try { microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken); } catch (Exception ex) { 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.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken); if (user is 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) { 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 (string.IsNullOrWhiteSpace(microsoft.Email)) { return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet."); } var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); if (!allowRegistration) { return Unauthorized("This Microsoft account is not linked to a Jobbjakt user yet."); } 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 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 (_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 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 async Task 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(); } [HttpGet("csrf")] [AllowAnonymous] public IActionResult EnsureCsrfCookie() { EnsureCsrfCookie(false); return NoContent(); } [HttpGet("me")] [Authorize] public async Task Me(CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is not null) { var roles = await _users.GetRolesAsync(user); var isAdmin = roles.Contains("Admin", StringComparer.OrdinalIgnoreCase); return Ok(WithBuildMetadata(ToMeResult(user, roles), isAdmin)); } var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email"); var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); var iss = User.FindFirstValue("iss") ?? string.Empty; var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : iss.Contains("login.microsoftonline.com", StringComparison.OrdinalIgnoreCase) ? "microsoft" : "external"; return Ok(WithBuildMetadata(new MeResult( Provider: provider, Id: sub, Email: email, UserName: null, FirstName: User.FindFirstValue("given_name"), LastName: User.FindFirstValue("family_name"), DisplayName: User.FindFirstValue("name"), ProfileCvText: null, ProfileCvStructureJson: null, AvatarImageDataUrl: null, Roles: Array.Empty(), Plan: "free", Entitlements: AccountPlans.ForRoles(Array.Empty()), GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null, MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null), false)); } [HttpPut("profile")] [Authorize(AuthenticationSchemes = "local")] public async Task UpdateProfile([FromBody] UpdateProfileRequest request) { var user = await _users.GetUserAsync(User); if (user is null) { return StatusCode(501, "Profile updates are only supported for local username/password accounts."); } // Partial update. A field is only touched when the request actually carries it: // - null / omitted -> leave unchanged (the caller isn't editing this field) // - "" -> explicitly clear // - "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. Email ownership changes use the // separate, token-confirmed email-change flow below. if (request.UserName is not null) { var v = request.UserName.Trim(); if (v.Length > 0) user.UserName = v; } if (request.FirstName is not null) user.FirstName = TrimOrNull(request.FirstName); if (request.LastName is not null) user.LastName = TrimOrNull(request.LastName); if (request.DisplayName is not null) user.DisplayName = TrimOrNull(request.DisplayName); if (request.ProfileCvText is not null) user.ProfileCvText = TrimOrNull(request.ProfileCvText); if (request.ProfileCvStructureJson is not null) user.ProfileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson); var res = await _users.UpdateAsync(user); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); 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> 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 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 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 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> LinkGoogle([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) { return Unauthorized(); } var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Google token is required."); GoogleTokenPrincipal google; try { google = await _googleTokens.ValidateAsync(token, cancellationToken); } catch (Exception ex) { return BadRequest(ex.Message); } var conflict = await _users.Users .Where(x => x.Id != user.Id) .FirstOrDefaultAsync(x => x.GoogleSubject == google.Subject || (!string.IsNullOrWhiteSpace(google.Email) && x.GoogleEmail == google.Email), cancellationToken); if (conflict is not null) { return Conflict("That Google account is already linked to another Jobbjakt user."); } user.GoogleSubject = google.Subject; user.GoogleEmail = google.Email; user.GoogleLinkedAt = DateTimeOffset.UtcNow; user.DisplayName ??= TrimOrNull(google.Name); user.FirstName ??= TrimOrNull(google.GivenName); user.LastName ??= TrimOrNull(google.FamilyName); var result = await _users.UpdateAsync(user); if (!result.Succeeded) { return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } return Ok(new GoogleLinkDto(true, user.GoogleEmail, user.GoogleLinkedAt)); } [HttpDelete("google/link")] [Authorize(AuthenticationSchemes = "local")] public async Task UnlinkGoogle() { var user = await _users.GetUserAsync(User); if (user is null) { return Unauthorized(); } user.GoogleSubject = null; user.GoogleEmail = null; user.GoogleLinkedAt = null; var result = await _users.UpdateAsync(user); if (!result.Succeeded) { return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } return NoContent(); } [HttpPost("microsoft/link")] [Authorize(AuthenticationSchemes = "local")] public async Task> LinkMicrosoft([FromBody] MicrosoftTokenRequest request, CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) { return Unauthorized(); } var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Microsoft token is required."); MicrosoftTokenPrincipal microsoft; try { microsoft = await _microsoftTokens.ValidateAsync(token, cancellationToken); } catch (Exception ex) { 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.MicrosoftTenantId == tenantId && x.MicrosoftObjectId == objectId, cancellationToken); if (conflict is not null) { 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; user.DisplayName ??= TrimOrNull(microsoft.Name); user.FirstName ??= TrimOrNull(microsoft.GivenName); user.LastName ??= TrimOrNull(microsoft.FamilyName); var result = await _users.UpdateAsync(user); if (!result.Succeeded) { 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 UnlinkMicrosoft([FromBody] MicrosoftUnlinkRequest request, CancellationToken cancellationToken) { 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("A current password is required before unlinking Microsoft."); user.MicrosoftSubject = null; user.MicrosoftTenantId = null; user.MicrosoftObjectId = null; user.MicrosoftEmail = null; user.MicrosoftLinkedAt = null; var result = await _users.UpdateAsync(user); if (!result.Succeeded) { 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(); } [HttpPost("avatar")] [Authorize(AuthenticationSchemes = "local")] [RequestSizeLimit(MaxAvatarBytes)] public async Task UploadAvatar([FromForm] IFormFile? file) { var user = await _users.GetUserAsync(User); if (user is null) { return Unauthorized(); } if (file is null || file.Length == 0) { return BadRequest("Image file is required."); } if (file.Length > MaxAvatarBytes) { return BadRequest("Avatar image is too large."); } var extension = Path.GetExtension(file.FileName ?? string.Empty); if (!AllowedAvatarExtensions.Contains(extension)) { return BadRequest("Only PNG, JPEG, or WebP images are supported."); } await using var stream = file.OpenReadStream(); using var memory = new MemoryStream(); await stream.CopyToAsync(memory); var bytes = memory.ToArray(); var detectedContentType = DetectAvatarContentType(bytes); if (detectedContentType is null) { return BadRequest("Only PNG, JPEG, or WebP images are supported."); } user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted); var result = await _users.UpdateAsync(user); if (!result.Succeeded) { return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) }); } [HttpDelete("avatar")] [Authorize(AuthenticationSchemes = "local")] public async Task DeleteAvatar() { var user = await _users.GetUserAsync(User); if (user is null) { return Unauthorized(); } var storedAvatar = user.AvatarImageDataUrl; user.AvatarImageDataUrl = null; var result = await _users.UpdateAsync(user); if (!result.Succeeded) { return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } AvatarStorage.Delete(storedAvatar); return NoContent(); } public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); [HttpPost("change-password")] [Authorize(AuthenticationSchemes = "local")] public async Task ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken cancellationToken) { var user = await _users.GetUserAsync(User); if (user is null) { return StatusCode(501, "Password changes are only supported for local username/password accounts."); } if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return BadRequest("CurrentPassword is required."); if (string.IsNullOrWhiteSpace(request.NewPassword)) return BadRequest("NewPassword is required."); var res = await _users.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword); 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(); } public sealed record RequestPasswordResetRequest(string Email); [HttpPost("request-password-reset")] [AllowAnonymous] [EnableRateLimiting("auth-email")] public async Task RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? string.Empty).Trim(); if (email.Length == 0) return NoContent(); var user = await _users.FindByEmailAsync(email); if (user is null || string.IsNullOrWhiteSpace(user.Email) || !user.EmailConfirmed || !await _users.HasPasswordAsync(user)) { return NoContent(); } var token = await _users.GeneratePasswordResetTokenAsync(user); var link = _externalOrigin.BuildPath($"/reset-password?email={Uri.EscapeDataString(user.Email)}&token={Uri.EscapeDataString(token)}"); try { await _email.SendAsync( user.Email, "Password reset", $"You requested a password reset for Jobbjakt.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.", cancellationToken ); } catch (Exception ex) { _logger.LogError(ex, "Failed to send password reset email to {Email}", user.Email); return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Password reset email could not be sent right now. Please try again later."); } return NoContent(); } public sealed record ResetPasswordRequest(string Email, string Token, string NewPassword); [HttpPost("reset-password")] [AllowAnonymous] [EnableRateLimiting("auth-email")] public async Task ResetPassword([FromBody] ResetPasswordRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? string.Empty).Trim(); var token = request.Token ?? string.Empty; var newPassword = request.NewPassword ?? string.Empty; if (email.Length == 0) return BadRequest("Email is required."); if (token.Length == 0) return BadRequest("Token is required."); if (newPassword.Length == 0) return BadRequest("NewPassword is required."); var user = await _users.FindByEmailAsync(email); if (user is null) return BadRequest("Invalid email or token."); var res = await _users.ResetPasswordAsync(user, token, newPassword); 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(); } public sealed record VerifyEmailRequest(string UserId, string Token); [HttpPost("verify-email")] [AllowAnonymous] [EnableRateLimiting("auth-email")] public async Task VerifyEmail([FromBody] VerifyEmailRequest request) { var userId = (request.UserId ?? string.Empty).Trim(); var token = request.Token ?? string.Empty; if (userId.Length == 0) return BadRequest("UserId is required."); if (token.Length == 0) return BadRequest("Token is required."); var user = await _users.FindByIdAsync(userId); if (user is null || user.EmailConfirmed) return BadRequest("Invalid or expired link."); var res = await _users.ConfirmEmailAsync(user, token); if (!res.Succeeded) { return BadRequest("Invalid or expired link."); } return NoContent(); } public sealed record ResendVerificationEmailRequest(string Email); [HttpPost("resend-verification-email")] [AllowAnonymous] [EnableRateLimiting("auth-email")] public async Task ResendVerificationEmail([FromBody] ResendVerificationEmailRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? string.Empty).Trim(); if (email.Length == 0) return NoContent(); // Mirrors request-password-reset's enumeration-avoidance: always NoContent, only actually // send when there's a matching local account that still needs verifying. var user = await _users.FindByEmailAsync(email); if (user is null || user.EmailConfirmed || string.IsNullOrWhiteSpace(user.Email) || !await _users.HasPasswordAsync(user)) { return NoContent(); } try { await SendVerificationEmailAsync(user, cancellationToken); } catch (Exception ex) { _logger.LogError(ex, "Failed to send verification email to {Email}", user.Email); return EmailDeliveryUnavailable("Verification email could not be sent right now. Please try again later."); } return NoContent(); } private async Task SendVerificationEmailAsync(ApplicationUser user, CancellationToken cancellationToken) { var token = await _users.GenerateEmailConfirmationTokenAsync(user); var link = _externalOrigin.BuildPath($"/verify-email?userId={Uri.EscapeDataString(user.Id)}&token={Uri.EscapeDataString(token)}"); await _email.SendAsync( user.Email!, "Verify your email", $"Welcome to Jobbjakt! Please verify your email address to finish setting up your account.\n\nVerification link:\n{link}\n\nIf you did not create this account, you can ignore this email.", cancellationToken ); } private IActionResult EmailDeliveryUnavailable(string detail) { return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); } // Shared by local/Google/Microsoft sign-in. If the account has TOTP 2FA enabled, this does // NOT issue the real session -- it hands back a short-lived opaque pending token that only // POST /api/auth/2fa/challenge can redeem, after the caller proves they hold the TOTP device // (or a recovery code). This is the gate that makes 2FA actually mandatory rather than // decorative: skipping straight to AppSessionIssuer here would defeat the whole feature. private async Task CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken) { if (user.DeletionStatus != AccountDeletionStatuses.Active) return StatusCode(StatusCodes.Status403Forbidden, new { error = "account_deletion_pending" }); // "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a // non-expired row for this exact user, skip straight to a real session, same as if 2FA // weren't required at all. Falls through to the normal gate for any other outcome // (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, _externalOrigin.UsesHttps, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } if (user.TwoFactorEnabled) { 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, _externalOrigin.UsesHttps, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } private void EnsureCsrfCookie(bool persistent) { var csrf = Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); Response.Cookies.Append(AuthSessionOptions.CsrfCookieName, csrf, AuthSessionOptions.BuildCsrfCookie(persistent, _externalOrigin.UsesHttps)); } private void ClearSessionCookies() { Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(_externalOrigin.UsesHttps)); Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(_externalOrigin.UsesHttps)); } private static string? DetectAvatarContentType(byte[] bytes) { if (bytes.Length >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 && bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A) { return "image/png"; } if (bytes.Length >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) { return "image/jpeg"; } if (bytes.Length >= 12 && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 && bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) { return "image/webp"; } return null; } private static string? TrimOrNull(string? value) { 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 roles) { var planEntitlements = AccountPlans.ForRoles(roles); var entitlements = planEntitlements with { Ai = planEntitlements.Ai && user.AiEnabled }; return new MeResult( Provider: "local", Id: user.Id, Email: user.Email, UserName: user.UserName, FirstName: user.FirstName, LastName: user.LastName, DisplayName: user.DisplayName, ProfileCvText: user.ProfileCvText, ProfileCvStructureJson: user.ProfileCvStructureJson, AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl), Roles: roles, 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.MicrosoftTenantId) && !string.IsNullOrWhiteSpace(user.MicrosoftObjectId), Email: user.MicrosoftEmail, LinkedAt: user.MicrosoftLinkedAt)); } private MeResult WithBuildMetadata(MeResult result, bool include) { if (!include) return result; return result with { AppVersion = BuildMetadata.ResolveVersion(_cfg), AppCommitSha = BuildMetadata.Normalize(_cfg["App:CommitSha"]), }; } }