Add provider-neutral Google authentication
This commit is contained in:
@@ -8,12 +8,17 @@ public sealed class AuthDataInitializer(
|
||||
AuthUserStore users,
|
||||
TokenBlacklistStore blacklist,
|
||||
SteamAuthTransactionStore steamTransactions,
|
||||
ExternalIdentityStore externalIdentities,
|
||||
ExternalAuthTransactionStore externalTransactions,
|
||||
PasswordResetStore passwordResets,
|
||||
ILogger<AuthDataInitializer> logger)
|
||||
{
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await BackfillNormalizedEmailsAsync();
|
||||
await externalIdentities.BackfillSteamAsync(logger);
|
||||
await externalIdentities.EnsureIndexesAsync();
|
||||
await externalTransactions.EnsureIndexesAsync();
|
||||
|
||||
await users.Collection.Indexes.CreateManyAsync([
|
||||
new CreateIndexModel<AuthUser>(
|
||||
|
||||
@@ -70,6 +70,15 @@ public sealed class AuthUserStore(MongoDatabaseProvider mongo)
|
||||
return (await _users.UpdateOneAsync(filter, update)).MatchedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> TrySetSteamIdAsync(string userId, string steamId)
|
||||
{
|
||||
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));
|
||||
return (await _users.UpdateOneAsync(filter,
|
||||
Builders<AuthUser>.Update.Set(user => user.SteamId, steamId))).MatchedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> ResetPasswordAsync(string userId, string passwordHash)
|
||||
{
|
||||
var update = Builders<AuthUser>.Update
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
using Microsoft.IdentityModel.Protocols;
|
||||
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QuestionsApi.Models;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public interface IExternalAuthProvider
|
||||
{
|
||||
string Id { get; }
|
||||
string DisplayName { get; }
|
||||
bool IsConfigured { get; }
|
||||
string CallbackUrl { get; }
|
||||
string BuildAuthorizationUrl(ExternalAuthTransaction transaction);
|
||||
Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
||||
IQueryCollection query,
|
||||
ExternalAuthTransaction transaction,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class SteamExternalAuthProvider(
|
||||
SteamOpenIdService openId,
|
||||
IConfiguration configuration) : IExternalAuthProvider
|
||||
{
|
||||
public string Id => ExternalAuthProviders.Steam;
|
||||
public string DisplayName => "Steam";
|
||||
public bool IsConfigured => true;
|
||||
public string CallbackUrl => configuration["ExternalAuth:SteamReturnUrl"] ??
|
||||
$"{Realm()}/api/Auth/external/steam/callback";
|
||||
|
||||
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction) =>
|
||||
SteamOpenIdService.BuildAuthenticationUrl(Realm(), CallbackUrlWithTransaction(transaction.Id));
|
||||
|
||||
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
||||
IQueryCollection query,
|
||||
ExternalAuthTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var subject = await openId.VerifyCallbackAsync(
|
||||
query, CallbackUrlWithTransaction(transaction.Id), cancellationToken);
|
||||
return subject is null ? null : new ExternalProviderClaims(subject, null, false);
|
||||
}
|
||||
|
||||
private string Realm() =>
|
||||
(configuration["Steam:Realm"] ?? "https://s3q.ranaze.com").TrimEnd('/');
|
||||
|
||||
private string CallbackUrlWithTransaction(string transactionId) =>
|
||||
$"{CallbackUrl}?transactionId={Uri.EscapeDataString(transactionId)}";
|
||||
}
|
||||
|
||||
public sealed class GoogleExternalAuthProvider : IExternalAuthProvider
|
||||
{
|
||||
private const string AuthorizationEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
private const string DiscoveryEndpoint = "https://accounts.google.com/.well-known/openid-configuration";
|
||||
private readonly IHttpClientFactory _httpClients;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IConfigurationManager<OpenIdConnectConfiguration> _discovery;
|
||||
|
||||
public GoogleExternalAuthProvider(IHttpClientFactory httpClients, IConfiguration configuration)
|
||||
{
|
||||
_httpClients = httpClients;
|
||||
_configuration = configuration;
|
||||
_discovery = new ConfigurationManager<OpenIdConnectConfiguration>(
|
||||
DiscoveryEndpoint, new OpenIdConnectConfigurationRetriever());
|
||||
}
|
||||
|
||||
public string Id => ExternalAuthProviders.Google;
|
||||
public string DisplayName => "Google";
|
||||
public string CallbackUrl => _configuration["Google:ReturnUrl"] ?? "";
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(ClientId) &&
|
||||
!string.IsNullOrWhiteSpace(ClientSecret) &&
|
||||
Uri.TryCreate(CallbackUrl, UriKind.Absolute, out var callback) && callback.Scheme == Uri.UriSchemeHttps;
|
||||
|
||||
private string ClientId => _configuration["Google:ClientId"] ?? "";
|
||||
private string ClientSecret => _configuration["Google:ClientSecret"] ?? "";
|
||||
|
||||
public string BuildAuthorizationUrl(ExternalAuthTransaction transaction)
|
||||
{
|
||||
var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(transaction.CodeVerifier ?? "")));
|
||||
return AuthorizationEndpoint + "?" + Query(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = ClientId,
|
||||
["redirect_uri"] = CallbackUrl,
|
||||
["response_type"] = "code",
|
||||
["scope"] = "openid email profile",
|
||||
["state"] = transaction.Id,
|
||||
["nonce"] = transaction.Nonce ?? "",
|
||||
["code_challenge"] = challenge,
|
||||
["code_challenge_method"] = "S256",
|
||||
["prompt"] = "select_account"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<ExternalProviderClaims?> VerifyCallbackAsync(
|
||||
IQueryCollection query,
|
||||
ExternalAuthTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!IsConfigured || !string.IsNullOrWhiteSpace(query["error"]) ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(query["state"].ToString()),
|
||||
Encoding.UTF8.GetBytes(transaction.Id)))
|
||||
return null;
|
||||
var code = query["code"].ToString();
|
||||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(transaction.CodeVerifier)) return null;
|
||||
|
||||
var client = _httpClients.CreateClient(nameof(GoogleExternalAuthProvider));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
using var response = await client.PostAsync(TokenEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
|
||||
{
|
||||
["client_id"] = ClientId,
|
||||
["client_secret"] = ClientSecret,
|
||||
["code"] = code,
|
||||
["code_verifier"] = transaction.CodeVerifier,
|
||||
["grant_type"] = "authorization_code",
|
||||
["redirect_uri"] = CallbackUrl
|
||||
}), cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
using var payload = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
|
||||
if (!payload.RootElement.TryGetProperty("id_token", out var idTokenElement)) return null;
|
||||
var idToken = idTokenElement.GetString();
|
||||
if (string.IsNullOrWhiteSpace(idToken)) return null;
|
||||
|
||||
var oidc = await _discovery.GetConfigurationAsync(cancellationToken);
|
||||
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
|
||||
ClaimsPrincipal principal;
|
||||
try
|
||||
{
|
||||
principal = handler.ValidateToken(idToken, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuers = ["https://accounts.google.com", "accounts.google.com"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = ClientId,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKeys = oidc.SigningKeys,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
}, out _);
|
||||
}
|
||||
catch (SecurityTokenException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var subject = principal.FindFirstValue("sub");
|
||||
var nonce = principal.FindFirstValue("nonce");
|
||||
var email = principal.FindFirstValue("email");
|
||||
var verified = string.Equals(principal.FindFirstValue("email_verified"), "true",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
if (string.IsNullOrWhiteSpace(subject) || string.IsNullOrWhiteSpace(nonce) ||
|
||||
!CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(nonce), Encoding.UTF8.GetBytes(transaction.Nonce ?? "")))
|
||||
return null;
|
||||
return new ExternalProviderClaims(subject, email, verified);
|
||||
}
|
||||
|
||||
internal static string NewVerifier() => Base64Url(RandomNumberGenerator.GetBytes(48));
|
||||
internal static string NewNonce() => Base64Url(RandomNumberGenerator.GetBytes(32));
|
||||
|
||||
private static string Query(Dictionary<string, string> values) => string.Join("&", values.Select(pair =>
|
||||
$"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"));
|
||||
|
||||
private static string Base64Url(byte[] bytes) =>
|
||||
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class ExternalIdentityStore(MongoDatabaseProvider mongo, AuthUserStore users)
|
||||
{
|
||||
private readonly IMongoCollection<ExternalIdentity> _identities =
|
||||
mongo.Database.GetCollection<ExternalIdentity>("ExternalIdentities");
|
||||
|
||||
public async Task<ExternalIdentity?> GetAsync(string provider, string subject) =>
|
||||
await _identities.Find(value => value.Provider == provider && value.Subject == subject)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<ExternalIdentity?> GetForUserAsync(string userId, string provider) =>
|
||||
await _identities.Find(value => value.UserId == userId && value.Provider == provider)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
public async Task<List<string>> ProvidersForUserAsync(string userId) =>
|
||||
await _identities.Find(value => value.UserId == userId)
|
||||
.Project(value => value.Provider).ToListAsync();
|
||||
|
||||
public async Task TouchAsync(string id) =>
|
||||
await _identities.UpdateOneAsync(value => value.Id == id,
|
||||
Builders<ExternalIdentity>.Update.Set(value => value.LastUsedUtc, DateTime.UtcNow));
|
||||
|
||||
public async Task<bool> TryLinkAsync(
|
||||
string userId,
|
||||
string provider,
|
||||
ExternalProviderClaims claims)
|
||||
{
|
||||
var bySubject = await GetAsync(provider, claims.Subject);
|
||||
if (bySubject is not null) return bySubject.UserId == userId;
|
||||
var byUser = await GetForUserAsync(userId, provider);
|
||||
if (byUser is not null) return byUser.Subject == claims.Subject;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await _identities.InsertOneAsync(new ExternalIdentity
|
||||
{
|
||||
UserId = userId,
|
||||
Provider = provider,
|
||||
Subject = claims.Subject,
|
||||
Email = claims.Email,
|
||||
EmailVerified = claims.EmailVerified,
|
||||
CreatedUtc = now,
|
||||
LastUsedUtc = now
|
||||
});
|
||||
if (provider == ExternalAuthProviders.Steam &&
|
||||
!await users.TrySetSteamIdAsync(userId, claims.Subject))
|
||||
{
|
||||
await _identities.DeleteOneAsync(value =>
|
||||
value.UserId == userId && value.Provider == provider && value.Subject == claims.Subject);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (MongoWriteException exception) when (
|
||||
exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task BackfillSteamAsync(ILogger logger)
|
||||
{
|
||||
var candidates = await users.Collection.Find(user => user.SteamId != null && user.SteamId != "")
|
||||
.ToListAsync();
|
||||
var inserted = 0;
|
||||
foreach (var user in candidates)
|
||||
{
|
||||
var filter = Builders<ExternalIdentity>.Filter.Eq(value => value.Provider, ExternalAuthProviders.Steam) &
|
||||
Builders<ExternalIdentity>.Filter.Eq(value => value.Subject, user.SteamId!);
|
||||
var update = Builders<ExternalIdentity>.Update
|
||||
.SetOnInsert(value => value.UserId, user.Id)
|
||||
.SetOnInsert(value => value.Provider, ExternalAuthProviders.Steam)
|
||||
.SetOnInsert(value => value.Subject, user.SteamId!)
|
||||
.SetOnInsert(value => value.CreatedUtc, user.CreatedUtc ?? DateTime.UtcNow)
|
||||
.SetOnInsert(value => value.LastUsedUtc, DateTime.UtcNow);
|
||||
var result = await _identities.UpdateOneAsync(filter, update, new UpdateOptions { IsUpsert = true });
|
||||
if (result.UpsertedId is not null) inserted++;
|
||||
var identity = await _identities.Find(filter).FirstAsync();
|
||||
if (identity.UserId != user.Id)
|
||||
throw new InvalidOperationException(
|
||||
$"Steam identity {user.SteamId} is associated with conflicting users.");
|
||||
}
|
||||
logger.LogInformation("External identity migration found {Candidates} Steam users and inserted {Inserted}",
|
||||
candidates.Count, inserted);
|
||||
}
|
||||
|
||||
public async Task EnsureIndexesAsync()
|
||||
{
|
||||
await _identities.Indexes.CreateManyAsync([
|
||||
new CreateIndexModel<ExternalIdentity>(
|
||||
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.Provider)
|
||||
.Ascending(value => value.Subject),
|
||||
new CreateIndexOptions { Unique = true, Name = "external_identity_provider_subject" }),
|
||||
new CreateIndexModel<ExternalIdentity>(
|
||||
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId)
|
||||
.Ascending(value => value.Provider),
|
||||
new CreateIndexOptions { Unique = true, Name = "external_identity_user_provider" }),
|
||||
new CreateIndexModel<ExternalIdentity>(
|
||||
Builders<ExternalIdentity>.IndexKeys.Ascending(value => value.UserId),
|
||||
new CreateIndexOptions { Name = "external_identity_user" })
|
||||
]);
|
||||
}
|
||||
|
||||
internal IMongoCollection<ExternalIdentity> Collection => _identities;
|
||||
}
|
||||
|
||||
public sealed class ExternalAuthTransactionStore(MongoDatabaseProvider mongo)
|
||||
{
|
||||
private readonly IMongoCollection<ExternalAuthTransaction> _transactions =
|
||||
mongo.Database.GetCollection<ExternalAuthTransaction>("ExternalAuthTransactions");
|
||||
|
||||
public Task CreateAsync(ExternalAuthTransaction transaction) => _transactions.InsertOneAsync(transaction);
|
||||
|
||||
public async Task<ExternalAuthTransaction?> GetAsync(string id) =>
|
||||
await _transactions.Find(value => value.Id == id).FirstOrDefaultAsync();
|
||||
|
||||
public async Task<bool> MarkVerifiedAsync(
|
||||
string id,
|
||||
ExternalProviderClaims claims,
|
||||
string status,
|
||||
string? userId,
|
||||
string? error = null)
|
||||
{
|
||||
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
|
||||
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "pending") &
|
||||
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<ExternalAuthTransaction>.Update
|
||||
.Set(value => value.Subject, claims.Subject)
|
||||
.Set(value => value.ProviderEmail, claims.Email)
|
||||
.Set(value => value.EmailVerified, claims.EmailVerified)
|
||||
.Set(value => value.Status, status)
|
||||
.Set(value => value.UserId, userId)
|
||||
.Set(value => value.Error, error);
|
||||
return (await _transactions.UpdateOneAsync(filter, update)).ModifiedCount == 1;
|
||||
}
|
||||
|
||||
public async Task<bool> MarkCompleteAsync(string id, string subject, string userId)
|
||||
{
|
||||
var filter = Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Id, id) &
|
||||
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Status, "requires_account") &
|
||||
Builders<ExternalAuthTransaction>.Filter.Eq(value => value.Subject, subject) &
|
||||
Builders<ExternalAuthTransaction>.Filter.Gt(value => value.ExpiresUtc, DateTime.UtcNow);
|
||||
var update = Builders<ExternalAuthTransaction>.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(value => value.UserId == userId);
|
||||
|
||||
public Task EnsureIndexesAsync() => _transactions.Indexes.CreateOneAsync(
|
||||
new CreateIndexModel<ExternalAuthTransaction>(
|
||||
Builders<ExternalAuthTransaction>.IndexKeys.Ascending(value => value.ExpiresUtc),
|
||||
new CreateIndexOptions { ExpireAfter = TimeSpan.Zero, Name = "external_auth_expiry" }));
|
||||
|
||||
internal IMongoCollection<ExternalAuthTransaction> Collection => _transactions;
|
||||
}
|
||||
@@ -15,7 +15,8 @@ public sealed class UserProfileStore
|
||||
"QuestionAttempts", "UserRatings", "QuestionBookmarks", "UserNotepads",
|
||||
"ReviewQueueItems", "QuestionReviewAttempts", "ReviewQueueBackfillStates",
|
||||
"CcsAttempts", "CcsUserRatings", "CcsRatedCaseResults", "ExamSimulations",
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles"
|
||||
"Step3Outcomes", "QuestionReports", "CcsCaseReports", "AppFeedback", "UserStudyProfiles",
|
||||
"ExternalIdentities", "ExternalAuthTransactions"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
Reference in New Issue
Block a user