749 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			749 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System.Globalization;
 | |
| using DysonNetwork.Pass.Auth.OpenId;
 | |
| using DysonNetwork.Pass.Localization;
 | |
| using DysonNetwork.Pass.Mailer;
 | |
| using DysonNetwork.Shared.Cache;
 | |
| using DysonNetwork.Shared.Models;
 | |
| using DysonNetwork.Shared.Proto;
 | |
| using DysonNetwork.Shared.Stream;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using Microsoft.Extensions.Localization;
 | |
| using NATS.Client.Core;
 | |
| using NATS.Net;
 | |
| using NodaTime;
 | |
| using OtpNet;
 | |
| using AuthService = DysonNetwork.Pass.Auth.AuthService;
 | |
| 
 | |
| namespace DysonNetwork.Pass.Account;
 | |
| 
 | |
| public class AccountService(
 | |
|     AppDatabase db,
 | |
|     MagicSpellService spells,
 | |
|     FileService.FileServiceClient files,
 | |
|     FileReferenceService.FileReferenceServiceClient fileRefs,
 | |
|     AccountUsernameService uname,
 | |
|     EmailService mailer,
 | |
|     RingService.RingServiceClient pusher,
 | |
|     IStringLocalizer<NotificationResource> localizer,
 | |
|     IStringLocalizer<EmailResource> emailLocalizer,
 | |
|     ICacheService cache,
 | |
|     ILogger<AccountService> logger,
 | |
|     INatsConnection nats
 | |
| )
 | |
| {
 | |
|     public static void SetCultureInfo(SnAccount 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(SnAccount account)
 | |
|     {
 | |
|         await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccount?> 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<SnAccount?> LookupAccountByConnection(string identifier, string provider)
 | |
|     {
 | |
|         var connection = await db.AccountConnections
 | |
|             .Where(c => c.ProvidedIdentifier == identifier && c.Provider == provider)
 | |
|             .Include(c => c.Account)
 | |
|             .FirstOrDefaultAsync();
 | |
|         return connection?.Account;
 | |
|     }
 | |
| 
 | |
|     public async Task<int?> GetAccountLevel(Guid accountId)
 | |
|     {
 | |
|         var profile = await db.AccountProfiles
 | |
|             .Where(a => a.AccountId == accountId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         return profile?.Level;
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccount> CreateAccount(
 | |
|         string name,
 | |
|         string nick,
 | |
|         string email,
 | |
|         string? password,
 | |
|         string language = "en-US",
 | |
|         string region = "en",
 | |
|         bool isEmailVerified = false,
 | |
|         bool isActivated = false
 | |
|     )
 | |
|     {
 | |
|         var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
 | |
|         if (dupeNameCount > 0)
 | |
|             throw new InvalidOperationException("Account name has already been taken.");
 | |
| 
 | |
|         var dupeEmailCount = await db.AccountContacts
 | |
|             .Where(c => c.Content == email && c.Type == Shared.Models.AccountContactType.Email
 | |
|             ).CountAsync();
 | |
|         if (dupeEmailCount > 0)
 | |
|             throw new InvalidOperationException("Account email has already been used.");
 | |
| 
 | |
|         var account = new SnAccount
 | |
|         {
 | |
|             Name = name,
 | |
|             Nick = nick,
 | |
|             Language = language,
 | |
|             Region = region,
 | |
|             Contacts =
 | |
|             [
 | |
|                 new()
 | |
|                 {
 | |
|                     Type = Shared.Models.AccountContactType.Email,
 | |
|                     Content = email,
 | |
|                     VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
 | |
|                     IsPrimary = true
 | |
|                 }
 | |
|             ],
 | |
|             AuthFactors = password is not null
 | |
|                 ? new List<SnAccountAuthFactor>
 | |
|                 {
 | |
|                     new SnAccountAuthFactor
 | |
|                     {
 | |
|                         Type = Shared.Models.AccountAuthFactorType.Password,
 | |
|                         Secret = password,
 | |
|                         EnabledAt = SystemClock.Instance.GetCurrentInstant()
 | |
|                     }.HashSecret()
 | |
|                 }
 | |
|                 : [],
 | |
|             Profile = new SnAccountProfile()
 | |
|         };
 | |
| 
 | |
|         if (isActivated)
 | |
|         {
 | |
|             account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|             var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
 | |
|             if (defaultGroup is not null)
 | |
|             {
 | |
|                 db.PermissionGroupMembers.Add(new SnPermissionGroupMember
 | |
|                 {
 | |
|                     Actor = $"user:{account.Id}",
 | |
|                     Group = defaultGroup
 | |
|                 });
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         db.Accounts.Add(account);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         if (isActivated) return account;
 | |
| 
 | |
|         var spell = await spells.CreateMagicSpell(
 | |
|             account,
 | |
|             MagicSpellType.AccountActivation,
 | |
|             new Dictionary<string, object>
 | |
|             {
 | |
|                 { "contact_method", account.Contacts.First().Content }
 | |
|             }
 | |
|         );
 | |
|         await spells.NotifyMagicSpell(spell, true);
 | |
| 
 | |
|         return account;
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccount> CreateAccount(OidcUserInfo userInfo)
 | |
|     {
 | |
|         if (string.IsNullOrEmpty(userInfo.Email))
 | |
|             throw new ArgumentException("Email is required for account creation");
 | |
| 
 | |
|         var displayName = !string.IsNullOrEmpty(userInfo.DisplayName)
 | |
|             ? userInfo.DisplayName
 | |
|             : $"{userInfo.FirstName} {userInfo.LastName}".Trim();
 | |
| 
 | |
|         // Generate username from email
 | |
|         var username = await uname.GenerateUsernameFromEmailAsync(userInfo.Email);
 | |
| 
 | |
|         return await CreateAccount(
 | |
|             username,
 | |
|             displayName,
 | |
|             userInfo.Email,
 | |
|             null,
 | |
|             "en-US",
 | |
|             "en",
 | |
|             userInfo.EmailVerified,
 | |
|             userInfo.EmailVerified
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccount> CreateBotAccount(SnAccount account, Guid automatedId, string? pictureId,
 | |
|         string? backgroundId)
 | |
|     {
 | |
|         var dupeAutomateCount = await db.Accounts.Where(a => a.AutomatedId == automatedId).CountAsync();
 | |
|         if (dupeAutomateCount > 0)
 | |
|             throw new InvalidOperationException("Automated ID has already been used.");
 | |
| 
 | |
|         var dupeNameCount = await db.Accounts.Where(a => a.Name == account.Name).CountAsync();
 | |
|         if (dupeNameCount > 0)
 | |
|             throw new InvalidOperationException("Account name has already been taken.");
 | |
| 
 | |
|         account.AutomatedId = automatedId;
 | |
|         account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|         account.IsSuperuser = false;
 | |
| 
 | |
|         if (!string.IsNullOrEmpty(pictureId))
 | |
|         {
 | |
|             var file = await files.GetFileAsync(new GetFileRequest { Id = pictureId });
 | |
|             await fileRefs.CreateReferenceAsync(
 | |
|                 new CreateReferenceRequest
 | |
|                 {
 | |
|                     ResourceId = account.Profile.ResourceIdentifier,
 | |
|                     FileId = pictureId,
 | |
|                     Usage = "profile.picture"
 | |
|                 }
 | |
|             );
 | |
|             account.Profile.Picture = SnCloudFileReferenceObject.FromProtoValue(file);
 | |
|         }
 | |
| 
 | |
|         if (!string.IsNullOrEmpty(backgroundId))
 | |
|         {
 | |
|             var file = await files.GetFileAsync(new GetFileRequest { Id = backgroundId });
 | |
|             await fileRefs.CreateReferenceAsync(
 | |
|                 new CreateReferenceRequest
 | |
|                 {
 | |
|                     ResourceId = account.Profile.ResourceIdentifier,
 | |
|                     FileId = backgroundId,
 | |
|                     Usage = "profile.background"
 | |
|                 }
 | |
|             );
 | |
|             account.Profile.Background = SnCloudFileReferenceObject.FromProtoValue(file);
 | |
|         }
 | |
| 
 | |
|         db.Accounts.Add(account);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         return account;
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccount?> GetBotAccount(Guid automatedId)
 | |
|     {
 | |
|         return await db.Accounts.FirstOrDefaultAsync(a => a.AutomatedId == automatedId);
 | |
|     }
 | |
| 
 | |
|     public async Task RequestAccountDeletion(SnAccount account)
 | |
|     {
 | |
|         var spell = await spells.CreateMagicSpell(
 | |
|             account,
 | |
|             MagicSpellType.AccountRemoval,
 | |
|             new Dictionary<string, object>(),
 | |
|             SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
 | |
|             preventRepeat: true
 | |
|         );
 | |
|         await spells.NotifyMagicSpell(spell);
 | |
|     }
 | |
| 
 | |
|     public async Task RequestPasswordReset(SnAccount account)
 | |
|     {
 | |
|         var spell = await spells.CreateMagicSpell(
 | |
|             account,
 | |
|             MagicSpellType.AuthPasswordReset,
 | |
|             new Dictionary<string, object>(),
 | |
|             SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
 | |
|             preventRepeat: true
 | |
|         );
 | |
|         await spells.NotifyMagicSpell(spell);
 | |
|     }
 | |
| 
 | |
|     public async Task<bool> CheckAuthFactorExists(SnAccount account, Shared.Models.AccountAuthFactorType type)
 | |
|     {
 | |
|         var isExists = await db.AccountAuthFactors
 | |
|             .Where(x => x.AccountId == account.Id && x.Type == type)
 | |
|             .AnyAsync();
 | |
|         return isExists;
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccountAuthFactor?> CreateAuthFactor(SnAccount account, Shared.Models.AccountAuthFactorType type, string? secret)
 | |
|     {
 | |
|         SnAccountAuthFactor? factor = null;
 | |
|         switch (type)
 | |
|         {
 | |
|             case Shared.Models.AccountAuthFactorType.Password:
 | |
|                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
 | |
|                 factor = new SnAccountAuthFactor
 | |
|                 {
 | |
|                     Type = Shared.Models.AccountAuthFactorType.Password,
 | |
|                     Trustworthy = 1,
 | |
|                     AccountId = account.Id,
 | |
|                     Secret = secret,
 | |
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(),
 | |
|                 }.HashSecret();
 | |
|                 break;
 | |
|             case Shared.Models.AccountAuthFactorType.EmailCode:
 | |
|                 factor = new SnAccountAuthFactor
 | |
|                 {
 | |
|                     Type = Shared.Models.AccountAuthFactorType.EmailCode,
 | |
|                     Trustworthy = 2,
 | |
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(),
 | |
|                 };
 | |
|                 break;
 | |
|             case Shared.Models.AccountAuthFactorType.InAppCode:
 | |
|                 factor = new SnAccountAuthFactor
 | |
|                 {
 | |
|                     Type = Shared.Models.AccountAuthFactorType.InAppCode,
 | |
|                     Trustworthy = 1,
 | |
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant()
 | |
|                 };
 | |
|                 break;
 | |
|             case Shared.Models.AccountAuthFactorType.TimedCode:
 | |
|                 var skOtp = KeyGeneration.GenerateRandomKey(20);
 | |
|                 var skOtp32 = Base32Encoding.ToString(skOtp);
 | |
|                 factor = new SnAccountAuthFactor
 | |
|                 {
 | |
|                     Secret = skOtp32,
 | |
|                     Type = Shared.Models.AccountAuthFactorType.TimedCode,
 | |
|                     Trustworthy = 2,
 | |
|                     EnabledAt = null, // It needs to be tired once to enable
 | |
|                     CreatedResponse = new Dictionary<string, object>
 | |
|                     {
 | |
|                         ["uri"] = new OtpUri(
 | |
|                             OtpType.Totp,
 | |
|                             skOtp32,
 | |
|                             account.Id.ToString(),
 | |
|                             "Solar Network"
 | |
|                         ).ToString(),
 | |
|                     }
 | |
|                 };
 | |
|                 break;
 | |
|             case Shared.Models.AccountAuthFactorType.PinCode:
 | |
|                 if (string.IsNullOrWhiteSpace(secret)) throw new ArgumentNullException(nameof(secret));
 | |
|                 if (!secret.All(char.IsDigit) || secret.Length != 6)
 | |
|                     throw new ArgumentException("PIN code must be exactly 6 digits");
 | |
|                 factor = new SnAccountAuthFactor
 | |
|                 {
 | |
|                     Type = Shared.Models.AccountAuthFactorType.PinCode,
 | |
|                     Trustworthy = 0, // Only for confirming, can't be used for login
 | |
|                     Secret = secret,
 | |
|                     EnabledAt = SystemClock.Instance.GetCurrentInstant(),
 | |
|                 }.HashSecret();
 | |
|                 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<SnAccountAuthFactor> EnableAuthFactor(SnAccountAuthFactor factor, string? code)
 | |
|     {
 | |
|         if (factor.EnabledAt is not null) throw new ArgumentException("The factor has been enabled.");
 | |
|         if (factor.Type is Shared.Models.AccountAuthFactorType.Password or Shared.Models.AccountAuthFactorType.TimedCode)
 | |
|         {
 | |
|             if (code is null || !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<SnAccountAuthFactor> DisableAuthFactor(SnAccountAuthFactor 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 = null;
 | |
|         db.Update(factor);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         return factor;
 | |
|     }
 | |
| 
 | |
|     public async Task DeleteAuthFactor(SnAccountAuthFactor 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();
 | |
|     }
 | |
| 
 | |
|     /// <summary>
 | |
|     /// Send the auth factor verification code to users, for factors like in-app code and email.
 | |
|     /// </summary>
 | |
|     /// <param name="account">The owner of the auth factor</param>
 | |
|     /// <param name="factor">The auth factor needed to send code</param>
 | |
|     public async Task SendFactorCode(SnAccount account, SnAccountAuthFactor factor)
 | |
|     {
 | |
|         var code = new Random().Next(100000, 999999).ToString("000000");
 | |
| 
 | |
|         switch (factor.Type)
 | |
|         {
 | |
|             case Shared.Models.AccountAuthFactorType.InAppCode:
 | |
|                 if (await _GetFactorCode(factor) is not null)
 | |
|                     throw new InvalidOperationException("A factor code has been sent and in active duration.");
 | |
| 
 | |
|                 await pusher.SendPushNotificationToUserAsync(
 | |
|                     new SendPushNotificationToUserRequest
 | |
|                     {
 | |
|                         UserId = account.Id.ToString(),
 | |
|                         Notification = new PushNotification
 | |
|                         {
 | |
|                             Topic = "auth.verification",
 | |
|                             Title = localizer["AuthCodeTitle"],
 | |
|                             Body = localizer["AuthCodeBody", code],
 | |
|                             IsSavable = false
 | |
|                         }
 | |
|                     }
 | |
|                 );
 | |
|                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5));
 | |
|                 break;
 | |
|             case Shared.Models.AccountAuthFactorType.EmailCode:
 | |
|                 if (await _GetFactorCode(factor) is not null)
 | |
|                     throw new InvalidOperationException("A factor code has been sent and in active duration.");
 | |
| 
 | |
|                 var contact = await db.AccountContacts
 | |
|                     .Where(c => c.Type == Shared.Models.AccountContactType.Email)
 | |
|                     .Where(c => c.VerifiedAt != null)
 | |
|                     .Where(c => c.IsPrimary)
 | |
|                     .Where(c => c.AccountId == account.Id)
 | |
|                     .Include(c => c.Account)
 | |
|                     .FirstOrDefaultAsync();
 | |
|                 if (contact is null)
 | |
|                 {
 | |
|                     logger.LogWarning(
 | |
|                         "Unable to send factor code to #{FactorId} with, due to no contact method was found...",
 | |
|                         factor.Id
 | |
|                     );
 | |
|                     return;
 | |
|                 }
 | |
| 
 | |
|                 await mailer
 | |
|                     .SendTemplatedEmailAsync<Emails.VerificationEmail, VerificationEmailModel>(
 | |
|                         account.Nick,
 | |
|                         contact.Content,
 | |
|                         emailLocalizer["VerificationEmail"],
 | |
|                         new VerificationEmailModel
 | |
|                         {
 | |
|                             Name = account.Name,
 | |
|                             Code = code
 | |
|                         }
 | |
|                     );
 | |
| 
 | |
|                 await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30));
 | |
|                 break;
 | |
|             case Shared.Models.AccountAuthFactorType.Password:
 | |
|             case Shared.Models.AccountAuthFactorType.TimedCode:
 | |
|             default:
 | |
|                 // No need to send, such as password etc...
 | |
|                 return;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public async Task<bool> VerifyFactorCode(SnAccountAuthFactor factor, string code)
 | |
|     {
 | |
|         switch (factor.Type)
 | |
|         {
 | |
|             case Shared.Models.AccountAuthFactorType.EmailCode:
 | |
|             case Shared.Models.AccountAuthFactorType.InAppCode:
 | |
|                 var correctCode = await _GetFactorCode(factor);
 | |
|                 var isCorrect = correctCode is not null &&
 | |
|                                 string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase);
 | |
|                 await cache.RemoveAsync($"{AuthFactorCachePrefix}{factor.Id}:code");
 | |
|                 return isCorrect;
 | |
|             case Shared.Models.AccountAuthFactorType.Password:
 | |
|             case Shared.Models.AccountAuthFactorType.TimedCode:
 | |
|             default:
 | |
|                 return factor.VerifyPassword(code);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     private const string AuthFactorCachePrefix = "authfactor:";
 | |
| 
 | |
|     private async Task _SetFactorCode(SnAccountAuthFactor factor, string code, TimeSpan expires)
 | |
|     {
 | |
|         await cache.SetAsync(
 | |
|             $"{AuthFactorCachePrefix}{factor.Id}:code",
 | |
|             code,
 | |
|             expires
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     private async Task<string?> _GetFactorCode(SnAccountAuthFactor factor)
 | |
|     {
 | |
|         return await cache.GetAsync<string?>(
 | |
|             $"{AuthFactorCachePrefix}{factor.Id}:code"
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     private async Task<bool> IsDeviceActive(Guid id)
 | |
|     {
 | |
|         return await db.AuthSessions
 | |
|             .Include(s => s.Challenge)
 | |
|             .AnyAsync(s => s.Challenge.ClientId == id);
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAuthClient> UpdateDeviceName(SnAccount account, string deviceId, string label)
 | |
|     {
 | |
|         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
 | |
|         );
 | |
|         if (device is null) throw new InvalidOperationException("Device was not found.");
 | |
| 
 | |
|         device.DeviceLabel = label;
 | |
|         db.Update(device);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         return device;
 | |
|     }
 | |
| 
 | |
|     public async Task DeleteSession(SnAccount account, Guid sessionId)
 | |
|     {
 | |
|         var session = await db.AuthSessions
 | |
|             .Include(s => s.Challenge)
 | |
|             .ThenInclude(s => s.Client)
 | |
|             .Where(s => s.Id == sessionId && s.AccountId == account.Id)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (session is null) throw new InvalidOperationException("Session was not found.");
 | |
| 
 | |
|         // The current session should be included in the sessions' list
 | |
|         db.AuthSessions.Remove(session);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         if (session.Challenge.ClientId.HasValue)
 | |
|         {
 | |
|             if (!await IsDeviceActive(session.Challenge.ClientId.Value))
 | |
|                 await pusher.UnsubscribePushNotificationsAsync(new UnsubscribePushNotificationsRequest()
 | |
|                     { DeviceId = session.Challenge.Client!.DeviceId }
 | |
|                 );
 | |
|         }
 | |
| 
 | |
|         logger.LogInformation("Deleted session #{SessionId}", session.Id);
 | |
| 
 | |
|         await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{session.Id}");
 | |
|     }
 | |
| 
 | |
|     public async Task DeleteDevice(SnAccount account, string deviceId)
 | |
|     {
 | |
|         var device = await db.AuthClients.FirstOrDefaultAsync(c => c.DeviceId == deviceId && c.AccountId == account.Id
 | |
|         );
 | |
|         if (device is null)
 | |
|             throw new InvalidOperationException("Device not found.");
 | |
| 
 | |
|         await pusher.UnsubscribePushNotificationsAsync(
 | |
|             new UnsubscribePushNotificationsRequest { DeviceId = device.DeviceId }
 | |
|         );
 | |
| 
 | |
|         var sessions = await db.AuthSessions
 | |
|             .Include(s => s.Challenge)
 | |
|             .Where(s => s.Challenge.ClientId == device.Id && s.AccountId == account.Id)
 | |
|             .ToListAsync();
 | |
| 
 | |
|         // The current session should be included in the sessions' list
 | |
|         var now = SystemClock.Instance.GetCurrentInstant();
 | |
|         await db.AuthSessions
 | |
|             .Include(s => s.Challenge)
 | |
|             .Where(s => s.Challenge.ClientId == device.Id)
 | |
|             .ExecuteUpdateAsync(p => p.SetProperty(s => s.DeletedAt, s => now));
 | |
| 
 | |
|         db.AuthClients.Remove(device);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         foreach (var item in sessions)
 | |
|             await cache.RemoveAsync($"{AuthService.AuthCachePrefix}{item.Id}");
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccountContact> CreateContactMethod(SnAccount account, Shared.Models.AccountContactType type, string content)
 | |
|     {
 | |
|         var isExists = await db.AccountContacts
 | |
|             .Where(x => x.AccountId == account.Id && x.Type == type && x.Content == content)
 | |
|             .AnyAsync();
 | |
|         if (isExists)
 | |
|             throw new InvalidOperationException("Contact method already exists.");
 | |
| 
 | |
|         var contact = new SnAccountContact
 | |
|         {
 | |
|             Type = type,
 | |
|             Content = content,
 | |
|             AccountId = account.Id,
 | |
|         };
 | |
| 
 | |
|         db.AccountContacts.Add(contact);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         return contact;
 | |
|     }
 | |
| 
 | |
|     public async Task VerifyContactMethod(SnAccount account, SnAccountContact contact)
 | |
|     {
 | |
|         var spell = await spells.CreateMagicSpell(
 | |
|             account,
 | |
|             MagicSpellType.ContactVerification,
 | |
|             new Dictionary<string, object> { { "contact_method", contact.Content } },
 | |
|             expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
 | |
|             preventRepeat: true
 | |
|         );
 | |
|         await spells.NotifyMagicSpell(spell);
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccountContact> SetContactMethodPrimary(SnAccount account, SnAccountContact contact)
 | |
|     {
 | |
|         if (contact.AccountId != account.Id)
 | |
|             throw new InvalidOperationException("Contact method does not belong to this account.");
 | |
|         if (contact.VerifiedAt is null)
 | |
|             throw new InvalidOperationException("Cannot set unverified contact method as primary.");
 | |
| 
 | |
|         await using var transaction = await db.Database.BeginTransactionAsync();
 | |
| 
 | |
|         try
 | |
|         {
 | |
|             await db.AccountContacts
 | |
|                 .Where(c => c.AccountId == account.Id && c.Type == contact.Type)
 | |
|                 .ExecuteUpdateAsync(s => s.SetProperty(x => x.IsPrimary, false));
 | |
| 
 | |
|             contact.IsPrimary = true;
 | |
|             db.AccountContacts.Update(contact);
 | |
|             await db.SaveChangesAsync();
 | |
| 
 | |
|             await transaction.CommitAsync();
 | |
|             return contact;
 | |
|         }
 | |
|         catch
 | |
|         {
 | |
|             await transaction.RollbackAsync();
 | |
|             throw;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public async Task<SnAccountContact> SetContactMethodPublic(SnAccount account, SnAccountContact contact, bool isPublic)
 | |
|     {
 | |
|         contact.IsPublic = isPublic;
 | |
|         db.AccountContacts.Update(contact);
 | |
|         await db.SaveChangesAsync();
 | |
|         return contact;
 | |
|     }
 | |
| 
 | |
|     public async Task DeleteContactMethod(SnAccount account, SnAccountContact contact)
 | |
|     {
 | |
|         if (contact.AccountId != account.Id)
 | |
|             throw new InvalidOperationException("Contact method does not belong to this account.");
 | |
|         if (contact.IsPrimary)
 | |
|             throw new InvalidOperationException("Cannot delete primary contact method.");
 | |
| 
 | |
|         db.AccountContacts.Remove(contact);
 | |
|         await db.SaveChangesAsync();
 | |
|     }
 | |
| 
 | |
|     /// <summary>
 | |
|     /// This method will grant a badge to the account.
 | |
|     /// Shouldn't be exposed to normal user and the user itself.
 | |
|     /// </summary>
 | |
|     public async Task<SnAccountBadge> GrantBadge(SnAccount account, SnAccountBadge badge)
 | |
|     {
 | |
|         badge.AccountId = account.Id;
 | |
|         db.Badges.Add(badge);
 | |
|         await db.SaveChangesAsync();
 | |
|         return badge;
 | |
|     }
 | |
| 
 | |
|     /// <summary>
 | |
|     /// This method will revoke a badge from the account.
 | |
|     /// Shouldn't be exposed to normal user and the user itself.
 | |
|     /// </summary>
 | |
|     public async Task RevokeBadge(SnAccount account, Guid badgeId)
 | |
|     {
 | |
|         var badge = await db.Badges
 | |
|             .Where(b => b.AccountId == account.Id && b.Id == badgeId)
 | |
|             .OrderByDescending(b => b.CreatedAt)
 | |
|             .FirstOrDefaultAsync() ?? throw new InvalidOperationException("Badge was not found.");
 | |
|         var profile = await db.AccountProfiles
 | |
|             .Where(p => p.AccountId == account.Id)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (profile?.ActiveBadge is not null && profile.ActiveBadge.Id == badge.Id)
 | |
|             profile.ActiveBadge = null;
 | |
| 
 | |
|         db.Remove(badge);
 | |
|         await db.SaveChangesAsync();
 | |
|     }
 | |
| 
 | |
|     public async Task ActiveBadge(SnAccount account, Guid badgeId)
 | |
|     {
 | |
|         await using var transaction = await db.Database.BeginTransactionAsync();
 | |
| 
 | |
|         try
 | |
|         {
 | |
|             var badge = await db.Badges
 | |
|                 .Where(b => b.AccountId == account.Id && b.Id == badgeId)
 | |
|                 .OrderByDescending(b => b.CreatedAt)
 | |
|                 .FirstOrDefaultAsync();
 | |
|             if (badge is null) throw new InvalidOperationException("Badge was not found.");
 | |
| 
 | |
|             await db.Badges
 | |
|                 .Where(b => b.AccountId == account.Id && b.Id != badgeId)
 | |
|                 .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActivatedAt, p => null));
 | |
| 
 | |
|             badge.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|             db.Update(badge);
 | |
|             await db.SaveChangesAsync();
 | |
| 
 | |
|             await db.AccountProfiles
 | |
|                 .Where(p => p.AccountId == account.Id)
 | |
|                 .ExecuteUpdateAsync(s => s.SetProperty(p => p.ActiveBadge, badge.ToReference()));
 | |
|             await PurgeAccountCache(account);
 | |
| 
 | |
|             await transaction.CommitAsync();
 | |
|         }
 | |
|         catch
 | |
|         {
 | |
|             await transaction.RollbackAsync();
 | |
|             throw;
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public async Task DeleteAccount(SnAccount account)
 | |
|     {
 | |
|         await db.AuthSessions
 | |
|             .Where(s => s.AccountId == account.Id)
 | |
|             .ExecuteDeleteAsync();
 | |
| 
 | |
|         db.Accounts.Remove(account);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         var js = nats.CreateJetStreamContext();
 | |
|         await js.PublishAsync(
 | |
|             AccountDeletedEvent.Type,
 | |
|             GrpcTypeHelper.ConvertObjectToByteString(new AccountDeletedEvent
 | |
|             {
 | |
|                 AccountId = account.Id,
 | |
|                 DeletedAt = SystemClock.Instance.GetCurrentInstant()
 | |
|             }).ToByteArray()
 | |
|         );
 | |
|     }
 | |
| } |