273 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			273 lines
		
	
	
		
			10 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using System.Globalization;
 | 
						|
using System.Security.Cryptography;
 | 
						|
using System.Text.Json;
 | 
						|
using DysonNetwork.Shared.Models;
 | 
						|
using DysonNetwork.Shared.Services;
 | 
						|
using MagicOnion.Server;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using Microsoft.Extensions.Localization;
 | 
						|
using NodaTime;
 | 
						|
using Microsoft.Extensions.Configuration;
 | 
						|
using Microsoft.Extensions.Logging;
 | 
						|
 | 
						|
namespace DysonNetwork.Pass.Account;
 | 
						|
 | 
						|
public class MagicSpellService(
 | 
						|
    AppDatabase db,
 | 
						|
    IConfiguration configuration,
 | 
						|
    ILogger<MagicSpellService> logger
 | 
						|
) : ServiceBase<IMagicSpellService>, IMagicSpellService
 | 
						|
{
 | 
						|
    public async Task<MagicSpell> CreateMagicSpell(
 | 
						|
        Shared.Models.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<MagicSpell?> GetMagicSpellAsync(string token)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var spell = await db.MagicSpells
 | 
						|
            .Where(s => s.Spell == token)
 | 
						|
            .Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
 | 
						|
        return spell;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<MagicSpell?> GetMagicSpellByIdAsync(Guid spellId)
 | 
						|
    {
 | 
						|
        return await db.MagicSpells.FirstOrDefaultAsync(s => s.Id == spellId);
 | 
						|
    }
 | 
						|
 | 
						|
    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(string token)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var spell = await db.MagicSpells
 | 
						|
            .Where(s => s.Spell == token)
 | 
						|
            .Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
 | 
						|
            .FirstOrDefaultAsync();
 | 
						|
        if (spell is null) throw new ArgumentException("Magic spell not found.");
 | 
						|
        
 | 
						|
        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);
 | 
						|
    }
 | 
						|
} |