diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index bc34c9f..dbb8655 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -119,6 +119,20 @@ public class AuthController( CurrentUser!.Id ); + // If this is the user's last email, we should also clear the user's password. + if ( + authMethod.AuthType == AuthType.Email + && authMethods.Count(a => a.AuthType == AuthType.Email) == 1 + ) + { + _logger.Debug( + "Deleted last email address for user {UserId}, resetting their password", + CurrentUser.Id + ); + CurrentUser.Password = null; + db.Update(CurrentUser); + } + db.Remove(authMethod); await db.SaveChangesAsync(); diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 6aadf65..bb1383c 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -1,3 +1,5 @@ +using System.Net; +using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; @@ -26,8 +28,8 @@ public class EmailAuthController( { private readonly ILogger _logger = logger.ForContext(); - [HttpPost("register")] - public async Task RegisterAsync( + [HttpPost("register/init")] + public async Task RegisterInitAsync( [FromBody] RegisterRequest req, CancellationToken ct = default ) @@ -62,25 +64,9 @@ public class EmailAuthController( CheckRequirements(); var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); - if (state == null) + if (state is not { ExistingUserId: null }) throw new ApiError.BadRequest("Invalid state", "state", req.State); - // If this callback is for an existing user, add the email address to their auth methods - if (state.ExistingUserId != null) - { - var authMethod = await authService.AddAuthMethodAsync( - state.ExistingUserId.Value, - AuthType.Email, - state.Email - ); - _logger.Debug( - "Added email auth {AuthId} for user {UserId}", - authMethod.Id, - state.ExistingUserId - ); - return NoContent(); - } - var ticket = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); @@ -96,7 +82,7 @@ public class EmailAuthController( ); } - [HttpPost("complete-registration")] + [HttpPost("register")] public async Task CompleteRegistrationAsync( [FromBody] CompleteRegistrationRequest req ) @@ -107,12 +93,6 @@ public class EmailAuthController( if (email == null) throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket); - // Check if username is valid at all - ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]); - // Check if username is already taken - if (await db.Users.AnyAsync(u => u.Username == req.Username)) - throw new ApiError.BadRequest("Username is already taken", "username", req.Username); - var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password); var frontendApp = await db.GetFrontendApplicationAsync(); @@ -184,7 +164,21 @@ public class EmailAuthController( ); } - [HttpPost("add")] + [HttpPost("change-password")] + [Authorize("*")] + public async Task UpdatePasswordAsync([FromBody] ChangePasswordRequest req) + { + if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current)) + throw new ApiError.Forbidden("Invalid password"); + + ValidationUtils.Validate([("new", ValidationUtils.ValidatePassword(req.New))]); + + await authService.SetUserPasswordAsync(CurrentUser!, req.New); + await db.SaveChangesAsync(); + return NoContent(); + } + + [HttpPost("add-email")] [Authorize("*")] public async Task AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) { @@ -204,11 +198,8 @@ public class EmailAuthController( if (emails.Count != 0) { - var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password); - if (!validPassword) - { + if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Password)) throw new ApiError.Forbidden("Invalid password"); - } } else { @@ -233,6 +224,48 @@ public class EmailAuthController( return NoContent(); } + [HttpPost("add-email/callback")] + [Authorize("*")] + public async Task AddEmailCallbackAsync([FromBody] CallbackRequest req) + { + CheckRequirements(); + + var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); + if (state?.ExistingUserId != CurrentUser!.Id) + throw new ApiError.BadRequest("Invalid state", "state", req.State); + + try + { + var authMethod = await authService.AddAuthMethodAsync( + CurrentUser.Id, + AuthType.Email, + state.Email + ); + _logger.Debug( + "Added email auth {AuthId} for user {UserId}", + authMethod.Id, + CurrentUser.Id + ); + + return Ok( + new AuthController.AddOauthAccountResponse( + authMethod.Id, + AuthType.Email, + authMethod.RemoteId, + RemoteUsername: null + ) + ); + } + catch (UniqueConstraintException) + { + throw new ApiError( + "That email address is already linked.", + HttpStatusCode.BadRequest, + ErrorCode.AccountAlreadyLinked + ); + } + } + public record AddEmailAddressRequest(string Email, string Password); private void CheckRequirements() @@ -248,4 +281,6 @@ public class EmailAuthController( public record CompleteRegistrationRequest(string Ticket, string Username, string Password); public record CallbackRequest(string State); + + public record ChangePasswordRequest(string Current, string New); } diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index f3e1467..bd3930e 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -25,7 +25,7 @@ public static class KeyCacheExtensions CancellationToken ct = default ) { - var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct); + var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); } @@ -52,12 +52,7 @@ public static class KeyCacheExtensions this KeyCacheService keyCacheService, string state, CancellationToken ct = default - ) => - await keyCacheService.GetKeyAsync( - $"email_state:{state}", - delete: true, - ct - ); + ) => await keyCacheService.GetKeyAsync($"email_state:{state}", ct: ct); public static async Task GenerateAddExtraAccountStateAsync( this KeyCacheService keyCacheService, diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index e3ec4c4..649d070 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -31,6 +31,16 @@ public class AuthService( CancellationToken ct = default ) { + // Validate username and whether it's not taken + ValidationUtils.Validate( + [ + ("username", ValidationUtils.ValidateUsername(username)), + ("password", ValidationUtils.ValidatePassword(password)), + ] + ); + if (await db.Users.AnyAsync(u => u.Username == username, ct)) + throw new ApiError.BadRequest("Username is already taken", "username", username); + var user = new User { Id = snowflakeGenerator.GenerateSnowflake(), @@ -49,7 +59,7 @@ public class AuthService( }; db.Add(user); - user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); + user.Password = await HashPasswordAsync(user, password, ct); return user; } @@ -70,6 +80,8 @@ public class AuthService( { AssertValidAuthType(authType, instance); + // Validate username and whether it's not taken + ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(username))]); if (await db.Users.AnyAsync(u => u.Username == username, ct)) throw new ApiError.BadRequest("Username is already taken", "username", username); @@ -121,10 +133,7 @@ public class AuthService( ErrorCode.UserNotFound ); - var pwResult = await Task.Run( - () => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), - ct - ); + var pwResult = await VerifyHashedPasswordAsync(user, password, ct); if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords? throw new ApiError.NotFound( "No user with that email address found, or password is incorrect", @@ -132,7 +141,7 @@ public class AuthService( ); if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) { - user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); + user.Password = await HashPasswordAsync(user, password, ct); await db.SaveChangesAsync(ct); } @@ -160,10 +169,7 @@ public class AuthService( throw new FoxnounsError("Password for user supplied to ValidatePasswordAsync was null"); } - var pwResult = await Task.Run( - () => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), - ct - ); + var pwResult = await VerifyHashedPasswordAsync(user, password, ct); return pwResult is PasswordVerificationResult.SuccessRehashNeeded or PasswordVerificationResult.Success; @@ -178,7 +184,7 @@ public class AuthService( CancellationToken ct = default ) { - user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); + user.Password = await HashPasswordAsync(user, password, ct); db.Update(user); } @@ -316,6 +322,22 @@ public class AuthService( ); } + private Task HashPasswordAsync( + User user, + string password, + CancellationToken ct = default + ) => Task.Run(() => _passwordHasher.HashPassword(user, password), ct); + + private Task VerifyHashedPasswordAsync( + User user, + string providedPassword, + CancellationToken ct = default + ) => + Task.Run( + () => _passwordHasher.VerifyHashedPassword(user, user.Password!, providedPassword), + ct + ); + private static (string, byte[]) GenerateToken() { var token = AuthUtils.RandomToken(); diff --git a/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs b/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs index 0193b7e..0b8617c 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.Strings.cs @@ -185,6 +185,27 @@ public static partial class ValidationUtils }; } + public const int MinimumPasswordLength = 12; + public const int MaximumPasswordLength = 1024; + + public static ValidationError? ValidatePassword(string password) => + password.Length switch + { + < MinimumPasswordLength => ValidationError.LengthError( + "Password is too short", + MinimumPasswordLength, + MaximumPasswordLength, + password.Length + ), + > MaximumPasswordLength => ValidationError.LengthError( + "Password is too long", + MinimumPasswordLength, + MaximumPasswordLength, + password.Length + ), + _ => null, + }; + [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")] private static partial Regex UsernameRegex(); diff --git a/Foxnouns.Backend/Views/Mail/AccountCreation.cshtml b/Foxnouns.Backend/Views/Mail/AccountCreation.cshtml index b2b0f2e..cf8d1bc 100644 --- a/Foxnouns.Backend/Views/Mail/AccountCreation.cshtml +++ b/Foxnouns.Backend/Views/Mail/AccountCreation.cshtml @@ -2,9 +2,9 @@

Please continue creating a new pronouns.cc account by using the following link: -
- Confirm your email address -
+
+ Confirm your email address +
Note that this link will expire in one hour.

diff --git a/Foxnouns.Backend/Views/Mail/AddEmail.cshtml b/Foxnouns.Backend/Views/Mail/AddEmail.cshtml index dabef6c..2423434 100644 --- a/Foxnouns.Backend/Views/Mail/AddEmail.cshtml +++ b/Foxnouns.Backend/Views/Mail/AddEmail.cshtml @@ -2,9 +2,9 @@

Hello @@@Model.Username, please confirm adding this email address to your account by using the following link: -
- Confirm your email address -
+
+ Confirm your email address +
Note that this link will expire in one hour.

diff --git a/Foxnouns.Frontend/src/lib/actions/callback.ts b/Foxnouns.Frontend/src/lib/actions/callback.ts new file mode 100644 index 0000000..865e106 --- /dev/null +++ b/Foxnouns.Frontend/src/lib/actions/callback.ts @@ -0,0 +1,69 @@ +import { apiRequest } from "$api"; +import ApiError, { ErrorCode } from "$api/error"; +import type { AddAccountResponse, CallbackResponse } from "$api/models"; +import { setToken } from "$lib"; +import log from "$lib/log"; +import { isRedirect, redirect, type ServerLoadEvent } from "@sveltejs/kit"; + +export default function createCallbackLoader( + callbackType: string, + bodyFn?: (event: ServerLoadEvent) => Promise, +) { + return async (event: ServerLoadEvent) => { + const { url, parent, fetch, cookies } = event; + + bodyFn ??= async ({ url }) => { + const code = url.searchParams.get("code") as string | null; + const state = url.searchParams.get("state") as string | null; + if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; + return { code, state }; + }; + + const { meUser } = await parent(); + if (meUser) { + try { + const resp = await apiRequest( + "POST", + `/auth/${callbackType}/add-account/callback`, + { + isInternal: true, + body: await bodyFn(event), + fetch, + cookies, + }, + ); + + return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; + } catch (e) { + if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj }; + log.error("error linking new %s account to user %s:", callbackType, meUser.id, e); + throw e; + } + } + + try { + const resp = await apiRequest("POST", `/auth/${callbackType}/callback`, { + body: await bodyFn(event), + isInternal: true, + fetch, + }); + + if (resp.has_account) { + setToken(cookies, resp.token!); + redirect(303, `/@${resp.user!.username}`); + } + + return { + hasAccount: false, + isLinkRequest: false, + ticket: resp.ticket!, + remoteUser: resp.remote_username!, + }; + } catch (e) { + if (isRedirect(e)) throw e; + if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj }; + log.error("error while requesting %s callback:", callbackType, e); + throw e; + } + }; +} diff --git a/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte b/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte index 43ca9b9..ec15087 100644 --- a/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte +++ b/Foxnouns.Frontend/src/lib/components/editor/FormStatusMarker.svelte @@ -4,8 +4,8 @@ import type { RawApiError } from "$api/error"; import ErrorAlert from "$components/ErrorAlert.svelte"; - type Props = { form: { error: RawApiError | null; ok: boolean } | null }; - let { form }: Props = $props(); + type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string }; + let { form, successMessage }: Props = $props(); {#if form?.error} @@ -13,6 +13,6 @@ {:else if form?.ok}

- {$t("edit-profile.saved-changes")} + {successMessage ?? $t("edit-profile.saved-changes")}

{/if} diff --git a/Foxnouns.Frontend/src/lib/components/settings/OauthRegistrationForm.svelte b/Foxnouns.Frontend/src/lib/components/settings/OauthRegistrationForm.svelte index 6199517..cae5efa 100644 --- a/Foxnouns.Frontend/src/lib/components/settings/OauthRegistrationForm.svelte +++ b/Foxnouns.Frontend/src/lib/components/settings/OauthRegistrationForm.svelte @@ -1,6 +1,5 @@ + + + {$t("auth.register-with-email")} • pronouns.cc + + +
+ {#if data.error} +

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

+ + {:else if data.isLinkRequest} + + {:else} +

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

+ + {#if form?.error} + + {/if} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+ {/if} +
diff --git a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts index 64339a1..fff5322 100644 --- a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts +++ b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts @@ -1,63 +1,14 @@ -import { apiRequest } from "$api"; import ApiError, { ErrorCode } from "$api/error"; -import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js"; -import { setToken } from "$lib"; -import createRegisterAction from "$lib/actions/register.js"; -import log from "$lib/log"; -import { isRedirect, redirect } from "@sveltejs/kit"; +import createCallbackLoader from "$lib/actions/callback"; +import createRegisterAction from "$lib/actions/register"; -export const load = async ({ parent, params, url, fetch, cookies }) => { +export const load = createCallbackLoader("fediverse", async ({ params, url }) => { const code = url.searchParams.get("code") as string | null; const state = url.searchParams.get("state") as string | null; if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; - const { meUser } = await parent(); - if (meUser) { - try { - const resp = await apiRequest( - "POST", - "/auth/fediverse/add-account/callback", - { - isInternal: true, - body: { code, state, instance: params.instance }, - fetch, - cookies, - }, - ); - - return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; - } catch (e) { - if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj }; - log.error("error linking new fediverse account to user %s:", meUser.id, e); - throw e; - } - } - - try { - const resp = await apiRequest("POST", "/auth/fediverse/callback", { - body: { code, state, instance: params.instance }, - isInternal: true, - fetch, - }); - - if (resp.has_account) { - setToken(cookies, resp.token!); - redirect(303, `/@${resp.user!.username}`); - } - - return { - hasAccount: false, - isLinkRequest: false, - ticket: resp.ticket!, - remoteUser: resp.remote_username!, - }; - } catch (e) { - if (isRedirect(e)) throw e; - if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj }; - log.error("error while requesting fediverse callback:", e); - throw e; - } -}; + return { code, state, instance: params.instance! }; +}); export const actions = { default: createRegisterAction("/auth/fediverse/register"), diff --git a/Foxnouns.Frontend/src/routes/auth/register/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/register/+page.server.ts new file mode 100644 index 0000000..7a90a22 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/register/+page.server.ts @@ -0,0 +1,35 @@ +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 ({ fetch, parent }) => { + const parentData = await parent(); + if (parentData.meUser) redirect(303, `/@${parentData.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, cookies }) => { + const body = await request.formData(); + const email = body.get("email") as string; + + try { + await fastRequest("POST", `/auth/email/register/init`, { + body: { email }, + isInternal: true, + fetch, + cookies, + }); + + return { ok: true, error: null }; + } catch (e) { + if (e instanceof ApiError) return { ok: false, error: e.obj }; + log.error("error initiating registration for email %s:", email, e); + throw e; + } + }, +}; diff --git a/Foxnouns.Frontend/src/routes/auth/register/+page.svelte b/Foxnouns.Frontend/src/routes/auth/register/+page.svelte new file mode 100644 index 0000000..b43b789 --- /dev/null +++ b/Foxnouns.Frontend/src/routes/auth/register/+page.svelte @@ -0,0 +1,29 @@ + + + + {$t("auth.register-with-email")} • pronouns.cc + + +
+
+

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

+ + + +
+ + + + +
+
+
diff --git a/Foxnouns.Frontend/src/routes/settings/auth/add-fediverse/+page.svelte b/Foxnouns.Frontend/src/routes/settings/auth/add-fediverse/+page.svelte index 2ca272a..36beec1 100644 --- a/Foxnouns.Frontend/src/routes/settings/auth/add-fediverse/+page.svelte +++ b/Foxnouns.Frontend/src/routes/settings/auth/add-fediverse/+page.svelte @@ -3,21 +3,23 @@ import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; -

Link a new Fediverse account

+
+

Link a new Fediverse account

-
- - - - -

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

-
+
+ + + + +

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

+
+
diff --git a/Foxnouns.Frontend/src/routes/settings/export/+page.svelte b/Foxnouns.Frontend/src/routes/settings/export/+page.svelte index 874f8e8..11065a9 100644 --- a/Foxnouns.Frontend/src/routes/settings/export/+page.svelte +++ b/Foxnouns.Frontend/src/routes/settings/export/+page.svelte @@ -5,6 +5,7 @@ import { Icon } from "@sveltestrap/sveltestrap"; import { t } from "$lib/i18n"; import { enhance } from "$app/forms"; + import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; type Props = { data: PageData; form: ActionData }; let { data, form }: Props = $props(); @@ -18,14 +19,7 @@

{$t("settings.export-title")}

- {#if form?.ok} -

- - {$t("settings.export-request-success")} -

- {:else if form?.error} - - {/if} +

{$t("settings.export-info")}