Files
step3/api/QuestionsApi/Services/ExternalIdentityStore.cs
T

165 lines
7.7 KiB
C#

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;
}