using System.Security.Claims; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; 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; public AuthController(IConfiguration cfg, UserManager users, ITokenService tokens, IAppEmailSender email) { _cfg = cfg; _users = users; _tokens = tokens; _email = email; } [HttpGet("config")] [AllowAnonymous] public IActionResult Config() { var requireAuth = _cfg.GetValue("Auth:Require", false); var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? "").Trim()); var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); return Ok(new { requireAuth, googleEnabled, localEnabled = true, allowRegistration }); } public sealed record LoginRequest(string Email, string Password); public sealed record RegisterRequest(string Email, string Password); public sealed record AuthResult(string AccessToken, string TokenType); [HttpPost("login")] [AllowAnonymous] public async Task> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? "").Trim(); var password = request.Password ?? ""; 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(); var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); return Ok(new AuthResult(token, "Bearer")); } [HttpPost("register")] [AllowAnonymous] 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 ?? "").Trim(); var password = request.Password ?? ""; 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))); } var token = await _tokens.CreateAccessTokenAsync(user, cancellationToken); return Ok(new AuthResult(token, "Bearer")); } [HttpGet("me")] [Authorize] public async Task Me(CancellationToken cancellationToken) { var u = await _users.GetUserAsync(User); if (u is not null) { var roles = await _users.GetRolesAsync(u); return Ok(new { provider = "local", id = u.Id, email = u.Email, userName = u.UserName, roles }); } // Google direct bearer tokens (or any other external JWT) won’t map to an Identity user. var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email"); var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); var iss = User.FindFirstValue("iss") ?? ""; var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external"; return Ok(new { provider, id = sub, email, roles = Array.Empty() }); } public sealed record UpdateProfileRequest(string? Email, string? UserName); [HttpPut("profile")] [Authorize] public async Task UpdateProfile([FromBody] UpdateProfileRequest request) { var u = await _users.GetUserAsync(User); if (u is null) { // Google bearer tokens are accepted, but they don't map to Identity users in this build. return StatusCode(501, "Profile updates are only supported for local username/password accounts."); } var email = string.IsNullOrWhiteSpace(request.Email) ? null : request.Email.Trim(); var userName = string.IsNullOrWhiteSpace(request.UserName) ? null : request.UserName.Trim(); if (email is not null) u.Email = email; if (userName is not null) u.UserName = userName; var res = await _users.UpdateAsync(u); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); return NoContent(); } public sealed record ChangePasswordRequest(string CurrentPassword, string NewPassword); [HttpPost("change-password")] [Authorize] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { var u = await _users.GetUserAsync(User); if (u 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(u, 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] public async Task RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken) { var email = (request.Email ?? "").Trim(); if (email.Length == 0) return NoContent(); var user = await _users.FindByEmailAsync(email); if (user is null || string.IsNullOrWhiteSpace(user.Email)) { // Avoid user enumeration. return NoContent(); } var token = await _users.GeneratePasswordResetTokenAsync(user); var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").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)}"; await _email.SendAsync( user.Email, "Password reset", $"You requested a password reset for Job Tracker.\n\nReset link:\n{link}\n\nIf you did not request this, you can ignore this email.", cancellationToken ); return NoContent(); } public sealed record ResetPasswordRequest(string Email, string Token, string NewPassword); [HttpPost("reset-password")] [AllowAnonymous] public async Task ResetPassword([FromBody] ResetPasswordRequest request) { var email = (request.Email ?? "").Trim(); var token = request.Token ?? ""; var newPassword = request.NewPassword ?? ""; 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(); } }