Merge authentication into Questions API

This commit is contained in:
hz
2026-07-20 17:21:15 -05:00
parent 2dd4fa6420
commit 5c406a271f
24 changed files with 1472 additions and 30 deletions
+9
View File
@@ -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
+11
View File
@@ -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 -
+91
View File
@@ -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", "[email protected]", "password1", null)]
[InlineData("ab", "[email protected]", "password1", "Username")]
[InlineData("invalid-user", "[email protected]", "password1", "Username")]
[InlineData("valid_user", "not-an-email", "password1", "email")]
[InlineData("valid_user", "[email protected]", "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<string, string?>
{
["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<string, string?>
{
["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<string, string?> values) =>
new ConfigurationBuilder().AddInMemoryCollection(values).Build();
}
@@ -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<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(ProfileResponse(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(ProfileResponse(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 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 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;
}
@@ -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<SteamAuthController> logger,
LegalPolicy legal) : ControllerBase
{
[HttpPost("start")]
[EnableRateLimiting("steam-start")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<SteamAuthTransaction?> 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 = $$"""
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<title>RanaZe Steam sign-in</title><style>body{font-family:system-ui;background:#081324;color:#f8fafc;display:grid;place-items:center;min-height:100vh;margin:0}.card{max-width:34rem;padding:2rem;border:1px solid #33415d;border-radius:8px}h1{color:{{color}};font-size:1.4rem}</style></head>
<body><main class="card"><h1>{{(success ? "Steam sign-in complete" : "Steam sign-in failed")}}</h1><p>{{System.Net.WebUtility.HtmlEncode(message)}}</p></main>
<script>if(window.opener){setTimeout(()=>window.close(),1200);}</script></body></html>
""";
return Content(html, "text/html; charset=utf-8");
}
private static string Base64Url(byte[] bytes) =>
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
}
+134
View File
@@ -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<LegalAcceptanceRecord> 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; } = "";
}
+69 -4
View File
@@ -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<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.ForwardLimit = 2;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddSingleton<MongoDatabaseProvider>();
builder.Services.AddSingleton<QuestionStore>();
@@ -26,10 +37,43 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<AuthUserStore>();
builder.Services.AddSingleton<TokenBlacklistStore>();
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<LegalPolicy>();
builder.Services.AddSingleton<SteamAuthTransactionStore>();
builder.Services.AddSingleton<SteamOpenIdService>();
builder.Services.AddSingleton<PasswordResetStore>();
builder.Services.AddSingleton<PasswordResetCodeService>();
builder.Services.AddSingleton<IPasswordResetEmailSender, SmtpPasswordResetEmailSender>();
builder.Services.AddSingleton<AuthDataInitializer>();
builder.Services.AddSingleton<DonationStore>();
builder.Services.AddSingleton<IStripeDonationGateway, StripeDonationGateway>();
builder.Services.AddSingleton<ServiceMetrics>();
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
builder.Services.AddHttpClient();
builder.Services.AddRateLimiter(options =>
{
static RateLimitPartition<string> 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<UserProfileStore>();
if (await profiles.IsDeletedAsync(userId)) context.Fail("Account deleted");
context.Fail("User identity missing");
return;
}
var services = context.HttpContext.RequestServices;
var userTask = services.GetRequiredService<AuthUserStore>().GetByIdAsync(userId);
var deletedTask = services.GetRequiredService<UserProfileStore>().IsDeletedAsync(userId);
var jti = context.Principal?.FindFirstValue(JwtRegisteredClaimNames.Jti);
var revokedTask = string.IsNullOrWhiteSpace(jti)
? Task.FromResult(false)
: services.GetRequiredService<TokenBlacklistStore>().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<AuthDataInitializer>().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();
+6
View File
@@ -7,6 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" />
<PackageReference Include="MongoDB.Driver" Version="3.4.3" />
@@ -14,4 +16,8 @@
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="QuestionsApi.Tests" />
</ItemGroup>
</Project>
@@ -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<AuthDataInitializer> logger)
{
public async Task InitializeAsync()
{
await BackfillNormalizedEmailsAsync();
await users.Collection.Indexes.CreateManyAsync([
new CreateIndexModel<AuthUser>(
Builders<AuthUser>.IndexKeys.Ascending(user => user.Username),
new CreateIndexOptions { Unique = true }),
new CreateIndexModel<AuthUser>(
Builders<AuthUser>.IndexKeys.Ascending(user => user.SteamId),
new CreateIndexOptions<AuthUser>
{
Unique = true,
PartialFilterExpression = new BsonDocument("SteamId", new BsonDocument("$type", "string"))
}),
new CreateIndexModel<AuthUser>(
Builders<AuthUser>.IndexKeys.Ascending(user => user.NormalizedEmail),
new CreateIndexOptions<AuthUser>
{
Unique = true,
PartialFilterExpression = new BsonDocument("NormalizedEmail", new BsonDocument("$type", "string"))
})
]);
await blacklist.Collection.Indexes.CreateOneAsync(new CreateIndexModel<BlacklistedToken>(
Builders<BlacklistedToken>.IndexKeys.Ascending(token => token.ExpiresAt),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
await steamTransactions.Collection.Indexes.CreateOneAsync(new CreateIndexModel<SteamAuthTransaction>(
Builders<SteamAuthTransaction>.IndexKeys.Ascending(transaction => transaction.ExpiresUtc),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero }));
await passwordResets.Collection.Indexes.CreateManyAsync([
new CreateIndexModel<PasswordResetRequest>(
Builders<PasswordResetRequest>.IndexKeys.Ascending(request => request.ExpiresUtc),
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "password_reset_expiry_ttl" }),
new CreateIndexModel<PasswordResetRequest>(
Builders<PasswordResetRequest>.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<AuthUser>.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);
}
}
@@ -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<AuthUser> _users = mongo.Database.GetCollection<AuthUser>("Users");
public async Task<AuthUser?> GetByUsernameAsync(string username) =>
await _users.Find(user => user.Username == username.Trim()).FirstOrDefaultAsync();
public async Task<AuthUser?> GetByIdAsync(string id) =>
await _users.Find(user => user.Id == id).FirstOrDefaultAsync();
public async Task<AuthUser?> GetBySteamIdAsync(string steamId) =>
await _users.Find(user => user.SteamId == steamId).FirstOrDefaultAsync();
public async Task<AuthUser?> GetUniqueByEmailAsync(string email)
{
var normalized = AuthValidation.NormalizeEmail(email);
var filter = Builders<AuthUser>.Filter.Eq(user => user.NormalizedEmail, normalized) |
Builders<AuthUser>.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<AuthUser?> ResolveUniqueLoginAsync(string login)
{
var username = await GetByUsernameAsync(login);
return username ?? await GetUniqueByEmailAsync(login);
}
public async Task<List<AuthUser>> 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<bool> EmailExistsAsync(string email, string? excludingUserId = null)
{
var normalized = AuthValidation.NormalizeEmail(email);
var filter = (Builders<AuthUser>.Filter.Eq(user => user.NormalizedEmail, normalized) |
Builders<AuthUser>.Filter.Regex(user => user.Email,
new BsonRegularExpression($"^{Regex.Escape(normalized)}$", "i")));
if (!string.IsNullOrWhiteSpace(excludingUserId))
filter &= Builders<AuthUser>.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<bool> TryLinkSteamAsync(string userId, string steamId, string refreshToken, DateTime refreshExpiry)
{
var filter = Builders<AuthUser>.Filter.Eq(user => user.Id, userId) &
(Builders<AuthUser>.Filter.Eq(user => user.SteamId, null) |
Builders<AuthUser>.Filter.Eq(user => user.SteamId, steamId));
var update = Builders<AuthUser>.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<bool> ResetPasswordAsync(string userId, string passwordHash)
{
var update = Builders<AuthUser>.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<List<AuthUser>> GetAllAsync() => _users.Find(FilterDefinition<AuthUser>.Empty).ToListAsync();
internal IMongoCollection<AuthUser> Collection => _users;
}
@@ -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;
}
}
+50
View File
@@ -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
});
}
}
@@ -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<PasswordResetRequest> _requests =
mongo.Database.GetCollection<PasswordResetRequest>("PasswordResetRequests");
public async Task<PasswordResetRequest?> 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<bool> 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<PasswordResetRequest>.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<PasswordResetRequest>.Update.Inc(request => request.FailedAttempts, 1));
public async Task<bool> TryConsumeAsync(string id)
{
var result = await _requests.UpdateOneAsync(
request => request.Id == id && request.ConsumedUtc == null &&
request.ExpiresUtc > DateTime.UtcNow && request.FailedAttempts < 5,
Builders<PasswordResetRequest>.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<PasswordResetRequest>.Update.Set(request => request.ConsumedUtc, DateTime.UtcNow));
public Task DeleteForUserAsync(string userId) =>
_requests.DeleteManyAsync(request => request.UserId == userId);
internal IMongoCollection<PasswordResetRequest> 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
};
}
@@ -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<SteamAuthTransaction> _transactions =
mongo.Database.GetCollection<SteamAuthTransaction>("SteamAuthTransactions");
public Task CreateAsync(SteamAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
public async Task<SteamAuthTransaction?> GetAsync(string id) =>
await _transactions.Find(transaction => transaction.Id == id).FirstOrDefaultAsync();
public async Task<bool> MarkVerifiedAsync(string id, string steamId, string status, string? userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(value => value.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(value => value.Status, "pending") &
Builders<SteamAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.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<bool> MarkCompleteAsync(string id, string steamId, string userId)
{
var filter = Builders<SteamAuthTransaction>.Filter.Eq(value => value.Id, id) &
Builders<SteamAuthTransaction>.Filter.Eq(value => value.Status, "requires_account") &
Builders<SteamAuthTransaction>.Filter.Eq(value => value.SteamId, steamId) &
Builders<SteamAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
var update = Builders<SteamAuthTransaction>.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<SteamAuthTransaction> Collection => _transactions;
}
public sealed partial class SteamOpenIdService(IHttpClientFactory httpClients)
{
public const string Endpoint = "https://steamcommunity.com/openid/login";
[GeneratedRegex("^https?://steamcommunity\\.com/openid/id/(?<id>[0-9]{17})$",
RegexOptions.CultureInvariant)]
private static partial Regex ClaimedIdPattern();
public static string BuildAuthenticationUrl(string realm, string returnUrl)
{
var parameters = new Dictionary<string, string>
{
["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<string?> 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;
}
}
@@ -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<BlacklistedToken> _tokens =
mongo.Database.GetCollection<BlacklistedToken>("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<bool> ContainsAsync(string jti) => _tokens.Find(token => token.Jti == jti).AnyAsync();
internal IMongoCollection<BlacklistedToken> 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();
}
@@ -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" } }
}
+19 -1
View File
@@ -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": "*"
}
+25
View File
@@ -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:
+14 -10
View File
@@ -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
+1 -1
View File
@@ -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 = {}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 = {}
+1 -1
View File
@@ -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
-11
View File
@@ -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;