using AuthApi.Models; using AuthApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.IdentityModel.Tokens; using MongoDB.Driver; using MongoDB.Bson; using System.IdentityModel.Tokens.Jwt; using System.Net.Mail; using System.Security.Claims; using System.Text; 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; public AuthController(UserService users, IConfiguration cfg, BlacklistService blacklist, IHttpClientFactory httpClients) { _users = users; _cfg = cfg; _blacklist = blacklist; _httpClients = httpClients; } [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 user = await _users.GetByUsernameAsync(req.Username); if (user == null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash)) return Unauthorized(); var (accessToken, jti, expUtc) = GenerateJwtToken(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) = GenerateJwtToken(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 (string token, string jti, DateTime expUtc) GenerateJwtToken(User user) { var key = Encoding.UTF8.GetBytes(_cfg["Jwt:Key"]!); var issuer = _cfg["Jwt:Issuer"] ?? "GameAuthApi"; var audience = _cfg["Jwt:Audience"] ?? issuer; var creds = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256); var jti = Guid.NewGuid().ToString("N"); var claims = new[] { new Claim(ClaimTypes.Name, user.Username), new Claim(ClaimTypes.NameIdentifier, user.Id), new Claim(ClaimTypes.Role, user.Role), new Claim(JwtRegisteredClaimNames.Jti, jti) }; var exp = DateTime.UtcNow.AddMinutes(15); var token = new JwtSecurityToken(issuer, audience, claims, expires: exp, signingCredentials: creds); return (new JwtSecurityTokenHandler().WriteToken(token), jti, exp); } 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); } }