904f3a8ec8
Auth:RequireEmailVerification (default off) gates whether local register requires confirming email before login. OAuth new-user paths are untouched -- Google/Microsoft already assert a verified email. Adds verify-email and resend-verification-email endpoints, mirroring the existing reset-password enumeration-avoidance and rate-limiting patterns, plus a login-embedded resend affordance and a verify-email landing page on the frontend. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
880 lines
34 KiB
C#
880 lines
34 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;
|
|
|
|
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db)
|
|
{
|
|
_cfg = cfg;
|
|
_users = users;
|
|
_tokens = tokens;
|
|
_email = email;
|
|
_googleTokens = googleTokens;
|
|
_microsoftTokens = microsoftTokens;
|
|
_logger = logger;
|
|
_twoFactorPending = twoFactorPending;
|
|
_db = db;
|
|
}
|
|
|
|
[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);
|
|
|
|
return Ok(new
|
|
{
|
|
requireAuth,
|
|
googleEnabled,
|
|
microsoftEnabled,
|
|
localEnabled = true,
|
|
allowRegistration,
|
|
requireEmailVerification,
|
|
});
|
|
}
|
|
|
|
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true);
|
|
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
|
|
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 MeResult(
|
|
string Provider,
|
|
string? Id,
|
|
string? Email,
|
|
string? UserName,
|
|
string? FirstName,
|
|
string? LastName,
|
|
string? DisplayName,
|
|
string? ProfileCvText,
|
|
string? ProfileCvStructureJson,
|
|
string? AvatarImageDataUrl,
|
|
IList<string> Roles,
|
|
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.");
|
|
|
|
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.");
|
|
|
|
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);
|
|
}
|
|
|
|
[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")]
|
|
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>(),
|
|
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.");
|
|
}
|
|
|
|
var email = TrimOrNull(request.Email);
|
|
var userName = TrimOrNull(request.UserName);
|
|
var firstName = TrimOrNull(request.FirstName);
|
|
var lastName = TrimOrNull(request.LastName);
|
|
var displayName = TrimOrNull(request.DisplayName);
|
|
var profileCvText = TrimOrNull(request.ProfileCvText);
|
|
var profileCvStructureJson = TrimOrNull(request.ProfileCvStructureJson);
|
|
|
|
if (email is not null) user.Email = email;
|
|
if (userName is not null) user.UserName = userName;
|
|
user.FirstName = firstName;
|
|
user.LastName = lastName;
|
|
user.DisplayName = displayName;
|
|
user.ProfileCvText = profileCvText;
|
|
user.ProfileCvStructureJson = 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.");
|
|
}
|
|
|
|
var base64 = Convert.ToBase64String(bytes);
|
|
user.AvatarImageDataUrl = $"data:{detectedContentType};base64,{base64}";
|
|
|
|
var result = await _users.UpdateAsync(user);
|
|
if (!result.Succeeded)
|
|
{
|
|
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
|
}
|
|
|
|
return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl });
|
|
}
|
|
|
|
[HttpDelete("avatar")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public async Task<IActionResult> DeleteAvatar()
|
|
{
|
|
var user = await _users.GetUserAsync(User);
|
|
if (user is null)
|
|
{
|
|
return Unauthorized();
|
|
}
|
|
|
|
user.AvatarImageDataUrl = null;
|
|
var result = await _users.UpdateAsync(user);
|
|
if (!result.Succeeded)
|
|
{
|
|
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
|
}
|
|
|
|
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, 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, 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 MeResult ToMeResult(ApplicationUser user, IList<string> 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: user.AvatarImageDataUrl,
|
|
Roles: roles,
|
|
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));
|
|
}
|
|
}
|