using System.Text.Json; using System.Security.Claims; 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 ILogger _logger; public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, ILogger logger) { _cfg = cfg; _users = users; _tokens = tokens; _email = email; _googleTokens = googleTokens; _logger = logger; } [HttpGet("config")] [AllowAnonymous] public IActionResult Config() { var requireAuth = _cfg.GetValue("Auth:Require", false); var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()); var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); return Ok(new { requireAuth, googleEnabled, localEnabled = true, allowRegistration, }); } 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 GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt); public sealed record MeResult( string Provider, string? Id, string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText, string? ProfileCvStructureJson, string? AvatarImageDataUrl, IList Roles, GoogleLinkDto? GoogleLink); 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); [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."); var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email); if (user is null) return Unauthorized(); var ok = await _users.CheckPasswordAsync(user, password); if (!ok) return Unauthorized(); await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); return Ok(new AuthSessionResult(true, "local")); } [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."); var existing = await _users.FindByEmailAsync(email); if (existing is not null) return BadRequest("User already exists."); var user = new ApplicationUser { UserName = email, Email = email, EmailConfirmed = true }; var res = await _users.CreateAsync(user, password); if (!res.Succeeded) { return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); } await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); return Ok(new AuthSessionResult(true, "local")); } [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) { return Unauthorized("This Google account is not linked to a Jobbjakt user yet."); } 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); } await SignInWithAppSessionAsync(user, request.RememberMe, cancellationToken); return Ok(new AuthSessionResult(true, "google")); } [HttpPost("logout")] 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" : "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(), GoogleLink: provider == "google" ? new GoogleLinkDto(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."); } 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> 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("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."); } 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 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 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(); } private IActionResult EmailDeliveryUnavailable(string detail) { return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: detail); } private async Task SignInWithAppSessionAsync(ApplicationUser user, bool rememberMe, CancellationToken cancellationToken) { var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); var secure = Request.IsHttps || string.Equals(Request.Headers["X-Forwarded-Proto"], "https", StringComparison.OrdinalIgnoreCase); Response.Cookies.Append(AuthSessionOptions.SessionCookieName, token, AuthSessionOptions.BuildSessionCookie(rememberMe, secure)); EnsureCsrfCookie(rememberMe, secure); } 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 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)); } }