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
+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: