diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index bbf41f5..bdf4b9a 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -183,6 +183,63 @@ public class EmailAuthController( return NoContent(); } + [HttpPost("forgot-password")] + public async Task 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 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 AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) diff --git a/Foxnouns.Backend/Dto/Auth.cs b/Foxnouns.Backend/Dto/Auth.cs index ea9e67d..fbf5951 100644 --- a/Foxnouns.Backend/Dto/Auth.cs +++ b/Foxnouns.Backend/Dto/Auth.cs @@ -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); diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index d7e8784..615cc3d 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -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 GetForgotPasswordStateAsync( this KeyCacheService keyCacheService, string state, + bool delete = true, CancellationToken ct = default ) => await keyCacheService.GetKeyAsync( $"forgot_password:{state}", - true, + delete, ct ); } diff --git a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs index cd5c97f..4d9e1b0 100644 --- a/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateDataExportInvocable.cs @@ -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, diff --git a/Foxnouns.Backend/Mailables/PasswordChangedMailable.cs b/Foxnouns.Backend/Mailables/PasswordChangedMailable.cs new file mode 100644 index 0000000..79d86e3 --- /dev/null +++ b/Foxnouns.Backend/Mailables/PasswordChangedMailable.cs @@ -0,0 +1,25 @@ +using Coravel.Mailer.Mail; + +namespace Foxnouns.Backend.Mailables; + +public class PasswordChangedMailable(Config config, PasswordChangedMailableView view) + : Mailable +{ + 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; diff --git a/Foxnouns.Backend/Mailables/ResetPasswordMailable.cs b/Foxnouns.Backend/Mailables/ResetPasswordMailable.cs new file mode 100644 index 0000000..0f89b90 --- /dev/null +++ b/Foxnouns.Backend/Mailables/ResetPasswordMailable.cs @@ -0,0 +1,32 @@ +using Coravel.Mailer.Mail; + +namespace Foxnouns.Backend.Mailables; + +public class ResetPasswordMailable(Config config, ResetPasswordMailableView view) + : Mailable +{ + 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; } +} diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index a1444d9..83458d6 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -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(string to, Mailable mailable) { try diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index 491694a..8a35cdc 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -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 } diff --git a/Foxnouns.Backend/Views/Mail/PasswordChanged.cshtml b/Foxnouns.Backend/Views/Mail/PasswordChanged.cshtml new file mode 100644 index 0000000..458dcdf --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/PasswordChanged.cshtml @@ -0,0 +1,8 @@ +@model Foxnouns.Backend.Mailables.PasswordChangedMailableView + +

+ Your password has been changed using a "forgot password" link. + If this wasn't you, please a password reset immediately: +
+ @Model.BaseUrl/auth/forgot-password +

\ No newline at end of file diff --git a/Foxnouns.Backend/Views/Mail/ResetPassword.cshtml b/Foxnouns.Backend/Views/Mail/ResetPassword.cshtml new file mode 100644 index 0000000..f141d8b --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/ResetPassword.cshtml @@ -0,0 +1,14 @@ +@model Foxnouns.Backend.Mailables.ResetPasswordMailableView + +

+ Somebody (hopefully you!) has requested a password reset. + You can use the following link to do this: +
+ @Model.BaseUrl/auth/forgot-password/@Model.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. +

\ No newline at end of file diff --git a/Foxnouns.Frontend/src/lib/components/settings/EmailSettings.svelte b/Foxnouns.Frontend/src/lib/components/settings/EmailSettings.svelte index 29a1197..4bd3318 100644 --- a/Foxnouns.Frontend/src/lib/components/settings/EmailSettings.svelte +++ b/Foxnouns.Frontend/src/lib/components/settings/EmailSettings.svelte @@ -35,7 +35,7 @@
- +

Change password

diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index cd45d49..f9de99f 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -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", diff --git a/Foxnouns.Frontend/src/routes/auth/forgot-password/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/forgot-password/+page.server.ts new file mode 100644 index 0000000..f7195a0 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/forgot-password/+page.server.ts @@ -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("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; + } + }, +}; diff --git a/Foxnouns.Frontend/src/routes/auth/forgot-password/+page.svelte b/Foxnouns.Frontend/src/routes/auth/forgot-password/+page.svelte new file mode 100644 index 0000000..97d902a --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/forgot-password/+page.svelte @@ -0,0 +1,35 @@ + + + + {$t("auth.forgot-password-title")} • pronouns.cc + + +
+
+

{$t("auth.forgot-password-title")}

+ + + + + + +
+ +
+ +
+
diff --git a/Foxnouns.Frontend/src/routes/auth/forgot-password/[code]/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/forgot-password/[code]/+page.server.ts new file mode 100644 index 0000000..896504e --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/forgot-password/[code]/+page.server.ts @@ -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("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; + } + }, +}; diff --git a/Foxnouns.Frontend/src/routes/auth/forgot-password/[code]/+page.svelte b/Foxnouns.Frontend/src/routes/auth/forgot-password/[code]/+page.svelte new file mode 100644 index 0000000..fa01587 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/forgot-password/[code]/+page.svelte @@ -0,0 +1,41 @@ + + + + {$t("auth.reset-password-title")} • pronouns.cc + + +
+
+

{$t("auth.reset-password-title")}

+ + + +
+ +
+ + +
+
+ + +
+
+ +
+
+
+
diff --git a/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte b/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte index 33d3e31..c6c47a9 100644 --- a/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte +++ b/Foxnouns.Frontend/src/routes/auth/log-in/+page.svelte @@ -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 @@
{#if data.urls.email_enabled} -
+

{$t("auth.log-in-form-title")}

- +
- +
- - - - {$t("auth.register-with-email-button")} - - +
+

+ {$t("auth.log-in-sign-up-link")} • + {$t("auth.log-in-forgot-password-link")} +

{:else}
{/if} -
+

{$t("auth.log-in-3rd-party-header")}

{$t("auth.log-in-3rd-party-desc")}

@@ -71,19 +75,20 @@ {#if form?.showFediBox}

{$t("auth.log-in-with-the-fediverse")}

- - + - - + +

{$t("auth.log-in-with-fediverse-error-blurb")} - +

{/if}