Login with Apple

This commit is contained in:
2025-06-15 17:29:30 +08:00
parent bf013a108b
commit 16ff5588b9
14 changed files with 4007 additions and 149 deletions

View File

@ -25,6 +25,7 @@ public class Account : ModelBase
public ICollection<Badge> Badges { get; set; } = new List<Badge>();
[JsonIgnore] public ICollection<AccountAuthFactor> AuthFactors { get; set; } = new List<AccountAuthFactor>();
[JsonIgnore] public ICollection<AccountConnection> Connections { get; set; } = new List<AccountConnection>();
[JsonIgnore] public ICollection<Auth.Session> Sessions { get; set; } = new List<Auth.Session>();
[JsonIgnore] public ICollection<Auth.Challenge> Challenges { get; set; } = new List<Auth.Challenge>();

View File

@ -82,51 +82,21 @@ public class AccountController(
{
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.");
var account = new Account
try
{
Name = request.Name,
Nick = request.Nick,
Language = request.Language,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = request.Email,
IsPrimary = true
}
},
AuthFactors = new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = request.Password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
},
Profile = new Profile()
};
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))
);
await spells.NotifyMagicSpell(spell, true);
return account;
var account = await accounts.CreateAccount(
request.Name,
request.Nick,
request.Email,
request.Password,
request.Language
);
return Ok(account);
}
catch (Exception ex)
{
return BadRequest(ex.Message);
}
}
public class RecoveryPasswordRequest

View File

@ -1,8 +1,10 @@
using System.Globalization;
using DysonNetwork.Sphere.Auth;
using DysonNetwork.Sphere.Auth.OpenId;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Storage;
using EFCore.BulkExtensions;
using Microsoft.EntityFrameworkCore;
@ -16,8 +18,9 @@ namespace DysonNetwork.Sphere.Account;
public class AccountService(
AppDatabase db,
MagicSpellService spells,
AccountUsernameService uname,
NotificationService nty,
EmailService email,
EmailService mailer,
IStringLocalizer<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> logger
@ -62,6 +65,114 @@ public class AccountService(
return profile?.Level;
}
public async Task<Account> CreateAccount(
string name,
string nick,
string email,
string? password,
string language = "en-US",
bool isEmailVerified = false,
bool isActivated = false
)
{
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
var dupeNameCount = await db.Accounts.Where(a => a.Name == name).CountAsync();
if (dupeNameCount > 0)
throw new InvalidOperationException("Account name has already been taken.");
var account = new Account
{
Name = name,
Nick = nick,
Language = language,
Contacts = new List<AccountContact>
{
new()
{
Type = AccountContactType.Email,
Content = email,
VerifiedAt = isEmailVerified ? SystemClock.Instance.GetCurrentInstant() : null,
IsPrimary = true
}
},
AuthFactors = password is not null
? new List<AccountAuthFactor>
{
new AccountAuthFactor
{
Type = AccountAuthFactorType.Password,
Secret = password,
EnabledAt = SystemClock.Instance.GetCurrentInstant()
}.HashSecret()
}
: [],
Profile = new Profile()
};
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 PermissionGroupMember
{
Actor = $"user:{account.Id}",
Group = defaultGroup
});
}
}
else
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountActivation,
new Dictionary<string, object>
{
{ "contact_method", account.Contacts.First().Content }
}
);
await spells.NotifyMagicSpell(spell, true);
}
db.Accounts.Add(account);
await db.SaveChangesAsync();
await transaction.CommitAsync();
return account;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
public async Task<Account> 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",
userInfo.EmailVerified,
userInfo.EmailVerified
);
}
public async Task RequestAccountDeletion(Account account)
{
var spell = await spells.CreateMagicSpell(
@ -265,7 +376,7 @@ public class AccountService(
return;
}
await email.SendTemplatedEmailAsync<VerificationEmail, VerificationEmailModel>(
await mailer.SendTemplatedEmailAsync<VerificationEmail, VerificationEmailModel>(
account.Nick,
contact.Content,
localizer["VerificationEmail"],

View File

@ -8,7 +8,7 @@ namespace DysonNetwork.Sphere.Account;
/// </summary>
public class AccountUsernameService(AppDatabase db)
{
private readonly Random _random = new Random();
private readonly Random _random = new();
/// <summary>
/// Generates a unique username based on the provided base name
@ -49,31 +49,6 @@ public class AccountUsernameService(AppDatabase db)
return $"{sanitized}{timestamp}";
}
/// <summary>
/// Generates a display name, adding numbers if needed
/// </summary>
/// <param name="baseName">The preferred display name</param>
/// <returns>A display name with optional suffix</returns>
public Task<string> GenerateUniqueDisplayNameAsync(string baseName)
{
// If the base name is empty, use a default
if (string.IsNullOrEmpty(baseName))
{
baseName = "User";
}
// Truncate if too long
if (baseName.Length > 50)
{
baseName = baseName.Substring(0, 50);
}
// Since display names can be duplicated, just return the base name
// But add a random suffix to make it more unique visually
var suffix = _random.Next(1000, 9999);
return Task.FromResult($"{baseName}{suffix}");
}
/// <summary>
/// Sanitizes a username by removing invalid characters and converting to lowercase
/// </summary>
@ -127,26 +102,4 @@ public class AccountUsernameService(AppDatabase db)
// Use the local part as the base for username generation
return await GenerateUniqueUsernameAsync(localPart);
}
/// <summary>
/// Generates a display name from an email address
/// </summary>
/// <param name="email">The email address to generate a display name from</param>
/// <returns>A display name derived from the email</returns>
public async Task<string> GenerateDisplayNameFromEmailAsync(string email)
{
if (string.IsNullOrEmpty(email))
return await GenerateUniqueDisplayNameAsync("User");
// Extract the local part of the email (before the @)
var localPart = email.Split('@')[0];
// Capitalize first letter and replace dots/underscores with spaces
var displayName = Regex.Replace(localPart, @"[._-]+", " ");
// Capitalize words
displayName = System.Globalization.CultureInfo.CurrentCulture.TextInfo.ToTitleCase(displayName);
return await GenerateUniqueDisplayNameAsync(displayName);
}
}