262 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			262 lines
		
	
	
		
			9.8 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.Security.Cryptography;
 | 
						|
using System.Text.Json;
 | 
						|
using DysonNetwork.Pass.Emails;
 | 
						|
using DysonNetwork.Pass.Mailer;
 | 
						|
using DysonNetwork.Shared.Cache;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using Microsoft.Extensions.Localization;
 | 
						|
using NodaTime;
 | 
						|
using EmailResource = DysonNetwork.Pass.Localization.EmailResource;
 | 
						|
 | 
						|
namespace DysonNetwork.Pass.Account;
 | 
						|
 | 
						|
public class MagicSpellService(
 | 
						|
    AppDatabase db,
 | 
						|
    IConfiguration configuration,
 | 
						|
    ILogger<MagicSpellService> logger,
 | 
						|
    IStringLocalizer<EmailResource> localizer,
 | 
						|
    EmailService email,
 | 
						|
    ICacheService cache
 | 
						|
)
 | 
						|
{
 | 
						|
    public async Task<SnMagicSpell> CreateMagicSpell(
 | 
						|
        SnAccount 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 is not null)
 | 
						|
                return existingSpell;
 | 
						|
        }
 | 
						|
 | 
						|
        var spellWord = _GenerateRandomString(128);
 | 
						|
        var spell = new SnMagicSpell
 | 
						|
        {
 | 
						|
            Spell = spellWord,
 | 
						|
            Type = type,
 | 
						|
            ExpiresAt = expiredAt,
 | 
						|
            AffectedAt = affectedAt,
 | 
						|
            AccountId = account.Id,
 | 
						|
            Meta = meta
 | 
						|
        };
 | 
						|
 | 
						|
        db.MagicSpells.Add(spell);
 | 
						|
        await db.SaveChangesAsync();
 | 
						|
 | 
						|
        return spell;
 | 
						|
    }
 | 
						|
 | 
						|
    private const string SpellNotifyCacheKeyPrefix = "spells:notify:";
 | 
						|
 | 
						|
    public async Task NotifyMagicSpell(SnMagicSpell spell, bool bypassVerify = false)
 | 
						|
    {
 | 
						|
        var cacheKey = SpellNotifyCacheKeyPrefix + spell.Id;
 | 
						|
        var (found, _) = await cache.GetAsyncWithStatus<bool?>(cacheKey);
 | 
						|
        if (found)
 | 
						|
        {
 | 
						|
            logger.LogInformation("Skip sending magic spell {SpellId} due to already sent.", spell.Id);
 | 
						|
            return;
 | 
						|
        }
 | 
						|
 | 
						|
        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["EmailPasswordResetTitle"],
 | 
						|
                        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;
 | 
						|
                case MagicSpellType.AccountDeactivation:
 | 
						|
                default:
 | 
						|
                    throw new ArgumentOutOfRangeException();
 | 
						|
            }
 | 
						|
 | 
						|
            await cache.SetAsync(cacheKey, true, TimeSpan.FromMinutes(5));
 | 
						|
        }
 | 
						|
        catch (Exception err)
 | 
						|
        {
 | 
						|
            logger.LogError($"Error sending magic spell (${spell.Spell})... {err}");
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task ApplyMagicSpell(SnMagicSpell 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 SnPermissionGroupMember
 | 
						|
                    {
 | 
						|
                        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(SnMagicSpell 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 SnAccountAuthFactor
 | 
						|
            {
 | 
						|
                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[..length];
 | 
						|
    }
 | 
						|
} |