using AuthApi.Models; using AuthApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using MongoDB.Driver; using MongoDB.Bson; using System.IdentityModel.Tokens.Jwt; using System.Net.Mail; using System.Security.Claims; using System.Text.RegularExpressions; using System.Net.Http.Json; namespace AuthApi.Controllers; [ApiController] [Route("api/[controller]")] public class AuthController : ControllerBase { private readonly UserService _users; private readonly IConfiguration _cfg; private readonly BlacklistService _blacklist; private readonly IHttpClientFactory _httpClients; private readonly JwtTokenService _tokens; public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist, IHttpClientFactory httpClients, JwtTokenService tokens) { _users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; _tokens = tokens; } [HttpPost("register")] public async Task Register([FromBody] RegisterRequest req) { var username = req.Username?.Trim() ?? ""; var password = req.Password ?? ""; var email = req.Email?.Trim() ?? ""; if (!Regex.IsMatch(username, "^[A-Za-z0-9_]{3,24}$")) return BadRequest("Username must be 3-24 characters using only letters, numbers, or underscores"); if (password.Length < 8) return BadRequest("Password must be at least 8 characters"); var emailDomain = email.Split('@').LastOrDefault() ?? ""; if (string.IsNullOrWhiteSpace(email) || !MailAddress.TryCreate(email, out _) || !emailDomain.Contains('.')) return BadRequest("A valid email address is required"); if (await _users.GetByUsernameAsync(username) != null) return Conflict("Username already exists"); var hash = BCrypt.Net.BCrypt.HashPassword(password); var user = new User { Username = username, PasswordHash = hash, Role = "USER", Email = email, CreatedUtc = DateTime.UtcNow }; try { await _users.CreateAsync(user); } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { return Conflict("Username already exists"); } return Ok("User created"); } [HttpPost("login")] public async Task Login([FromBody] LoginRequest req) { var candidates = await _users.GetByLoginCandidatesAsync(req.Username); var user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(req.Password, candidate.PasswordHash)); if (user == null) return Unauthorized(); var (accessToken, jti, expUtc) = _tokens.Generate(user); user.RefreshToken = Guid.NewGuid().ToString("N"); user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); await _users.UpdateAsync(user); return Ok(new { accessToken, refreshToken = user.RefreshToken, user.Username, user.Role, jti, exp = expUtc }); } [HttpPost("refresh")] public async Task Refresh([FromBody] RefreshRequest req) { var user = await _users.GetByUsernameAsync(req.Username); if (user == null || user.RefreshToken != req.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow) return Unauthorized("Invalid or expired refresh token"); var (accessToken, _, expUtc) = _tokens.Generate(user); return Ok(new { accessToken, exp = expUtc }); } [HttpPost("logout")] [Authorize(Roles = "USER,SUPER")] public async Task Logout() { var token = HttpContext.Request.Headers["Authorization"].FirstOrDefault()?.Replace("Bearer ", ""); if (string.IsNullOrWhiteSpace(token)) return BadRequest("Token missing"); var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo); return Ok("Logged out."); } [HttpGet("me")] [Authorize(Roles = "USER,SUPER")] public async Task Me() { var user = await CurrentUserAsync(); return user is null ? Unauthorized() : Ok(ProfileResponse(user)); } [HttpPatch("me")] [Authorize(Roles = "USER,SUPER")] public async Task UpdateProfile([FromBody] UpdateProfileRequest req) { var user = await CurrentUserAsync(); if (user is null) return Unauthorized(); var displayName = req.DisplayName?.Trim(); var email = req.Email?.Trim().ToLowerInvariant(); if (!string.IsNullOrEmpty(displayName) && displayName.Length > 80) return BadRequest("Display name must be 80 characters or fewer."); if (!string.IsNullOrEmpty(email) && !ValidEmail(email)) return BadRequest("A valid email address is required."); if (!string.IsNullOrEmpty(email)) { var existing = await _users.GetByEmailAsync(email); if (existing is not null && existing.Id != user.Id) return Conflict("Email address is already in use."); } user.DisplayName = string.IsNullOrEmpty(displayName) ? null : displayName; user.Email = string.IsNullOrEmpty(email) ? null : email; user.CreatedUtc ??= CreationDate(user); await _users.UpdateAsync(user); return Ok(ProfileResponse(user)); } [HttpPost("me/change-password")] [Authorize(Roles = "USER,SUPER")] public async Task ChangePassword([FromBody] ChangePasswordRequest req) { var user = await CurrentUserAsync(); if (user is null) return Unauthorized(); if (!BCrypt.Net.BCrypt.Verify(req.CurrentPassword, user.PasswordHash)) return BadRequest("Current password is incorrect."); if (req.NewPassword.Length < 8) return BadRequest("Password must be at least 8 characters."); user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword); user.RefreshToken = null; user.RefreshTokenExpiry = null; await _users.UpdateAsync(user); await RevokeCurrentTokenAsync(); return NoContent(); } [HttpDelete("me")] [Authorize(Roles = "USER,SUPER")] public async Task DeleteAccount([FromBody] DeleteAccountRequest req) { var user = await CurrentUserAsync(); if (user is null) return Unauthorized(); if (req.Confirmation != "DELETE") return BadRequest("Type DELETE to confirm permanent deletion."); if (!BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return BadRequest("Password is incorrect."); var baseUrl = _cfg["QuestionsApi:BaseUrl"]?.TrimEnd('/'); var secret = _cfg["InternalPurge:Secret"]; if (string.IsNullOrWhiteSpace(baseUrl) || string.IsNullOrWhiteSpace(secret)) return StatusCode(503, "Account deletion is temporarily unavailable."); var client = _httpClients.CreateClient(); client.Timeout = TimeSpan.FromSeconds(30); using var purge = new HttpRequestMessage(HttpMethod.Delete, $"{baseUrl}/api/profile/internal/users/{Uri.EscapeDataString(user.Id)}"); purge.Headers.Add("X-MedMind-Internal-Key", secret); using var response = await client.SendAsync(purge); if (!response.IsSuccessStatusCode) return StatusCode(503, "Study data could not be removed. The account was not deleted; please try again."); await RevokeCurrentTokenAsync(); await _users.DeleteAsync(user.Id); return NoContent(); } [HttpPost("role")] [Authorize(Roles = "SUPER")] public async Task ChangeUserRole([FromBody] ChangeRoleRequest req) { if (req.NewRole is not ("USER" or "SUPER")) return BadRequest("Role must be 'USER' or 'SUPER'"); var user = await _users.GetByUsernameAsync(req.Username); if (user is null) return NotFound("User not found"); user.Role = req.NewRole; await _users.UpdateAsync(user); return Ok($"{req.Username}'s role updated to {req.NewRole}"); } [HttpGet("users")] [Authorize(Roles = "SUPER")] public async Task GetAllUsers() => Ok(await _users.GetAllAsync()); private async Task CurrentUserAsync() { var id = User.FindFirstValue(ClaimTypes.NameIdentifier); return string.IsNullOrWhiteSpace(id) ? null : await _users.GetByIdAsync(id); } private static bool ValidEmail(string email) { var domain = email.Split('@').LastOrDefault() ?? ""; return MailAddress.TryCreate(email, out _) && domain.Contains('.'); } private static DateTime CreationDate(User user) { return ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow; } private static object ProfileResponse(User user) => new { userId = user.Id, user.Username, user.DisplayName, user.Email, user.Role, createdUtc = user.CreatedUtc ?? CreationDate(user) }; private async Task RevokeCurrentTokenAsync() { var token = HttpContext.Request.Headers.Authorization.FirstOrDefault()?.Replace("Bearer ", ""); if (string.IsNullOrWhiteSpace(token)) return; var jwt = new JwtSecurityTokenHandler().ReadJwtToken(token); await _blacklist.AddToBlacklistAsync(jwt.Id, jwt.ValidTo); } }