From 77856996e95ce9c10527d100f26c5a6a2e0aa603 Mon Sep 17 00:00:00 2001 From: PawiX25 Date: Sat, 4 Apr 2026 04:33:39 +0200 Subject: [PATCH 01/16] feat: add email confirmation, factor deletion, TOTP/recovery codes management --- mail/src/lib.rs | 2 - packages/api-schema/api.d.ts | 1048 ++++++++++++++--- server/src/database.rs | 2 + server/src/main.rs | 9 +- server/src/routes/api.rs | 2 + server/src/routes/api/confirm_email.rs | 120 ++ server/src/routes/api/login/password.rs | 28 +- server/src/routes/api/register.rs | 30 +- server/src/routes/api/settings/factors/pgp.rs | 5 +- .../api/settings/factors/pgp/disable.rs | 70 ++ .../api/settings/factors/recovery_codes.rs | 5 +- .../settings/factors/recovery_codes/reset.rs | 84 ++ .../src/routes/api/settings/factors/totp.rs | 7 +- .../api/settings/factors/totp/disable.rs | 73 ++ .../api/settings/factors/totp/enable.rs | 4 +- .../routes/api/settings/factors/webauthn.rs | 2 + .../api/settings/factors/webauthn/delete.rs | 73 ++ 17 files changed, 1377 insertions(+), 187 deletions(-) create mode 100644 server/src/routes/api/confirm_email.rs create mode 100644 server/src/routes/api/settings/factors/pgp/disable.rs create mode 100644 server/src/routes/api/settings/factors/recovery_codes/reset.rs create mode 100644 server/src/routes/api/settings/factors/webauthn/delete.rs diff --git a/mail/src/lib.rs b/mail/src/lib.rs index 2bac664..842dcda 100644 --- a/mail/src/lib.rs +++ b/mail/src/lib.rs @@ -68,7 +68,6 @@ impl MailService { .await } - /// Not yet implemented — will be called once email confirmation flow is ready. pub async fn send_email_confirmation(&self, to: &str, token: &str) -> Result<()> { let confirm_url = format!("{}/confirm-email?token={token}", self.public_url); let html = templates::email_confirmation(&confirm_url); @@ -80,7 +79,6 @@ impl MailService { .await } - /// Not yet implemented — will be called once login notification flow is ready (after DB migration). pub async fn send_login_notification(&self, to: &str, ip: &str) -> Result<()> { let html = templates::login_notification(ip); self.send( diff --git a/packages/api-schema/api.d.ts b/packages/api-schema/api.d.ts index 3cf37d0..238180b 100644 --- a/packages/api-schema/api.d.ts +++ b/packages/api-schema/api.d.ts @@ -14,7 +14,45 @@ export interface paths { /** Get applications */ get: operations["get_applications"]; put?: never; - post?: never; + /** Create application */ + post: operations["create_application"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/factors/password/authenticate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Authenticate Docs here */ + post: operations["password_authenticate"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/confirm-email": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Confirm email address + * @description Validates the confirmation token and marks the user's email as confirmed. Tokens expire after 24 hours and are deleted on use. + */ + post: operations["confirm_email"]; delete?: never; options?: never; head?: never; @@ -173,9 +211,11 @@ export interface paths { put?: never; /** * Finish discoverable (passwordless) WebAuthn authentication - * @description Completes the discoverable credential authentication flow. Requires a previous call to `/api/login/webauthn/passwordless/start`. The server identifies the user from the credential's embedded user handle (UUID). + * @description Completes the discoverable credential authentication flow. Requires a previous call to + * `/api/login/webauthn/passwordless/start`. The server identifies the user from the credential's + * embedded user handle (UUID). */ - post: operations["discoverable_finish"]; + post: operations["passwordless_finish"]; delete?: never; options?: never; head?: never; @@ -193,9 +233,11 @@ export interface paths { put?: never; /** * Start discoverable (passwordless) WebAuthn authentication - * @description Initiates a discoverable credential authentication flow. No username is required — the authenticator will present the user with a list of available passkeys. + * @description Initiates a discoverable credential authentication flow. No username is required — the authenticator + * will present the user with a list of available passkeys. After receiving the browser response, + * call `/api/login/webauthn/passwordless/finish` to complete authentication. */ - post: operations["discoverable_start"]; + post: operations["passwordless_start"]; delete?: never; options?: never; head?: never; @@ -231,21 +273,7 @@ export interface paths { }; get?: never; put?: never; - /** Request password reset */ - post: { - requestBody: { - content: { - "application/json": components["schemas"]["RequestResetBody"]; - }; - }; - responses: { - 200: { - content: { - "application/json": components["schemas"]["RequestResetResponse"]; - }; - }; - }; - }; + post: operations["request_reset"]; delete?: never; options?: never; head?: never; @@ -261,26 +289,12 @@ export interface paths { }; get?: never; put?: never; - /** Confirm password reset */ - post: { - requestBody: { - content: { - "application/json": components["schemas"]["ConfirmResetBody"]; - }; - }; - responses: { - 200: { - content: { - "application/json": components["schemas"]["ConfirmResetResponse"]; - }; - }; - 400: { - content: { - "application/json": { error: string }; - }; - }; - }; - }; + /** + * Confirm password reset + * @description Validates the token and sets the new password. Tokens expire after 1 hour + * and are deleted on use. + */ + post: operations["confirm_reset"]; delete?: never; options?: never; head?: never; @@ -324,6 +338,58 @@ export interface paths { patch?: never; trace?: never; }; + "/api/settings/factors/password/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["password_disable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/factors/password/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations["password_enable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/factors/pgp/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Disable PGP + * @description Removes the PGP authentication factor from the user's account. + */ + delete: operations["disable_pgp"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/settings/factors/pgp/enable": { parameters: { query?: never; @@ -364,6 +430,46 @@ export interface paths { patch?: never; trace?: never; }; + "/api/settings/factors/recovery-codes/reset": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Reset recovery codes + * @description Invalidates all existing recovery codes and generates a fresh set of 10. + */ + post: operations["reset_recovery_codes"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/settings/factors/totp/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Disable TOTP + * @description Removes the TOTP authentication factor from the user's account. + */ + delete: operations["disable_totp"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/settings/factors/totp/enable": { parameters: { query?: never; @@ -404,6 +510,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/settings/factors/webauthn/delete/{display_name}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete WebAuthn key + * @description Removes a WebAuthn passkey by its display name. + */ + delete: operations["delete_webauthn"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/settings/factors/webauthn/finish": { parameters: { query?: never; @@ -444,6 +570,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/settings/password/change": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Change password + * @description Changes the current user's password. Requires the current password for verification. + */ + post: operations["change_password"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -452,31 +598,22 @@ export interface components { AllowCredentials: { /** @description The id of the credential. */ id: string; - /** @description - * may be usb, nfc, ble, internal */ + /** + * @description + * may be usb, nfc, ble, internal + */ transports?: components["schemas"]["AuthenticatorTransport"][] | null; /** @description The type of credential. */ type: string; }; - /** @example { + /** + * @example { * "error": "TOTP is already enabled. To rotate your TOTP secret, disable it first and then enable it again." - * } */ + * } + */ AlreadyEnabledError: { error: string; }; - RequestResetBody: { - email: string; - }; - RequestResetResponse: { - success: boolean; - }; - ConfirmResetBody: { - token: string; - new_password: string; - }; - ConfirmResetResponse: { - success: boolean; - }; /** * @description * @enum {string} @@ -489,8 +626,16 @@ export interface components { * @enum {string} */ AttestationFormat: "packed" | "tpm" | "android-key" | "android-safetynet" | "fido-u2f" | "apple" | "none"; - /** @description - * The default option here for Options are None, so it can be derived */ + AuthenticateResponse: components["schemas"]["NoData"] & { + /** @description Indicates whether additional factors are required to complete authentication. */ + fully_authenticated: boolean; + /** @description A list of factors to choose from for the next authentication step. */ + next: string[]; + }; + /** + * @description + * The default option here for Options are None, so it can be derived + */ AuthenticationExtensionsClientOutputs: { /** @description Indicates whether the client used the provided appid extension */ appid?: boolean | null; @@ -527,14 +672,18 @@ export interface components { /** @description */ AuthenticatorSelectionCriteria: { authenticatorAttachment?: null | components["schemas"]["AuthenticatorAttachment"]; - /** @description Hint to the credential to create a resident key. Note this can not be enforced + /** + * @description Hint to the credential to create a resident key. Note this can not be enforced * or validated, so the authenticator may choose to ignore this parameter. - * */ + * + */ requireResidentKey: boolean; residentKey?: null | components["schemas"]["ResidentKeyRequirement"]; - /** @description The user verification level to request during registration. Depending on if this + /** + * @description The user verification level to request during registration. Depending on if this * authenticator provides verification may affect future interactions as this is - * associated to the credential during registration. */ + * associated to the credential during registration. + */ userVerification: components["schemas"]["UserVerificationPolicy"]; }; /** @@ -542,54 +691,88 @@ export interface components { * @enum {string} */ AuthenticatorTransport: "usb" | "nfc" | "ble" | "internal" | "hybrid" | "test" | "unknown"; - /** @example { - * "error": "User with this username or email already existsd" - * } */ + /** + * @example { + * "error": "User with this username or email already exists" + * } + */ BadRequestError: { error: string; }; + ChangePasswordBody: { + current_password: string; + new_password: string; + }; + ChangePasswordResponse: { + success: boolean; + }; /** @enum {string} */ ClientType: "public" | "confidential"; - /** @example { + ConfirmEmailBody: { + token: string; + }; + ConfirmEmailResponse: { + success: boolean; + }; + ConfirmResetBody: { + new_password: string; + token: string; + }; + ConfirmResetResponse: { + success: boolean; + }; + /** + * @example { * "success": true - * } */ + * } + */ ConfirmTotpResponse: { success: boolean; }; - /** @example { + /** + * @example { * "success": true, * "id": "60c72b2f9b1d8c001c8e4f5a" - * } */ + * } + */ CreateSuccess: { id: string; success: boolean; }; - /** @description A JSON serializable challenge which is issued to the user's web browser + /** + * @description A JSON serializable challenge which is issued to the user's web browser * for handling. This is meant to be opaque, that is, you should not need * to inspect or alter the content of the struct - you should serialise it - * and transmit it to the client only. */ + * and transmit it to the client only. + */ CreationChallengeResponse: { /** @description The options. */ publicKey: components["schemas"]["PublicKeyCredentialCreationOptions"]; }; /** @description */ CredProps: { - /** @description A user agent supplied hint that this credential *may* have created a resident key. It is + /** + * @description A user agent supplied hint that this credential *may* have created a resident key. It is * retured from the user agent, not the authenticator meaning that this is an unreliable * signal. * - * Note that this extension is UNSIGNED and may have been altered by page javascript. */ + * Note that this extension is UNSIGNED and may have been altered by page javascript. + */ rk: boolean; }; - /** @description The desired options for the client's use of the `credProtect` extension + /** + * @description The desired options for the client's use of the `credProtect` extension * - * */ + * + */ CredProtect: { /** @description The credential policy to enact */ credentialProtectionPolicy: components["schemas"]["CredentialProtectionPolicy"]; - /** @description Whether it is better for the authenticator to fail to create a + /** + * @description Whether it is better for the authenticator to fail to create a * credential rather than ignore the protection policy - * If no value is provided, the client treats it as `false`. */ + * If no value is provided, the client treats it as `false`. + */ enforceCredentialProtectionPolicy?: boolean | null; }; /** @@ -597,14 +780,49 @@ export interface components { * @enum {string} */ CredentialProtectionPolicy: "userVerificationOptional" | "userVerificationOptionalWithCredentialIDList" | "userVerificationRequired"; + /** + * @example { + * "success": true + * } + */ + DeleteWebAuthnResponse: { + success: boolean; + }; + /** + * @example { + * "success": true + * } + */ + DisablePgpResponse: { + success: boolean; + }; + /** + * @example { + * "success": true + * } + */ + DisableTotpResponse: { + success: boolean; + }; + /** @description A partial version of `PublicApplication` omitting the field(s): id, client_id. Field attributes are copied. */ + EditApplicationBody: { + allowed_groups: string[]; + client_type: components["schemas"]["ClientType"]; + icon?: string | null; + name: string; + redirect_uris: string[]; + slug: string; + }; EnablePgpBody: { /** @description The display name for the TOTP factor (for example authenticator app name). */ display_name: string; public_key: string; }; - /** @example { + /** + * @example { * "success": true - * } */ + * } + */ EnablePgpResponse: { success: boolean; }; @@ -612,6 +830,12 @@ export interface components { /** @description Generated security codes. Save them securely as they won't be shown again. */ codes: string[]; }; + EnableResponse: components["schemas"]["NoData"] & { + /** @description Indicates whether the factor is now enabled after this call. */ + enabled: boolean; + /** @description Indicates whether fully enabling the factor requires a call to `confirm_enable`. */ + requires_confirmation: boolean; + }; EnableTotpBody: { /** @description The display name for the TOTP factor (for example authenticator app name). */ display_name: string; @@ -622,11 +846,27 @@ export interface components { /** @description The secret won't be shown again, so save it securely. */ secret: string; }; + FactorDisableError: "NotEnabled" | "CannotDisableOnlyPrimary" | { + Other: components["schemas"]["String"]; + }; + FactorEnableError: "AlreadyEnabled" | { + Other: components["schemas"]["String"]; + }; /** @enum {string} */ FirstFactor: "password" | "webauthnpasswordless" | "pgp"; - /** @description The inputs to the hmac secret if it was created during registration. + /** + * @example { + * "error": "Forbidden" + * } + */ + ForbiddenError: { + error: string; + }; + /** + * @description The inputs to the hmac secret if it was created during registration. * - * */ + * + */ HmacGetSecretInput: { /** @description Retrieve a symmetric secrets from the authenticator with this input. */ output1: string; @@ -640,27 +880,35 @@ export interface components { /** @description Output of HMAC(Salt 2 || Client Secret) */ output2?: string | null; }; - /** @example { + /** + * @example { * "error": "Invalid 2FA code" - * } */ + * } + */ Invalid2faCode: { error: string; }; - /** @example { + /** + * @example { * "error": "Invalid recovery code" - * } */ + * } + */ InvalidRecoveryCode: { error: string; }; - /** @example { + /** + * @example { * "error": "Invalid signature" - * } */ + * } + */ InvalidSignature: { error: string; }; - /** @example { + /** + * @example { * "error": "Invalid username or password" - * } */ + * } + */ InvalidUserOrPass: { error: string; }; @@ -675,6 +923,8 @@ export interface components { * @enum {string} */ Mediation: "conditional"; + /** @default null */ + NoData: unknown; OptionsRepsonse: { options: components["schemas"]["FirstFactor"][]; recent_factor?: null | components["schemas"]["FirstFactor"]; @@ -703,7 +953,7 @@ export interface components { allowed_groups: string[]; client_id: string; client_type: components["schemas"]["ClientType"]; - icon: string; + icon?: string | null; name: string; redirect_uris: string[]; slug: string; @@ -716,12 +966,14 @@ export interface components { totp?: null | components["schemas"]["PublicTOTPFactor"]; webauthn: components["schemas"]["PublicWebAuthnFactor"][]; }; - /** @description A client response to an authentication challenge. This contains all required + /** + * @description A client response to an authentication challenge. This contains all required * information to asses and assert trust in a credentials legitimacy, followed * by authentication to a user. * * You should not need to handle the inner content of this structure - you should - * provide this to the correctly handling function of Webauthn only. */ + * provide this to the correctly handling function of Webauthn only. + */ PublicKeyCredential: { /** @description Unsigned Client processed extensions. */ extensions?: components["schemas"]["AuthenticationExtensionsClientOutputs"]; @@ -763,8 +1015,10 @@ export interface components { PublicKeyCredentialDescriptor: { /** @description The credential id. */ id: string; - /** @description The allowed transports for this credential. Note this is a hint, and is NOT - * enforced. */ + /** + * @description The allowed transports for this credential. Note this is a hint, and is NOT + * enforced. + */ transports?: components["schemas"]["AuthenticatorTransport"][] | null; /** @description The type of credential */ type: string; @@ -828,35 +1082,43 @@ export interface components { password: string; preferred_username: string; }; - /** @description A client response to a registration challenge. This contains all required + /** + * @description A client response to a registration challenge. This contains all required * information to assess and assert trust in a credential's legitimacy, followed * by registration to a user. * * You should not need to handle the inner content of this structure - you should * provide this to the correctly handling function of Webauthn only. - * */ + * + */ RegisterPublicKeyCredential: { /** @description Unsigned Client processed extensions. */ extensions?: components["schemas"]["RegistrationExtensionsClientOutputs"]; - /** @description The id of the PublicKey credential, likely in base64. + /** + * @description The id of the PublicKey credential, likely in base64. * * This is NEVER actually * used in a real registration, because the true credential ID is taken from the - * attestation data. */ + * attestation data. + */ id: string; - /** @description The id of the credential, as binary. + /** + * @description The id of the credential, as binary. * * This is NEVER actually * used in a real registration, because the true credential ID is taken from the - * attestation data. */ + * attestation data. + */ rawId: string; /** @description */ response: components["schemas"]["AuthenticatorAttestationResponseRaw"]; /** @description The type of credential. */ type: string; }; - /** @description - * The default option here for Options are None, so it can be derived */ + /** + * @description + * The default option here for Options are None, so it can be derived + */ RegistrationExtensionsClientOutputs: { /** @description Indicates whether the client used the provided appid extension */ appid?: boolean | null; @@ -877,43 +1139,67 @@ export interface components { /** @description The name of the relying party. */ name: string; }; - /** @description Extension option inputs for PublicKeyCredentialRequestOptions + /** + * @description Extension option inputs for PublicKeyCredentialRequestOptions * - * Implements \[AuthenticatorExtensionsClientInputs\] from the spec */ + * Implements \[AuthenticatorExtensionsClientInputs\] from the spec + */ RequestAuthenticationExtensions: { /** @description The `appid` extension options */ appid?: string | null; hmacGetSecret?: null | components["schemas"]["HmacGetSecretInput"]; - /** @description ⚠️ - Browsers do not support this! - * Uvm */ + /** + * @description ⚠️ - Browsers do not support this! + * Uvm + */ uvm?: boolean | null; }; - /** @description A JSON serializable challenge which is issued to the user's webbrowser + /** + * @description A JSON serializable challenge which is issued to the user's webbrowser * for handling. This is meant to be opaque, that is, you should not need * to inspect or alter the content of the struct - you should serialise it - * and transmit it to the client only. */ + * and transmit it to the client only. + */ RequestChallengeResponse: { mediation?: null | components["schemas"]["Mediation"]; /** @description The options. */ publicKey: components["schemas"]["PublicKeyCredentialRequestOptions"]; }; - /** @description Extension option inputs for PublicKeyCredentialCreationOptions. + /** + * @description Extension option inputs for PublicKeyCredentialCreationOptions. * - * Implements \[AuthenticatorExtensionsClientInputs\] from the spec. */ + * Implements \[AuthenticatorExtensionsClientInputs\] from the spec. + */ RequestRegistrationExtensions: (null | components["schemas"]["CredProtect"]) & { - /** @description ⚠️ - This extension result is always unsigned, and only indicates if the + /** + * @description ⚠️ - This extension result is always unsigned, and only indicates if the * browser *requests* a residentKey to be created. It has no bearing on the - * true rk state of the credential. */ + * true rk state of the credential. + */ credProps?: boolean | null; - /** @description ⚠️ - Browsers support the *creation* of the secret, but not the retrieval of it. - * CTAP2.1 create hmac secret */ + /** + * @description ⚠️ - Browsers support the *creation* of the secret, but not the retrieval of it. + * CTAP2.1 create hmac secret + */ hmacCreateSecret?: boolean | null; /** @description CTAP2.1 Minumum pin length */ minPinLength?: boolean | null; - /** @description ⚠️ - Browsers do not support this! - * Uvm */ + /** + * @description ⚠️ - Browsers do not support this! + * Uvm + */ uvm?: boolean | null; }; + RequestResetBody: { + email: string; + }; + RequestResetResponse: { + success: boolean; + }; + ResetRecoveryCodesResponse: { + /** @description New recovery codes. Save them securely as they won't be shown again. */ + codes: string[]; + }; /** * @description The Relying Party's requirements for client-side discoverable credentials. * @@ -923,6 +1209,13 @@ export interface components { ResidentKeyRequirement: "discouraged" | "preferred" | "required"; /** @enum {string} */ SecondFactor: "totp" | "webauthn" | "recoverycode"; + String: "NotEnabled" | { + Unauthorized: string; + } | { + BadRequest: string; + } | { + Other: string; + }; SuccessfulLoginResponse: { recent_factor?: null | components["schemas"]["SecondFactor"]; second_factors?: components["schemas"]["SecondFactor"][] | null; @@ -932,23 +1225,31 @@ export interface components { /** @description TOTP code to confirm enabling the factor. */ code: string; }; - /** @example { + /** + * @example { * "error": "Unauthorized" - * } */ + * } + */ UnauthorizedError: { error: string; }; /** @description User Entity */ User: { - /** @description The user's preferred name for display. This value **can** change, so - * **must not** be used as a primary key. */ + /** + * @description The user's preferred name for display. This value **can** change, so + * **must not** be used as a primary key. + */ displayName: string; - /** @description The user's id in base64 form. This MUST be a unique id, and + /** + * @description The user's id in base64 form. This MUST be a unique id, and * must NOT contain personally identifying information, as this value can NEVER - * be changed. If in doubt, use a UUID. */ + * be changed. If in doubt, use a UUID. + */ id: string; - /** @description A detailed name for the account, such as an email address. This value - * **can** change, so **must not** be used as a primary key. */ + /** + * @description A detailed name for the account, such as an email address. This value + * **can** change, so **must not** be used as a primary key. + */ name: string; }; /** @@ -986,9 +1287,11 @@ export interface components { * @enum {string} */ UserVerificationPolicy: "required" | "preferred" | "discouraged"; - /** @example { + /** + * @example { * "success": true - * } */ + * } + */ WebAuthnFinishSuccess: { success: boolean; }; @@ -1022,6 +1325,132 @@ export interface operations { "application/json": components["schemas"]["PublicApplication"][]; }; }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenError"]; + }; + }; + }; + }; + create_application: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EditApplicationBody"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicApplication"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ForbiddenError"]; + }; + }; + }; + }; + password_authenticate: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NoData"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AuthenticateResponse"]; + }; + }; + /** @description Error */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["String"]; + }; + }; + }; + }; + confirm_email: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmEmailBody"]; + }; + }; + responses: { + /** @description Email confirmed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfirmEmailResponse"]; + }; + }; + /** @description Invalid or expired token */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; }; }; get_health: { @@ -1139,7 +1568,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PgpChallengeResponse"]; + "application/json": components["schemas"]["SuccessfulLoginResponse"]; }; }; /** @description Unauthorized */ @@ -1219,27 +1648,40 @@ export interface operations { }; }; }; - discoverable_start: { + webauthn_finish_login: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["PublicKeyCredential"]; + }; + }; responses: { - /** @description Challenge created */ + /** @description Success */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RequestChallengeResponse"]; + "application/json": components["schemas"]["SuccessfulLoginResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; }; }; }; }; - discoverable_finish: { + passwordless_finish: { parameters: { query?: never; header?: never; @@ -1272,18 +1714,34 @@ export interface operations { }; }; }; - webauthn_finish_login: { + passwordless_start: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["PublicKeyCredential"]; + requestBody?: never; + responses: { + /** @description Challenge created */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RequestChallengeResponse"]; + }; }; }; + }; + webauthn_start_login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; responses: { /** @description Success */ 200: { @@ -1291,7 +1749,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["SuccessfulLoginResponse"]; + "application/json": components["schemas"]["RequestChallengeResponse"]; }; }; /** @description Unauthorized */ @@ -1305,31 +1763,68 @@ export interface operations { }; }; }; - webauthn_start_login: { + request_reset: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["RequestResetBody"]; + }; + }; responses: { - /** @description Success */ + /** @description Email sent (or address not found — always succeeds) */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["RequestChallengeResponse"]; + "application/json": components["schemas"]["RequestResetResponse"]; }; }; - /** @description Unauthorized */ - 401: { + /** @description Mail not configured */ + 503: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["UnauthorizedError"]; + "application/json": string; + }; + }; + }; + }; + confirm_reset: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ConfirmResetBody"]; + }; + }; + responses: { + /** @description Password updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ConfirmResetResponse"]; + }; + }; + /** @description Invalid or expired token */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; }; }; }; @@ -1396,6 +1891,110 @@ export interface operations { }; }; }; + password_disable: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NoData"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NoData"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FactorDisableError"]; + }; + }; + }; + }; + password_enable: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["NoData"]; + }; + }; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EnableResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["FactorEnableError"]; + }; + }; + }; + }; + disable_pgp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DisablePgpResponse"]; + }; + }; + /** @description PGP not enabled */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + }; + }; enable_pgp: { parameters: { query?: never; @@ -1458,6 +2057,82 @@ export interface operations { }; }; }; + reset_recovery_codes: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ResetRecoveryCodesResponse"]; + }; + }; + /** @description Recovery codes not enabled */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + }; + }; + disable_totp: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DisableTotpResponse"]; + }; + }; + /** @description TOTP not enabled */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + }; + }; enable_totp: { parameters: { query?: never; @@ -1542,6 +2217,47 @@ export interface operations { }; }; }; + delete_webauthn: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Display name of the WebAuthn key to delete */ + display_name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Success */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeleteWebAuthnResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + /** @description Key not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + }; + }; webauthn_finish_setup: { parameters: { query?: never; @@ -1608,4 +2324,46 @@ export interface operations { }; }; }; + change_password: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChangePasswordBody"]; + }; + }; + responses: { + /** @description Password changed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ChangePasswordResponse"]; + }; + }; + /** @description Invalid current password or password not set */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": string; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnauthorizedError"]; + }; + }; + }; + }; } diff --git a/server/src/database.rs b/server/src/database.rs index c6aade4..754e5ee 100644 --- a/server/src/database.rs +++ b/server/src/database.rs @@ -259,6 +259,8 @@ database_object!(User { display_name: String, preferred_username: String, email: String, + #[serde(default)] + email_confirmed: bool, auth_factors: AuthFactors, #[serde(with = "vec_oid_to_vec_string")] diff --git a/server/src/main.rs b/server/src/main.rs index 9bde9f7..224e905 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -78,9 +78,12 @@ async fn main() -> Result<()> { settings.general.public_url ); - axum::serve(listener, app.into_make_service()) - .await - .wrap_err("failed to run server")?; + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .wrap_err("failed to run server")?; Ok(()) } diff --git a/server/src/routes/api.rs b/server/src/routes/api.rs index 3a31bab..3ca302a 100644 --- a/server/src/routes/api.rs +++ b/server/src/routes/api.rs @@ -1,4 +1,5 @@ mod admin; +mod confirm_email; mod health; mod login; mod password_reset; @@ -20,6 +21,7 @@ pub fn routes() -> OpenApiRouter { .layer(middleware::from_fn(require_auth)); let public = OpenApiRouter::new() + .nest("/confirm-email", confirm_email::routes()) .nest("/health", health::routes()) .nest("/login", login::routes()) .nest("/password-reset", password_reset::routes()) diff --git a/server/src/routes/api/confirm_email.rs b/server/src/routes/api/confirm_email.rs new file mode 100644 index 0000000..6e88fab --- /dev/null +++ b/server/src/routes/api/confirm_email.rs @@ -0,0 +1,120 @@ +use axum::{Extension, Json}; +use axum_valid::Valid; +use chrono::{DateTime, Duration, Utc}; +use color_eyre::eyre; +use mongodb::bson::{doc, oid::ObjectId}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; +use validator::Validate; + +use crate::{ + axum_error::{AxumError, AxumResult}, + database::User, + state::AppState, + utils::{generate_reset_token, hash_token}, +}; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(confirm_email)) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct EmailConfirmationToken { + pub token_hash: String, + pub user_id: ObjectId, + pub expires_at: DateTime, +} + +pub async fn send_confirmation_email( + state: &AppState, + user_id: ObjectId, + email: &str, +) -> AxumResult<()> { + let Some(mail) = &state.mail_service else { + return Ok(()); + }; + + let token = generate_reset_token(); + let token_hash = hash_token(&token); + let expires_at = Utc::now() + Duration::hours(24); + + state + .database + .collection::("email_confirmation_tokens") + .insert_one(EmailConfirmationToken { + token_hash, + user_id, + expires_at, + }) + .await?; + + let mail = mail.clone(); + let email = email.to_owned(); + tokio::spawn(async move { + if let Err(e) = mail.send_email_confirmation(&email, &token).await { + tracing::warn!(error = ?e, "Failed to send confirmation email"); + } + }); + + Ok(()) +} + +#[derive(Deserialize, ToSchema, Validate)] +struct ConfirmEmailBody { + token: String, +} + +#[derive(Serialize, ToSchema)] +struct ConfirmEmailResponse { + success: bool, +} + +/// Confirm email address +/// +/// Validates the confirmation token and marks the user's email as confirmed. Tokens expire after 24 hours and are deleted on use. +#[utoipa::path( + method(post), + path = "/", + request_body = ConfirmEmailBody, + responses( + (status = OK, description = "Email confirmed", body = ConfirmEmailResponse, content_type = "application/json"), + (status = BAD_REQUEST, description = "Invalid or expired token", body = String, content_type = "application/json"), + ), + tag = "Email Confirmation" +)] +async fn confirm_email( + Extension(state): Extension, + Valid(Json(body)): Valid>, +) -> AxumResult> { + let token_hash = hash_token(&body.token); + + let token_doc = state + .database + .collection::("email_confirmation_tokens") + .find_one_and_delete(doc! { "token_hash": &token_hash }) + .await?; + + let Some(token_doc) = token_doc else { + return Err(AxumError::bad_request(eyre::eyre!("Invalid or expired token"))); + }; + + if Utc::now() > token_doc.expires_at { + return Err(AxumError::bad_request(eyre::eyre!("Invalid or expired token"))); + } + + let result = state + .database + .collection::("users") + .update_one( + doc! { "_id": token_doc.user_id }, + doc! { "$set": { "email_confirmed": true } }, + ) + .await?; + + if result.matched_count == 0 { + return Err(AxumError::not_found(eyre::eyre!("User not found"))); + } + + Ok(Json(ConfirmEmailResponse { success: true })) +} diff --git a/server/src/routes/api/login/password.rs b/server/src/routes/api/login/password.rs index 8514d2d..5fdd749 100644 --- a/server/src/routes/api/login/password.rs +++ b/server/src/routes/api/login/password.rs @@ -2,10 +2,11 @@ use argon2::{ Argon2, PasswordHash, PasswordVerifier, password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, }; -use axum::{Extension, Json}; +use axum::{Extension, Json, extract::ConnectInfo}; use color_eyre::eyre; use mongodb::bson::doc; use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; use tower_sessions::Session; use utoipa::ToSchema; use utoipa_axum::{router::OpenApiRouter, routes}; @@ -36,19 +37,6 @@ pub struct InvalidUserOrPass { error: String, } -trait A { - type T; -} - -#[derive(ToSchema)] -struct B {} - -impl A for B { - type T = InvalidUserOrPass; -} - -type C = ::T; - /// Log in with password /// /// If user is not found or the password isn't enabled for the user returns the same response as if the password was incorrect. @@ -64,6 +52,7 @@ type C = ::T; async fn login_with_password( Extension(state): Extension, session: Session, + ConnectInfo(addr): ConnectInfo, Json(body): Json, ) -> AxumResult> { let user = get_user(&state.database, &body.username).await?; @@ -111,6 +100,17 @@ async fn login_with_password( .insert("auth_state", AuthState::Authenticated) .await?; + if let Some(mail) = &state.mail_service { + let ip = addr.ip().to_string(); + let email = user.email.clone(); + let mail = mail.clone(); + tokio::spawn(async move { + if let Err(e) = mail.send_login_notification(&email, &ip).await { + tracing::warn!(error = ?e, "Failed to send login notification"); + } + }); + } + return Ok(Json(SuccessfulLoginResponse { two_factor_required: false, second_factors: None, diff --git a/server/src/routes/api/register.rs b/server/src/routes/api/register.rs index 5b2a2db..292a84e 100644 --- a/server/src/routes/api/register.rs +++ b/server/src/routes/api/register.rs @@ -1,7 +1,3 @@ -use argon2::{ - Argon2, - password_hash::{PasswordHasher, SaltString, rand_core::OsRng}, -}; use axum::{Extension, Json}; use axum_valid::Valid; use color_eyre::eyre::{self, ContextCompat}; @@ -15,8 +11,9 @@ use validator::Validate; use crate::{ axum_error::{AxumError, AxumResult}, database::{AuthFactors, PartialUser, PasswordFactor, User}, - routes::api::CreateSuccess, + routes::api::{CreateSuccess, confirm_email::send_confirmation_email}, state::AppState, + utils::hash_password, validators::username_validator, }; @@ -88,13 +85,7 @@ async fn register( ))); } - let salt = SaltString::generate(&mut OsRng); - let argon2 = Argon2::default(); - - let hashed_password = argon2 - .hash_password(body.password.as_bytes(), &salt) - .map_err(|_| eyre::eyre!("Failed to compute hash"))?; - + let hashed_password = hash_password(&body.password)?; let uuid = Uuid::new_v4(); let user = PartialUser { @@ -103,10 +94,11 @@ async fn register( last_name: body.last_name, display_name: body.display_name, preferred_username: body.preferred_username, - email: body.email, + email: body.email.clone(), + email_confirmed: false, auth_factors: AuthFactors { password: PasswordFactor { - password_hash: Some(hashed_password.to_string()), + password_hash: Some(hashed_password), }, ..Default::default() }, @@ -122,8 +114,12 @@ async fn register( let id = inserted .inserted_id .as_object_id() - .wrap_err("Failed to fetch project ID")? - .to_string(); + .wrap_err("Failed to fetch project ID")?; + + send_confirmation_email(&state, id, &body.email).await?; - Ok(Json(CreateSuccess { success: true, id })) + Ok(Json(CreateSuccess { + success: true, + id: id.to_string(), + })) } diff --git a/server/src/routes/api/settings/factors/pgp.rs b/server/src/routes/api/settings/factors/pgp.rs index 75c0189..2880a53 100644 --- a/server/src/routes/api/settings/factors/pgp.rs +++ b/server/src/routes/api/settings/factors/pgp.rs @@ -2,8 +2,11 @@ use utoipa_axum::router::OpenApiRouter; use crate::state::AppState; +mod disable; mod enable; pub fn routes() -> OpenApiRouter { - OpenApiRouter::new().nest("/enable", enable::routes()) + OpenApiRouter::new() + .nest("/enable", enable::routes()) + .nest("/disable", disable::routes()) } diff --git a/server/src/routes/api/settings/factors/pgp/disable.rs b/server/src/routes/api/settings/factors/pgp/disable.rs new file mode 100644 index 0000000..b124fa1 --- /dev/null +++ b/server/src/routes/api/settings/factors/pgp/disable.rs @@ -0,0 +1,70 @@ +use axum::{Extension, Json}; +use color_eyre::eyre::{self, ContextCompat}; +use mongodb::bson::doc; +use serde::Serialize; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::{ + axum_error::{AxumError, AxumResult}, + database::{User, get_user_by_id}, + middlewares::require_auth::{UnauthorizedError, UserId}, + state::AppState, +}; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(disable_pgp)) +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ "success": true }))] +struct DisablePgpResponse { + success: bool, +} + +/// Disable PGP +/// +/// Removes the PGP authentication factor from the user's account. +#[utoipa::path( + method(delete), + path = "/", + responses( + (status = OK, description = "Success", body = DisablePgpResponse, content_type = "application/json"), + (status = UNAUTHORIZED, description = "Unauthorized", body = UnauthorizedError, content_type = "application/json"), + (status = BAD_REQUEST, description = "PGP not enabled", body = String, content_type = "application/json"), + ), + tag = "Settings" +)] +async fn disable_pgp( + Extension(state): Extension, + Extension(user_id): Extension, +) -> AxumResult> { + let user = get_user_by_id(&state.database, &user_id) + .await? + .wrap_err("User not found")?; + + if user.auth_factors.pgp.is_none() { + return Err(AxumError::bad_request(eyre::eyre!("PGP is not enabled"))); + } + + state + .database + .collection::("users") + .update_one( + doc! { "_id": *user_id }, + doc! { "$unset": { "auth_factors.pgp": "" } }, + ) + .await?; + + if let Some(mail) = &state.mail_service { + let email = user.email.clone(); + let mail = mail.clone(); + tokio::spawn(async move { + if let Err(e) = mail.send_factor_removed(&email, "PGP key").await { + tracing::warn!(error = ?e, "Failed to send factor removed notification"); + } + }); + } + + Ok(Json(DisablePgpResponse { success: true })) +} diff --git a/server/src/routes/api/settings/factors/recovery_codes.rs b/server/src/routes/api/settings/factors/recovery_codes.rs index a905f2c..2653a1b 100644 --- a/server/src/routes/api/settings/factors/recovery_codes.rs +++ b/server/src/routes/api/settings/factors/recovery_codes.rs @@ -1,4 +1,5 @@ mod enable; +mod reset; use argon2::{ Argon2, PasswordHash, PasswordVerifier, @@ -15,7 +16,9 @@ use crate::{ }; pub fn routes() -> OpenApiRouter { - OpenApiRouter::new().nest("/enable", enable::routes()) + OpenApiRouter::new() + .nest("/enable", enable::routes()) + .nest("/reset", reset::routes()) } pub fn generate_recovery_code(len: usize) -> String { diff --git a/server/src/routes/api/settings/factors/recovery_codes/reset.rs b/server/src/routes/api/settings/factors/recovery_codes/reset.rs new file mode 100644 index 0000000..e39f0d8 --- /dev/null +++ b/server/src/routes/api/settings/factors/recovery_codes/reset.rs @@ -0,0 +1,84 @@ +use axum::{Extension, Json}; +use color_eyre::eyre::{self, ContextCompat}; +use mongodb::bson::doc; +use serde::Serialize; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::{ + axum_error::{AxumError, AxumResult}, + database::{RecoveryCodeFactor, User, get_user_by_id}, + middlewares::require_auth::{UnauthorizedError, UserId}, + routes::api::settings::factors::recovery_codes::{generate_recovery_codes, hash_recovery_codes}, + state::AppState, +}; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(reset_recovery_codes)) +} + +#[derive(Serialize, ToSchema)] +pub struct ResetRecoveryCodesResponse { + /// New recovery codes. Save them securely as they won't be shown again. + pub codes: Vec, +} + +/// Reset recovery codes +/// +/// Invalidates all existing recovery codes and generates a fresh set of 10. +#[utoipa::path( + method(post), + path = "/", + responses( + (status = OK, description = "Success", body = ResetRecoveryCodesResponse, content_type = "application/json"), + (status = UNAUTHORIZED, description = "Unauthorized", body = UnauthorizedError, content_type = "application/json"), + (status = BAD_REQUEST, description = "Recovery codes not enabled", body = String, content_type = "application/json"), + ), + tag = "Settings" +)] +async fn reset_recovery_codes( + Extension(state): Extension, + Extension(user_id): Extension, +) -> AxumResult> { + let user = get_user_by_id(&state.database, &user_id) + .await? + .wrap_err("User not found")?; + + if user.auth_factors.recovery_codes.is_empty() { + return Err(AxumError::bad_request(eyre::eyre!( + "Recovery codes are not enabled" + ))); + } + + let codes = generate_recovery_codes(10, 12); + let hashed_codes = hash_recovery_codes(codes.clone())?; + + let db_codes = hashed_codes + .iter() + .map(|code| RecoveryCodeFactor { + code_hash: code.clone(), + used: false, + }) + .collect::>(); + + state + .database + .collection::("users") + .update_one( + doc! { "_id": *user_id }, + doc! { "$set": { "auth_factors.recovery_codes": db_codes } }, + ) + .await?; + + if let Some(mail) = &state.mail_service { + let email = user.email.clone(); + let mail = mail.clone(); + tokio::spawn(async move { + if let Err(e) = mail.send_factor_added(&email, "recovery codes (regenerated)").await { + tracing::warn!(error = ?e, "Failed to send factor notification"); + } + }); + } + + Ok(Json(ResetRecoveryCodesResponse { codes })) +} diff --git a/server/src/routes/api/settings/factors/totp.rs b/server/src/routes/api/settings/factors/totp.rs index 7fee81b..6306f24 100644 --- a/server/src/routes/api/settings/factors/totp.rs +++ b/server/src/routes/api/settings/factors/totp.rs @@ -1,4 +1,4 @@ -// mod disable; +mod disable; mod enable; use base32::{Alphabet, decode}; @@ -15,8 +15,9 @@ use crate::{ }; pub fn routes() -> OpenApiRouter { - OpenApiRouter::new().nest("/enable", enable::routes()) - // .nest("/disable", disable::routes()) + OpenApiRouter::new() + .nest("/enable", enable::routes()) + .nest("/disable", disable::routes()) } pub fn create_totp_instance( diff --git a/server/src/routes/api/settings/factors/totp/disable.rs b/server/src/routes/api/settings/factors/totp/disable.rs index 8b13789..2c2fb63 100644 --- a/server/src/routes/api/settings/factors/totp/disable.rs +++ b/server/src/routes/api/settings/factors/totp/disable.rs @@ -1 +1,74 @@ +use axum::{Extension, Json}; +use color_eyre::eyre::{self, ContextCompat}; +use mongodb::bson::doc; +use serde::Serialize; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; +use crate::{ + axum_error::{AxumError, AxumResult}, + database::{User, get_user_by_id}, + middlewares::require_auth::{UnauthorizedError, UserId}, + state::AppState, +}; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(disable_totp)) +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ "success": true }))] +struct DisableTotpResponse { + success: bool, +} + +/// Disable TOTP +/// +/// Removes the TOTP authentication factor from the user's account. +#[utoipa::path( + method(delete), + path = "/", + responses( + (status = OK, description = "Success", body = DisableTotpResponse, content_type = "application/json"), + (status = UNAUTHORIZED, description = "Unauthorized", body = UnauthorizedError, content_type = "application/json"), + (status = BAD_REQUEST, description = "TOTP not enabled", body = String, content_type = "application/json"), + ), + tag = "Settings" +)] +async fn disable_totp( + Extension(state): Extension, + Extension(user_id): Extension, +) -> AxumResult> { + let user = get_user_by_id(&state.database, &user_id) + .await? + .wrap_err("User not found")?; + + if !user + .auth_factors + .totp + .is_some_and(|totp| totp.fully_enabled) + { + return Err(AxumError::bad_request(eyre::eyre!("TOTP is not enabled"))); + } + + state + .database + .collection::("users") + .update_one( + doc! { "_id": *user_id }, + doc! { "$unset": { "auth_factors.totp": "" } }, + ) + .await?; + + if let Some(mail) = &state.mail_service { + let email = user.email.clone(); + let mail = mail.clone(); + tokio::spawn(async move { + if let Err(e) = mail.send_factor_removed(&email, "TOTP authenticator").await { + tracing::warn!(error = ?e, "Failed to send factor removed notification"); + } + }); + } + + Ok(Json(DisableTotpResponse { success: true })) +} diff --git a/server/src/routes/api/settings/factors/totp/enable.rs b/server/src/routes/api/settings/factors/totp/enable.rs index 4e6a990..9ce186f 100644 --- a/server/src/routes/api/settings/factors/totp/enable.rs +++ b/server/src/routes/api/settings/factors/totp/enable.rs @@ -20,7 +20,9 @@ use crate::{ }; pub fn routes() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(enable_totp)) + OpenApiRouter::new() + .routes(routes!(enable_totp)) + .nest("/confirm", confirm::routes()) } #[derive(Deserialize, ToSchema, Validate)] diff --git a/server/src/routes/api/settings/factors/webauthn.rs b/server/src/routes/api/settings/factors/webauthn.rs index b5b9b3e..f212441 100644 --- a/server/src/routes/api/settings/factors/webauthn.rs +++ b/server/src/routes/api/settings/factors/webauthn.rs @@ -2,6 +2,7 @@ use utoipa_axum::router::OpenApiRouter; use crate::state::AppState; +mod delete; mod finish; mod start; @@ -9,4 +10,5 @@ pub fn routes() -> OpenApiRouter { OpenApiRouter::new() .nest("/start", start::routes()) .nest("/finish", finish::routes()) + .nest("/delete", delete::routes()) } diff --git a/server/src/routes/api/settings/factors/webauthn/delete.rs b/server/src/routes/api/settings/factors/webauthn/delete.rs new file mode 100644 index 0000000..faf186f --- /dev/null +++ b/server/src/routes/api/settings/factors/webauthn/delete.rs @@ -0,0 +1,73 @@ +use axum::{Extension, Json, extract::Path}; +use color_eyre::eyre; +use mongodb::bson::doc; +use serde::Serialize; +use utoipa::ToSchema; +use utoipa_axum::{router::OpenApiRouter, routes}; + +use crate::{ + axum_error::{AxumError, AxumResult}, + database::{User, get_user_by_id}, + middlewares::require_auth::{UnauthorizedError, UserId}, + state::AppState, +}; + +pub fn routes() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(delete_webauthn)) +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ "success": true }))] +struct DeleteWebAuthnResponse { + success: bool, +} + +/// Delete WebAuthn key +/// +/// Removes a WebAuthn passkey by its display name. +#[utoipa::path( + method(delete), + path = "/{display_name}", + params( + ("display_name" = String, Path, description = "Display name of the WebAuthn key to delete") + ), + responses( + (status = OK, description = "Success", body = DeleteWebAuthnResponse, content_type = "application/json"), + (status = UNAUTHORIZED, description = "Unauthorized", body = UnauthorizedError, content_type = "application/json"), + (status = NOT_FOUND, description = "Key not found", body = String, content_type = "application/json"), + ), + tag = "Settings" +)] +async fn delete_webauthn( + Extension(state): Extension, + Extension(user_id): Extension, + Path(display_name): Path, +) -> AxumResult> { + let result = state + .database + .collection::("users") + .update_one( + doc! { "_id": *user_id, "auth_factors.webauthn.display_name": &display_name }, + doc! { "$pull": { "auth_factors.webauthn": { "display_name": &display_name } } }, + ) + .await?; + + if result.matched_count == 0 { + return Err(AxumError::not_found(eyre::eyre!("WebAuthn key not found"))); + } + + if let Some(mail) = &state.mail_service { + let user = get_user_by_id(&state.database, &user_id).await?; + if let Some(user) = user { + let email = user.email; + let mail = mail.clone(); + tokio::spawn(async move { + if let Err(e) = mail.send_factor_removed(&email, "WebAuthn passkey").await { + tracing::warn!(error = ?e, "Failed to send factor removed notification"); + } + }); + } + } + + Ok(Json(DeleteWebAuthnResponse { success: true })) +} From 87da319459161f38ebedb0b4a18bb10bb026051f Mon Sep 17 00:00:00 2001 From: PawiX25 Date: Sat, 4 Apr 2026 04:33:45 +0200 Subject: [PATCH 02/16] feat: add account security dashboard with factor management UI --- apps/frontend/app/dashboard/page.tsx | 560 ++++++++++++++++++++++++++- apps/frontend/app/globals.css | 4 + 2 files changed, 559 insertions(+), 5 deletions(-) diff --git a/apps/frontend/app/dashboard/page.tsx b/apps/frontend/app/dashboard/page.tsx index 9bfd936..a3dd331 100644 --- a/apps/frontend/app/dashboard/page.tsx +++ b/apps/frontend/app/dashboard/page.tsx @@ -1,14 +1,564 @@ 'use client'; -import { Button } from '@components/ui/button'; + +import { useState, useRef, useEffect } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { useQueryClient } from '@tanstack/react-query'; +import { $api } from '@lib/providers/api'; import { useWebAuthnRegistration } from '@lib/hooks'; +import { Button } from '@components/ui/button'; +import { Input } from '@components/ui/input'; +import { Label } from '@components/ui/label'; +import { + IconLock, + IconDeviceMobile, + IconFingerprint, + IconKey, + IconLifebuoy, + IconTrash, + IconCheck, + IconCopy, + IconEye, + IconEyeOff, + IconChevronRight, +} from '@tabler/icons-react'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function CopyButton({ text }: { text: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function ErrorMsg({ msg }: { msg: string }) { + if (!msg) return null; + return

{msg}

; +} + +function ExpandForm({ open, children }: { open: boolean; children: React.ReactNode }) { + return ( + + {open && ( + +
{children}
+
+ )} +
+ ); +} + +function SmoothResize({ children }: { children: React.ReactNode }) { + const ref = useRef(null); + const [height, setHeight] = useState('auto'); + + useEffect(() => { + if (!ref.current) return; + const ro = new ResizeObserver(([entry]) => setHeight(entry.contentRect.height)); + ro.observe(ref.current); + return () => ro.disconnect(); + }, []); + + return ( + +
{children}
+
+ ); +} + +function PasswordInput({ value, onChange, placeholder, required, minLength, id }: { + value: string; onChange: (v: string) => void; placeholder?: string; required?: boolean; minLength?: number; id?: string; +}) { + const [show, setShow] = useState(false); + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} className="h-9 pr-9 text-sm" required={required} minLength={minLength} id={id} /> + +
+ ); +} + +// ── Row shell ───────────────────────────────────────────────────────────────── + +function StatusTag({ label, enabled }: { label: string; enabled: boolean }) { + return ( + + {label} + + ); +} + +function FactorRow({ + icon, name, description, tag, onToggle, children, last, open, +}: { + icon: React.ReactNode; name: string; description: string; tag: { label: string; enabled: boolean }; onToggle?: () => void; children: React.ReactNode; last?: boolean; open?: boolean; +}) { + return ( +
+ + {children} +
+ ); +} + +// ── Password ────────────────────────────────────────────────────────────────── + +function PasswordRow({ isSet, onRefetch }: { isSet: boolean; onRefetch: () => void }) { + const [open, setOpen] = useState(false); + const [current, setCurrent] = useState(''); + const [next, setNext] = useState(''); + const [error, setError] = useState(''); + const change = $api.useMutation('post', '/api/settings/password/change'); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + try { + await change.mutateAsync({ body: { current_password: current, new_password: next } }); + setCurrent(''); setNext(''); setOpen(false); onRefetch(); + } catch { setError('Incorrect current password or invalid new password.'); } + }; + + return ( + } name="Password" description="Authenticate using a knowledge-based password." tag={{ label: isSet ? 'Enabled' : 'Disabled', enabled: isSet }} onToggle={() => setOpen(v => !v)} open={open}> + +
+ {isSet && ( +
+ + +
+ )} +
+ + +
+ +
+ + +
+ +
+
+ ); +} + +// ── TOTP ────────────────────────────────────────────────────────────────────── + +function TotpRow({ totp, onRefetch }: { totp: { display_name: string; fully_enabled: boolean } | null | undefined; onRefetch: () => void }) { + const [step, setStep] = useState<'idle' | 'name' | 'confirm'>('idle'); + const [setupData, setSetupData] = useState<{ secret: string; qr: string } | null>(null); + const [displayName, setDisplayName] = useState(''); + const [code, setCode] = useState(''); + const [error, setError] = useState(''); + + const enable = $api.useMutation('post', '/api/settings/factors/totp/enable'); + const confirm = $api.useMutation('post', '/api/settings/factors/totp/enable/confirm'); + const disable = $api.useMutation('delete', '/api/settings/factors/totp/disable'); + + const isEnabled = totp?.fully_enabled ?? false; + + const startSetup = async (e: React.FormEvent) => { + e.preventDefault(); setError(''); + try { const d = await enable.mutateAsync({ body: { display_name: displayName } }); setSetupData(d); setStep('confirm'); } + catch { setError('Failed to start setup.'); } + }; + + const confirmSetup = async (e: React.FormEvent) => { + e.preventDefault(); setError(''); + try { await confirm.mutateAsync({ body: { code } }); setStep('idle'); setSetupData(null); setCode(''); setDisplayName(''); onRefetch(); } + catch { setError('Invalid code, try again.'); } + }; + + const handleDisable = async () => { + setError(''); + try { await disable.mutateAsync({}); onRefetch(); } + catch { setError('Failed to disable.'); } + }; + + + const handleToggle = () => { + if (step !== 'idle') { setStep('idle'); setSetupData(null); setError(''); } + else if (isEnabled) { setStep('idle'); } + else { setStep('name'); } + }; + + return ( + } name="Authenticator App" description="Time-based one-time passwords from your authenticator app." tag={{ label: isEnabled ? totp?.display_name ?? 'Enabled' : 'Disabled', enabled: isEnabled }} onToggle={handleToggle} open={step !== 'idle'}> +
+ {step === 'idle' && isEnabled && ( + <> + + + + )} + + +
+
+ + setDisplayName(e.target.value)} + placeholder="Authy, Google Authenticator…" className="h-9 text-sm" required maxLength={32} /> +
+ +
+ + +
+ +
+ + + {setupData && ( +
+
+

Add to your authenticator app, then enter the code below.

+
+
+ Secret + +
+ {setupData.secret} +
+
+
+ OTP URL + +
+ {setupData.qr} +
+
+
+
+ + setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="000000" className="h-9 font-mono tracking-[0.3em] text-sm text-center" maxLength={6} required /> +
+ +
+ + +
+ +
+ )} +
+
+
+ ); +} + +// ── WebAuthn ────────────────────────────────────────────────────────────────── + +function WebAuthnRow({ keys, onRefetch }: { keys: { display_name: string }[]; onRefetch: () => void }) { + const [adding, setAdding] = useState(false); + const [newName, setNewName] = useState(''); + const [error, setError] = useState(''); + const [deletingKey, setDeletingKey] = useState(null); -export default function Page() { const webAuthn = useWebAuthnRegistration(); + const deleteKey = $api.useMutation('delete', '/api/settings/factors/webauthn/delete/{display_name}'); + + const handleAdd = async (e: React.FormEvent) => { + e.preventDefault(); setError(''); + try { await webAuthn.registerAsync(newName); setNewName(''); setAdding(false); onRefetch(); } + catch { setError('Failed to register passkey.'); } + }; + + const handleDelete = async (name: string) => { + setDeletingKey(name); + try { await deleteKey.mutateAsync({ params: { path: { display_name: name } } }); onRefetch(); } + catch { setError('Failed to delete.'); } + finally { setDeletingKey(null); } + }; + return ( -
- Welcome! - + } name="Passkeys" description="Phishing-resistant authentication using your device or a hardware security key." tag={{ label: keys.length > 0 ? `${keys.length} key${keys.length > 1 ? 's' : ''}` : 'Disabled', enabled: keys.length > 0 }} onToggle={() => setAdding(v => !v)} open={adding}> +
+ {keys.length > 0 && ( +
+ {keys.map(key => ( +
+
+ + {key.display_name} +
+ +
+ ))} +
+ )} + + +
+
+ + setNewName(e.target.value)} + placeholder="YubiKey 5, iPhone Face ID…" className="h-9 text-sm" required maxLength={32} /> +
+
+ + +
+
+
+
+
+ ); +} + +// ── PGP ─────────────────────────────────────────────────────────────────────── + +function PgpRow({ pgp, onRefetch }: { pgp: { fingerprint: string; display_name: string }[]; onRefetch: () => void }) { + const [open, setOpen] = useState(false); + const [displayName, setDisplayName] = useState(''); + const [publicKey, setPublicKey] = useState(''); + const [error, setError] = useState(''); + + const enable = $api.useMutation('post', '/api/settings/factors/pgp/enable'); + const disable = $api.useMutation('delete', '/api/settings/factors/pgp/disable'); + + const isEnabled = pgp.length > 0; + + const handleEnable = async (e: React.FormEvent) => { + e.preventDefault(); setError(''); + try { await enable.mutateAsync({ body: { display_name: displayName, public_key: publicKey } }); setOpen(false); setDisplayName(''); setPublicKey(''); onRefetch(); } + catch { setError('Invalid key or failed to add.'); } + }; + + const handleDisable = async () => { + setError(''); + try { await disable.mutateAsync({}); onRefetch(); } + catch { setError('Failed to remove.'); } + }; + + + return ( + } name="PGP Key" description="Authenticate by signing a server challenge with your PGP private key." tag={{ label: isEnabled ? pgp[0]?.display_name ?? 'Enabled' : 'Disabled', enabled: isEnabled }} onToggle={() => { if (!isEnabled) setOpen(v => !v); }} open={open}> +
+ {isEnabled ? ( +
+
+

Fingerprint

+ {pgp[0]?.fingerprint} +
+ + +
+ ) : ( + +
+
+ + setDisplayName(e.target.value)} + placeholder="Work key" className="h-9 text-sm" required maxLength={32} /> +
+
+ +