using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using MongoDB.Bson; using MongoDB.Driver; using QuestionsApi.Models; using QuestionsApi.Services; using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; namespace QuestionsApi.Controllers; [ApiController] [Route("api/Auth")] public sealed class AuthController( AuthUserStore users, TokenBlacklistStore blacklist, JwtTokenService tokens, LegalPolicy legal, UserProfileStore profiles, PasswordResetStore passwordResets, SteamAuthTransactionStore steamTransactions, PasswordResetCodeService resetCodes, IPasswordResetEmailSender resetEmail, IConfiguration configuration, ILogger logger) : ControllerBase { [HttpGet("legal")] [AllowAnonymous] public IActionResult Legal() => Ok(legal.Status()); [HttpPost("register")] [AllowAnonymous] [EnableRateLimiting("auth-register")] public async Task Register(RegisterRequest request) { var username = request.Username.Trim(); var password = request.Password; var email = AuthValidation.NormalizeEmail(request.Email ?? ""); if (!request.AcceptLegal) return BadRequest("You must agree to the Terms of Service and acknowledge the Privacy Policy"); if (AuthValidation.ValidateNewAccount(username, email, password) is { } validation) return BadRequest(validation); if (await users.GetByUsernameAsync(username) is not null) return Conflict("Username already exists"); if (await users.EmailExistsAsync(email)) return Conflict("Email address is already in use."); var user = new AuthUser { Username = username, PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), Role = "USER", Email = email, NormalizedEmail = email, CreatedUtc = DateTime.UtcNow }; legal.Accept(user, "password_registration"); try { await users.CreateAsync(user); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { return Conflict("Username or email address already exists"); } return Ok("User created"); } [HttpPost("login")] [AllowAnonymous] [EnableRateLimiting("auth-login")] public async Task Login(LoginRequest request) { var candidates = await users.GetByLoginCandidatesAsync(request.Username); var user = candidates.FirstOrDefault(candidate => BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash)); if (user is null) return Unauthorized(); var (accessToken, jti, expiresUtc) = tokens.Generate(user); user.RefreshToken = JwtTokenService.NewRefreshToken(); user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); await users.UpdateAsync(user); return Ok(SessionResponse(user, accessToken, expiresUtc, jti, user.RefreshToken)); } [HttpPost("refresh")] [AllowAnonymous] [EnableRateLimiting("auth-refresh")] public async Task Refresh(RefreshRequest request) { var user = await users.GetByUsernameAsync(request.Username); if (user is null || user.RefreshToken != request.RefreshToken || user.RefreshTokenExpiry < DateTime.UtcNow) return Unauthorized("Invalid or expired refresh token"); var (accessToken, _, expiresUtc) = tokens.Generate(user); return Ok(SessionResponse(user, accessToken, expiresUtc)); } [HttpPost("logout")] [Authorize(Roles = "USER,SUPER")] public async Task Logout() { if (ReadCurrentToken() is not { } token) return BadRequest("Token missing"); await blacklist.AddAsync(token.Id, token.ValidTo); return Ok("Logged out."); } [HttpGet("me")] [Authorize(Roles = "USER,SUPER")] public async Task Me() => await CurrentUserAsync() is { } user ? Ok(ProfileResponse(user)) : Unauthorized(); [HttpPost("legal/accept")] [Authorize(Roles = "USER,SUPER")] public async Task AcceptLegal(AcceptLegalRequest request) { if (!request.Accept) return BadRequest("Legal acceptance is required."); var user = await CurrentUserAsync(); if (user is null) return Unauthorized(); legal.Accept(user, "existing_user_prompt"); await users.UpdateAsync(user); return Ok(legal.Status(user)); } [HttpPatch("me")] [Authorize(Roles = "USER,SUPER")] public async Task UpdateProfile(UpdateAuthProfileRequest request) { var user = await CurrentUserAsync(); if (user is null) return Unauthorized(); var displayName = request.DisplayName?.Trim(); var email = string.IsNullOrWhiteSpace(request.Email) ? null : AuthValidation.NormalizeEmail(request.Email); if (!string.IsNullOrEmpty(displayName) && displayName.Length > 80) return BadRequest("Display name must be 80 characters or fewer."); if (email is not null && !AuthValidation.ValidEmail(email)) return BadRequest("A valid email address is required."); if (email is not null && await users.EmailExistsAsync(email, user.Id)) return Conflict("Email address is already in use."); user.DisplayName = string.IsNullOrEmpty(displayName) ? null : displayName; user.Email = email; user.NormalizedEmail = email; user.CreatedUtc ??= CreationDate(user); try { await users.UpdateAsync(user); } catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) { return Conflict("Email address is already in use."); } return Ok(ProfileResponse(user)); } [HttpPost("me/change-password")] [Authorize(Roles = "USER,SUPER")] public async Task ChangePassword(ChangePasswordRequest request) { var user = await CurrentUserAsync(); if (user is null) return Unauthorized(); if (!BCrypt.Net.BCrypt.Verify(request.CurrentPassword, user.PasswordHash)) return BadRequest("Current password is incorrect."); if (request.NewPassword.Length < 8) return BadRequest("Password must be at least 8 characters."); if (!await users.ResetPasswordAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(request.NewPassword))) return StatusCode(StatusCodes.Status503ServiceUnavailable); await RevokeCurrentTokenAsync(); return NoContent(); } [HttpDelete("me")] [Authorize(Roles = "USER,SUPER")] public async Task DeleteAccount(DeleteAccountRequest request) { var user = await CurrentUserAsync(); if (user is null) return Unauthorized(); if (request.Confirmation != "DELETE") return BadRequest("Type DELETE to confirm permanent deletion."); if (!BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash)) return BadRequest("Password is incorrect."); try { await profiles.PurgeAsync(user.Id); await passwordResets.DeleteForUserAsync(user.Id); await steamTransactions.DeleteForUserAsync(user.Id); await RevokeCurrentTokenAsync(); await users.DeleteAsync(user.Id); return NoContent(); } catch (Exception exception) { logger.LogError(exception, "Account purge failed for user {UserId}", user.Id); return StatusCode(StatusCodes.Status503ServiceUnavailable, "Study data could not be removed. The account was not deleted; please try again."); } } [HttpPost("password-reset/request")] [AllowAnonymous] [EnableRateLimiting("password-reset-request")] public async Task RequestPasswordReset( PasswordResetStartRequest request, CancellationToken cancellationToken) { if (!PasswordResetEnabled()) return StatusCode(StatusCodes.Status503ServiceUnavailable, "Password reset is temporarily unavailable."); var user = await users.ResolveUniqueLoginAsync(request.Login); if (user is null || string.IsNullOrWhiteSpace(user.Email) || await passwordResets.IssuedRecentlyAsync(user.Id, DateTime.UtcNow.AddMinutes(-1))) return Ok(new { message = "If the account exists, a verification code has been sent." }); var code = resetCodes.GenerateCode(); var reset = new PasswordResetRequest { UserId = user.Id, CreatedUtc = DateTime.UtcNow, ExpiresUtc = DateTime.UtcNow.AddMinutes(10) }; reset.CodeHash = resetCodes.Hash(reset.Id, code); try { await passwordResets.CreateAsync(reset); await resetEmail.SendAsync(user.Email, user.Username, code, cancellationToken); } catch (Exception exception) { await passwordResets.ConsumeForUserAsync(user.Id); logger.LogError(exception, "Unable to deliver password reset email for user {UserId}", user.Id); } return Ok(new { message = "If the account exists, a verification code has been sent." }); } [HttpPost("password-reset/confirm")] [AllowAnonymous] [EnableRateLimiting("password-reset-confirm")] public async Task ConfirmPasswordReset(PasswordResetConfirmRequest request) { if (!PasswordResetEnabled()) return StatusCode(StatusCodes.Status503ServiceUnavailable, "Password reset is temporarily unavailable."); if (request.NewPassword.Length < 8) return BadRequest("Password must be at least 8 characters."); if (request.Code.Length != 6 || !request.Code.All(char.IsAsciiDigit)) return BadRequest("The verification code is invalid or expired."); var user = await users.ResolveUniqueLoginAsync(request.Login); if (user is null) return BadRequest("The verification code is invalid or expired."); var reset = await passwordResets.LatestActiveAsync(user.Id); if (reset is null) return BadRequest("The verification code is invalid or expired."); if (!resetCodes.Verify(reset.Id, request.Code, reset.CodeHash)) { await passwordResets.RecordFailureAsync(reset.Id); return BadRequest("The verification code is invalid or expired."); } if (!await passwordResets.TryConsumeAsync(reset.Id)) return BadRequest("The verification code is invalid or expired."); if (!await users.ResetPasswordAsync(user.Id, BCrypt.Net.BCrypt.HashPassword(request.NewPassword))) return StatusCode(StatusCodes.Status503ServiceUnavailable); await passwordResets.ConsumeForUserAsync(user.Id); return NoContent(); } [HttpPost("role")] [Authorize(Roles = "SUPER")] public async Task ChangeUserRole(ChangeRoleRequest request) { if (request.NewRole is not ("USER" or "SUPER")) return BadRequest("Role must be 'USER' or 'SUPER'"); var user = await users.GetByUsernameAsync(request.Username); if (user is null) return NotFound("User not found"); user.Role = request.NewRole; user.AuthVersion++; user.RefreshToken = null; user.RefreshTokenExpiry = null; await users.UpdateAsync(user); return Ok($"{request.Username}'s role updated to {request.NewRole}"); } [HttpGet("users")] [Authorize(Roles = "SUPER")] public async Task GetAllUsers() => Ok((await users.GetAllAsync()).Select(ProfileResponse)); private async Task CurrentUserAsync() { var id = User.FindFirstValue(ClaimTypes.NameIdentifier); return string.IsNullOrWhiteSpace(id) ? null : await users.GetByIdAsync(id); } private static DateTime CreationDate(AuthUser user) => ObjectId.TryParse(user.Id, out var id) ? id.CreationTime : DateTime.UtcNow; private object ProfileResponse(AuthUser user) => new { userId = user.Id, user.Username, user.DisplayName, user.Email, user.Role, createdUtc = user.CreatedUtc ?? CreationDate(user), legal = legal.Status(user) }; private object SessionResponse(AuthUser user, string accessToken, DateTime expiresUtc, string? jti = null, string? refreshToken = null) => new { accessToken, refreshToken, user.Username, user.Role, jti, exp = expiresUtc, legal = legal.Status(user) }; private JwtSecurityToken? ReadCurrentToken() { var value = Request.Headers.Authorization.FirstOrDefault(); if (string.IsNullOrWhiteSpace(value)) return null; return new JwtSecurityTokenHandler().ReadJwtToken(value.Replace("Bearer ", "")); } private async Task RevokeCurrentTokenAsync() { if (ReadCurrentToken() is { } token) await blacklist.AddAsync(token.Id, token.ValidTo); } private bool PasswordResetEnabled() => configuration.GetValue("PasswordReset:Enabled", false) && resetCodes.IsConfigured; }