diff --git a/.gitea/workflows/deploy-questions.yml b/.gitea/workflows/deploy-questions.yml index 1bd44d1..833fdc7 100644 --- a/.gitea/workflows/deploy-questions.yml +++ b/.gitea/workflows/deploy-questions.yml @@ -91,6 +91,15 @@ jobs: --from-literal=webhook-secret="$STRIPE_WEBHOOK_SECRET" \ --dry-run=client -o yaml | kubectl apply -n step3-questions -f - + - name: Configure authentication secrets + env: + AUTH_JWT_KEY: ${{ secrets.AUTH_JWT_KEY }} + run: | + test -n "$AUTH_JWT_KEY" + kubectl create secret generic ranaze-auth \ + --from-literal=jwt-key="$AUTH_JWT_KEY" \ + --dry-run=client -o yaml | kubectl apply -n step3-questions -f - + - name: Apply Questions deployment & service run: | kubectl apply -f api/QuestionsApi/k8s/deployment.yaml -n step3-questions diff --git a/.gitea/workflows/deploy-web.yml b/.gitea/workflows/deploy-web.yml index 9016c91..9570add 100644 --- a/.gitea/workflows/deploy-web.yml +++ b/.gitea/workflows/deploy-web.yml @@ -68,6 +68,17 @@ jobs: - name: Deploy Web game run: | + for attempt in $(seq 1 30); do + if curl -fsS --max-time 5 https://s3q.ranaze.com/api/Auth/legal >/dev/null; then + echo "Merged authentication API is ready." + break + fi + if [ "$attempt" -eq 30 ]; then + echo "Merged authentication API did not become ready; preserving the current web deployment." + exit 1 + fi + sleep 10 + done kubectl create namespace step3-web --dry-run=client -o yaml | kubectl apply -f - kubectl apply -f web/k8s/service.yaml -n step3-web sed "s|step3-web:latest|step3-web:${BUILD_TAG}|" web/k8s/deployment.yaml | kubectl apply -n step3-web -f - diff --git a/api/QuestionsApi.Tests/AuthTests.cs b/api/QuestionsApi.Tests/AuthTests.cs new file mode 100644 index 0000000..539e352 --- /dev/null +++ b/api/QuestionsApi.Tests/AuthTests.cs @@ -0,0 +1,91 @@ +using Microsoft.Extensions.Configuration; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.IdentityModel.Tokens.Jwt; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class AuthTests +{ + [Theory] + [InlineData("valid_user", "person@example.com", "password1", null)] + [InlineData("ab", "person@example.com", "password1", "Username")] + [InlineData("invalid-user", "person@example.com", "password1", "Username")] + [InlineData("valid_user", "not-an-email", "password1", "email")] + [InlineData("valid_user", "person@example.com", "short", "Password")] + public void NewAccountValidationRejectsInvalidInput( + string username, string email, string password, string? expectedError) + { + var result = AuthValidation.ValidateNewAccount(username, email, password); + if (expectedError is null) Assert.Null(result); + else Assert.Contains(expectedError, result!, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void PasswordResetCodesAreSixDigitsAndBoundToTheirRequest() + { + var service = new PasswordResetCodeService(Configuration(new Dictionary + { + ["PasswordReset:HmacKey"] = "A-test-only-key-that-is-longer-than-thirty-two-bytes" + })); + var code = service.GenerateCode(); + var hash = service.Hash("request-one", code); + + Assert.Matches("^[0-9]{6}$", code); + Assert.True(service.Verify("request-one", code, hash)); + Assert.False(service.Verify("request-two", code, hash)); + Assert.False(service.Verify("request-one", "000000", hash)); + } + + [Fact] + public void PasswordResetCanRemainUnconfiguredWithoutBreakingAuthentication() + { + var service = new PasswordResetCodeService(Configuration([])); + Assert.False(service.IsConfigured); + } + + [Fact] + public void JwtContainsCompatibleIdentityRoleAndAuthVersionClaims() + { + var service = new JwtTokenService(Configuration(new Dictionary + { + ["Jwt:Key"] = "A-test-only-jwt-key-that-is-longer-than-thirty-two-bytes", + ["Jwt:Issuer"] = "promiscuity", + ["Jwt:Audience"] = "promiscuity-auth-api" + })); + var user = new AuthUser + { + Id = "507f1f77bcf86cd799439011", + Username = "doctor", + Role = "SUPER", + AuthVersion = 4 + }; + + var generated = service.Generate(user); + var token = new JwtSecurityTokenHandler().ReadJwtToken(generated.Token); + + Assert.Equal("promiscuity", token.Issuer); + Assert.Contains("promiscuity-auth-api", token.Audiences); + Assert.Equal("4", token.Claims.Single(claim => claim.Type == "auth_ver").Value); + Assert.Equal(generated.Jti, token.Id); + } + + [Fact] + public void LegacyUsersDefaultToAuthVersionZero() + { + Assert.Equal(0, new AuthUser().AuthVersion); + } + + [Fact] + public void SteamClaimedIdExtractionRejectsUnexpectedHostsAndLengths() + { + Assert.Equal("76561198000000000", + SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/76561198000000000")); + Assert.Null(SteamOpenIdService.ExtractSteamId("https://example.com/openid/id/76561198000000000")); + Assert.Null(SteamOpenIdService.ExtractSteamId("https://steamcommunity.com/openid/id/123")); + } + + private static IConfiguration Configuration(Dictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); +} diff --git a/api/QuestionsApi/Controllers/AuthController.cs b/api/QuestionsApi/Controllers/AuthController.cs new file mode 100644 index 0000000..c936867 --- /dev/null +++ b/api/QuestionsApi/Controllers/AuthController.cs @@ -0,0 +1,331 @@ +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; +} diff --git a/api/QuestionsApi/Controllers/SteamAuthController.cs b/api/QuestionsApi/Controllers/SteamAuthController.cs new file mode 100644 index 0000000..1c894a3 --- /dev/null +++ b/api/QuestionsApi/Controllers/SteamAuthController.cs @@ -0,0 +1,219 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using MongoDB.Driver; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Cryptography; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/Auth/steam")] +public sealed class SteamAuthController( + AuthUserStore users, + SteamAuthTransactionStore transactions, + SteamOpenIdService openId, + JwtTokenService tokens, + IConfiguration configuration, + ILogger logger, + LegalPolicy legal) : ControllerBase +{ + [HttpPost("start")] + [EnableRateLimiting("steam-start")] + public async Task Start() + { + var transactionId = Base64Url(RandomNumberGenerator.GetBytes(32)); + var lifetime = Math.Clamp(configuration.GetValue("Steam:TransactionTtlMinutes", 5), 2, 15); + var expiresUtc = DateTime.UtcNow.AddMinutes(lifetime); + await transactions.CreateAsync(new SteamAuthTransaction + { + Id = transactionId, + CreatedUtc = DateTime.UtcNow, + ExpiresUtc = expiresUtc + }); + return Ok(new + { + transactionId, + authUrl = SteamOpenIdService.BuildAuthenticationUrl(ConfiguredRealm(), ReturnUrl(transactionId)), + expiresUtc + }); + } + + [HttpGet("callback")] + [EnableRateLimiting("steam-complete")] + public async Task Callback([FromQuery] string transactionId, CancellationToken cancellationToken) + { + var transaction = await ActiveTransactionAsync(transactionId); + if (transaction is null || transaction.Status != "pending") + return CallbackPage(false, "This Steam sign-in request has expired. Return to RanaZe and try again."); + var steamId = await openId.VerifyCallbackAsync(Request.Query, ReturnUrl(transactionId), cancellationToken); + if (steamId is null) + { + logger.LogWarning("Steam OpenID verification failed"); + return CallbackPage(false, "Steam could not verify this sign-in. Return to RanaZe and try again."); + } + + var user = await users.GetBySteamIdAsync(steamId); + var status = user is null ? "requires_account" : "complete"; + if (!await transactions.MarkVerifiedAsync(transactionId, steamId, status, user?.Id)) + return CallbackPage(false, "This Steam sign-in request has already been used."); + if (user is not null) + { + user.RefreshToken = JwtTokenService.NewRefreshToken(); + user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); + await users.UpdateAsync(user); + } + return CallbackPage(true, "Steam verified your identity. You can return to RanaZe."); + } + + [HttpGet("status/{transactionId}")] + [EnableRateLimiting("steam-poll")] + public async Task Status(string transactionId) + { + Response.Headers.CacheControl = "no-store"; + var transaction = await ActiveTransactionAsync(transactionId); + if (transaction is null) return NotFound(new { message = "Steam sign-in expired." }); + if (transaction.Status != "complete") return Ok(new { status = transaction.Status }); + if (string.IsNullOrWhiteSpace(transaction.UserId)) + return Conflict(new { message = "Steam sign-in is incomplete." }); + + var user = await users.GetByIdAsync(transaction.UserId); + if (user is null || user.SteamId != transaction.SteamId) + return Conflict(new { message = "Steam account link is no longer valid." }); + if (string.IsNullOrWhiteSpace(user.RefreshToken) || user.RefreshTokenExpiry <= DateTime.UtcNow) + { + user.RefreshToken = JwtTokenService.NewRefreshToken(); + user.RefreshTokenExpiry = DateTime.UtcNow.AddDays(7); + await users.UpdateAsync(user); + } + var (accessToken, jti, expiresUtc) = tokens.Generate(user); + return Ok(new + { + status = "complete", + accessToken, + refreshToken = user.RefreshToken, + user.Username, + user.Role, + jti, + exp = expiresUtc, + legal = legal.Status(user) + }); + } + + [HttpPost("complete")] + [EnableRateLimiting("steam-complete")] + public async Task Complete(CompleteSteamRequest request) + { + var transaction = await ActiveTransactionAsync(request.TransactionId); + if (transaction?.Status == "complete") return Ok(new { status = "complete" }); + if (transaction is null || transaction.Status != "requires_account" || + string.IsNullOrWhiteSpace(transaction.SteamId)) + return BadRequest(new { message = "Steam sign-in is not ready or has expired." }); + + AuthUser user; + var createdUser = false; + if (request.Action == "create") + { + if (!request.AcceptLegal) + return BadRequest(new + { + message = "You must agree to the Terms of Service and acknowledge the Privacy Policy." + }); + var username = request.Username.Trim(); + var email = AuthValidation.NormalizeEmail(request.Email ?? ""); + if (AuthValidation.ValidateNewAccount(username, email, request.Password) is { } validation) + return BadRequest(new { message = validation }); + if (await users.GetByUsernameAsync(username) is not null) + return Conflict(new { message = "Username already exists." }); + if (await users.EmailExistsAsync(email)) + return Conflict(new { message = "Email address is already in use." }); + user = new AuthUser + { + Username = username, + Email = email, + NormalizedEmail = email, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(request.Password), + Role = "USER", + CreatedUtc = DateTime.UtcNow, + SteamId = transaction.SteamId, + RefreshToken = JwtTokenService.NewRefreshToken(), + RefreshTokenExpiry = DateTime.UtcNow.AddDays(7) + }; + legal.Accept(user, "steam_registration"); + try + { + await users.CreateAsync(user); + createdUser = true; + } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return Conflict(new { message = "That username, email, or Steam account is already connected." }); + } + } + else if (request.Action == "link") + { + var candidates = await users.GetByLoginCandidatesAsync(request.Username); + user = candidates.FirstOrDefault(candidate => + BCrypt.Net.BCrypt.Verify(request.Password, candidate.PasswordHash))!; + if (user is null) return Unauthorized(new { message = "The account credentials are incorrect." }); + if (!string.IsNullOrWhiteSpace(user.SteamId) && user.SteamId != transaction.SteamId) + return Conflict(new { message = "That RanaZe account is already connected to another Steam account." }); + try + { + if (!await users.TryLinkSteamAsync(user.Id, transaction.SteamId, + JwtTokenService.NewRefreshToken(), DateTime.UtcNow.AddDays(7))) + return Conflict(new { message = "That account could not be connected to Steam." }); + } + catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey) + { + return Conflict(new { message = "That Steam account is already connected." }); + } + } + else + { + return BadRequest(new { message = "Action must be create or link." }); + } + + if (!await transactions.MarkCompleteAsync(request.TransactionId, transaction.SteamId, user.Id)) + { + if (createdUser) await users.DeleteAsync(user.Id); + return Conflict(new { message = "This Steam sign-in was already completed." }); + } + return Ok(new { status = "complete" }); + } + + private async Task ActiveTransactionAsync(string id) + { + if (string.IsNullOrWhiteSpace(id) || id.Length > 128) return null; + var transaction = await transactions.GetAsync(id); + return transaction is not null && transaction.ExpiresUtc > DateTime.UtcNow ? transaction : null; + } + + private string ConfiguredRealm() => + (configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/'); + + private string ReturnUrl(string transactionId) + { + var callback = configuration["Steam:ReturnUrl"] ?? $"{ConfiguredRealm()}/api/Auth/steam/callback"; + return $"{callback}?transactionId={Uri.EscapeDataString(transactionId)}"; + } + + private ContentResult CallbackPage(bool success, string message) + { + Response.Headers.CacheControl = "no-store"; + Response.Headers.ContentSecurityPolicy = "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'"; + Response.Headers.XFrameOptions = "DENY"; + Response.Headers["Referrer-Policy"] = "no-referrer"; + var color = success ? "#22c55e" : "#ef4444"; + var html = $$""" + + RanaZe Steam sign-in +

{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}

{{System.Net.WebUtility.HtmlEncode(message)}}

+ + """; + return Content(html, "text/html; charset=utf-8"); + } + + private static string Base64Url(byte[] bytes) => + Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); +} diff --git a/api/QuestionsApi/Models/AuthModels.cs b/api/QuestionsApi/Models/AuthModels.cs new file mode 100644 index 0000000..11ad41f --- /dev/null +++ b/api/QuestionsApi/Models/AuthModels.cs @@ -0,0 +1,134 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public sealed class AuthUser +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = default!; + public string Username { get; set; } = ""; + public string PasswordHash { get; set; } = ""; + public string Role { get; set; } = "USER"; + public string? Email { get; set; } + public string? NormalizedEmail { get; set; } + public string? DisplayName { get; set; } + public DateTime? CreatedUtc { get; set; } + public string? RefreshToken { get; set; } + public DateTime? RefreshTokenExpiry { get; set; } + public string? SteamId { get; set; } + public int AuthVersion { get; set; } + public string? TermsAcceptedVersion { get; set; } + public string? PrivacyAcceptedVersion { get; set; } + public DateTime? LegalAcceptedUtc { get; set; } + public List LegalAcceptances { get; set; } = []; +} + +public sealed class LegalAcceptanceRecord +{ + public string TermsVersion { get; set; } = ""; + public string PrivacyVersion { get; set; } = ""; + public DateTime AcceptedUtc { get; set; } = DateTime.UtcNow; + public string Source { get; set; } = ""; +} + +public sealed class SteamAuthTransaction +{ + [BsonId] public string Id { get; set; } = ""; + public string Status { get; set; } = "pending"; + public string? SteamId { get; set; } + public string? UserId { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } +} + +public sealed class PasswordResetRequest +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = ObjectId.GenerateNewId().ToString(); + public string UserId { get; set; } = ""; + public string CodeHash { get; set; } = ""; + public DateTime CreatedUtc { get; set; } + public DateTime ExpiresUtc { get; set; } + public int FailedAttempts { get; set; } + public DateTime? ConsumedUtc { get; set; } +} + +public sealed class BlacklistedToken +{ + [BsonId] public string Jti { get; set; } = ""; + public DateTime ExpiresAt { get; set; } +} + +public sealed class RegisterRequest +{ + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public string? Email { get; set; } + public bool AcceptLegal { get; set; } +} + +public sealed class LoginRequest +{ + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; +} + +public sealed class ChangeRoleRequest +{ + public string Username { get; set; } = ""; + public string NewRole { get; set; } = ""; +} + +public sealed class RefreshRequest +{ + public string Username { get; set; } = ""; + public string RefreshToken { get; set; } = ""; +} + +public sealed class UpdateAuthProfileRequest +{ + public string? DisplayName { get; set; } + public string? Email { get; set; } +} + +public sealed class ChangePasswordRequest +{ + public string CurrentPassword { get; set; } = ""; + public string NewPassword { get; set; } = ""; +} + +public sealed class DeleteAccountRequest +{ + public string Password { get; set; } = ""; + public string Confirmation { get; set; } = ""; +} + +public sealed class CompleteSteamRequest +{ + public string TransactionId { get; set; } = ""; + public string Action { get; set; } = ""; + public string Username { get; set; } = ""; + public string? Email { get; set; } + public string Password { get; set; } = ""; + public bool AcceptLegal { get; set; } +} + +public sealed class AcceptLegalRequest +{ + public bool Accept { get; set; } +} + +public sealed class PasswordResetStartRequest +{ + public string Login { get; set; } = ""; +} + +public sealed class PasswordResetConfirmRequest +{ + public string Login { get; set; } = ""; + public string Code { get; set; } = ""; + public string NewPassword { get; set; } = ""; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index b9d6c5b..29bc6b2 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -1,13 +1,24 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.HttpOverrides; using Microsoft.IdentityModel.Tokens; using Microsoft.OpenApi.Models; using QuestionsApi.Services; using MongoDB.Bson; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; using System.Text; +using System.Threading.RateLimiting; var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllers(); +builder.Services.Configure(options => +{ + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + options.ForwardLimit = 2; + options.KnownNetworks.Clear(); + options.KnownProxies.Clear(); +}); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -26,10 +37,43 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); +builder.Services.AddHttpClient(); +builder.Services.AddRateLimiter(options => +{ + static RateLimitPartition Fixed(HttpContext context, int permits, TimeSpan window) => + RateLimitPartition.GetFixedWindowLimiter( + context.Connection.RemoteIpAddress?.ToString() ?? "unknown", + _ => new FixedWindowRateLimiterOptions + { + PermitLimit = permits, + Window = window, + QueueLimit = 0, + AutoReplenishment = true + }); + options.AddPolicy("auth-register", context => Fixed(context, 5, TimeSpan.FromMinutes(15))); + options.AddPolicy("auth-login", context => Fixed(context, 20, TimeSpan.FromMinutes(5))); + options.AddPolicy("auth-refresh", context => Fixed(context, 60, TimeSpan.FromMinutes(5))); + options.AddPolicy("password-reset-request", context => Fixed(context, 5, TimeSpan.FromMinutes(15))); + options.AddPolicy("password-reset-confirm", context => Fixed(context, 10, TimeSpan.FromMinutes(15))); + options.AddPolicy("steam-start", context => Fixed(context, 10, TimeSpan.FromMinutes(1))); + options.AddPolicy("steam-poll", context => Fixed(context, 120, TimeSpan.FromMinutes(1))); + options.AddPolicy("steam-complete", context => Fixed(context, 20, TimeSpan.FromMinutes(1))); + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; +}); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => @@ -78,12 +122,30 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) { OnTokenValidated = async context => { - var userId = context.Principal?.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - if (!string.IsNullOrWhiteSpace(userId)) + var userId = context.Principal?.FindFirstValue(ClaimTypes.NameIdentifier); + if (string.IsNullOrWhiteSpace(userId)) { - var profiles = context.HttpContext.RequestServices.GetRequiredService(); - if (await profiles.IsDeletedAsync(userId)) context.Fail("Account deleted"); + context.Fail("User identity missing"); + return; } + var services = context.HttpContext.RequestServices; + var userTask = services.GetRequiredService().GetByIdAsync(userId); + var deletedTask = services.GetRequiredService().IsDeletedAsync(userId); + var jti = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Jti); + var revokedTask = string.IsNullOrWhiteSpace(jti) + ? Task.FromResult(false) + : services.GetRequiredService().ContainsAsync(jti); + await Task.WhenAll(userTask, deletedTask, revokedTask); + var user = await userTask; + var tokenVersion = int.TryParse(context.Principal?.FindFirstValue("auth_ver"), out var parsedVersion) + ? parsedVersion + : 0; + if (user is null || user.AuthVersion != tokenVersion) + context.Fail("Session revoked"); + else if (await deletedTask) + context.Fail("Account deleted"); + else if (await revokedTask) + context.Fail("Token revoked"); } }; }); @@ -91,7 +153,9 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) builder.Services.AddAuthorization(); var app = builder.Build(); +await app.Services.GetRequiredService().InitializeAsync(); +app.UseForwardedHeaders(); app.Use(async (context, next) => { var started = System.Diagnostics.Stopwatch.StartNew(); @@ -152,6 +216,7 @@ app.UseSwaggerUI(o => o.SwaggerEndpoint("/swagger/v1/swagger.json", "Questions API v1"); o.RoutePrefix = "swagger"; }); +app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); diff --git a/api/QuestionsApi/QuestionsApi.csproj b/api/QuestionsApi/QuestionsApi.csproj index 8f1c9dd..d627762 100644 --- a/api/QuestionsApi/QuestionsApi.csproj +++ b/api/QuestionsApi/QuestionsApi.csproj @@ -7,6 +7,8 @@ + + @@ -14,4 +16,8 @@ + + + + diff --git a/api/QuestionsApi/Services/AuthDataInitializer.cs b/api/QuestionsApi/Services/AuthDataInitializer.cs new file mode 100644 index 0000000..8a536c6 --- /dev/null +++ b/api/QuestionsApi/Services/AuthDataInitializer.cs @@ -0,0 +1,81 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class AuthDataInitializer( + AuthUserStore users, + TokenBlacklistStore blacklist, + SteamAuthTransactionStore steamTransactions, + PasswordResetStore passwordResets, + ILogger logger) +{ + public async Task InitializeAsync() + { + await BackfillNormalizedEmailsAsync(); + + await users.Collection.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(user => user.Username), + new CreateIndexOptions { Unique = true }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(user => user.SteamId), + new CreateIndexOptions + { + Unique = true, + PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string")) + }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(user => user.NormalizedEmail), + new CreateIndexOptions + { + Unique = true, + PartialFilterExpression = new BsonDocument("NormalizedEmail", new BsonDocument("$type", "string")) + }) + ]); + + await blacklist.Collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(token => token.ExpiresAt), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero })); + await steamTransactions.Collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(transaction => transaction.ExpiresUtc), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero })); + await passwordResets.Collection.Indexes.CreateManyAsync([ + new CreateIndexModel( + Builders.IndexKeys.Ascending(request => request.ExpiresUtc), + new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "password_reset_expiry_ttl" }), + new CreateIndexModel( + Builders.IndexKeys.Ascending(request => request.UserId) + .Descending(request => request.CreatedUtc), + new CreateIndexOptions { Name = "password_reset_user_created" }) + ]); + } + + private async Task BackfillNormalizedEmailsAsync() + { + var candidates = await users.Collection.Find(user => + user.Email != null && user.Email != "" && user.NormalizedEmail == null).ToListAsync(); + var groups = candidates.GroupBy(user => AuthValidation.NormalizeEmail(user.Email!), StringComparer.Ordinal) + .ToList(); + var ambiguous = 0; + foreach (var group in groups) + { + var existing = await users.Collection.Find(user => user.NormalizedEmail == group.Key).Limit(1).AnyAsync(); + if (group.Count() != 1 || existing) + { + ambiguous += group.Count(); + continue; + } + var user = group.Single(); + await users.Collection.UpdateOneAsync(value => value.Id == user.Id, + Builders.Update + .Set(value => value.Email, group.Key) + .Set(value => value.NormalizedEmail, group.Key)); + } + if (ambiguous > 0) + logger.LogWarning( + "Skipped normalized-email backfill for {Count} users with ambiguous legacy email addresses; username recovery remains available", + ambiguous); + } +} diff --git a/api/QuestionsApi/Services/AuthUserStore.cs b/api/QuestionsApi/Services/AuthUserStore.cs new file mode 100644 index 0000000..48fea74 --- /dev/null +++ b/api/QuestionsApi/Services/AuthUserStore.cs @@ -0,0 +1,87 @@ +using MongoDB.Bson; +using MongoDB.Driver; +using QuestionsApi.Models; +using System.Text.RegularExpressions; + +namespace QuestionsApi.Services; + +public sealed class AuthUserStore(MongoDatabaseProvider mongo) +{ + private readonly IMongoCollection _users = mongo.Database.GetCollection("Users"); + + public async Task GetByUsernameAsync(string username) => + await _users.Find(user => user.Username == username.Trim()).FirstOrDefaultAsync(); + + public async Task GetByIdAsync(string id) => + await _users.Find(user => user.Id == id).FirstOrDefaultAsync(); + + public async Task GetBySteamIdAsync(string steamId) => + await _users.Find(user => user.SteamId == steamId).FirstOrDefaultAsync(); + + public async Task GetUniqueByEmailAsync(string email) + { + var normalized = AuthValidation.NormalizeEmail(email); + var filter = Builders.Filter.Eq(user => user.NormalizedEmail, normalized) | + Builders.Filter.Regex(user => user.Email, + new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i")); + var matches = await _users.Find(filter).Limit(2).ToListAsync(); + return matches.Count == 1 ? matches[0] : null; + } + + public async Task ResolveUniqueLoginAsync(string login) + { + var username = await GetByUsernameAsync(login); + return username ?? await GetUniqueByEmailAsync(login); + } + + public async Task> GetByLoginCandidatesAsync(string login) + { + var username = await GetByUsernameAsync(login); + if (username is not null) return [username]; + var email = await GetUniqueByEmailAsync(login); + return email is null ? [] : [email]; + } + + public Task EmailExistsAsync(string email, string? excludingUserId = null) + { + var normalized = AuthValidation.NormalizeEmail(email); + var filter = (Builders.Filter.Eq(user => user.NormalizedEmail, normalized) | + Builders.Filter.Regex(user => user.Email, + new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i"))); + if (!string.IsNullOrWhiteSpace(excludingUserId)) + filter &= Builders.Filter.Ne(user => user.Id, excludingUserId); + return _users.Find(filter).AnyAsync(); + } + + public Task CreateAsync(AuthUser user) => _users.InsertOneAsync(user); + + public Task UpdateAsync(AuthUser user) => + _users.ReplaceOneAsync(value => value.Id == user.Id, user); + + public async Task TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry) + { + var filter = Builders.Filter.Eq(user => user.Id, userId) & + (Builders.Filter.Eq(user => user.SteamId, null) | + Builders.Filter.Eq(user => user.SteamId, steamId)); + var update = Builders.Update + .Set(user => user.SteamId, steamId) + .Set(user => user.RefreshToken, refreshToken) + .Set(user => user.RefreshTokenExpiry, refreshExpiry); + return (await _users.UpdateOneAsync(filter, update)).MatchedCount == 1; + } + + public async Task ResetPasswordAsync(string userId, string passwordHash) + { + var update = Builders.Update + .Set(user => user.PasswordHash, passwordHash) + .Set(user => user.RefreshToken, null) + .Set(user => user.RefreshTokenExpiry, null) + .Inc(user => user.AuthVersion, 1); + return (await _users.UpdateOneAsync(user => user.Id == userId, update)).ModifiedCount == 1; + } + + public Task DeleteAsync(string id) => _users.DeleteOneAsync(user => user.Id == id); + public Task> GetAllAsync() => _users.Find(FilterDefinition.Empty).ToListAsync(); + + internal IMongoCollection Collection => _users; +} diff --git a/api/QuestionsApi/Services/AuthValidation.cs b/api/QuestionsApi/Services/AuthValidation.cs new file mode 100644 index 0000000..871c8c4 --- /dev/null +++ b/api/QuestionsApi/Services/AuthValidation.cs @@ -0,0 +1,30 @@ +using System.Net.Mail; +using System.Text.RegularExpressions; + +namespace QuestionsApi.Services; + +public static partial class AuthValidation +{ + [GeneratedRegex("^[A-Za-z0-9_]{3,24}$", RegexOptions.CultureInvariant)] + private static partial Regex UsernamePattern(); + + public static string NormalizeEmail(string email) => email.Trim().ToLowerInvariant(); + + public static bool ValidEmail(string email) + { + var normalized = NormalizeEmail(email); + var domain = normalized.Split('@').LastOrDefault() ?? ""; + return MailAddress.TryCreate(normalized, out _) && domain.Contains('.'); + } + + public static string? ValidateNewAccount(string username, string email, string password) + { + if (!UsernamePattern().IsMatch(username.Trim())) + return "Username must be 3-24 characters using only letters, numbers, or underscores."; + if (password.Length < 8) + return "Password must be at least 8 characters."; + if (!ValidEmail(email)) + return "A valid email address is required."; + return null; + } +} diff --git a/api/QuestionsApi/Services/LegalPolicy.cs b/api/QuestionsApi/Services/LegalPolicy.cs new file mode 100644 index 0000000..87ddf2a --- /dev/null +++ b/api/QuestionsApi/Services/LegalPolicy.cs @@ -0,0 +1,50 @@ +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed record LegalPolicyStatus( + string TermsVersion, + string PrivacyVersion, + DateTime EffectiveUtc, + string TermsUrl, + string PrivacyUrl, + bool RequiresLegalAcceptance); + +public sealed class LegalPolicy(IConfiguration configuration) +{ + public const string DefaultVersion = "2026-07-20"; + + public string TermsVersion => configuration["Legal:TermsVersion"] ?? DefaultVersion; + public string PrivacyVersion => configuration["Legal:PrivacyVersion"] ?? DefaultVersion; + public string TermsUrl => configuration["Legal:TermsUrl"] ?? "https://doctor.ranaze.com/terms.html"; + public string PrivacyUrl => configuration["Legal:PrivacyUrl"] ?? "https://doctor.ranaze.com/privacy.html"; + public DateTime EffectiveUtc => DateTime.TryParse(configuration["Legal:EffectiveUtc"], out var value) + ? value.ToUniversalTime() + : new DateTime(2026, 7, 20, 0, 0, 0, DateTimeKind.Utc); + + public bool IsCurrent(AuthUser user) => + user.TermsAcceptedVersion == TermsVersion && user.PrivacyAcceptedVersion == PrivacyVersion; + + public LegalPolicyStatus Status(AuthUser? user = null) => new( + TermsVersion, PrivacyVersion, EffectiveUtc, TermsUrl, PrivacyUrl, + user is not null && !IsCurrent(user)); + + public void Accept(AuthUser user, string source, DateTime? acceptedUtc = null) + { + var timestamp = acceptedUtc ?? DateTime.UtcNow; + user.LegalAcceptances ??= []; + if (IsCurrent(user) && user.LegalAcceptances.Any(record => + record.TermsVersion == TermsVersion && record.PrivacyVersion == PrivacyVersion)) + return; + user.TermsAcceptedVersion = TermsVersion; + user.PrivacyAcceptedVersion = PrivacyVersion; + user.LegalAcceptedUtc = timestamp; + user.LegalAcceptances.Add(new LegalAcceptanceRecord + { + TermsVersion = TermsVersion, + PrivacyVersion = PrivacyVersion, + AcceptedUtc = timestamp, + Source = source + }); + } +} diff --git a/api/QuestionsApi/Services/PasswordResetServices.cs b/api/QuestionsApi/Services/PasswordResetServices.cs new file mode 100644 index 0000000..69fbda5 --- /dev/null +++ b/api/QuestionsApi/Services/PasswordResetServices.cs @@ -0,0 +1,134 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using MongoDB.Driver; +using QuestionsApi.Models; +using System.Security.Cryptography; +using System.Text; + +namespace QuestionsApi.Services; + +public sealed class PasswordResetCodeService +{ + private readonly byte[]? _key; + + public PasswordResetCodeService(IConfiguration configuration) + { + var key = configuration["PasswordReset:HmacKey"]; + if (!string.IsNullOrWhiteSpace(key) && Encoding.UTF8.GetByteCount(key) >= 32) + _key = Encoding.UTF8.GetBytes(key); + } + + public bool IsConfigured => _key is not null; + + public string GenerateCode() => RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); + + public string Hash(string requestId, string code) + { + if (_key is null) + throw new InvalidOperationException("PasswordReset:HmacKey must contain at least 32 bytes."); + using var hmac = new HMACSHA256(_key); + return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes($"{requestId}:{code}"))); + } + + public bool Verify(string requestId, string code, string expectedHash) + { + if (string.IsNullOrWhiteSpace(expectedHash)) return false; + var actual = Convert.FromHexString(Hash(requestId, code)); + byte[] expected; + try { expected = Convert.FromHexString(expectedHash); } + catch (FormatException) { return false; } + return actual.Length == expected.Length && CryptographicOperations.FixedTimeEquals(actual, expected); + } +} + +public sealed class PasswordResetStore(MongoDatabaseProvider mongo) +{ + private readonly IMongoCollection _requests = + mongo.Database.GetCollection("PasswordResetRequests"); + + public async Task LatestActiveAsync(string userId) => + await _requests.Find(request => request.UserId == userId && request.ConsumedUtc == null && + request.ExpiresUtc > DateTime.UtcNow && request.FailedAttempts < 5) + .SortByDescending(request => request.CreatedUtc).FirstOrDefaultAsync(); + + public Task IssuedRecentlyAsync(string userId, DateTime sinceUtc) => + _requests.Find(request => request.UserId == userId && request.CreatedUtc >= sinceUtc).AnyAsync(); + + public async Task CreateAsync(PasswordResetRequest request) + { + await _requests.UpdateManyAsync( + value => value.UserId == request.UserId && value.ConsumedUtc == null, + Builders.Update.Set(value => value.ConsumedUtc, DateTime.UtcNow)); + await _requests.InsertOneAsync(request); + } + + public Task RecordFailureAsync(string id) => _requests.UpdateOneAsync( + request => request.Id == id && request.ConsumedUtc == null, + Builders.Update.Inc(request => request.FailedAttempts, 1)); + + public async Task TryConsumeAsync(string id) + { + var result = await _requests.UpdateOneAsync( + request => request.Id == id && request.ConsumedUtc == null && + request.ExpiresUtc > DateTime.UtcNow && request.FailedAttempts < 5, + Builders.Update.Set(request => request.ConsumedUtc, DateTime.UtcNow)); + return result.ModifiedCount == 1; + } + + public Task ConsumeForUserAsync(string userId) => _requests.UpdateManyAsync( + request => request.UserId == userId && request.ConsumedUtc == null, + Builders.Update.Set(request => request.ConsumedUtc, DateTime.UtcNow)); + + public Task DeleteForUserAsync(string userId) => + _requests.DeleteManyAsync(request => request.UserId == userId); + + internal IMongoCollection Collection => _requests; +} + +public interface IPasswordResetEmailSender +{ + Task SendAsync(string destination, string username, string code, CancellationToken cancellationToken); +} + +public sealed class SmtpPasswordResetEmailSender(IConfiguration configuration) : IPasswordResetEmailSender +{ + public async Task SendAsync(string destination, string username, string code, + CancellationToken cancellationToken) + { + var host = Required("Smtp:Host"); + var fromAddress = Required("Smtp:FromAddress"); + var fromName = configuration["Smtp:FromName"] ?? "RanaZe: Step 3 Trainer"; + var message = new MimeMessage(); + message.From.Add(new MailboxAddress(fromName, fromAddress)); + message.To.Add(MailboxAddress.Parse(destination)); + message.Subject = "Your RanaZe password reset code"; + message.Body = new TextPart("plain") + { + Text = $"Hello {username},\n\nYour RanaZe: Step 3 Trainer password reset code is {code}. " + + "It expires in 10 minutes and can be used once.\n\n" + + "If you did not request this code, you can ignore this email." + }; + + using var client = new SmtpClient(); + client.Timeout = 15_000; + await client.ConnectAsync(host, configuration.GetValue("Smtp:Port", 587), SecurityOption(), cancellationToken); + var usernameValue = configuration["Smtp:Username"]; + if (!string.IsNullOrWhiteSpace(usernameValue)) + await client.AuthenticateAsync(usernameValue, Required("Smtp:Password"), cancellationToken); + await client.SendAsync(message, cancellationToken); + await client.DisconnectAsync(true, cancellationToken); + } + + private string Required(string key) => configuration[key] + ?? throw new InvalidOperationException($"{key} is required."); + + private SecureSocketOptions SecurityOption() => + (configuration["Smtp:Security"] ?? "StartTls").Trim().ToLowerInvariant() switch + { + "ssl" or "sslontconnect" => SecureSocketOptions.SslOnConnect, + "none" => SecureSocketOptions.None, + "auto" => SecureSocketOptions.Auto, + _ => SecureSocketOptions.StartTls + }; +} diff --git a/api/QuestionsApi/Services/SteamAuthServices.cs b/api/QuestionsApi/Services/SteamAuthServices.cs new file mode 100644 index 0000000..9f685dd --- /dev/null +++ b/api/QuestionsApi/Services/SteamAuthServices.cs @@ -0,0 +1,103 @@ +using MongoDB.Driver; +using QuestionsApi.Models; +using System.Text.RegularExpressions; + +namespace QuestionsApi.Services; + +public sealed class SteamAuthTransactionStore(MongoDatabaseProvider mongo) +{ + private readonly IMongoCollection _transactions = + mongo.Database.GetCollection("SteamAuthTransactions"); + + public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction); + public async Task GetAsync(string id) => + await _transactions.Find(transaction => transaction.Id == id).FirstOrDefaultAsync(); + + public async Task MarkVerifiedAsync(string id, string steamId, string status, string? userId) + { + var filter = Builders.Filter.Eq(value => value.Id, id) & + Builders.Filter.Eq(value => value.Status, "pending") & + Builders.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow); + var update = Builders.Update + .Set(value => value.SteamId, steamId) + .Set(value => value.Status, status) + .Set(value => value.UserId, userId); + return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } + + public async Task MarkCompleteAsync(string id, string steamId, string userId) + { + var filter = Builders.Filter.Eq(value => value.Id, id) & + Builders.Filter.Eq(value => value.Status, "requires_account") & + Builders.Filter.Eq(value => value.SteamId, steamId) & + Builders.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow); + var update = Builders.Update + .Set(value => value.Status, "complete") + .Set(value => value.UserId, userId); + return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1; + } + + public Task DeleteForUserAsync(string userId) => + _transactions.DeleteManyAsync(transaction => transaction.UserId == userId); + + internal IMongoCollection Collection => _transactions; +} + +public sealed partial class SteamOpenIdService(IHttpClientFactory httpClients) +{ + public const string Endpoint = "https://steamcommunity.com/openid/login"; + + [GeneratedRegex("^https?://steamcommunity\\.com/openid/id/(?[0-9]{17})$", + RegexOptions.CultureInvariant)] + private static partial Regex ClaimedIdPattern(); + + public static string BuildAuthenticationUrl(string realm, string returnUrl) + { + var parameters = new Dictionary + { + ["openid.ns"] = "http://specs.openid.net/auth/2.0", + ["openid.mode"] = "checkid_setup", + ["openid.return_to"] = returnUrl, + ["openid.realm"] = realm, + ["openid.identity"] = "http://specs.openid.net/auth/2.0/identifier_select", + ["openid.claimed_id"] = "http://specs.openid.net/auth/2.0/identifier_select" + }; + return Endpoint + "?" + string.Join("&", parameters.Select(pair => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}")); + } + + public async Task VerifyCallbackAsync( + IQueryCollection query, string expectedReturnUrl, CancellationToken cancellationToken) + { + if (!string.Equals(query["openid.mode"], "id_res", StringComparison.Ordinal) || + !string.Equals(query["openid.op_endpoint"].ToString().TrimEnd('/'), Endpoint.TrimEnd('/'), + StringComparison.OrdinalIgnoreCase) || + !string.Equals(query["openid.return_to"], expectedReturnUrl, StringComparison.Ordinal)) + return null; + + var claimedId = query["openid.claimed_id"].ToString(); + if (!string.Equals(claimedId, query["openid.identity"], StringComparison.Ordinal)) return null; + var match = ClaimedIdPattern().Match(claimedId); + if (!match.Success) return null; + + var fields = query.Where(pair => pair.Key.StartsWith("openid.", StringComparison.Ordinal)) + .ToDictionary(pair => pair.Key, pair => pair.Value.ToString()); + fields["openid.mode"] = "check_authentication"; + var client = httpClients.CreateClient(nameof(SteamOpenIdService)); + client.Timeout = TimeSpan.FromSeconds(15); + using var response = await client.PostAsync(Endpoint, new FormUrlEncodedContent(fields), cancellationToken); + if (!response.IsSuccessStatusCode) return null; + var validation = await response.Content.ReadAsStringAsync(cancellationToken); + return IsValidAuthenticationResponse(validation) ? match.Groups["id"].Value : null; + } + + internal static bool IsValidAuthenticationResponse(string response) => + response.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Any(line => string.Equals(line, "is_valid:true", StringComparison.Ordinal)); + + internal static string? ExtractSteamId(string claimedId) + { + var match = ClaimedIdPattern().Match(claimedId); + return match.Success ? match.Groups["id"].Value : null; + } +} diff --git a/api/QuestionsApi/Services/TokenServices.cs b/api/QuestionsApi/Services/TokenServices.cs new file mode 100644 index 0000000..3114d69 --- /dev/null +++ b/api/QuestionsApi/Services/TokenServices.cs @@ -0,0 +1,51 @@ +using Microsoft.IdentityModel.Tokens; +using MongoDB.Driver; +using QuestionsApi.Models; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; + +namespace QuestionsApi.Services; + +public sealed class TokenBlacklistStore(MongoDatabaseProvider mongo) +{ + private readonly IMongoCollection _tokens = + mongo.Database.GetCollection("BlacklistedTokens"); + + public Task AddAsync(string jti, DateTime expiresAt) => + _tokens.ReplaceOneAsync(token => token.Jti == jti, + new BlacklistedToken { Jti = jti, ExpiresAt = expiresAt }, + new ReplaceOptions { IsUpsert = true }); + + public Task ContainsAsync(string jti) => _tokens.Find(token => token.Jti == jti).AnyAsync(); + internal IMongoCollection Collection => _tokens; +} + +public sealed class JwtTokenService(IConfiguration configuration) +{ + public (string Token, string Jti, DateTime ExpiresUtc) Generate(AuthUser user) + { + var key = Encoding.UTF8.GetBytes(configuration["Jwt:Key"] + ?? throw new InvalidOperationException("Jwt:Key missing")); + var issuer = configuration["Jwt:Issuer"] ?? "promiscuity"; + var audience = configuration["Jwt:Audience"] ?? issuer; + var credentials = new SigningCredentials(new SymmetricSecurityKey(key), SecurityAlgorithms.HmacSha256); + var jti = Guid.NewGuid().ToString("N"); + var expiresUtc = DateTime.UtcNow.AddMinutes(15); + 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), + new Claim("auth_ver", user.AuthVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)) + }; + var token = new JwtSecurityToken(issuer, audience, claims, expires: expiresUtc, + signingCredentials: credentials); + return (new JwtSecurityTokenHandler().WriteToken(token), jti, expiresUtc); + } + + public static string NewRefreshToken() => + Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(); +} diff --git a/api/QuestionsApi/appsettings.Development.json b/api/QuestionsApi/appsettings.Development.json index abeee2a..2574885 100644 --- a/api/QuestionsApi/appsettings.Development.json +++ b/api/QuestionsApi/appsettings.Development.json @@ -7,5 +7,9 @@ "CancelUrl": "https://doctor.ranaze.com/donation-canceled.html" }, "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "PasswordReset": { + "Enabled": false, + "HmacKey": "DevelopmentOnlyPasswordResetKey-ReplaceInProduction" + }, "Logging": { "LogLevel": { "Default": "Information" } } } diff --git a/api/QuestionsApi/appsettings.json b/api/QuestionsApi/appsettings.json index d4537a1..2c9d234 100644 --- a/api/QuestionsApi/appsettings.json +++ b/api/QuestionsApi/appsettings.json @@ -6,7 +6,25 @@ "SuccessUrl": "https://doctor.ranaze.com/donation-complete.html?session_id={CHECKOUT_SESSION_ID}", "CancelUrl": "https://doctor.ranaze.com/donation-canceled.html" }, - "Jwt": { "Key": "SuperUltraSecureJwtKeyWithAtLeast32Chars!!", "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, + "Steam": { + "Realm": "https://s3q.ranaze.com", + "ReturnUrl": "https://s3q.ranaze.com/api/Auth/steam/callback", + "TransactionTtlMinutes": 5 + }, + "Legal": { + "TermsVersion": "2026-07-20", + "PrivacyVersion": "2026-07-20", + "EffectiveUtc": "2026-07-20T00:00:00Z", + "TermsUrl": "https://doctor.ranaze.com/terms.html", + "PrivacyUrl": "https://doctor.ranaze.com/privacy.html" + }, + "PasswordReset": { "Enabled": false }, + "Smtp": { + "Port": 587, + "Security": "StartTls", + "FromName": "RanaZe: Step 3 Trainer" + }, + "Jwt": { "Issuer": "promiscuity", "Audience": "promiscuity-auth-api" }, "Logging": { "LogLevel": { "Default": "Information" } }, "AllowedHosts": "*" } diff --git a/api/QuestionsApi/k8s/deployment.yaml b/api/QuestionsApi/k8s/deployment.yaml index 12767a6..d03a434 100644 --- a/api/QuestionsApi/k8s/deployment.yaml +++ b/api/QuestionsApi/k8s/deployment.yaml @@ -30,6 +30,31 @@ spec: ports: - containerPort: 5008 env: + - name: Jwt__Key + valueFrom: + secretKeyRef: + name: ranaze-auth + key: jwt-key + - name: Jwt__Issuer + value: promiscuity + - name: Jwt__Audience + value: promiscuity-auth-api + - name: PasswordReset__Enabled + value: "false" + - name: Steam__Realm + value: https://s3q.ranaze.com + - name: Steam__ReturnUrl + value: https://s3q.ranaze.com/api/Auth/steam/callback + - name: Legal__TermsVersion + value: "2026-07-20" + - name: Legal__PrivacyVersion + value: "2026-07-20" + - name: Legal__EffectiveUtc + value: "2026-07-20T00:00:00Z" + - name: Legal__TermsUrl + value: https://doctor.ranaze.com/terms.html + - name: Legal__PrivacyUrl + value: https://doctor.ranaze.com/privacy.html - name: InternalPurge__Secret valueFrom: secretKeyRef: diff --git a/docs/PROFILE_ACCOUNT_OPERATIONS.md b/docs/PROFILE_ACCOUNT_OPERATIONS.md index 5c4fc6d..ca4beaf 100644 --- a/docs/PROFILE_ACCOUNT_OPERATIONS.md +++ b/docs/PROFILE_ACCOUNT_OPERATIONS.md @@ -5,19 +5,23 @@ The profile screen reads account identity from `GET /api/Auth/me`, study prefere ## Deployment -Set the same non-empty `INTERNAL_PURGE_SECRET` Gitea Actions secret in both the Step 3 and -Promiscuity repositories. Their deployment workflows create namespace-local -`medmind-internal-api` Kubernetes secrets from that value. +Authentication is hosted by the Questions API under `/api/Auth`; it does not depend on the +legacy Promiscuity Auth API. The merged service continues using the existing `Users`, +`BlacklistedTokens`, and `SteamAuthTransactions` collections in the `promiscuity` database. -Deploy in this order: +Configure this Gitea repository secret before deploying: -1. Questions API -2. Auth API -3. Godot web client +- `AUTH_JWT_KEY`: the existing production signing key, unchanged during cutover -The Auth API only removes an account after the Questions API confirms that the user's study -records were purged. The purge endpoint is idempotent and creates a tombstone that prevents an -old access token from recreating study data. +The deployment creates the `ranaze-auth` Kubernetes secret. Steam OpenID callbacks use +`https://s3q.ranaze.com/api/Auth/steam/callback`. Account deletion invokes the study-data purge +in process before deleting the user record. The internal purge endpoint remains temporarily for +older clients and can be removed after the old auth deployment is retired. + +Password reset is disabled until an SMTP provider is configured. When enabled, codes expire after +10 minutes, are stored only as keyed hashes, and invalidate all existing sessions when consumed. +New and updated accounts require a unique normalized email. Legacy accounts with duplicate emails +must initiate recovery with their exact username. ## Data export diff --git a/scripts/autoload/account_profile_service.gd b/scripts/autoload/account_profile_service.gd index fa91768..b847264 100644 --- a/scripts/autoload/account_profile_service.gd +++ b/scripts/autoload/account_profile_service.gd @@ -8,7 +8,7 @@ signal account_deleted signal archive_saved(path: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const AUTH_URL := "https://pauth.ranaze.com/api/Auth" +const AUTH_URL := "https://s3q.ranaze.com/api/Auth" const PROFILE_URL := "https://s3q.ranaze.com/api/profile" var account: Dictionary = {} diff --git a/scripts/autoload/auth_state.gd b/scripts/autoload/auth_state.gd index 05745a3..d52a0b1 100644 --- a/scripts/autoload/auth_state.gd +++ b/scripts/autoload/auth_state.gd @@ -9,7 +9,7 @@ signal session_refresh_failed(message: String, terminal: bool) signal session_expired(reason: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_AUTH_BASE_URL := "https://pauth.ranaze.com/api/Auth" +const DEFAULT_AUTH_BASE_URL := "https://s3q.ranaze.com/api/Auth" const WARNING_SECONDS := 120 const REFRESH_TIMEOUT_SECONDS := 20.0 diff --git a/scripts/autoload/legal_service.gd b/scripts/autoload/legal_service.gd index 5bafa36..b0e015f 100644 --- a/scripts/autoload/legal_service.gd +++ b/scripts/autoload/legal_service.gd @@ -4,7 +4,7 @@ signal acceptance_succeeded signal acceptance_failed(message: String) const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const DEFAULT_AUTH_BASE_URL := "https://pauth.ranaze.com/api/Auth" +const DEFAULT_AUTH_BASE_URL := "https://s3q.ranaze.com/api/Auth" const DOCUMENT_PATH := "res://legal/legal_documents.json" var documents: Dictionary = {} diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 5a4ff9a..7e630f4 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -2,7 +2,7 @@ extends Control const Brand := preload("res://scripts/ui/medmind_style.gd") const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") -const AUTH_BASE := "https://pauth.ranaze.com/api/Auth" +const AUTH_BASE := "https://s3q.ranaze.com/api/Auth" const STEAM_AUTH_STORAGE_KEY := "ranaze_steam_auth_url" var main: Control diff --git a/web/nginx.conf b/web/nginx.conf index 331eca6..bd2a295 100644 --- a/web/nginx.conf +++ b/web/nginx.conf @@ -13,17 +13,6 @@ server { return 200 "ok\n"; } - location ^~ /api/Auth/ { - proxy_pass https://pauth.ranaze.com; - proxy_ssl_server_name on; - proxy_ssl_name pauth.ranaze.com; - proxy_set_header Host pauth.ranaze.com; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto https; - proxy_connect_timeout 10s; - proxy_read_timeout 30s; - } - location ^~ /api/ { proxy_pass https://s3q.ranaze.com; proxy_ssl_server_name on;