using System.Text.Json; using System.Security.Claims; 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; 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) { _cfg = cfg; _users = users; _tokens = tokens; _email = email; _googleTokens = googleTokens; _microsoftTokens = microsoftTokens; _logger = logger; _twoFactorPending = twoFactorPending; _db = db; _httpClients = httpClients; _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 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 EntitlementsDto(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes); 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, EntitlementsDto Entitlements, GoogleLinkDto? GoogleLink, MicrosoftLinkDto? MicrosoftLink); 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); [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 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); } var user = await _users.Users.FirstOrDefaultAsync( x => x.MicrosoftSubject == microsoft.Subject || (!string.IsNullOrWhiteSpace(microsoft.Email) && x.MicrosoftEmail == microsoft.Email), cancellationToken); if (user is null && microsoft.EmailVerified && !string.IsNullOrWhiteSpace(microsoft.Email)) { user = await _users.FindByEmailAsync(microsoft.Email); if (user is not null) { _logger.LogInformation("Auto-linking Microsoft sign-in for existing local account {Email}", microsoft.Email); } } if (user is null) { if (!microsoft.EmailVerified || 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."); } user = new ApplicationUser { UserName = microsoft.Email, Email = microsoft.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 Microsoft sign-up for {Email}", microsoft.Email); } 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); } return await CompleteSignInAsync(user, request.RememberMe, "microsoft", cancellationToken); } [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() { 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); return Ok(ToMeResult(user, roles)); } 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(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: BuildEntitlements(Array.Empty()), GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null, MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null)); } [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. if (request.Email is not null) { var v = request.Email.Trim(); if (v.Length > 0) user.Email = v; } 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(); } [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); } 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); if (conflict is not null) { return Conflict("That Microsoft account is already linked to another Jobbjakt user."); } 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); var result = await _users.UpdateAsync(user); if (!result.Succeeded) { return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); } return Ok(new MicrosoftLinkDto(true, user.MicrosoftEmail, user.MicrosoftLinkedAt)); } [HttpDelete("microsoft/link")] [Authorize(AuthenticationSchemes = "local")] public async Task UnlinkMicrosoft() { var user = await _users.GetUserAsync(User); if (user is null) { return Unauthorized(); } user.MicrosoftSubject = 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))); } 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) { 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))); 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)) { 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)}"; 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) { 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))); 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) 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 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)}"; 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) { // "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, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } if (user.TwoFactorEnabled) { var pendingToken = _twoFactorPending.IssuePendingToken(user.Id, rememberMe); return Ok(new TwoFactorRequiredResult(true, pendingToken)); } await AppSessionIssuer.IssueAsync(Request, Response, _tokens, _db, _cfg, user, rememberMe, cancellationToken); return Ok(new AuthSessionResult(true, provider)); } private void EnsureCsrfCookie(bool persistent, bool? secureOverride = null) { 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)); } 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)); } 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 EntitlementsDto BuildEntitlements(IList roles) { var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase); return new EntitlementsDto(premium, premium, premium, premium, premium ? 5_000_000_000 : 250_000_000); } private static MeResult ToMeResult(ApplicationUser user, IList roles) { var entitlements = BuildEntitlements(roles); 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: entitlements.AdvancedAi ? "premium" : "free", Entitlements: entitlements, GoogleLink: new GoogleLinkDto( Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject), Email: user.GoogleEmail, LinkedAt: user.GoogleLinkedAt), MicrosoftLink: new MicrosoftLinkDto( Linked: !string.IsNullOrWhiteSpace(user.MicrosoftSubject), Email: user.MicrosoftEmail, LinkedAt: user.MicrosoftLinkedAt)); } }