165 lines
7.7 KiB
C#
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;
|
|
}
|