Reset password

This commit is contained in:
LittleSheep 2025-05-27 22:41:38 +08:00
parent 25c721a42b
commit fcaeb9afbe
11 changed files with 261 additions and 33 deletions

View File

@ -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; }
@ -422,7 +448,8 @@ public class AccountController(
[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();

View File

@ -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()
{

View File

@ -11,7 +11,7 @@ public enum MagicSpellType
AccountActivation,
AccountDeactivation,
AccountRemoval,
AuthFactorReset,
AuthPasswordReset,
ContactVerification,
}

View File

@ -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();

View File

@ -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"];

View File

@ -11,3 +11,9 @@ 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; }
}

View 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!;
}

View File

@ -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">

View File

@ -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();
}

View File

@ -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>

View File

@ -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>