Merge authentication into Questions API
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user