Merge authentication into Questions API
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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('/', '_');
|
||||
}
|
||||
@@ -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; } = "";
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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" } }
|
||||
}
|
||||
|
||||
@@ -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": "*"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user