✨ Reset password
This commit is contained in:
		| @@ -34,7 +34,7 @@ public class AccountController( | ||||
|             .FirstOrDefaultAsync(); | ||||
|         return account is null ? new NotFoundResult() : account; | ||||
|     } | ||||
|      | ||||
|  | ||||
|     [HttpGet("{name}/badges")] | ||||
|     [ProducesResponseType<List<Badge>>(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<ActionResult> 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<List<ActionLog>>(StatusCodes.Status200OK)] | ||||
|     [ProducesResponseType(StatusCodes.Status401Unauthorized)] | ||||
|     public async Task<ActionResult<List<ActionLog>>> GetActionLogs([FromQuery] int take = 20, [FromQuery] int offset = 0) | ||||
|     public async Task<ActionResult<List<ActionLog>>> 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<List<Account>> 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")] | ||||
|   | ||||
| @@ -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<string, object>(), | ||||
|             SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)), | ||||
|             preventRepeat: true | ||||
|         ); | ||||
|         await spells.NotifyMagicSpell(spell); | ||||
|     } | ||||
|  | ||||
|     /// Maintenance methods for server administrator | ||||
|     public async Task EnsureAccountProfileCreated() | ||||
|     { | ||||
|   | ||||
| @@ -11,7 +11,7 @@ public enum MagicSpellType | ||||
|     AccountActivation, | ||||
|     AccountDeactivation, | ||||
|     AccountRemoval, | ||||
|     AuthFactorReset, | ||||
|     AuthPasswordReset, | ||||
|     ContactVerification, | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -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<PasswordResetEmail, PasswordResetEmailModel>( | ||||
|                         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(); | ||||
|   | ||||
| @@ -18,6 +18,8 @@ public class AuthService(IConfiguration config, IHttpClientFactory httpClientFac | ||||
| { | ||||
|     public async Task<bool> ValidateCaptcha(string token) | ||||
|     { | ||||
|         if (string.IsNullOrWhiteSpace(token)) return false; | ||||
|          | ||||
|         var provider = config.GetSection("Captcha")["Provider"]?.ToLower(); | ||||
|         var apiSecret = config.GetSection("Captcha")["ApiSecret"]; | ||||
|  | ||||
|   | ||||
| @@ -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; }  | ||||
| } | ||||
							
								
								
									
										65
									
								
								DysonNetwork.Sphere/Pages/Emails/PasswordResetEmail.razor
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								DysonNetwork.Sphere/Pages/Emails/PasswordResetEmail.razor
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,65 @@ | ||||
| @using DysonNetwork.Sphere.Localization | ||||
| @using Microsoft.Extensions.Localization | ||||
| @using EmailResource = DysonNetwork.Sphere.Localization.EmailResource | ||||
|  | ||||
| <EmailLayout> | ||||
|     <table class="container"> | ||||
|         <tr> | ||||
|             <td class="columns"> | ||||
|                 <h1 style="font-size: 1.875rem; font-weight: 700; color: #111827; margin: 0; text-align: center;"> | ||||
|                     @(Localizer["PasswordResetHeader"]) | ||||
|                 </h1> | ||||
|             </td> | ||||
|         </tr> | ||||
|  | ||||
|         <tr> | ||||
|             <td class="columns"> | ||||
|                 <p style="color: #374151; margin: 0;"> | ||||
|                     @(Localizer["PasswordResetPara1"]) @@@Name, | ||||
|                 </p> | ||||
|                 <p style="color: #374151; margin: 0;"> | ||||
|                     @(Localizer["PasswordResetPara2"]) | ||||
|                 </p> | ||||
|                 <p style="color: #374151; margin: 0;"> | ||||
|                     @(Localizer["PasswordResetPara3"]) | ||||
|                 </p> | ||||
|             </td> | ||||
|         </tr> | ||||
|  | ||||
|         <tr> | ||||
|             <td class="columns"> | ||||
|                 <div style="text-align: center;"> | ||||
|                     <a href="@Link" target="_blank" | ||||
|                        style="background-color: #2563eb; color: #ffffff; padding: 0.75rem 1.5rem; border-radius: 0.5rem; font-weight: 600; text-decoration: none;"> | ||||
|                         @(Localizer["PasswordResetButton"]) | ||||
|                     </a> | ||||
|                 </div> | ||||
|             </td> | ||||
|         </tr> | ||||
|  | ||||
|         <tr> | ||||
|             <td class="columns"> | ||||
|                 <p style="color: #374151; margin: 0;"> | ||||
|                     @(LocalizerShared["EmailLinkHint"]) | ||||
|                     <br> | ||||
|                     <a href="@Link" style="color: #2563eb; word-break: break-all;">@Link</a> | ||||
|                 </p> | ||||
|                 <p style="color: #374151; margin: 0;"> | ||||
|                     @(Localizer["PasswordResetPara4"]) | ||||
|                 </p> | ||||
|                 <p style="color: #374151; margin: 2rem 0 0 0;"> | ||||
|                     @(LocalizerShared["EmailFooter1"]) <br /> | ||||
|                     @(LocalizerShared["EmailFooter2"]) | ||||
|                 </p> | ||||
|             </td> | ||||
|         </tr> | ||||
|     </table> | ||||
| </EmailLayout> | ||||
|  | ||||
| @code { | ||||
|     [Parameter] public required string Name { get; set; } | ||||
|     [Parameter] public required string Link { get; set; } | ||||
|  | ||||
|     [Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!; | ||||
|     [Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!; | ||||
| } | ||||
| @@ -1,4 +1,5 @@ | ||||
| @page "/spells/{spellWord}" | ||||
| @using DysonNetwork.Sphere.Account | ||||
| @model DysonNetwork.Sphere.Pages.Spell.MagicSpellPage | ||||
|  | ||||
| @{ | ||||
| @@ -25,11 +26,13 @@ | ||||
|             } | ||||
|             else | ||||
|             { | ||||
|                 <div class="px-4 py-12 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-lg rounded-lg mb-6"> | ||||
|                 <div | ||||
|                     class="px-4 py-12 bg-white dark:bg-gray-800 text-gray-900 dark:text-white shadow-lg rounded-lg mb-6"> | ||||
|                     <div class="mb-2"> | ||||
|                         <p> | ||||
|                             <span class="font-medium">The spell is for </span> | ||||
|                             <span class="font-bold">@System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")</span> | ||||
|                             <span | ||||
|                                 class="font-bold">@System.Text.RegularExpressions.Regex.Replace(Model.CurrentSpell!.Type.ToString(), "([a-z])([A-Z])", "$1 $2")</span> | ||||
|                         </p> | ||||
|                         <p><span class="font-medium">for @@</span>@Model.CurrentSpell.Account?.Name</p> | ||||
|                     </div> | ||||
| @@ -48,6 +51,28 @@ | ||||
|  | ||||
|                 <form method="post" class="mt-4"> | ||||
|                     <input type="hidden" asp-for="CurrentSpell!.Id"/> | ||||
|  | ||||
|                     @if (Model.CurrentSpell?.Type == MagicSpellType.AuthPasswordReset) | ||||
|                     { | ||||
|                         <div class="mb-4"> | ||||
|                             <label | ||||
|                                 asp-for="NewPassword" | ||||
|                                 class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" | ||||
|                             > | ||||
|                                 New Password | ||||
|                             </label> | ||||
|                             <input type="password" | ||||
|                                    asp-for="NewPassword" | ||||
|                                    required | ||||
|                                    minlength="8" | ||||
|                                    style="padding: 0.5rem 1rem" | ||||
|                                    placeholder="Your new password" | ||||
|                                    class="w-full border-2 border-gray-300 dark:border-gray-600 rounded-lg  | ||||
|                                           focus:ring-2 focus:ring-blue-400 | ||||
|                                           dark:text-white bg-gray-100 dark:bg-gray-800"/> | ||||
|                         </div> | ||||
|                     } | ||||
|  | ||||
|                     <button type="submit" | ||||
|                             class="px-6 py-3 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors | ||||
|                                    transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-400"> | ||||
|   | ||||
| @@ -8,8 +8,8 @@ namespace DysonNetwork.Sphere.Pages.Spell; | ||||
|  | ||||
| public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageModel | ||||
| { | ||||
|     [BindProperty] | ||||
|     public MagicSpell? CurrentSpell { get; set; } | ||||
|     [BindProperty] public MagicSpell? CurrentSpell { get; set; } | ||||
|     [BindProperty] public string? NewPassword { get; set; } | ||||
|  | ||||
|     public bool IsSuccess { get; set; } | ||||
|  | ||||
| @@ -39,10 +39,13 @@ public class MagicSpellPage(AppDatabase db, MagicSpellService spells) : PageMode | ||||
|             .Where(e => e.AffectedAt == null || now >= e.AffectedAt) | ||||
|             .FirstOrDefaultAsync(); | ||||
|  | ||||
|         if (spell == null) | ||||
|         if (spell == null || spell.Type == MagicSpellType.AuthPasswordReset && string.IsNullOrWhiteSpace(NewPassword)) | ||||
|             return Page(); | ||||
|  | ||||
|         await spells.ApplyMagicSpell(spell); | ||||
|         if (spell.Type == MagicSpellType.AuthPasswordReset) | ||||
|             await spells.ApplyPasswordReset(spell, NewPassword!); | ||||
|         else | ||||
|             await spells.ApplyMagicSpell(spell); | ||||
|         IsSuccess = true; | ||||
|         return Page(); | ||||
|     } | ||||
|   | ||||
| @@ -60,4 +60,25 @@ | ||||
|     <data name="EmailAccountDeletionTitle" xml:space="preserve"> | ||||
|         <value>Confirm your account deletion</value> | ||||
|     </data> | ||||
|     <data name="EmailPasswordResetTitle" xml:space="preserve"> | ||||
|         <value>Reset your password</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetButton" xml:space="preserve"> | ||||
|         <value>Reset Password</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetHeader" xml:space="preserve"> | ||||
|         <value>Password Reset Request</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara1" xml:space="preserve"> | ||||
|         <value>Dear,</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara2" xml:space="preserve"> | ||||
|         <value>We recieved a request to reset your Solar Network account password.</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara3" xml:space="preserve"> | ||||
|         <value>You can click the button below to continue reset your password.</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara4" xml:space="preserve"> | ||||
|         <value>If you didn't request this, you can ignore this email safety.</value> | ||||
|     </data> | ||||
| </root> | ||||
| @@ -53,4 +53,25 @@ | ||||
|     <data name="EmailAccountDeletionTitle" xml:space="preserve"> | ||||
|         <value>确认删除您的账户</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetHeader" xml:space="preserve"> | ||||
|         <value>密码重置请求</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara1" xml:space="preserve"> | ||||
|         <value>尊敬的 </value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara2" xml:space="preserve"> | ||||
|         <value>我们收到了重置您 Solar Network 账户密码的请求。</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara3" xml:space="preserve"> | ||||
|         <value>请点击下方按钮重置您的密码。此链接将在24小时后失效。</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetButton" xml:space="preserve"> | ||||
|         <value>重置密码</value> | ||||
|     </data> | ||||
|     <data name="PasswordResetPara4" xml:space="preserve"> | ||||
|         <value>如果您并未请求重置密码,你可以安全地忽略此邮件。</value> | ||||
|     </data> | ||||
|     <data name="EmailPasswordResetTitle" xml:space="preserve"> | ||||
|         <value>重置您的密码</value> | ||||
|     </data> | ||||
| </root> | ||||
		Reference in New Issue
	
	Block a user