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, ExternalIdentityStore externalIdentities, ExternalAuthTransactionStore externalTransactions, IEnumerable externalProviders, 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(await ProfileResponseAsync(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(await ProfileResponseAsync(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 externalTransactions.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 async Task ProfileResponseAsync(AuthUser user) => new { userId = user.Id, user.Username, user.DisplayName, user.Email, user.Role, createdUtc = user.CreatedUtc ?? CreationDate(user), connectedProviders = await externalIdentities.ProvidersForUserAsync(user.Id), availableProviders = externalProviders.Where(provider => provider.IsConfigured) .Select(provider => provider.Id).OrderBy(provider => provider), 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; }