Files
jobtrackingapp/JobTrackerApi/Controllers/AuthController.cs
T
cesnimda 9cd2e5c2e3
CI and Deploy / test (push) Successful in 2m41s
CI and Deploy / deploy (push) Successful in 59s
feat: protect auth with Turnstile
2026-07-30 23:08:36 +02:00

946 lines
38 KiB
C#

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<ApplicationUser> _users;
private readonly ITokenService _tokens;
private readonly IAppEmailSender _email;
private readonly IGoogleTokenValidator _googleTokens;
private readonly IMicrosoftTokenValidator _microsoftTokens;
private readonly ILogger<AuthController> _logger;
private readonly ITwoFactorPendingTokenService _twoFactorPending;
private readonly JobTrackerContext _db;
private readonly string _avatarDataRoot;
private readonly IHttpClientFactory? _httpClients;
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)
{
_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<string> Roles,
string Plan,
EntitlementsDto Entitlements,
GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink);
private const int MaxAvatarBytes = 1_000_000;
private static readonly HashSet<string> 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<IActionResult> 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<IActionResult> 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<bool> 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<string, string>
{
["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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string>(),
Plan: "free",
Entitlements: BuildEntitlements(Array.Empty<string>()),
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<IActionResult> 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<ActionResult<GoogleLinkDto>> 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<IActionResult> 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<ActionResult<MicrosoftLinkDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<string> 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<string> 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));
}
}