Files
step3/api/QuestionsApi/Controllers/AuthController.cs
T

350 lines
14 KiB
C#

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<IExternalAuthProvider> externalProviders,
PasswordResetCodeService resetCodes,
IPasswordResetEmailSender resetEmail,
IConfiguration configuration,
ILogger<AuthController> logger) : ControllerBase
{
[HttpGet("legal")]
[AllowAnonymous]
public IActionResult Legal() => Ok(legal.Status());
[HttpPost("register")]
[AllowAnonymous]
[EnableRateLimiting("auth-register")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Me() =>
await CurrentUserAsync() is { } user ? Ok(await ProfileResponseAsync(user)) : Unauthorized();
[HttpPost("legal/accept")]
[Authorize(Roles = "USER,SUPER")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetAllUsers() => Ok((await users.GetAllAsync()).Select(ProfileResponse));
private async Task<AuthUser?> 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<object> 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;
}