feat: forgot password/reset password
This commit is contained in:
parent
26b32b40e2
commit
9d33093339
17 changed files with 374 additions and 25 deletions
|
@ -183,6 +183,63 @@ public class EmailAuthController(
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("forgot-password")]
|
||||
public async Task<IActionResult> ForgotPasswordAsync([FromBody] EmailForgotPasswordRequest req)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
if (!req.Email.Contains('@'))
|
||||
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
|
||||
|
||||
AuthMethod? authMethod = await db
|
||||
.AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email)
|
||||
.FirstOrDefaultAsync();
|
||||
if (authMethod == null)
|
||||
return NoContent();
|
||||
|
||||
string state = await keyCacheService.GenerateForgotPasswordStateAsync(
|
||||
req.Email,
|
||||
authMethod.UserId
|
||||
);
|
||||
|
||||
if (IsRateLimited())
|
||||
return NoContent();
|
||||
|
||||
mailService.QueueResetPasswordEmail(req.Email, state);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("reset-password")]
|
||||
public async Task<IActionResult> ResetPasswordAsync([FromBody] EmailResetPasswordRequest req)
|
||||
{
|
||||
ForgotPasswordState? state = await keyCacheService.GetForgotPasswordStateAsync(req.State);
|
||||
if (state == null)
|
||||
throw new ApiError.BadRequest("Unknown state", "state", req.State);
|
||||
|
||||
if (
|
||||
!await db
|
||||
.AuthMethods.Where(m =>
|
||||
m.AuthType == AuthType.Email
|
||||
&& m.RemoteId == state.Email
|
||||
&& m.UserId == state.UserId
|
||||
)
|
||||
.AnyAsync()
|
||||
)
|
||||
{
|
||||
throw new ApiError.BadRequest("Invalid state");
|
||||
}
|
||||
|
||||
ValidationUtils.Validate([("password", ValidationUtils.ValidatePassword(req.Password))]);
|
||||
|
||||
User user = await db.Users.FirstAsync(u => u.Id == state.UserId);
|
||||
await authService.SetUserPasswordAsync(user, req.Password);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
mailService.QueuePasswordChangedEmail(state.Email);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("add-account")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
|
||||
|
|
|
@ -59,4 +59,8 @@ public record EmailCallbackRequest(string State);
|
|||
|
||||
public record EmailChangePasswordRequest(string Current, string New);
|
||||
|
||||
public record EmailForgotPasswordRequest(string Email);
|
||||
|
||||
public record EmailResetPasswordRequest(string State, string Password);
|
||||
|
||||
public record FediverseCallbackRequest(string Instance, string Code, string? State = null);
|
||||
|
|
|
@ -28,7 +28,7 @@ public static class KeyCacheExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
string state = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct);
|
||||
return state;
|
||||
}
|
||||
|
@ -51,8 +51,7 @@ public static class KeyCacheExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
// This state is used in links, not just as JSON values, so make it URL-safe
|
||||
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
string state = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync(
|
||||
$"email_state:{state}",
|
||||
new RegisterEmailState(email, userId),
|
||||
|
@ -112,11 +111,12 @@ public static class KeyCacheExtensions
|
|||
public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync(
|
||||
this KeyCacheService keyCacheService,
|
||||
string state,
|
||||
bool delete = true,
|
||||
CancellationToken ct = default
|
||||
) =>
|
||||
await keyCacheService.GetKeyAsync<ForgotPasswordState>(
|
||||
$"forgot_password:{state}",
|
||||
true,
|
||||
delete,
|
||||
ct
|
||||
);
|
||||
}
|
||||
|
|
|
@ -102,7 +102,7 @@ public class CreateDataExportInvocable(
|
|||
stream.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
// Upload the file!
|
||||
string filename = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
|
||||
string filename = AuthUtils.RandomToken();
|
||||
await objectStorageService.PutObjectAsync(
|
||||
ExportPath(user.Id, filename),
|
||||
stream,
|
||||
|
|
25
Foxnouns.Backend/Mailables/PasswordChangedMailable.cs
Normal file
25
Foxnouns.Backend/Mailables/PasswordChangedMailable.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using Coravel.Mailer.Mail;
|
||||
|
||||
namespace Foxnouns.Backend.Mailables;
|
||||
|
||||
public class PasswordChangedMailable(Config config, PasswordChangedMailableView view)
|
||||
: Mailable<PasswordChangedMailableView>
|
||||
{
|
||||
private string PlainText() =>
|
||||
$"""
|
||||
Your password has been changed using a "forgot password" link.
|
||||
If this wasn't you, request a password reset immediately:
|
||||
{view.BaseUrl}/auth/forgot-password
|
||||
""";
|
||||
|
||||
public override void Build()
|
||||
{
|
||||
To(view.To)
|
||||
.From(config.EmailAuth.From!)
|
||||
.Subject("Your password has been changed")
|
||||
.View("~/Views/Mail/PasswordChanged.cshtml", view)
|
||||
.Text(PlainText());
|
||||
}
|
||||
}
|
||||
|
||||
public class PasswordChangedMailableView : BaseView;
|
32
Foxnouns.Backend/Mailables/ResetPasswordMailable.cs
Normal file
32
Foxnouns.Backend/Mailables/ResetPasswordMailable.cs
Normal file
|
@ -0,0 +1,32 @@
|
|||
using Coravel.Mailer.Mail;
|
||||
|
||||
namespace Foxnouns.Backend.Mailables;
|
||||
|
||||
public class ResetPasswordMailable(Config config, ResetPasswordMailableView view)
|
||||
: Mailable<ResetPasswordMailableView>
|
||||
{
|
||||
private string PlainText() =>
|
||||
$"""
|
||||
Somebody (hopefully you!) has requested a password reset.
|
||||
You can use the following link to do this:
|
||||
{view.BaseUrl}/auth/forgot-password/{view.Code}
|
||||
Note that this link will expire in one hour.
|
||||
|
||||
If you weren't expecting this email, you don't have to do anything.
|
||||
Your password can't be changed without the above link.
|
||||
""";
|
||||
|
||||
public override void Build()
|
||||
{
|
||||
To(view.To)
|
||||
.From(config.EmailAuth.From!)
|
||||
.Subject("Reset your account's password")
|
||||
.View("~/Views/Mail/ResetPassword.cshtml", view)
|
||||
.Text(PlainText());
|
||||
}
|
||||
}
|
||||
|
||||
public class ResetPasswordMailableView : BaseView
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
}
|
|
@ -63,6 +63,41 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
|||
});
|
||||
}
|
||||
|
||||
public void QueueResetPasswordEmail(string to, string code)
|
||||
{
|
||||
_logger.Debug("Sending add email address email to {ToEmail}", to);
|
||||
queue.QueueAsyncTask(async () =>
|
||||
{
|
||||
await SendEmailAsync(
|
||||
to,
|
||||
new ResetPasswordMailable(
|
||||
config,
|
||||
new ResetPasswordMailableView
|
||||
{
|
||||
BaseUrl = config.BaseUrl,
|
||||
To = to,
|
||||
Code = code,
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public void QueuePasswordChangedEmail(string to)
|
||||
{
|
||||
_logger.Debug("Sending add email address email to {ToEmail}", to);
|
||||
queue.QueueAsyncTask(async () =>
|
||||
{
|
||||
await SendEmailAsync(
|
||||
to,
|
||||
new PasswordChangedMailable(
|
||||
config,
|
||||
new PasswordChangedMailableView { BaseUrl = config.BaseUrl, To = to }
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SendEmailAsync<T>(string to, Mailable<T> mailable)
|
||||
{
|
||||
try
|
||||
|
|
|
@ -131,7 +131,12 @@ public static class AuthUtils
|
|||
}
|
||||
|
||||
public static string RandomToken(int bytes = 48) =>
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
|
||||
Convert
|
||||
.ToBase64String(RandomNumberGenerator.GetBytes(bytes))
|
||||
.Trim('=')
|
||||
// Make the token URL-safe
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
|
||||
public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc
|
||||
}
|
||||
|
|
8
Foxnouns.Backend/Views/Mail/PasswordChanged.cshtml
Normal file
8
Foxnouns.Backend/Views/Mail/PasswordChanged.cshtml
Normal file
|
@ -0,0 +1,8 @@
|
|||
@model Foxnouns.Backend.Mailables.PasswordChangedMailableView
|
||||
|
||||
<p>
|
||||
Your password has been changed using a "forgot password" link.
|
||||
If this wasn't you, please a password reset immediately:
|
||||
<br />
|
||||
<a href="@Model.BaseUrl/auth/forgot-password">@Model.BaseUrl/auth/forgot-password</a>
|
||||
</p>
|
14
Foxnouns.Backend/Views/Mail/ResetPassword.cshtml
Normal file
14
Foxnouns.Backend/Views/Mail/ResetPassword.cshtml
Normal file
|
@ -0,0 +1,14 @@
|
|||
@model Foxnouns.Backend.Mailables.ResetPasswordMailableView
|
||||
|
||||
<p>
|
||||
Somebody (hopefully you!) has requested a password reset.
|
||||
You can use the following link to do this:
|
||||
<br />
|
||||
<a href="@Model.BaseUrl/auth/forgot-password/@Model.Code">@Model.BaseUrl/auth/forgot-password/@Model.Code</a>
|
||||
<br />
|
||||
Note that this link will expire in one hour.
|
||||
</p>
|
||||
<p>
|
||||
If you weren't expecting this email, you don't have to do anything.
|
||||
Your password can't be changed without the above link.
|
||||
</p>
|
Loading…
Add table
Add a link
Reference in a new issue