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
@@ -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();
}