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

View file

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

View file

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

View file

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

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)
{
try

View file

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

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

View file

@ -61,7 +61,13 @@
"add-email-address": "Add email address",
"no-email-addresses": "You haven't linked any email addresses yet.",
"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": {
"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 { t } from "$lib/i18n";
import { enhance } from "$app/forms";
import { Button, ButtonGroup, Input, InputGroup } from "@sveltestrap/sveltestrap";
import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { data: PageData; form: ActionData };
@ -21,29 +20,34 @@
</div>
<div class="row">
{#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>
<form method="POST" action="?/login" use:enhance>
<div class="mb-2">
<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 class="mb-2">
<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>
<ButtonGroup>
<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>
<button class="btn btn-primary" type="submit">{$t("auth.log-in-button")}</button>
</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>
{:else}
<div class="col-lg-3"></div>
{/if}
<div class="col col-md">
<div class="col-md">
<h3>{$t("auth.log-in-3rd-party-header")}</h3>
<p>{$t("auth.log-in-3rd-party-desc")}</p>
<form method="POST" action="?/fediToggle" use:enhance>
@ -71,19 +75,20 @@
{#if form?.showFediBox}
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
<form method="POST" action="?/fedi" use:enhance>
<InputGroup>
<Input
<div class="input-group">
<input
class="form-control"
name="instance"
type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/>
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
</InputGroup>
<button class="btn btn-secondary" type="submit">{$t("auth.log-in-button")}</button>
</div>
<p>
{$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")}
</Button>
</button>
</p>
</form>
{/if}