Magic spell for one time code

🗑️ Drop the usage of casbin
♻️ Refactor the permission service
♻️ Refactor the flow of creating an account
🧱 Email infra structure
This commit is contained in:
2025-04-29 20:37:10 +08:00
parent 82288fa52c
commit 0ebeab672b
35 changed files with 1789 additions and 5214 deletions

View File

@@ -13,6 +13,7 @@ public class Account : ModelBase
[MaxLength(256)] public string Name { get; set; } = string.Empty;
[MaxLength(256)] public string Nick { get; set; } = string.Empty;
[MaxLength(32)] public string Language { get; set; } = string.Empty;
public Instant? ActivatedAt { get; set; }
public bool IsSuperuser { get; set; } = false;
public Profile Profile { get; set; } = null!;
@@ -24,8 +25,6 @@ public class Account : ModelBase
[JsonIgnore] public ICollection<Relationship> OutgoingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<Relationship> IncomingRelationships { get; set; } = new List<Relationship>();
[JsonIgnore] public ICollection<PermissionGroupMember> GroupMemberships { get; set; } = new List<PermissionGroupMember>();
}
public class Profile : ModelBase

View File

@@ -1,15 +1,23 @@
using System.ComponentModel.DataAnnotations;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Storage;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
[ApiController]
[Route("/accounts")]
public class AccountController(AppDatabase db, FileService fs, IMemoryCache memCache) : ControllerBase
public class AccountController(
AppDatabase db,
FileService fs,
AuthService auth,
MagicSpellService spells,
IMemoryCache memCache
) : ControllerBase
{
[HttpGet("{name}")]
[ProducesResponseType<Account>(StatusCodes.Status200OK)]
@@ -39,6 +47,10 @@ public class AccountController(AppDatabase db, FileService fs, IMemoryCache memC
[MinLength(4)]
[MaxLength(128)]
public string Password { get; set; } = string.Empty;
[MaxLength(128)] public string Language { get; set; } = "en";
[Required] public string CaptchaToken { get; set; } = string.Empty;
}
[HttpPost]
@@ -46,6 +58,8 @@ public class AccountController(AppDatabase db, FileService fs, IMemoryCache memC
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Account>> CreateAccount([FromBody] AccountCreateRequest request)
{
if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token.");
var dupeNameCount = await db.Accounts.Where(a => a.Name == request.Name).CountAsync();
if (dupeNameCount > 0)
return BadRequest("The name is already taken.");
@@ -54,6 +68,7 @@ public class AccountController(AppDatabase db, FileService fs, IMemoryCache memC
{
Name = request.Name,
Nick = request.Nick,
Language = request.Language,
Contacts = new List<AccountContact>
{
new()
@@ -75,6 +90,18 @@ public class AccountController(AppDatabase db, FileService fs, IMemoryCache memC
await db.Accounts.AddAsync(account);
await db.SaveChangesAsync();
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
},
expiredAt: SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromDays(7))
);
spells.NotifyMagicSpell(spell);
return account;
}
@@ -110,7 +137,7 @@ public class AccountController(AppDatabase db, FileService fs, IMemoryCache memC
if (request.Nick is not null) account.Nick = request.Nick;
if (request.Language is not null) account.Language = request.Language;
memCache.Remove($"user_${account.Id}");
await db.SaveChangesAsync();
@@ -171,9 +198,9 @@ public class AccountController(AppDatabase db, FileService fs, IMemoryCache memC
db.Update(profile);
await db.SaveChangesAsync();
memCache.Remove($"user_${userId}");
return profile;
}
}

View File

@@ -1,10 +1,11 @@
using Casbin;
using DysonNetwork.Sphere.Permission;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class AccountService(AppDatabase db, IEnforcer enforcer)
public class AccountService(AppDatabase db, PermissionService pm)
{
public async Task<Account?> LookupAccount(string probe)
{
@@ -145,18 +146,18 @@ public class AccountService(AppDatabase db, IEnforcer enforcer)
// others: use the default permissions by design
var domain = $"user:{relationship.AccountId.ToString()}";
var target = relationship.RelatedId.ToString();
var target = $"user:{relationship.RelatedId.ToString()}";
await enforcer.DeleteRolesForUserAsync(target, domain);
await pm.RemovePermissionNode(target, domain, "*");
string role = relationship.Status switch
bool? value = relationship.Status switch
{
RelationshipStatus.Friends => "friends",
RelationshipStatus.Blocked => "blocked",
_ => "default" // fallback role
RelationshipStatus.Friends => true,
RelationshipStatus.Blocked => false,
_ => null,
};
if (role == "default") return;
if (value is null) return;
await enforcer.AddRoleForUserAsync(target, role, domain);
await pm.AddPermissionNode(target, domain, "*", value);
}
}

View File

@@ -0,0 +1,49 @@
using MailKit.Net.Smtp;
using MimeKit;
namespace DysonNetwork.Sphere.Account;
public class EmailServiceConfiguration
{
public string Server { get; set; }
public int Port { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string FromAddress { get; set; }
public string FromName { get; set; }
public string SubjectPrefix { get; set; }
}
public class EmailService
{
private readonly EmailServiceConfiguration _configuration;
public EmailService(IConfiguration configuration)
{
var cfg = configuration.GetValue<EmailServiceConfiguration>("Email");
_configuration = cfg ?? throw new ArgumentException("Email service was not configured.");
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
{
subject = $"[{_configuration.SubjectPrefix}] {subject}";
var emailMessage = new MimeMessage();
emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress));
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
emailMessage.Subject = subject;
var bodyBuilder = new BodyBuilder
{
TextBody = textBody
};
emailMessage.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_configuration.Server, _configuration.Port, true);
await client.AuthenticateAsync(_configuration.Username, _configuration.Password);
await client.SendAsync(emailMessage);
await client.DisconnectAsync(true);
}
}

View File

@@ -0,0 +1,30 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public enum MagicSpellType
{
AccountActivation,
AccountDeactivation,
AccountRemoval,
AuthFactorReset,
ContactVerification,
}
[Index(nameof(Spell), IsUnique = true)]
public class MagicSpell : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[JsonIgnore] [MaxLength(1024)] public string Spell { get; set; } = null!;
public MagicSpellType Type { get; set; }
public Instant? ExpiresAt { get; set; }
public Instant? AffectedAt { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; }
public long? AccountId { get; set; }
public Account? Account { get; set; }
}

View File

@@ -0,0 +1,122 @@
using System.Security.Cryptography;
using DysonNetwork.Sphere.Permission;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class MagicSpellService(AppDatabase db, EmailService email, ILogger<MagicSpellService> logger)
{
public async Task<MagicSpell> CreateMagicSpell(
Account account,
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
Instant? affectedAt = null
)
{
var spellWord = _GenerateRandomString(128);
var spell = new MagicSpell
{
Spell = spellWord,
Type = type,
ExpiresAt = expiredAt,
AffectedAt = affectedAt,
Account = account,
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)
.Include(c => c.Account)
.FirstOrDefaultAsync();
if (contact is null) throw new ArgumentException("Account has no contact method that can use");
// TODO replace the baseurl
var link = $"https://api.sn.solsynth.dev/spells/{spell}";
try
{
switch (spell.Type)
{
case MagicSpellType.AccountActivation:
await email.SendEmailAsync(
contact.Account.Name,
contact.Content,
"Confirm your registration",
"Thank you for creating an account.\n" +
"For accessing all the features, confirm your registration with the link below:\n\n" +
$"{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.AccountActivation:
var contact = await
db.AccountContacts.FirstOrDefaultAsync(c =>
c.Account.Id == spell.AccountId && c.Content == spell.Meta["contact_method"] as string
);
if (contact is not null)
{
contact.VerifiedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(contact);
}
var 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
});
}
await db.SaveChangesAsync();
break;
default:
throw new ArgumentOutOfRangeException();
}
}
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);
}
}