using System.Globalization; using DysonNetwork.Sphere.Auth; using DysonNetwork.Sphere.Storage; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; using NodaTime; using OtpNet; namespace DysonNetwork.Sphere.Account; public class AccountService( AppDatabase db, MagicSpellService spells, NotificationService nty, ICacheService cache ) { public static void SetCultureInfo(Account account) { SetCultureInfo(account.Language); } public static void SetCultureInfo(string? languageCode) { var info = new CultureInfo(languageCode ?? "en-us", false); CultureInfo.CurrentCulture = info; CultureInfo.CurrentUICulture = info; } public const string AccountCachePrefix = "account:"; public async Task PurgeAccountCache(Account account) { await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}"); } public async Task LookupAccount(string probe) { var account = await db.Accounts.Where(a => a.Name == probe).FirstOrDefaultAsync(); if (account is not null) return account; var contact = await db.AccountContacts .Where(c => c.Content == probe) .Include(c => c.Account) .FirstOrDefaultAsync(); return contact?.Account; } public async Task GetAccountLevel(Guid accountId) { var profile = await db.AccountProfiles .Where(a => a.AccountId == accountId) .FirstOrDefaultAsync(); return profile?.Level; } public async Task RequestAccountDeletion(Account account) { var spell = await spells.CreateMagicSpell( account, MagicSpellType.AccountRemoval, new Dictionary(), SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true ); await spells.NotifyMagicSpell(spell); } public async Task RequestPasswordReset(Account account) { var spell = await spells.CreateMagicSpell( account, MagicSpellType.AuthPasswordReset, new Dictionary(), SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), preventRepeat: true ); await spells.NotifyMagicSpell(spell); } public async Task CheckAuthFactorExists(Account account, AccountAuthFactorType type) { var isExists = await db.AccountAuthFactors .Where(x => x.AccountId == account.Id && x.Type == type) .AnyAsync(); return isExists; } public async Task CreateAuthFactor(Account account, AccountAuthFactorType type, string? secret) { AccountAuthFactor? factor = null; switch (type) { case AccountAuthFactorType.Password: if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret)); factor = new AccountAuthFactor { Type = AccountAuthFactorType.Password, Trustworthy = 1, AccountId = account.Id, Secret = secret, EnabledAt = SystemClock.Instance.GetCurrentInstant(), }.HashSecret(); break; case AccountAuthFactorType.EmailCode: factor = new AccountAuthFactor { Type = AccountAuthFactorType.EmailCode, Trustworthy = 2, EnabledAt = SystemClock.Instance.GetCurrentInstant(), }; break; case AccountAuthFactorType.InAppCode: factor = new AccountAuthFactor { Type = AccountAuthFactorType.InAppCode, Trustworthy = 1, EnabledAt = SystemClock.Instance.GetCurrentInstant() }; break; case AccountAuthFactorType.TimedCode: var skOtp = KeyGeneration.GenerateRandomKey(20); var skOtp32 = Base32Encoding.ToString(skOtp); factor = new AccountAuthFactor { Secret = skOtp32, Type = AccountAuthFactorType.TimedCode, Trustworthy = 2, EnabledAt = null, // It needs to be tired once to enable CreatedResponse = new Dictionary { ["uri"] = new OtpUri( OtpType.Totp, skOtp32, account.Id.ToString(), "Solar Network" ).ToString(), } }; break; default: throw new ArgumentOutOfRangeException(nameof(type), type, null); } if (factor is null) throw new InvalidOperationException("Unable to create auth factor."); factor.AccountId = account.Id; db.AccountAuthFactors.Add(factor); await db.SaveChangesAsync(); return factor; } public async Task EnableAuthFactor(AccountAuthFactor factor, string code) { if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled."); if (!factor.VerifyPassword(code)) throw new InvalidOperationException( "Invalid code, you need to enter the correct code to enable the factor." ); factor.EnabledAt = SystemClock.Instance.GetCurrentInstant(); db.Update(factor); await db.SaveChangesAsync(); return factor; } public async Task DisableAuthFactor(AccountAuthFactor factor) { if (factor.EnabledAt is null) throw new ArgumentException("The factor has been disabled."); var count = await db.AccountAuthFactors .Where(f => f.AccountId == factor.AccountId && f.EnabledAt != null) .CountAsync(); if (count <= 1) throw new InvalidOperationException( "Disabling this auth factor will cause you have no active auth factors."); factor.EnabledAt = SystemClock.Instance.GetCurrentInstant(); return factor; } public async Task DeleteAuthFactor(AccountAuthFactor factor) { var count = await db.AccountAuthFactors .Where(f => f.AccountId == factor.AccountId) .If(factor.EnabledAt is not null, q => q.Where(f => f.EnabledAt != null)) .CountAsync(); if (count <= 1) throw new InvalidOperationException("Deleting this auth factor will cause you have no auth factor."); db.AccountAuthFactors.Remove(factor); await db.SaveChangesAsync(); } public async Task DeleteSession(Account account, Guid sessionId) { var session = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.Id == sessionId && s.AccountId == account.Id) .FirstOrDefaultAsync(); if (session is null) throw new InvalidOperationException("Session was not found."); var sessions = await db.AuthSessions .Include(s => s.Challenge) .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) .ToListAsync(); if(session.Challenge.DeviceId is not null) await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); // The current session should be included in the sessions' list db.AuthSessions.RemoveRange(sessions); await db.SaveChangesAsync(); foreach (var item in sessions) await cache.RemoveAsync($"{DysonTokenAuthHandler.AuthCachePrefix}{item.Id}"); } /// Maintenance methods for server administrator public async Task EnsureAccountProfileCreated() { var accountsId = await db.Accounts.Select(a => a.Id).ToListAsync(); var existingId = await db.AccountProfiles.Select(p => p.AccountId).ToListAsync(); var missingId = accountsId.Except(existingId).ToList(); if (missingId.Count != 0) { var newProfiles = missingId.Select(id => new Profile { Id = Guid.NewGuid(), AccountId = id }).ToList(); await db.BulkInsertAsync(newProfiles); } } }