✨ Login with Apple
This commit is contained in:
@ -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>();
|
||||
|
||||
|
@ -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
|
||||
|
@ -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"],
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user