252 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			252 lines
		
	
	
		
			9.4 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
| using System.Globalization;
 | |
| using System.Security.Cryptography;
 | |
| using System.Text.Json;
 | |
| using DysonNetwork.Sphere.Email;
 | |
| using DysonNetwork.Sphere.Pages.Emails;
 | |
| using DysonNetwork.Sphere.Permission;
 | |
| using DysonNetwork.Sphere.Resources.Localization;
 | |
| using DysonNetwork.Sphere.Resources.Pages.Emails;
 | |
| using Microsoft.EntityFrameworkCore;
 | |
| using Microsoft.Extensions.Localization;
 | |
| using NodaTime;
 | |
| 
 | |
| namespace DysonNetwork.Sphere.Account;
 | |
| 
 | |
| public class MagicSpellService(
 | |
|     AppDatabase db,
 | |
|     EmailService email,
 | |
|     IConfiguration configuration,
 | |
|     ILogger<MagicSpellService> logger,
 | |
|     IStringLocalizer<Localization.EmailResource> localizer
 | |
| )
 | |
| {
 | |
|     public async Task<MagicSpell> CreateMagicSpell(
 | |
|         Account account,
 | |
|         MagicSpellType type,
 | |
|         Dictionary<string, object> meta,
 | |
|         Instant? expiredAt = null,
 | |
|         Instant? affectedAt = null,
 | |
|         bool preventRepeat = false
 | |
|     )
 | |
|     {
 | |
|         if (preventRepeat)
 | |
|         {
 | |
|             var now = SystemClock.Instance.GetCurrentInstant();
 | |
|             var existingSpell = await db.MagicSpells
 | |
|                 .Where(s => s.AccountId == account.Id)
 | |
|                 .Where(s => s.Type == type)
 | |
|                 .Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
 | |
|                 .FirstOrDefaultAsync();
 | |
| 
 | |
|             if (existingSpell != null)
 | |
|             {
 | |
|                 throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         var spellWord = _GenerateRandomString(128);
 | |
|         var spell = new MagicSpell
 | |
|         {
 | |
|             Spell = spellWord,
 | |
|             Type = type,
 | |
|             ExpiresAt = expiredAt,
 | |
|             AffectedAt = affectedAt,
 | |
|             AccountId = account.Id,
 | |
|             Meta = meta
 | |
|         };
 | |
| 
 | |
|         db.MagicSpells.Add(spell);
 | |
|         await db.SaveChangesAsync();
 | |
| 
 | |
|         return spell;
 | |
|     }
 | |
| 
 | |
|     public async Task NotifyMagicSpell(MagicSpell spell, bool bypassVerify = false)
 | |
|     {
 | |
|         var contact = await db.AccountContacts
 | |
|             .Where(c => c.Account.Id == spell.AccountId)
 | |
|             .Where(c => c.Type == AccountContactType.Email)
 | |
|             .Where(c => c.VerifiedAt != null || bypassVerify)
 | |
|             .OrderByDescending(c => c.IsPrimary)
 | |
|             .Include(c => c.Account)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (contact is null) throw new ArgumentException("Account has no contact method that can use");
 | |
| 
 | |
|         var link = $"{configuration.GetValue<string>("BaseUrl")}/spells/{Uri.EscapeDataString(spell.Spell)}";
 | |
| 
 | |
|         logger.LogInformation("Sending magic spell... {Link}", link);
 | |
| 
 | |
|         var accountLanguage = await db.Accounts
 | |
|             .Where(a => a.Id == spell.AccountId)
 | |
|             .Select(a => a.Language)
 | |
|             .FirstOrDefaultAsync();
 | |
|         AccountService.SetCultureInfo(accountLanguage);
 | |
| 
 | |
|         try
 | |
|         {
 | |
|             switch (spell.Type)
 | |
|             {
 | |
|                 case MagicSpellType.AccountActivation:
 | |
|                     await email.SendTemplatedEmailAsync<LandingEmail, LandingEmailModel>(
 | |
|                         contact.Account.Nick,
 | |
|                         contact.Content,
 | |
|                         localizer["EmailLandingTitle"],
 | |
|                         new LandingEmailModel
 | |
|                         {
 | |
|                             Name = contact.Account.Name,
 | |
|                             Link = link
 | |
|                         }
 | |
|                     );
 | |
|                     break;
 | |
|                 case MagicSpellType.AccountRemoval:
 | |
|                     await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
 | |
|                         contact.Account.Nick,
 | |
|                         contact.Content,
 | |
|                         localizer["EmailAccountDeletionTitle"],
 | |
|                         new AccountDeletionEmailModel
 | |
|                         {
 | |
|                             Name = contact.Account.Name,
 | |
|                             Link = link
 | |
|                         }
 | |
|                     );
 | |
|                     break;
 | |
|                 case MagicSpellType.AuthPasswordReset:
 | |
|                     await email.SendTemplatedEmailAsync<PasswordResetEmail, PasswordResetEmailModel>(
 | |
|                         contact.Account.Nick,
 | |
|                         contact.Content,
 | |
|                         localizer["EmailAccountDeletionTitle"],
 | |
|                         new PasswordResetEmailModel
 | |
|                         {
 | |
|                             Name = contact.Account.Name,
 | |
|                             Link = link
 | |
|                         }
 | |
|                     );
 | |
|                     break;
 | |
|                 case MagicSpellType.ContactVerification:
 | |
|                     if (spell.Meta["contact_method"] is not string contactMethod)
 | |
|                         throw new InvalidOperationException("Contact method is not found.");
 | |
|                     await email.SendTemplatedEmailAsync<ContactVerificationEmail, ContactVerificationEmailModel>(
 | |
|                         contact.Account.Nick,
 | |
|                         contactMethod!,
 | |
|                         localizer["EmailContactVerificationTitle"],
 | |
|                         new ContactVerificationEmailModel
 | |
|                         {
 | |
|                             Name = contact.Account.Name,
 | |
|                             Link = link
 | |
|                         }
 | |
|                     );
 | |
|                     break;
 | |
|                 default:
 | |
|                     throw new ArgumentOutOfRangeException();
 | |
|             }
 | |
|         }
 | |
|         catch (Exception err)
 | |
|         {
 | |
|             logger.LogError($"Error sending magic spell (${spell.Spell})... {err}");
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     public async Task ApplyMagicSpell(MagicSpell spell)
 | |
|     {
 | |
|         switch (spell.Type)
 | |
|         {
 | |
|             case MagicSpellType.AuthPasswordReset:
 | |
|                 throw new ArgumentException(
 | |
|                     "For password reset spell, please use the ApplyPasswordReset method instead."
 | |
|                 );
 | |
|             case MagicSpellType.AccountRemoval:
 | |
|                 var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
 | |
|                 if (account is null) break;
 | |
|                 db.Accounts.Remove(account);
 | |
|                 break;
 | |
|             case MagicSpellType.AccountActivation:
 | |
|                 var contactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
 | |
|                 var contact = await
 | |
|                     db.AccountContacts.FirstOrDefaultAsync(c =>
 | |
|                         c.Content == contactMethod
 | |
|                     );
 | |
|                 if (contact is not null)
 | |
|                 {
 | |
|                     contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|                     db.Update(contact);
 | |
|                 }
 | |
| 
 | |
|                 account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
 | |
|                 if (account is not null)
 | |
|                 {
 | |
|                     account.ActivatedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|                     db.Update(account);
 | |
|                 }
 | |
| 
 | |
|                 var defaultGroup = await db.PermissionGroups.FirstOrDefaultAsync(g => g.Key == "default");
 | |
|                 if (defaultGroup is not null && account is not null)
 | |
|                 {
 | |
|                     db.PermissionGroupMembers.Add(new PermissionGroupMember
 | |
|                     {
 | |
|                         Actor = $"user:{account.Id}",
 | |
|                         Group = defaultGroup
 | |
|                     });
 | |
|                 }
 | |
| 
 | |
|                 break;
 | |
|             case MagicSpellType.ContactVerification:
 | |
|                 var verifyContactMethod = (spell.Meta["contact_method"] as JsonElement? ?? default).ToString();
 | |
|                 var verifyContact = await db.AccountContacts
 | |
|                     .FirstOrDefaultAsync(c => c.Content == verifyContactMethod);
 | |
|                 if (verifyContact is not null)
 | |
|                 {
 | |
|                     verifyContact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
 | |
|                     db.Update(verifyContact);
 | |
|                 }
 | |
| 
 | |
|                 break;
 | |
|             default:
 | |
|                 throw new ArgumentOutOfRangeException();
 | |
|         }
 | |
| 
 | |
|         db.Remove(spell);
 | |
|         await db.SaveChangesAsync();
 | |
|     }
 | |
| 
 | |
|     public async Task ApplyPasswordReset(MagicSpell spell, string newPassword)
 | |
|     {
 | |
|         if (spell.Type != MagicSpellType.AuthPasswordReset)
 | |
|             throw new ArgumentException("This spell is not a password reset spell.");
 | |
| 
 | |
|         var passwordFactor = await db.AccountAuthFactors
 | |
|             .Include(f => f.Account)
 | |
|             .Where(f => f.Type == AccountAuthFactorType.Password && f.Account.Id == spell.AccountId)
 | |
|             .FirstOrDefaultAsync();
 | |
|         if (passwordFactor is null)
 | |
|         {
 | |
|             var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId);
 | |
|             if (account is null) throw new InvalidOperationException("Both account and auth factor was not found.");
 | |
|             passwordFactor = new AccountAuthFactor
 | |
|             {
 | |
|                 Type = AccountAuthFactorType.Password,
 | |
|                 Account = account,
 | |
|                 Secret = newPassword
 | |
|             }.HashSecret();
 | |
|             db.AccountAuthFactors.Add(passwordFactor);
 | |
|         }
 | |
|         else
 | |
|         {
 | |
|             passwordFactor.Secret = newPassword;
 | |
|             passwordFactor.HashSecret();
 | |
|             db.Update(passwordFactor);
 | |
|         }
 | |
| 
 | |
|         await db.SaveChangesAsync();
 | |
|     }
 | |
| 
 | |
|     private static string _GenerateRandomString(int length)
 | |
|     {
 | |
|         using var rng = RandomNumberGenerator.Create();
 | |
|         var randomBytes = new byte[length];
 | |
|         rng.GetBytes(randomBytes);
 | |
| 
 | |
|         var base64String = Convert.ToBase64String(randomBytes);
 | |
| 
 | |
|         return base64String.Substring(0, length);
 | |
|     }
 | |
| } |