feat: forgot password/reset password

This commit is contained in:
sam 2024-12-14 16:32:08 +01:00
parent 26b32b40e2
commit 9d33093339
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
17 changed files with 374 additions and 25 deletions

View file

@ -183,6 +183,63 @@ public class EmailAuthController(
return NoContent(); 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")] [HttpPost("add-account")]
[Authorize("*")] [Authorize("*")]
public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)

View file

@ -59,4 +59,8 @@ public record EmailCallbackRequest(string State);
public record EmailChangePasswordRequest(string Current, string New); 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); public record FediverseCallbackRequest(string Instance, string Code, string? State = null);

View file

@ -28,7 +28,7 @@ public static class KeyCacheExtensions
CancellationToken ct = default CancellationToken ct = default
) )
{ {
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); string state = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct);
return state; return state;
} }
@ -51,8 +51,7 @@ public static class KeyCacheExtensions
CancellationToken ct = default CancellationToken ct = default
) )
{ {
// This state is used in links, not just as JSON values, so make it URL-safe string state = AuthUtils.RandomToken();
string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_');
await keyCacheService.SetKeyAsync( await keyCacheService.SetKeyAsync(
$"email_state:{state}", $"email_state:{state}",
new RegisterEmailState(email, userId), new RegisterEmailState(email, userId),
@ -112,11 +111,12 @@ public static class KeyCacheExtensions
public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync( public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync(
this KeyCacheService keyCacheService, this KeyCacheService keyCacheService,
string state, string state,
bool delete = true,
CancellationToken ct = default CancellationToken ct = default
) => ) =>
await keyCacheService.GetKeyAsync<ForgotPasswordState>( await keyCacheService.GetKeyAsync<ForgotPasswordState>(
$"forgot_password:{state}", $"forgot_password:{state}",
true, delete,
ct ct
); );
} }

View file

@ -102,7 +102,7 @@ public class CreateDataExportInvocable(
stream.Seek(0, SeekOrigin.Begin); stream.Seek(0, SeekOrigin.Begin);
// Upload the file! // Upload the file!
string filename = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); string filename = AuthUtils.RandomToken();
await objectStorageService.PutObjectAsync( await objectStorageService.PutObjectAsync(
ExportPath(user.Id, filename), ExportPath(user.Id, filename),
stream, stream,

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

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

View file

@ -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) private async Task SendEmailAsync<T>(string to, Mailable<T> mailable)
{ {
try try

View file

@ -131,7 +131,12 @@ public static class AuthUtils
} }
public static string RandomToken(int bytes = 48) => 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 public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc
} }

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

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

View file

@ -35,7 +35,7 @@
</div> </div>
</div> </div>
<div class="col-md"> <div class="col-md">
<FormStatusMarker {form} /> <FormStatusMarker {form} successMessage={$t("auth.password-changed-hint")} />
<h4>Change password</h4> <h4>Change password</h4>
<form method="POST" action="?/password"> <form method="POST" action="?/password">
<div class="mb-1"> <div class="mb-1">

View file

@ -61,7 +61,13 @@
"add-email-address": "Add email address", "add-email-address": "Add email address",
"no-email-addresses": "You haven't linked any email addresses yet.", "no-email-addresses": "You haven't linked any email addresses yet.",
"check-inbox-for-link-hint": "Check your inbox for a link!", "check-inbox-for-link-hint": "Check your inbox for a link!",
"successful-link-email": "Your account has successfully been linked to the following email address:" "successful-link-email": "Your account has successfully been linked to the following email address:",
"reset-password-button": "Reset password",
"log-in-forgot-password-link": "Forgot your password?",
"log-in-sign-up-link": "Sign up with email",
"forgot-password-title": "Forgot password",
"reset-password-title": "Reset password",
"password-changed-hint": "Your password has been changed!"
}, },
"error": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",

View file

@ -0,0 +1,34 @@
import { apiRequest, fastRequest } from "$api";
import ApiError from "$api/error.js";
import type { AuthUrls } from "$api/models/auth";
import log from "$lib/log.js";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent, fetch }) => {
const { meUser } = await parent();
if (meUser) redirect(303, `/@${meUser.username}`);
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
if (!urls.email_enabled) redirect(303, "/");
};
export const actions = {
default: async ({ request, fetch }) => {
const data = await request.formData();
const email = data.get("email") as string;
try {
await fastRequest("POST", "/auth/email/forgot-password", {
body: { email },
isInternal: true,
fetch,
});
return { ok: true, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj };
log.error("error sending forget password email:", e);
throw e;
}
},
};

View file

@ -0,0 +1,35 @@
<script lang="ts">
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import { t } from "$lib/i18n";
import type { ActionData } from "./$types";
type Props = { form: ActionData };
let { form }: Props = $props();
</script>
<svelte:head>
<title>{$t("auth.forgot-password-title")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="mx-auto w-lg-50">
<h3>{$t("auth.forgot-password-title")}</h3>
<FormStatusMarker {form} successMessage={$t("auth.check-inbox-for-link-hint")} />
<form method="POST">
<label for="email" class="form-label">{$t("auth.log-in-form-email-label")}</label>
<input
required
type="email"
id="email"
name="email"
placeholder="me@example.com"
class="form-control mb-2"
/>
<div class="d-grid">
<button type="submit" class="btn btn-primary">{$t("auth.reset-password-button")}</button>
</div>
</form>
</div>
</div>

View file

@ -0,0 +1,48 @@
import { apiRequest, fastRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuthUrls } from "$api/models";
import log from "$lib/log";
import { redirect } from "@sveltejs/kit";
export const load = async ({ params, parent, fetch }) => {
const { meUser } = await parent();
if (meUser) redirect(303, `/@${meUser.username}`);
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
if (!urls.email_enabled) redirect(303, "/");
return { state: params.code };
};
export const actions = {
default: async ({ request, fetch }) => {
const data = await request.formData();
const state = data.get("state") as string;
const password = data.get("password") as string;
const password2 = data.get("confirm-password") as string;
if (password !== password2) {
return {
ok: false,
error: {
status: 400,
message: "Passwords don't match",
code: ErrorCode.BadRequest,
} as RawApiError,
};
}
try {
await fastRequest("POST", "/auth/email/reset-password", {
body: { state, password },
isInternal: true,
fetch,
});
return { ok: true, error: null };
} catch (e) {
if (e instanceof ApiError) return { ok: false, error: e.obj };
log.error("error resetting password:", e);
throw e;
}
},
};

View file

@ -0,0 +1,41 @@
<script lang="ts">
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("auth.reset-password-title")} • pronouns.cc</title>
</svelte:head>
<div class="container">
<div class="mx-auto w-lg-50">
<h3>{$t("auth.reset-password-title")}</h3>
<FormStatusMarker {form} successMessage={$t("auth.password-changed-hint")} />
<form method="POST">
<input type="hidden" name="state" readonly value={data.state} />
<div class="mb-2">
<label for="password" class="form-label">{$t("auth.log-in-form-password-label")}</label>
<input required type="password" id="password" name="password" class="form-control" />
</div>
<div class="mb-2">
<label for="confirm-password" class="form-label">{$t("auth.confirm-password-label")}</label>
<input
required
type="password"
id="confirm-password"
name="confirm-password"
class="form-control"
/>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">{$t("auth.reset-password-button")}</button>
</div>
</form>
</div>
</div>

View file

@ -2,7 +2,6 @@
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import { Button, ButtonGroup, Input, InputGroup } from "@sveltestrap/sveltestrap";
import ErrorAlert from "$components/ErrorAlert.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
@ -21,29 +20,34 @@
</div> </div>
<div class="row"> <div class="row">
{#if data.urls.email_enabled} {#if data.urls.email_enabled}
<div class="col col-md mb-4"> <div class="col-md mb-4">
<h2>{$t("auth.log-in-form-title")}</h2> <h2>{$t("auth.log-in-form-title")}</h2>
<form method="POST" action="?/login" use:enhance> <form method="POST" action="?/login" use:enhance>
<div class="mb-2"> <div class="mb-2">
<label class="form-label" for="email">{$t("auth.log-in-form-email-label")}</label> <label class="form-label" for="email">{$t("auth.log-in-form-email-label")}</label>
<Input type="email" id="email" name="email" placeholder="me@example.com" /> <input
class="form-control"
type="email"
id="email"
name="email"
placeholder="me@example.com"
/>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label" for="password">{$t("auth.log-in-form-password-label")}</label> <label class="form-label" for="password">{$t("auth.log-in-form-password-label")}</label>
<Input type="password" id="password" name="password" /> <input class="form-control" type="password" id="password" name="password" />
</div> </div>
<ButtonGroup> <button class="btn btn-primary" type="submit">{$t("auth.log-in-button")}</button>
<Button type="submit" color="primary">{$t("auth.log-in-button")}</Button>
<a class="btn btn-secondary" href="/auth/register">
{$t("auth.register-with-email-button")}
</a>
</ButtonGroup>
</form> </form>
<p class="mt-2">
<a href="/auth/register">{$t("auth.log-in-sign-up-link")}</a>
<a href="/auth/forgot-password">{$t("auth.log-in-forgot-password-link")}</a>
</p>
</div> </div>
{:else} {:else}
<div class="col-lg-3"></div> <div class="col-lg-3"></div>
{/if} {/if}
<div class="col col-md"> <div class="col-md">
<h3>{$t("auth.log-in-3rd-party-header")}</h3> <h3>{$t("auth.log-in-3rd-party-header")}</h3>
<p>{$t("auth.log-in-3rd-party-desc")}</p> <p>{$t("auth.log-in-3rd-party-desc")}</p>
<form method="POST" action="?/fediToggle" use:enhance> <form method="POST" action="?/fediToggle" use:enhance>
@ -71,19 +75,20 @@
{#if form?.showFediBox} {#if form?.showFediBox}
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4> <h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
<form method="POST" action="?/fedi" use:enhance> <form method="POST" action="?/fedi" use:enhance>
<InputGroup> <div class="input-group">
<Input <input
class="form-control"
name="instance" name="instance"
type="text" type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")} placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/> />
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> <button class="btn btn-secondary" type="submit">{$t("auth.log-in-button")}</button>
</InputGroup> </div>
<p> <p>
{$t("auth.log-in-with-fediverse-error-blurb")} {$t("auth.log-in-with-fediverse-error-blurb")}
<Button formaction="?/fediForceRefresh" type="submit" color="link"> <button class="btn btn-link" formaction="?/fediForceRefresh" type="submit">
{$t("auth.log-in-with-fediverse-force-refresh-button")} {$t("auth.log-in-with-fediverse-force-refresh-button")}
</Button> </button>
</p> </p>
</form> </form>
{/if} {/if}