Add provider-neutral Google authentication
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user