From fcaeb9afbecf2b4179b9dea96aca131cfa768cc5 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 27 May 2025 22:41:38 +0800 Subject: [PATCH] :sparkles: Reset password --- .../Account/AccountController.cs | 59 ++++++++++++----- DysonNetwork.Sphere/Account/AccountService.cs | 19 ++++-- DysonNetwork.Sphere/Account/MagicSpell.cs | 2 +- .../Account/MagicSpellService.cs | 59 ++++++++++++++++- DysonNetwork.Sphere/Auth/AuthService.cs | 2 + DysonNetwork.Sphere/Email/EmailModels.cs | 6 ++ .../Pages/Emails/PasswordResetEmail.razor | 65 +++++++++++++++++++ .../Pages/Spell/MagicSpellPage.cshtml | 29 ++++++++- .../Pages/Spell/MagicSpellPage.cshtml.cs | 11 ++-- .../Resources/Localization/EmailResource.resx | 21 ++++++ .../Localization/EmailResource.zh-hans.resx | 21 ++++++ 11 files changed, 261 insertions(+), 33 deletions(-) create mode 100644 DysonNetwork.Sphere/Pages/Emails/PasswordResetEmail.razor diff --git a/DysonNetwork.Sphere/Account/AccountController.cs b/DysonNetwork.Sphere/Account/AccountController.cs index 09b1183..dd9910e 100644 --- a/DysonNetwork.Sphere/Account/AccountController.cs +++ b/DysonNetwork.Sphere/Account/AccountController.cs @@ -34,7 +34,7 @@ public class AccountController( .FirstOrDefaultAsync(); return account is null ? new NotFoundResult() : account; } - + [HttpGet("{name}/badges")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] @@ -46,7 +46,7 @@ public class AccountController( .FirstOrDefaultAsync(); return account is null ? NotFound() : account.Badges.ToList(); } - + public class AccountCreateRequest { @@ -150,7 +150,7 @@ public class AccountController( if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); var account = await db.Accounts.FirstAsync(a => a.Id == currentUser.Id); - + if (request.Nick is not null) account.Nick = request.Nick; if (request.Language is not null) account.Language = request.Language; @@ -237,6 +237,32 @@ public class AccountController( return Ok(); } + public class RecoveryPasswordRequest + { + [Required] public string Account { get; set; } = null!; + [Required] public string CaptchaToken { get; set; } = null!; + } + + [HttpPost("recovery/password")] + public async Task RequestResetPassword([FromBody] RecoveryPasswordRequest request) + { + if (!await auth.ValidateCaptcha(request.CaptchaToken)) return BadRequest("Invalid captcha token."); + + var account = await accounts.LookupAccount(request.Account); + if (account is null) return BadRequest("Unable to find the account."); + + try + { + await accounts.RequestPasswordReset(account); + } + catch (InvalidOperationException) + { + return BadRequest("You already requested password reset within 24 hours."); + } + + return Ok(); + } + public class StatusRequest { public StatusAttitude Attitude { get; set; } @@ -279,17 +305,17 @@ public class AccountController( .OrderByDescending(e => e.CreatedAt) .FirstOrDefaultAsync(); if (status is null) return NotFound(); - + status.Attitude = request.Attitude; status.IsInvisible = request.IsInvisible; status.IsNotDisturb = request.IsNotDisturb; status.Label = request.Label; status.ClearedAt = request.ClearedAt; - + db.Update(status); await db.SaveChangesAsync(); events.PurgeStatusCache(currentUser.Id); - + return status; } @@ -342,13 +368,13 @@ public class AccountController( var today = now.InUtc().Date; var startOfDay = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); var endOfDay = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant(); - + var result = await db.AccountCheckInResults .Where(x => x.AccountId == userId) .Where(x => x.CreatedAt >= startOfDay && x.CreatedAt < endOfDay) .OrderByDescending(x => x.CreatedAt) .FirstOrDefaultAsync(); - + return result is null ? NotFound() : Ok(result); } @@ -417,30 +443,31 @@ public class AccountController( var calendar = await events.GetEventCalendar(account, month.Value, year.Value, replaceInvisible: true); return Ok(calendar); } - + [Authorize] [HttpGet("me/actions")] [ProducesResponseType>(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] - public async Task>> GetActionLogs([FromQuery] int take = 20, [FromQuery] int offset = 0) + public async Task>> GetActionLogs([FromQuery] int take = 20, + [FromQuery] int offset = 0) { if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); - + var query = db.ActionLogs .Where(log => log.AccountId == currentUser.Id) .OrderByDescending(log => log.CreatedAt); - + var total = await query.CountAsync(); Response.Headers.Append("X-Total", total.ToString()); - + var logs = await query .Skip(offset) .Take(take) .ToListAsync(); - + return Ok(logs); } - + [HttpGet("search")] public async Task> Search([FromQuery] string query, [FromQuery] int take = 20) { @@ -453,7 +480,7 @@ public class AccountController( .Take(take) .ToListAsync(); } - + [HttpPost("/maintenance/ensureProfileCreated")] [Authorize] [RequiredPermission("maintenance", "accounts.profiles")] diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index e55dfb4..cc76571 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -1,13 +1,6 @@ -using System.Globalization; -using System.Reflection; -using DysonNetwork.Sphere.Localization; -using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Storage; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging.Abstractions; using NodaTime; namespace DysonNetwork.Sphere.Account; @@ -59,6 +52,18 @@ public class AccountService( await spells.NotifyMagicSpell(spell); } + public async Task RequestPasswordReset(Account account) + { + var spell = await spells.CreateMagicSpell( + account, + MagicSpellType.AuthPasswordReset, + new Dictionary(), + SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), + preventRepeat: true + ); + await spells.NotifyMagicSpell(spell); + } + /// Maintenance methods for server administrator public async Task EnsureAccountProfileCreated() { diff --git a/DysonNetwork.Sphere/Account/MagicSpell.cs b/DysonNetwork.Sphere/Account/MagicSpell.cs index 0d14612..72ff781 100644 --- a/DysonNetwork.Sphere/Account/MagicSpell.cs +++ b/DysonNetwork.Sphere/Account/MagicSpell.cs @@ -11,7 +11,7 @@ public enum MagicSpellType AccountActivation, AccountDeactivation, AccountRemoval, - AuthFactorReset, + AuthPasswordReset, ContactVerification, } diff --git a/DysonNetwork.Sphere/Account/MagicSpellService.cs b/DysonNetwork.Sphere/Account/MagicSpellService.cs index 1b42b5d..8ab6f23 100644 --- a/DysonNetwork.Sphere/Account/MagicSpellService.cs +++ b/DysonNetwork.Sphere/Account/MagicSpellService.cs @@ -36,13 +36,13 @@ public class MagicSpellService( .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 { @@ -111,6 +111,18 @@ public class MagicSpellService( } ); break; + case MagicSpellType.AuthPasswordReset: + await email.SendTemplatedEmailAsync( + contact.Account.Name, + contact.Content, + localizer["EmailAccountDeletionTitle"], + new PasswordResetEmailModel + { + Name = contact.Account.Name, + Link = link + } + ); + break; default: throw new ArgumentOutOfRangeException(); } @@ -125,6 +137,16 @@ public class MagicSpellService( { 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); + await db.SaveChangesAsync(); + break; case MagicSpellType.AccountActivation: var contactMethod = spell.Meta["contact_method"] as string; var contact = await @@ -137,7 +159,7 @@ public class MagicSpellService( db.Update(contact); } - var account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); + account = await db.Accounts.FirstOrDefaultAsync(c => c.Id == spell.AccountId); if (account is not null) { account.ActivatedAt = SystemClock.Instance.GetCurrentInstant(); @@ -162,6 +184,37 @@ public class MagicSpellService( } } + 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(); diff --git a/DysonNetwork.Sphere/Auth/AuthService.cs b/DysonNetwork.Sphere/Auth/AuthService.cs index ccf713d..76601c5 100644 --- a/DysonNetwork.Sphere/Auth/AuthService.cs +++ b/DysonNetwork.Sphere/Auth/AuthService.cs @@ -18,6 +18,8 @@ public class AuthService(IConfiguration config, IHttpClientFactory httpClientFac { public async Task ValidateCaptcha(string token) { + if (string.IsNullOrWhiteSpace(token)) return false; + var provider = config.GetSection("Captcha")["Provider"]?.ToLower(); var apiSecret = config.GetSection("Captcha")["ApiSecret"]; diff --git a/DysonNetwork.Sphere/Email/EmailModels.cs b/DysonNetwork.Sphere/Email/EmailModels.cs index 2b03e37..9864c7b 100644 --- a/DysonNetwork.Sphere/Email/EmailModels.cs +++ b/DysonNetwork.Sphere/Email/EmailModels.cs @@ -10,4 +10,10 @@ public class AccountDeletionEmailModel { public required string Name { get; set; } public required string Link { get; set; } +} + +public class PasswordResetEmailModel +{ + public required string Name { get; set; } + public required string Link { get; set; } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Emails/PasswordResetEmail.razor b/DysonNetwork.Sphere/Pages/Emails/PasswordResetEmail.razor new file mode 100644 index 0000000..a4a452a --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Emails/PasswordResetEmail.razor @@ -0,0 +1,65 @@ +@using DysonNetwork.Sphere.Localization +@using Microsoft.Extensions.Localization +@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource + + + + + + + + + + + + + + + + + + +
+

+ @(Localizer["PasswordResetHeader"]) +

+
+

+ @(Localizer["PasswordResetPara1"]) @@@Name, +

+

+ @(Localizer["PasswordResetPara2"]) +

+

+ @(Localizer["PasswordResetPara3"]) +

+
+ +
+

+ @(LocalizerShared["EmailLinkHint"]) +
+ @Link +

+

+ @(Localizer["PasswordResetPara4"]) +

+

+ @(LocalizerShared["EmailFooter1"])
+ @(LocalizerShared["EmailFooter2"]) +

+
+
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string Link { get; set; } + + [Inject] IStringLocalizer Localizer { get; set; } = null!; + [Inject] IStringLocalizer LocalizerShared { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml b/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml index d2fe5d6..5f3e8dd 100644 --- a/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml +++ b/DysonNetwork.Sphere/Pages/Spell/MagicSpellPage.cshtml @@ -1,4 +1,5 @@ @page "/spells/{spellWord}" +@using DysonNetwork.Sphere.Account @model DysonNetwork.Sphere.Pages.Spell.MagicSpellPage @{ @@ -25,11 +26,13 @@ } else { -
+

The spell is for - @System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2") + @System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")

for @@@Model.CurrentSpell.Account?.Name

@@ -48,6 +51,28 @@
+ + @if (Model.CurrentSpell?.Type == MagicSpellType.AuthPasswordReset) + { +
+ + +
+ } +