From d80247ba43a4b635ac058e658a3ca630a76723bc Mon Sep 17 00:00:00 2001 From: lewis Date: Mon, 22 Dec 2025 21:09:09 +0200 Subject: [PATCH] Security improvements --- ...50a5bc1908b447b0dbba2b18de09a4e0c0e09.json | 23 + ...580a02c92e579869f647cb08f65ac777854f5.json | 3 +- ...fdfa8a2e37aae3f171a2be73bee3057f86e08.json | 3 +- ...60cef459764ca3c38b97693f00d67fd6bdd7e.json | 20 + ...914ad3bf77d47dd393a0aae1c030b8ce98bcc.json | 3 +- ...8be7b4582b70dd240f456e0c216eb953e4414.json | 28 ++ ...c8e3383f9f0f37a5a63470cc86bcd95d1cbb8.json | 14 + ...f80354bbe2f53f494e8e072ef92ef1418b034.json | 22 - ...3a86790d811632624c00de1e9b599d7b0a7fd.json | 34 ++ ...0470aba3425fd55f32052a17ed00719f533f.json} | 4 +- ...635c06b4179b5858a7b388404c4b03fc92ab4.json | 3 +- ...a597ba1d3d9ea32855704b18948fd6bbae38.json} | 38 +- TODO.md | 17 +- frontend/src/components/ReauthModal.svelte | 3 + frontend/src/lib/api.ts | 23 +- frontend/src/routes/Register.svelte | 6 - frontend/src/routes/RegisterPasskey.svelte | 6 - frontend/src/routes/Security.svelte | 222 ++++++++- frontend/src/routes/Sessions.svelte | 70 ++- migrations/20251229_legacy_login_security.sql | 8 + src/api/error.rs | 67 +-- src/api/identity/account.rs | 37 +- src/api/server/account_status.rs | 10 +- src/api/server/mod.rs | 10 +- src/api/server/passkey_account.rs | 8 +- src/api/server/passkeys.rs | 9 + src/api/server/password.rs | 28 +- src/api/server/reauth.rs | 103 +++- src/api/server/session.rs | 448 +++++++++++++++--- src/api/server/totp.rs | 52 +- src/api/validation.rs | 14 +- src/auth/webauthn.rs | 19 +- src/comms/mod.rs | 1 + src/comms/service.rs | 38 ++ src/comms/types.rs | 1 + src/config.rs | 67 +++ src/lib.rs | 12 + src/oauth/endpoints/authorize.rs | 57 ++- src/rate_limit.rs | 5 + src/state.rs | 4 + src/validation/mod.rs | 68 +++ tests/admin_search.rs | 2 +- tests/change_password.rs | 18 +- tests/common/mod.rs | 2 +- tests/delete_account.rs | 14 +- tests/did_web.rs | 12 +- tests/email_update.rs | 2 +- tests/helpers/mod.rs | 2 +- tests/identity.rs | 8 +- tests/jwt_security.rs | 2 +- tests/lifecycle_session.rs | 8 +- tests/lifecycle_social.rs | 2 +- tests/oauth.rs | 14 +- tests/oauth_lifecycle.rs | 4 +- tests/oauth_scopes.rs | 8 +- tests/oauth_security.rs | 20 +- tests/password_reset.rs | 18 +- tests/rate_limit.rs | 2 +- tests/server.rs | 8 +- tests/session_management.rs | 4 +- tests/signing_key.rs | 10 +- 61 files changed, 1470 insertions(+), 298 deletions(-) create mode 100644 .sqlx/query-14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09.json create mode 100644 .sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json create mode 100644 .sqlx/query-5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414.json create mode 100644 .sqlx/query-6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8.json delete mode 100644 .sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json create mode 100644 .sqlx/query-8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd.json rename .sqlx/{query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json => query-e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f.json} (50%) rename .sqlx/{query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json => query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json} (54%) create mode 100644 migrations/20251229_legacy_login_security.sql diff --git a/.sqlx/query-14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09.json b/.sqlx/query-14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09.json new file mode 100644 index 0000000..bbc47db --- /dev/null +++ b/.sqlx/query-14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "did", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Bool", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09" +} diff --git a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json index 10b2ebf..c73334a 100644 --- a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json +++ b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json @@ -39,7 +39,8 @@ "plc_operation", "two_factor_code", "channel_verification", - "passkey_recovery" + "passkey_recovery", + "legacy_login_alert" ] } } diff --git a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json index 22f63d9..5de15c2 100644 --- a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json +++ b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json @@ -47,7 +47,8 @@ "plc_operation", "two_factor_code", "channel_verification", - "passkey_recovery" + "passkey_recovery", + "legacy_login_alert" ] } } diff --git a/.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json b/.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json new file mode 100644 index 0000000..3ef7a34 --- /dev/null +++ b/.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Timestamptz", + "Timestamptz", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e" +} diff --git a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json index b1ef31f..d29765e 100644 --- a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json +++ b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json @@ -39,7 +39,8 @@ "plc_operation", "two_factor_code", "channel_verification", - "passkey_recovery" + "passkey_recovery", + "legacy_login_alert" ] } } diff --git a/.sqlx/query-5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414.json b/.sqlx/query-5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414.json new file mode 100644 index 0000000..577d9b9 --- /dev/null +++ b/.sqlx/query-5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n u.allow_legacy_login,\n (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR\n EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as \"has_mfa!\"\n FROM users u WHERE u.did = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "allow_legacy_login", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "has_mfa!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414" +} diff --git a/.sqlx/query-6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8.json b/.sqlx/query-6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8.json new file mode 100644 index 0000000..956c260 --- /dev/null +++ b/.sqlx/query-6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8" +} diff --git a/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json b/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json deleted file mode 100644 index 3b32eaa..0000000 --- a/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "one", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034" -} diff --git a/.sqlx/query-8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd.json b/.sqlx/query-8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd.json new file mode 100644 index 0000000..48068a6 --- /dev/null +++ b/.sqlx/query-8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "legacy_login", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "mfa_verified", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "last_reauth_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd" +} diff --git a/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json b/.sqlx/query-e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f.json similarity index 50% rename from .sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json rename to .sqlx/query-e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f.json index e775ef3..eb4f537 100644 --- a/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json +++ b/.sqlx/query-e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", + "query": "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979" + "hash": "e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f" } diff --git a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json index 19e35a1..2c6ef01 100644 --- a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json +++ b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json @@ -42,7 +42,8 @@ "plc_operation", "two_factor_code", "channel_verification", - "passkey_recovery" + "passkey_recovery", + "legacy_login_alert" ] } } diff --git a/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json b/.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json similarity index 54% rename from .sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json rename to .sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json index a84c501..72ef1d5 100644 --- a/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json +++ b/.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", + "query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1", "describe": { "columns": [ { @@ -45,13 +45,40 @@ }, { "ordinal": 8, + "name": "allow_legacy_login", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "preferred_comms_channel: crate::comms::CommsChannel", + "type_info": { + "Custom": { + "name": "comms_channel", + "kind": { + "Enum": [ + "email", + "discord", + "telegram", + "signal" + ] + } + } + } + }, + { + "ordinal": 10, "name": "key_bytes", "type_info": "Bytea" }, { - "ordinal": 9, + "ordinal": 11, "name": "encryption_version", "type_info": "Int4" + }, + { + "ordinal": 12, + "name": "totp_enabled", + "type_info": "Bool" } ], "parameters": { @@ -69,8 +96,11 @@ false, false, false, - true + false, + false, + true, + null ] }, - "hash": "c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590" + "hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38" } diff --git a/TODO.md b/TODO.md index e4e6285..79d24ee 100644 --- a/TODO.md +++ b/TODO.md @@ -9,21 +9,6 @@ So like... make the thing unique, make it cool. - [ ] Unique "brand" style both unauthed and authed - [ ] Better documentation on how to sub out the entire frontend for whatever the users want -### Passkeys and 2FA -Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. - -- [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) -- [x] user_totp table (did, secret_encrypted, verified, created_at, last_used) -- [x] WebAuthn registration challenge generation and attestation verification -- [x] TOTP secret generation with QR code setup flow -- [x] Backup codes (hashed, one-time use) with recovery flow -- [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) -- [ ] Passkey-only account creation (no password) -- [x] Settings UI for managing passkeys, TOTP, backup codes -- [ ] Trusted devices option (remember this browser) -- [x] Rate limit 2FA attempts -- [ ] Re-auth for sensitive actions (email change, adding new auth methods) - ### Delegated accounts Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model. @@ -103,3 +88,5 @@ Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection. Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons. + +Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods. diff --git a/frontend/src/components/ReauthModal.svelte b/frontend/src/components/ReauthModal.svelte index 264a372..f6cc6a6 100644 --- a/frontend/src/components/ReauthModal.svelte +++ b/frontend/src/components/ReauthModal.svelte @@ -29,6 +29,9 @@ activeMethod = 'totp' } else if (availableMethods.includes('passkey')) { activeMethod = 'passkey' + if (availableMethods.length === 1) { + handlePasskeyAuth() + } } } }) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 37f7363..b93f5bf 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -37,7 +37,7 @@ async function xrpc(method: string, options?: { }) if (!res.ok) { const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) - throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods) + throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods) } return res.json() } @@ -331,9 +331,23 @@ export const api = { return xrpc('com.tranquil.account.getPasswordStatus', { token }) }, + async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { + return xrpc('com.tranquil.account.getLegacyLoginPreference', { token }) + }, + + async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> { + return xrpc('com.tranquil.account.updateLegacyLoginPreference', { + method: 'POST', + token, + body: { allowLegacyLogin }, + }) + }, + async listSessions(token: string): Promise<{ sessions: Array<{ id: string + sessionType: string + clientName: string | null createdAt: string expiresAt: string isCurrent: boolean @@ -350,6 +364,13 @@ export const api = { }) }, + async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { + return xrpc('com.tranquil.account.revokeAllSessions', { + method: 'POST', + token, + }) + }, + async searchAccounts(token: string, options?: { handle?: string cursor?: string diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte index 78b7802..4b358ec 100644 --- a/frontend/src/routes/Register.svelte +++ b/frontend/src/routes/Register.svelte @@ -27,12 +27,6 @@ const auth = getAuthState() - $effect(() => { - if (auth.session) { - navigate('/dashboard') - } - }) - $effect(() => { if (!serverInfoLoaded) { serverInfoLoaded = true diff --git a/frontend/src/routes/RegisterPasskey.svelte b/frontend/src/routes/RegisterPasskey.svelte index d29e598..c96bfe0 100644 --- a/frontend/src/routes/RegisterPasskey.svelte +++ b/frontend/src/routes/RegisterPasskey.svelte @@ -30,12 +30,6 @@ let resendingCode = $state(false) let resendMessage = $state(null) - $effect(() => { - if (auth.session) { - navigate('/dashboard') - } - }) - $effect(() => { if (!serverInfoLoaded) { serverInfoLoaded = true diff --git a/frontend/src/routes/Security.svelte b/frontend/src/routes/Security.svelte index bb286df..59dc9a3 100644 --- a/frontend/src/routes/Security.svelte +++ b/frontend/src/routes/Security.svelte @@ -44,6 +44,11 @@ let showRemovePasswordForm = $state(false) let removePasswordLoading = $state(false) + let allowLegacyLogin = $state(true) + let hasMfa = $state(false) + let legacyLoginLoading = $state(true) + let legacyLoginUpdating = $state(false) + let showReauthModal = $state(false) let reauthMethods = $state(['password']) let pendingAction = $state<(() => Promise) | null>(null) @@ -59,6 +64,7 @@ loadTotpStatus() loadPasskeys() loadPasswordStatus() + loadLegacyLoginPreference() } }) @@ -75,6 +81,47 @@ } } + async function loadLegacyLoginPreference() { + if (!auth.session) return + legacyLoginLoading = true + try { + const pref = await api.getLegacyLoginPreference(auth.session.accessJwt) + allowLegacyLogin = pref.allowLegacyLogin + hasMfa = pref.hasMfa + } catch { + allowLegacyLogin = true + hasMfa = false + } finally { + legacyLoginLoading = false + } + } + + async function handleToggleLegacyLogin() { + if (!auth.session) return + legacyLoginUpdating = true + try { + const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin) + allowLegacyLogin = result.allowLegacyLogin + showMessage('success', allowLegacyLogin + ? 'Legacy app login enabled' + : 'Legacy app login disabled - only OAuth apps can sign in') + } catch (e) { + if (e instanceof ApiError) { + if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { + reauthMethods = e.reauthMethods || ['password'] + pendingAction = handleToggleLegacyLogin + showReauthModal = true + } else { + showMessage('error', e.message) + } + } else { + showMessage('error', 'Failed to update preference') + } + } finally { + legacyLoginUpdating = false + } + } + async function handleRemovePassword() { if (!auth.session) return removePasswordLoading = true @@ -572,9 +619,11 @@ - + {#if hasPassword || passkeys.length > 1} + + {/if} {/if} @@ -670,6 +719,63 @@ Manage Trusted Devices → + + {#if hasMfa} +
+

App Compatibility

+

+ Control whether apps that don't support modern authentication (like the official Bluesky app) can sign in to your account. +

+ + {#if legacyLoginLoading} +
Loading...
+ {:else} +
+
+ Allow legacy app login + + {#if allowLegacyLogin} + Legacy apps can sign in with just your password, but sensitive actions (like changing your password) will require MFA verification. + {:else} + Only OAuth-compatible apps can sign in. Legacy apps will be blocked. + {/if} + +
+ +
+ + {#if totpEnabled} +
+ Important: Password changes in Bluesky app will fail +

+ With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked. + To change your password, you have two options: +

+
    +
  1. Change it here: Use this website's Settings page where you can verify with your authenticator app.
  2. +
  3. Verify your session first: Use the re-authenticate option to verify your Bluesky session with TOTP, then password changes will work temporarily.
  4. +
+
+ {/if} + +
+ What are legacy apps? +

+ Some apps (like the official Bluesky app) use older authentication that only requires your password. + When you have MFA enabled, these apps bypass your second factor. + Disabling legacy login forces all apps to use OAuth, which properly enforces MFA. +

+
+ {/if} +
+ {/if} {/if} @@ -1076,4 +1182,114 @@ .info-box-inline li { margin-bottom: 0.25rem; } + + .info-box-inline p { + margin: 0; + color: var(--text-secondary); + } + + .toggle-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 1rem; + background: var(--bg-card); + border: 1px solid var(--border-color-light); + border-radius: 6px; + margin-bottom: 1rem; + } + + .toggle-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + } + + .toggle-label { + font-weight: 500; + } + + .toggle-description { + font-size: 0.875rem; + color: var(--text-secondary); + } + + .toggle-button { + position: relative; + width: 50px; + height: 26px; + padding: 0; + border: none; + border-radius: 13px; + cursor: pointer; + transition: background 0.2s; + flex-shrink: 0; + } + + .toggle-button.on { + background: var(--success-text); + } + + .toggle-button.off { + background: var(--text-secondary); + } + + .toggle-button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .toggle-slider { + position: absolute; + top: 3px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: left 0.2s; + } + + .toggle-button.on .toggle-slider { + left: 27px; + } + + .toggle-button.off .toggle-slider { + left: 3px; + } + + .warning-box { + background: var(--warning-bg); + border: 1px solid var(--warning-border, var(--border-color)); + border-left: 4px solid var(--warning-text); + border-radius: 6px; + padding: 1rem; + margin-bottom: 1rem; + } + + .warning-box strong { + display: block; + margin-bottom: 0.5rem; + color: var(--warning-text); + } + + .warning-box p { + margin: 0 0 0.75rem 0; + font-size: 0.875rem; + color: var(--text-primary); + } + + .warning-box ol { + margin: 0; + padding-left: 1.25rem; + font-size: 0.875rem; + } + + .warning-box li { + margin-bottom: 0.5rem; + } + + .warning-box a { + color: var(--accent); + } diff --git a/frontend/src/routes/Sessions.svelte b/frontend/src/routes/Sessions.svelte index b071e24..27a81c7 100644 --- a/frontend/src/routes/Sessions.svelte +++ b/frontend/src/routes/Sessions.svelte @@ -7,6 +7,8 @@ let error = $state(null) let sessions = $state !s.isCurrent).length + if (otherCount === 0) { + error = 'No other sessions to revoke' + return + } + if (!confirm(`This will revoke ${otherCount} other session${otherCount > 1 ? 's' : ''}. Continue?`)) return + try { + await api.revokeAllSessions(auth.session.accessJwt) + sessions = sessions.filter(s => s.isCurrent) + } catch (e) { + error = e instanceof ApiError ? e.message : 'Failed to revoke sessions' + } + } function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleString() } @@ -87,9 +104,13 @@
{#if session.isCurrent} - Current Session - {:else} - Session + Current + {/if} + + {session.sessionType === 'oauth' ? 'OAuth' : 'Session'} + + {#if session.clientName} + {session.clientName} {/if}
@@ -115,7 +136,12 @@
{/each}
- +
+ + {#if sessions.filter(s => !s.isCurrent).length > 0} + + {/if} +
{/if} {/if} @@ -174,10 +200,14 @@ } .session-header { margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; } - .session-label { + .client-name { font-weight: 500; - color: var(--text-secondary); + color: var(--text-primary); } .badge { display: inline-block; @@ -190,6 +220,16 @@ background: var(--accent); color: white; } + .badge.type { + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + } + .badge.type.oauth { + background: #e6f4ea; + color: #1e7e34; + border-color: #b8d9c5; + } .session-details { display: flex; flex-direction: column; @@ -224,8 +264,13 @@ .revoke-btn.danger:hover { background: var(--error-bg); } - .refresh-btn { + .actions-bar { margin-top: 1rem; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + .refresh-btn { padding: 0.5rem 1rem; background: transparent; border: 1px solid var(--border-color); @@ -237,4 +282,15 @@ background: var(--bg-card); border-color: var(--accent); } + .revoke-all-btn { + padding: 0.5rem 1rem; + background: transparent; + border: 1px solid var(--error-text); + border-radius: 4px; + cursor: pointer; + color: var(--error-text); + } + .revoke-all-btn:hover { + background: var(--error-bg); + } diff --git a/migrations/20251229_legacy_login_security.sql b/migrations/20251229_legacy_login_security.sql new file mode 100644 index 0000000..33ab24a --- /dev/null +++ b/migrations/20251229_legacy_login_security.sql @@ -0,0 +1,8 @@ +ALTER TABLE users ADD COLUMN allow_legacy_login BOOLEAN NOT NULL DEFAULT TRUE; + +ALTER TABLE session_tokens ADD COLUMN mfa_verified BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE session_tokens ADD COLUMN legacy_login BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE INDEX idx_session_tokens_legacy ON session_tokens(did, legacy_login) WHERE legacy_login = TRUE; + +ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'legacy_login_alert'; diff --git a/src/api/error.rs b/src/api/error.rs index 2567c34..562b485 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -4,10 +4,11 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::Serialize; +use std::borrow::Cow; #[derive(Debug, Serialize)] -struct ErrorBody { - error: &'static str, +struct ErrorBody<'a> { + error: Cow<'a, str>, #[serde(skip_serializing_if = "Option::is_none")] message: Option, } @@ -90,41 +91,43 @@ impl ApiError { | Self::InvalidSwap => StatusCode::BAD_REQUEST, } } - fn error_name(&self) -> &'static str { + fn error_name(&self) -> Cow<'static, str> { match self { - Self::InternalError | Self::DatabaseError => "InternalError", - Self::UpstreamFailure | Self::UpstreamUnavailable(_) => "UpstreamFailure", - Self::UpstreamTimeout => "UpstreamTimeout", + Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"), + Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"), + Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"), Self::UpstreamError { error, .. } => { if let Some(e) = error { - return Box::leak(e.clone().into_boxed_str()); + return Cow::Owned(e.clone()); } - "UpstreamError" + Cow::Borrowed("UpstreamError") } - Self::AuthenticationRequired => "AuthenticationRequired", - Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => "AuthenticationFailed", - Self::InvalidToken => "InvalidToken", - Self::ExpiredToken | Self::ExpiredTokenMsg(_) => "ExpiredToken", - Self::TokenRequired => "TokenRequired", - Self::AccountDeactivated => "AccountDeactivated", - Self::AccountTakedown => "AccountTakedown", - Self::Forbidden => "Forbidden", - Self::InvitesDisabled => "InvitesDisabled", - Self::AccountNotFound => "AccountNotFound", - Self::RepoNotFound | Self::RepoNotFoundMsg(_) => "RepoNotFound", - Self::RecordNotFound => "RecordNotFound", - Self::BlobNotFound => "BlobNotFound", - Self::AppPasswordNotFound => "AppPasswordNotFound", - Self::InvalidRequest(_) => "InvalidRequest", - Self::InvalidHandle => "InvalidHandle", - Self::HandleNotAvailable => "HandleNotAvailable", - Self::HandleTaken => "HandleTaken", - Self::InvalidEmail => "InvalidEmail", - Self::EmailTaken => "EmailTaken", - Self::InvalidInviteCode => "InvalidInviteCode", - Self::DuplicateCreate => "DuplicateCreate", - Self::DuplicateAppPassword => "DuplicateAppPassword", - Self::InvalidSwap => "InvalidSwap", + Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), + Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => { + Cow::Borrowed("AuthenticationFailed") + } + Self::InvalidToken => Cow::Borrowed("InvalidToken"), + Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"), + Self::TokenRequired => Cow::Borrowed("TokenRequired"), + Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), + Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), + Self::Forbidden => Cow::Borrowed("Forbidden"), + Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"), + Self::AccountNotFound => Cow::Borrowed("AccountNotFound"), + Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"), + Self::RecordNotFound => Cow::Borrowed("RecordNotFound"), + Self::BlobNotFound => Cow::Borrowed("BlobNotFound"), + Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"), + Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"), + Self::InvalidHandle => Cow::Borrowed("InvalidHandle"), + Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"), + Self::HandleTaken => Cow::Borrowed("HandleTaken"), + Self::InvalidEmail => Cow::Borrowed("InvalidEmail"), + Self::EmailTaken => Cow::Borrowed("EmailTaken"), + Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"), + Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"), + Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"), + Self::InvalidSwap => Cow::Borrowed("InvalidSwap"), } } fn message(&self) -> Option { diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 7b4eebf..c70f064 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -2,6 +2,7 @@ use super::did::verify_did_web; use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; use crate::state::{AppState, RateLimitKind}; +use crate::validation::validate_password; use axum::{ Json, extract::State, @@ -124,19 +125,20 @@ pub async fn create_account( .unwrap_or(false); if is_migration { - let migration_did = input.did.as_ref().unwrap(); - let auth_did = migration_auth.as_ref().unwrap(); - if migration_did != auth_did { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AuthorizationError", - "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) - })), - ) - .into_response(); + if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) + { + if migration_did != auth_did { + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "AuthorizationError", + "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) + })), + ) + .into_response(); + } + info!(did = %migration_did, "Processing account migration"); } - info!(did = %migration_did, "Processing account migration"); } let hostname_for_validation = @@ -670,6 +672,17 @@ pub async fn create_account( } } } + if let Err(e) = validate_password(&input.password) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidPassword", + "message": e.to_string() + })), + ) + .into_response(); + } + let password_hash = match hash(&input.password, DEFAULT_COST) { Ok(h) => h, Err(e) => { diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index 1216a8c..34124cf 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -304,7 +304,7 @@ pub async fn request_account_delete( "https://{}/xrpc/com.atproto.server.requestAccountDelete", std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) ); - let did = match crate::auth::validate_token_with_dpop( + let validated = match crate::auth::validate_token_with_dpop( &state.db, &extracted.token, extracted.is_dpop, @@ -315,9 +315,15 @@ pub async fn request_account_delete( ) .await { - Ok(user) => user.did, + Ok(user) => user, Err(e) => return ApiError::from(e).into_response(), }; + let did = validated.did.clone(); + + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await { + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await; + } + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) .fetch_optional(&state.db) .await diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs index 043fbf4..39d9b12 100644 --- a/src/api/server/mod.rs +++ b/src/api/server/mod.rs @@ -33,13 +33,15 @@ pub use password::{ change_password, get_password_status, remove_password, request_password_reset, reset_password, }; pub use reauth::{ - check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start, - reauth_password, reauth_required_response, reauth_totp, + check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response, + reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response, + reauth_totp, update_mfa_verified, }; pub use service_auth::get_service_auth; pub use session::{ - confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session, - resend_verification, revoke_session, + confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session, + list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session, + update_legacy_login_preference, }; pub use signing_key::reserve_signing_key; pub use totp::{ diff --git a/src/api/server/passkey_account.rs b/src/api/server/passkey_account.rs index e7fe88e..89e0d0c 100644 --- a/src/api/server/passkey_account.rs +++ b/src/api/server/passkey_account.rs @@ -16,6 +16,7 @@ use tracing::{error, info, warn}; use uuid::Uuid; use crate::state::{AppState, RateLimitKind}; +use crate::validation::validate_password; fn extract_client_ip(headers: &HeaderMap) -> String { if let Some(forwarded) = headers.get("x-forwarded-for") @@ -1108,10 +1109,13 @@ pub async fn recover_passkey_account( State(state): State, Json(input): Json, ) -> Response { - if input.new_password.len() < 8 { + if let Err(e) = validate_password(&input.new_password) { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "WeakPassword", "message": "Password must be at least 8 characters"})), + Json(json!({ + "error": "InvalidPassword", + "message": e.to_string() + })), ) .into_response(); } diff --git a/src/api/server/passkeys.rs b/src/api/server/passkeys.rs index 909b3f8..8da624e 100644 --- a/src/api/server/passkeys.rs +++ b/src/api/server/passkeys.rs @@ -294,6 +294,15 @@ pub async fn delete_passkey( auth: BearerAuth, Json(input): Json, ) -> Response { + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) + .await; + } + + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; + } + let id: uuid::Uuid = match input.id.parse() { Ok(id) => id, Err(_) => { diff --git a/src/api/server/password.rs b/src/api/server/password.rs index 2a3255f..a0c672d 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -1,5 +1,6 @@ use crate::auth::BearerAuth; use crate::state::{AppState, RateLimitKind}; +use crate::validation::validate_password; use axum::{ Json, extract::State, @@ -164,6 +165,16 @@ pub async fn reset_password( ) .into_response(); } + if let Err(e) = validate_password(password) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": "InvalidPassword", + "message": e.to_string() + })), + ) + .into_response(); + } let user = sqlx::query!( "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", token @@ -326,6 +337,11 @@ pub async fn change_password( auth: BearerAuth, Json(input): Json, ) -> Response { + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) + .await; + } + let current_password = &input.current_password; let new_password = &input.new_password; if current_password.is_empty() { @@ -342,10 +358,13 @@ pub async fn change_password( ) .into_response(); } - if new_password.len() < 8 { + if let Err(e) = validate_password(new_password) { return ( StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})), + Json(json!({ + "error": "InvalidPassword", + "message": e.to_string() + })), ) .into_response(); } @@ -447,6 +466,11 @@ pub async fn get_password_status(State(state): State, auth: BearerAuth } pub async fn remove_password(State(state): State, auth: BearerAuth) -> Response { + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) + .await; + } + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; } diff --git a/src/api/server/reauth.rs b/src/api/server/reauth.rs index d73fb1f..2fa36fe 100644 --- a/src/api/server/reauth.rs +++ b/src/api/server/reauth.rs @@ -11,7 +11,7 @@ use sqlx::PgPool; use tracing::{error, info, warn}; use crate::auth::BearerAuth; -use crate::state::AppState; +use crate::state::{AppState, RateLimitKind}; const REAUTH_WINDOW_SECONDS: i64 = 300; @@ -155,6 +155,21 @@ pub async fn reauth_totp( auth: BearerAuth, Json(input): Json, ) -> Response { + if !state + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) + .await + { + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ + "error": "RateLimitExceeded", + "message": "Too many verification attempts. Please try again in a few minutes." + })), + ) + .into_response(); + } + let valid = crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code) .await; @@ -352,14 +367,29 @@ pub async fn reauth_passkey_finish( }; let cred_id_bytes = auth_result.cred_id().as_ref(); - if let Err(e) = crate::auth::webauthn::update_passkey_counter( + match crate::auth::webauthn::update_passkey_counter( &state.db, cred_id_bytes, auth_result.counter(), ) .await { - error!("Failed to update passkey counter: {:?}", e); + Ok(false) => { + warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key"); + let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; + return ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "PasskeyCounterAnomaly", + "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key." + })), + ) + .into_response(); + } + Err(e) => { + error!("Failed to update passkey counter: {:?}", e); + } + Ok(true) => {} } let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; @@ -383,7 +413,7 @@ pub async fn reauth_passkey_finish( async fn update_last_reauth(db: &PgPool, did: &str) -> Result, sqlx::Error> { let now = Utc::now(); sqlx::query!( - "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", + "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", now, did ) @@ -419,20 +449,6 @@ async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec { methods.push("password".to_string()); } - let has_app_password = sqlx::query_scalar!( - "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", - did - ) - .fetch_optional(db) - .await - .ok() - .flatten() - .is_some(); - - if has_app_password && !methods.contains(&"password".to_string()) { - methods.push("password".to_string()); - } - let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await; if has_totp { methods.push("totp".to_string()); @@ -480,3 +496,54 @@ pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response { ) .into_response() } + +pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool { + let session = sqlx::query!( + "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", + did + ) + .fetch_optional(db) + .await; + + match session { + Ok(Some(row)) => { + if !row.legacy_login { + return true; + } + if row.mfa_verified { + return true; + } + if let Some(last_reauth) = row.last_reauth_at { + let elapsed = chrono::Utc::now().signed_duration_since(last_reauth); + if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { + return true; + } + } + false + } + _ => true, + } +} + +pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> { + sqlx::query!( + "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", + did + ) + .execute(db) + .await?; + Ok(()) +} + +pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response { + let methods = get_available_reauth_methods(db, did).await; + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "MfaVerificationRequired", + "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.", + "reauthMethods": methods + })), + ) + .into_response() +} diff --git a/src/api/server/session.rs b/src/api/server/session.rs index 2f485d3..248ac7d 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -92,7 +92,10 @@ pub async fn create_session( r#"SELECT u.id, u.did, u.handle, u.password_hash, u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, - k.key_bytes, k.encryption_version + u.allow_legacy_login, + u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel", + k.key_bytes, k.encryption_version, + (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, @@ -161,6 +164,23 @@ pub async fn create_session( ) .into_response(); } + let has_totp = row.totp_enabled.unwrap_or(false); + let is_legacy_login = has_totp; + if has_totp && !row.allow_legacy_login { + warn!( + "Legacy login blocked for TOTP-enabled account: {}", + row.did + ); + return ( + StatusCode::FORBIDDEN, + Json(json!({ + "error": "MfaRequired", + "message": "This account requires MFA. Please use an OAuth client that supports TOTP verification.", + "did": row.did + })), + ) + .into_response(); + } let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, Err(e) => { @@ -176,12 +196,14 @@ pub async fn create_session( } }; if let Err(e) = sqlx::query!( - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", row.did, access_meta.jti, refresh_meta.jti, access_meta.expires_at, - refresh_meta.expires_at + refresh_meta.expires_at, + is_legacy_login, + false ) .execute(&state.db) .await @@ -189,6 +211,25 @@ pub async fn create_session( error!("Failed to insert session: {:?}", e); return ApiError::InternalError.into_response(); } + if is_legacy_login { + warn!( + did = %row.did, + ip = %client_ip, + "Legacy login on TOTP-enabled account - sending notification" + ); + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); + if let Err(e) = crate::comms::queue_legacy_login_notification( + &state.db, + row.id, + &hostname, + &client_ip, + row.preferred_comms_channel, + ) + .await + { + error!("Failed to queue legacy login notification: {:?}", e); + } + } let handle = full_handle(&row.handle, &pds_hostname); Json(CreateSessionOutput { access_jwt: access_meta.token, @@ -617,12 +658,14 @@ pub async fn confirm_signup( } }; if let Err(e) = sqlx::query!( - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)", row.did, access_meta.jti, refresh_meta.jti, access_meta.expires_at, - refresh_meta.expires_at + refresh_meta.expires_at, + false, + false ) .execute(&state.db) .await @@ -746,6 +789,8 @@ pub async fn resend_verification( #[serde(rename_all = "camelCase")] pub struct SessionInfo { pub id: String, + pub session_type: String, + pub client_name: Option, pub created_at: String, pub expires_at: String, pub is_current: bool, @@ -767,7 +812,10 @@ pub async fn list_sessions( .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .and_then(|token| crate::auth::get_jti_from_token(token).ok()); - let result = sqlx::query_as::< + + let mut sessions: Vec = Vec::new(); + + let jwt_result = sqlx::query_as::< _, ( i32, @@ -786,28 +834,90 @@ pub async fn list_sessions( .bind(&auth.0.did) .fetch_all(&state.db) .await; - match result { + + match jwt_result { Ok(rows) => { - let sessions: Vec = rows - .into_iter() - .map(|(id, access_jti, created_at, expires_at)| SessionInfo { - id: id.to_string(), + for (id, access_jti, created_at, expires_at) in rows { + sessions.push(SessionInfo { + id: format!("jwt:{}", id), + session_type: "legacy".to_string(), + client_name: None, created_at: created_at.to_rfc3339(), expires_at: expires_at.to_rfc3339(), is_current: current_jti.as_ref() == Some(&access_jti), - }) - .collect(); - (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() + }); + } } Err(e) => { - error!("DB error in list_sessions: {:?}", e); - ( + error!("DB error fetching JWT sessions: {:?}", e); + return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), ) - .into_response() + .into_response(); } } + + let oauth_result = sqlx::query_as::< + _, + ( + i32, + String, + chrono::DateTime, + chrono::DateTime, + String, + ), + >( + r#" + SELECT id, token_id, created_at, expires_at, client_id + FROM oauth_token + WHERE did = $1 AND expires_at > NOW() + ORDER BY created_at DESC + "#, + ) + .bind(&auth.0.did) + .fetch_all(&state.db) + .await; + + match oauth_result { + Ok(rows) => { + for (id, token_id, created_at, expires_at, client_id) in rows { + let client_name = extract_client_name(&client_id); + let is_current_oauth = auth.0.is_oauth + && current_jti.as_ref() == Some(&token_id); + sessions.push(SessionInfo { + id: format!("oauth:{}", id), + session_type: "oauth".to_string(), + client_name: Some(client_name), + created_at: created_at.to_rfc3339(), + expires_at: expires_at.to_rfc3339(), + is_current: is_current_oauth, + }); + } + } + Err(e) => { + error!("DB error fetching OAuth sessions: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + + sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + + (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() +} + +fn extract_client_name(client_id: &str) -> String { + if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") { + "Localhost App".to_string() + } else if let Ok(parsed) = reqwest::Url::parse(client_id) { + parsed.host_str().unwrap_or("Unknown App").to_string() + } else { + client_id.to_string() + } } #[derive(Deserialize)] @@ -821,57 +931,277 @@ pub async fn revoke_session( auth: BearerAuth, Json(input): Json, ) -> Response { - let session_id: i32 = match input.session_id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), - ) - .into_response(); - } - }; - let session = sqlx::query_as::<_, (String,)>( - "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", - ) - .bind(session_id) - .bind(&auth.0.did) - .fetch_optional(&state.db) - .await; - let access_jti = match session { - Ok(Some((jti,))) => jti, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "SessionNotFound", "message": "Session not found"})), - ) - .into_response(); - } - Err(e) => { - error!("DB error in revoke_session: {:?}", e); + if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { + let session_id: i32 = match jwt_id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), + ) + .into_response(); + } + }; + let session = sqlx::query_as::<_, (String,)>( + "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", + ) + .bind(session_id) + .bind(&auth.0.did) + .fetch_optional(&state.db) + .await; + let access_jti = match session { + Ok(Some((jti,))) => jti, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error in revoke_session: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + }; + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") + .bind(session_id) + .execute(&state.db) + .await + { + error!("DB error deleting session: {:?}", e); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError"})), ) .into_response(); } - }; - if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") - .bind(session_id) - .execute(&state.db) - .await - { - error!("DB error deleting session: {:?}", e); + let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); + if let Err(e) = state.cache.delete(&cache_key).await { + warn!("Failed to invalidate session cache: {:?}", e); + } + info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked"); + } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { + let session_id: i32 = match oauth_id.parse() { + Ok(id) => id, + Err(_) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), + ) + .into_response(); + } + }; + let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2") + .bind(session_id) + .bind(&auth.0.did) + .execute(&state.db) + .await; + match result { + Ok(r) if r.rows_affected() == 0 => { + return ( + StatusCode::NOT_FOUND, + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), + ) + .into_response(); + } + Err(e) => { + error!("DB error deleting OAuth session: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + _ => {} + } + info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked"); + } else { return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})), ) .into_response(); } - let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); - if let Err(e) = state.cache.delete(&cache_key).await { - warn!("Failed to invalidate session cache: {:?}", e); - } - info!(did = %auth.0.did, session_id = %session_id, "Session revoked"); (StatusCode::OK, Json(json!({}))).into_response() } + +pub async fn revoke_all_sessions( + State(state): State, + headers: HeaderMap, + auth: BearerAuth, +) -> Response { + let current_jti = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .and_then(|token| crate::auth::get_jti_from_token(token).ok()); + + if let Some(ref jti) = current_jti { + if auth.0.is_oauth { + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1") + .bind(&auth.0.did) + .execute(&state.db) + .await + { + error!("DB error revoking JWT sessions: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2") + .bind(&auth.0.did) + .bind(jti) + .execute(&state.db) + .await + { + error!("DB error revoking OAuth sessions: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } else { + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") + .bind(&auth.0.did) + .bind(jti) + .execute(&state.db) + .await + { + error!("DB error revoking JWT sessions: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") + .bind(&auth.0.did) + .execute(&state.db) + .await + { + error!("DB error revoking OAuth sessions: {:?}", e); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response(); + } + } + } else { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})), + ) + .into_response(); + } + + info!(did = %auth.0.did, "All other sessions revoked"); + (StatusCode::OK, Json(json!({"success": true}))).into_response() +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LegacyLoginPreferenceOutput { + pub allow_legacy_login: bool, + pub has_mfa: bool, +} + +pub async fn get_legacy_login_preference( + State(state): State, + auth: BearerAuth, +) -> Response { + let result = sqlx::query!( + r#"SELECT + u.allow_legacy_login, + (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR + EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!" + FROM users u WHERE u.did = $1"#, + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + match result { + Ok(Some(row)) => Json(LegacyLoginPreferenceOutput { + allow_legacy_login: row.allow_legacy_login, + has_mfa: row.has_mfa, + }) + .into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound"})), + ) + .into_response(), + Err(e) => { + error!("DB error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateLegacyLoginInput { + pub allow_legacy_login: bool, +} + +pub async fn update_legacy_login_preference( + State(state): State, + auth: BearerAuth, + Json(input): Json, +) -> Response { + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) + .await; + } + + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; + } + + let result = sqlx::query!( + "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", + input.allow_legacy_login, + auth.0.did + ) + .fetch_optional(&state.db) + .await; + + match result { + Ok(Some(_)) => { + info!( + did = %auth.0.did, + allow_legacy_login = input.allow_legacy_login, + "Legacy login preference updated" + ); + Json(json!({ + "allowLegacyLogin": input.allow_legacy_login + })) + .into_response() + } + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(json!({"error": "AccountNotFound"})), + ) + .into_response(), + Err(e) => { + error!("DB error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "InternalError"})), + ) + .into_response() + } + } +} diff --git a/src/api/server/totp.rs b/src/api/server/totp.rs index 9d54220..b127a57 100644 --- a/src/api/server/totp.rs +++ b/src/api/server/totp.rs @@ -4,7 +4,7 @@ use crate::auth::totp::{ generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, verify_backup_code, verify_totp_code, }; -use crate::state::AppState; +use crate::state::{AppState, RateLimitKind}; use axum::{ Json, extract::State, @@ -149,6 +149,21 @@ pub async fn enable_totp( auth: BearerAuth, Json(input): Json, ) -> Response { + if !state + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) + .await + { + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ + "error": "RateLimitExceeded", + "message": "Too many verification attempts. Please try again in a few minutes." + })), + ) + .into_response(); + } + let totp_row = sqlx::query!( "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", auth.0.did @@ -309,6 +324,26 @@ pub async fn disable_totp( auth: BearerAuth, Json(input): Json, ) -> Response { + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) + .await; + } + + if !state + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) + .await + { + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ + "error": "RateLimitExceeded", + "message": "Too many verification attempts. Please try again in a few minutes." + })), + ) + .into_response(); + } + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) .fetch_optional(&state.db) .await; @@ -516,6 +551,21 @@ pub async fn regenerate_backup_codes( auth: BearerAuth, Json(input): Json, ) -> Response { + if !state + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) + .await + { + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); + return ( + StatusCode::TOO_MANY_REQUESTS, + Json(json!({ + "error": "RateLimitExceeded", + "message": "Too many verification attempts. Please try again in a few minutes." + })), + ) + .into_response(); + } + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) .fetch_optional(&state.db) .await; diff --git a/src/api/validation.rs b/src/api/validation.rs index f1fa9ee..0731dbe 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -64,14 +64,16 @@ pub fn validate_short_handle(handle: &str) -> Result Result<(), sqlx::Error> { +) -> Result { + let stored = get_passkey_by_credential_id(pool, credential_id).await?; + let Some(stored) = stored else { + return Err(sqlx::Error::RowNotFound); + }; + + if new_counter > 0 && new_counter <= stored.sign_count as u32 { + tracing::warn!( + credential_id = ?credential_id, + stored_counter = stored.sign_count, + new_counter = new_counter, + "Passkey counter did not increment - possible cloned key!" + ); + return Ok(false); + } + sqlx::query!( "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", new_counter as i32, @@ -349,7 +364,7 @@ pub async fn update_passkey_counter( ) .execute(pool) .await?; - Ok(()) + Ok(true) } pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result { diff --git a/src/comms/mod.rs b/src/comms/mod.rs index 1287b10..c0f3d32 100644 --- a/src/comms/mod.rs +++ b/src/comms/mod.rs @@ -11,6 +11,7 @@ pub use service::{ CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery, enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, + queue_legacy_login_notification, }; pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms}; diff --git a/src/comms/service.rs b/src/comms/service.rs index ed21cb2..83efca2 100644 --- a/src/comms/service.rs +++ b/src/comms/service.rs @@ -527,3 +527,41 @@ pub async fn enqueue_signup_verification( ) .await } + +pub async fn queue_legacy_login_notification( + db: &PgPool, + user_id: Uuid, + hostname: &str, + client_ip: &str, + channel: CommsChannel, +) -> Result { + let prefs = get_user_comms_prefs(db, user_id).await?; + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); + let body = format!( + "Hello @{},\n\n\ + A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\n\ + Details:\n\ + - Time: {}\n\ + - IP Address: {}\n\n\ + Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\n\ + If this wasn't you, please:\n\ + 1. Change your password immediately\n\ + 2. Review your active sessions\n\ + 3. Consider disabling legacy app logins in your security settings\n\n\ + Stay safe,\n\ + {}", + prefs.handle, timestamp, client_ip, hostname + ); + enqueue_comms( + db, + NewComms::new( + user_id, + channel, + super::types::CommsType::LegacyLoginAlert, + prefs.email.clone().unwrap_or_default(), + Some(format!("Security Alert: Legacy Login Detected - {}", hostname)), + body, + ), + ) + .await +} diff --git a/src/comms/types.rs b/src/comms/types.rs index de314b5..43441b7 100644 --- a/src/comms/types.rs +++ b/src/comms/types.rs @@ -33,6 +33,7 @@ pub enum CommsType { PlcOperation, TwoFactorCode, PasskeyRecovery, + LegacyLoginAlert, } #[derive(Debug, Clone, FromRow)] diff --git a/src/config.rs b/src/config.rs index b1998f1..05d6aa4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,7 @@ pub struct AuthConfig { pub signing_key_x: String, pub signing_key_y: String, key_encryption_key: [u8; 32], + device_cookie_key: [u8; 32], } impl AuthConfig { @@ -112,6 +113,10 @@ impl AuthConfig { hk.expand(b"tranquil-pds-user-key-encryption", &mut key_encryption_key) .expect("HKDF expansion failed"); + let mut device_cookie_key = [0u8; 32]; + hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key) + .expect("HKDF expansion failed"); + AuthConfig { jwt_secret, dpop_secret, @@ -120,6 +125,7 @@ impl AuthConfig { signing_key_x, signing_key_y, key_encryption_key, + device_cookie_key, } }) } @@ -138,6 +144,67 @@ impl AuthConfig { &self.dpop_secret } + pub fn sign_device_cookie(&self, device_id: &str) -> String { + use hmac::Mac; + type HmacSha256 = hmac::Hmac; + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let message = format!("{}:{}", device_id, timestamp); + let mut mac = ::new_from_slice(&self.device_cookie_key) + .expect("HMAC key size is valid"); + mac.update(message.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + format!("{}.{}.{}", device_id, timestamp, signature) + } + + pub fn verify_device_cookie(&self, cookie_value: &str) -> Option { + use hmac::Mac; + type HmacSha256 = hmac::Hmac; + + let parts: Vec<&str> = cookie_value.splitn(3, '.').collect(); + if parts.len() != 3 { + return None; + } + + let device_id = parts[0]; + let timestamp_str = parts[1]; + let provided_signature = parts[2]; + + let timestamp: u64 = timestamp_str.parse().ok()?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let max_age_days = 400; + if now.saturating_sub(timestamp) > max_age_days * 24 * 60 * 60 { + return None; + } + + let message = format!("{}:{}", device_id, timestamp); + let mut mac = ::new_from_slice(&self.device_cookie_key) + .expect("HMAC key size is valid"); + mac.update(message.as_bytes()); + let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + + use subtle::ConstantTimeEq; + if provided_signature + .as_bytes() + .ct_eq(expected_signature.as_bytes()) + .into() + { + Some(device_id.to_string()) + } else { + None + } + } + pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result, String> { use rand::RngCore; diff --git a/src/lib.rs b/src/lib.rs index 1eeb62e..1a96cbc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,10 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.tranquil.account.revokeSession", post(api::server::revoke_session), ) + .route( + "/xrpc/com.tranquil.account.revokeAllSessions", + post(api::server::revoke_all_sessions), + ) .route( "/xrpc/com.atproto.server.deleteSession", post(api::server::delete_session), @@ -230,6 +234,14 @@ pub fn app(state: AppState) -> Router { "/xrpc/com.tranquil.account.reauthPasskeyFinish", post(api::server::reauth_passkey_finish), ) + .route( + "/xrpc/com.tranquil.account.getLegacyLoginPreference", + get(api::server::get_legacy_login_preference), + ) + .route( + "/xrpc/com.tranquil.account.updateLegacyLoginPreference", + post(api::server::update_legacy_login_preference), + ) .route( "/xrpc/com.tranquil.account.listTrustedDevices", get(api::server::list_trusted_devices), diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index 0a0864e..ac04b66 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -39,7 +39,7 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option { for cookie in cookie_str.split(';') { let cookie = cookie.trim(); if let Some(value) = cookie.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) { - return Some(value.to_string()); + return crate::config::AuthConfig::get().verify_device_cookie(value); } } None @@ -69,9 +69,10 @@ fn extract_user_agent(headers: &HeaderMap) -> Option { } fn make_device_cookie(device_id: &str) -> String { + let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id); format!( "{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000", - DEVICE_COOKIE_NAME, device_id + DEVICE_COOKIE_NAME, signed_value ) } @@ -1511,6 +1512,17 @@ pub async fn authorize_2fa_post( "No 2FA challenge found. Please start over.", ); } + if !state + .check_rate_limit(RateLimitKind::TotpVerify, &did) + .await + { + tracing::warn!(did = %did, "TOTP verification rate limit exceeded"); + return json_error( + StatusCode::TOO_MANY_REQUESTS, + "RateLimitExceeded", + "Too many verification attempts. Please try again in a few minutes.", + ); + } let totp_valid = crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; if !totp_valid { @@ -2067,15 +2079,30 @@ pub async fn passkey_finish( tracing::warn!(error = %e, "Failed to delete authentication state"); } - if auth_result.needs_update() - && let Err(e) = crate::auth::webauthn::update_passkey_counter( + if auth_result.needs_update() { + match crate::auth::webauthn::update_passkey_counter( &state.db, auth_result.cred_id(), auth_result.counter(), ) .await - { - tracing::warn!(error = %e, "Failed to update passkey counter"); + { + Ok(false) => { + tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key"); + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Security key counter anomaly detected. This may indicate a cloned key." + })), + ) + .into_response(); + } + Err(e) => { + tracing::warn!(error = %e, "Failed to update passkey counter"); + } + Ok(true) => {} + } } tracing::info!(did = %did, "Passkey authentication successful"); @@ -2469,14 +2496,28 @@ pub async fn authorize_passkey_finish( let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await; - if let Err(e) = crate::auth::webauthn::update_passkey_counter( + match crate::auth::webauthn::update_passkey_counter( &state.db, credential.id.as_ref(), auth_result.counter(), ) .await { - tracing::warn!("Failed to update passkey counter: {:?}", e); + Ok(false) => { + tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key"); + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "access_denied", + "error_description": "Security key counter anomaly detected. This may indicate a cloned key." + })), + ) + .into_response(); + } + Err(e) => { + tracing::warn!("Failed to update passkey counter: {:?}", e); + } + Ok(true) => {} } let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await; diff --git a/src/rate_limit.rs b/src/rate_limit.rs index ee33978..663a78e 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -29,6 +29,7 @@ pub struct RateLimiters { pub oauth_introspect: Arc, pub app_password: Arc, pub email_update: Arc, + pub totp_verify: Arc, } impl Default for RateLimiters { @@ -73,6 +74,10 @@ impl RateLimiters { email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( NonZeroU32::new(5).unwrap(), ))), + totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60)) + .unwrap() + .allow_burst(NonZeroU32::new(5).unwrap()), + )), } } diff --git a/src/state.rs b/src/state.rs index 0fd51d6..c31de94 100644 --- a/src/state.rs +++ b/src/state.rs @@ -35,6 +35,7 @@ pub enum RateLimitKind { OAuthIntrospect, AppPassword, EmailUpdate, + TotpVerify, } impl RateLimitKind { @@ -51,6 +52,7 @@ impl RateLimitKind { Self::OAuthIntrospect => "oauth_introspect", Self::AppPassword => "app_password", Self::EmailUpdate => "email_update", + Self::TotpVerify => "totp_verify", } } @@ -67,6 +69,7 @@ impl RateLimitKind { Self::OAuthIntrospect => (30, 60_000), Self::AppPassword => (10, 60_000), Self::EmailUpdate => (5, 3_600_000), + Self::TotpVerify => (5, 300_000), } } } @@ -142,6 +145,7 @@ impl AppState { RateLimitKind::OAuthIntrospect => &self.rate_limiters.oauth_introspect, RateLimitKind::AppPassword => &self.rate_limiters.app_password, RateLimitKind::EmailUpdate => &self.rate_limiters.email_update, + RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, }; let ok = limiter.check_key(&client_ip.to_string()).is_ok(); diff --git a/src/validation/mod.rs b/src/validation/mod.rs index c51f80c..5ce440a 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -409,6 +409,74 @@ pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> Ok(()) } +#[derive(Debug)] +pub struct PasswordValidationError { + pub errors: Vec, +} + +impl std::fmt::Display for PasswordValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.errors.join("; ")) + } +} + +impl std::error::Error for PasswordValidationError {} + +pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> { + let mut errors = Vec::new(); + + if password.len() < 8 { + errors.push("Password must be at least 8 characters".to_string()); + } + + if password.len() > 256 { + errors.push("Password must be at most 256 characters".to_string()); + } + + if !password.chars().any(|c| c.is_ascii_lowercase()) { + errors.push("Password must contain at least one lowercase letter".to_string()); + } + + if !password.chars().any(|c| c.is_ascii_uppercase()) { + errors.push("Password must contain at least one uppercase letter".to_string()); + } + + if !password.chars().any(|c| c.is_ascii_digit()) { + errors.push("Password must contain at least one number".to_string()); + } + + if is_common_password(password) { + errors.push("Password is too common, please choose a different one".to_string()); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(PasswordValidationError { errors }) + } +} + +fn is_common_password(password: &str) -> bool { + const COMMON_PASSWORDS: &[&str] = &[ + "password", "Password1", "Password123", "Passw0rd", "Passw0rd!", + "12345678", "123456789", "1234567890", + "qwerty123", "Qwerty123", "qwertyui", "Qwertyui", + "letmein1", "Letmein1", "welcome1", "Welcome1", + "admin123", "Admin123", "password1", "Password1!", + "iloveyou", "Iloveyou1", "monkey123", "Monkey123", + "dragon12", "Dragon123", "master12", "Master123", + "login123", "Login123", "abc12345", "Abc12345", + "football", "Football1", "baseball", "Baseball1", + "trustno1", "Trustno1", "sunshine", "Sunshine1", + "princess", "Princess1", "computer", "Computer1", + "whatever", "Whatever1", "nintendo", "Nintendo1", + "bluesky1", "Bluesky1", "Bluesky123", + ]; + + let lower = password.to_lowercase(); + COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/admin_search.rs b/tests/admin_search.rs index cbf81b3..33d68c6 100644 --- a/tests/admin_search.rs +++ b/tests/admin_search.rs @@ -63,7 +63,7 @@ async fn test_search_accounts_with_handle_filter() { let create_payload = serde_json::json!({ "handle": unique_handle, "email": format!("unique-{}@searchtest.com", ts), - "password": "test-password-123" + "password": "Testpass123!" }); let create_res = client .post(format!( diff --git a/tests/change_password.rs b/tests/change_password.rs index c19bd1a..3c084b7 100644 --- a/tests/change_password.rs +++ b/tests/change_password.rs @@ -11,8 +11,8 @@ async fn test_change_password_success() { let ts = chrono::Utc::now().timestamp_millis(); let handle = format!("change-pw-{}.test", ts); let email = format!("change-pw-{}@test.com", ts); - let old_password = "old-password-123"; - let new_password = "new-password-456"; + let old_password = "Oldpass123!"; + let new_password = "Newpass456!"; let create_payload = json!({ "handle": handle, "email": email, @@ -92,8 +92,8 @@ async fn test_change_password_wrong_current() { )) .bearer_auth(&jwt) .json(&json!({ - "currentPassword": "wrong-password", - "newPassword": "new-password-123" + "currentPassword": "Wrongpass999!", + "newPassword": "Newpass123!" })) .send() .await @@ -109,7 +109,7 @@ async fn test_change_password_too_short() { let ts = chrono::Utc::now().timestamp_millis(); let handle = format!("change-pw-short-{}.test", ts); let email = format!("change-pw-short-{}@test.com", ts); - let password = "correct-password"; + let password = "Correct123!"; let create_payload = json!({ "handle": handle, "email": email, @@ -158,7 +158,7 @@ async fn test_change_password_empty_current() { .bearer_auth(&jwt) .json(&json!({ "currentPassword": "", - "newPassword": "new-password-123" + "newPassword": "Newpass123!" })) .send() .await @@ -177,7 +177,7 @@ async fn test_change_password_empty_new() { )) .bearer_auth(&jwt) .json(&json!({ - "currentPassword": "e2e-password-123", + "currentPassword": "E2epass123!", "newPassword": "" })) .send() @@ -195,8 +195,8 @@ async fn test_change_password_requires_auth() { base_url().await )) .json(&json!({ - "currentPassword": "old", - "newPassword": "new-password-123" + "currentPassword": "Oldpass123!", + "newPassword": "Newpass123!" })) .send() .await diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 8d4ad4d..f820f93 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -418,7 +418,7 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) -> let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password" + "password": "Testpass123!" }); let res = match client .post(format!( diff --git a/tests/delete_account.rs b/tests/delete_account.rs index 0e2cd60..5b0b678 100644 --- a/tests/delete_account.rs +++ b/tests/delete_account.rs @@ -49,7 +49,7 @@ async fn test_delete_account_full_flow() { let ts = Utc::now().timestamp_millis(); let handle = format!("delete-test-{}.test", ts); let email = format!("delete-test-{}@test.com", ts); - let password = "delete-password-123"; + let password = "Delete123pass!"; let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; let request_delete_res = client .post(format!( @@ -106,7 +106,7 @@ async fn test_delete_account_wrong_password() { let ts = Utc::now().timestamp_millis(); let handle = format!("delete-wrongpw-{}.test", ts); let email = format!("delete-wrongpw-{}@test.com", ts); - let password = "correct-password"; + let password = "Correct123!"; let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; let request_delete_res = client .post(format!( @@ -153,7 +153,7 @@ async fn test_delete_account_invalid_token() { let ts = Utc::now().timestamp_millis(); let handle = format!("delete-badtoken-{}.test", ts); let email = format!("delete-badtoken-{}@test.com", ts); - let password = "delete-password"; + let password = "Delete123!"; let create_res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", @@ -196,7 +196,7 @@ async fn test_delete_account_expired_token() { let ts = Utc::now().timestamp_millis(); let handle = format!("delete-expired-{}.test", ts); let email = format!("delete-expired-{}@test.com", ts); - let password = "delete-password"; + let password = "Delete123!"; let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; let request_delete_res = client .post(format!( @@ -250,12 +250,12 @@ async fn test_delete_account_token_mismatch() { let ts = Utc::now().timestamp_millis(); let handle1 = format!("delete-user1-{}.test", ts); let email1 = format!("delete-user1-{}@test.com", ts); - let password1 = "user1-password"; + let password1 = "User1pass123!"; let (did1, jwt1) = create_verified_account(&client, &base_url, &handle1, &email1, password1).await; let handle2 = format!("delete-user2-{}.test", ts); let email2 = format!("delete-user2-{}@test.com", ts); - let password2 = "user2-password"; + let password2 = "User2pass123!"; let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await; let request_delete_res = client .post(format!( @@ -302,7 +302,7 @@ async fn test_delete_account_with_app_password() { let ts = Utc::now().timestamp_millis(); let handle = format!("delete-apppw-{}.test", ts); let email = format!("delete-apppw-{}@test.com", ts); - let main_password = "main-password-123"; + let main_password = "Mainpass123!"; let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, main_password).await; let app_password_res = client diff --git a/tests/did_web.rs b/tests/did_web.rs index 484c1b5..c1c2357 100644 --- a/tests/did_web.rs +++ b/tests/did_web.rs @@ -12,7 +12,7 @@ async fn test_create_self_hosted_did_web() { let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "didType": "web" }); let res = client @@ -139,7 +139,7 @@ async fn test_external_did_web_no_local_doc() { let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "didType": "web-external", "did": did, "signingKey": signing_key @@ -181,7 +181,7 @@ async fn test_plc_operations_blocked_for_did_web() { let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "didType": "web" }); let res = client @@ -246,7 +246,7 @@ async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() { let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "didType": "web" }); let res = client @@ -295,7 +295,7 @@ async fn test_did_plc_still_works_with_did_type_param() { let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "didType": "plc" }); let res = client @@ -324,7 +324,7 @@ async fn test_external_did_web_requires_did_field() { let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "didType": "web-external" }); let res = client diff --git a/tests/email_update.rs b/tests/email_update.rs index b32997b..00bba9c 100644 --- a/tests/email_update.rs +++ b/tests/email_update.rs @@ -26,7 +26,7 @@ async fn create_verified_account( .json(&json!({ "handle": handle, "email": email, - "password": "password" + "password": "Testpass123!" })) .send() .await diff --git a/tests/helpers/mod.rs b/tests/helpers/mod.rs index d6bb8c4..65ba9bd 100644 --- a/tests/helpers/mod.rs +++ b/tests/helpers/mod.rs @@ -10,7 +10,7 @@ pub async fn setup_new_user(handle_prefix: &str) -> (String, String) { let ts = Utc::now().timestamp_millis(); let handle = format!("{}-{}.test", handle_prefix, ts); let email = format!("{}-{}@test.com", handle_prefix, ts); - let password = "e2e-password-123"; + let password = "E2epass123!"; let create_account_payload = json!({ "handle": handle, "email": email, diff --git a/tests/identity.rs b/tests/identity.rs index a97d97c..bf7cb7f 100644 --- a/tests/identity.rs +++ b/tests/identity.rs @@ -12,7 +12,7 @@ async fn test_resolve_handle_success() { let payload = json!({ "handle": short_handle, "email": format!("{}@example.com", short_handle), - "password": "password" + "password": "Testpass123!" }); let res = client .post(format!( @@ -142,7 +142,7 @@ async fn test_create_did_web_account_and_resolve() { let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "did": did, "signingKey": signing_key }); @@ -188,7 +188,7 @@ async fn test_create_account_duplicate_handle() { let payload = json!({ "handle": handle, "email": email, - "password": "password" + "password": "Testpass123!" }); let res = client .post(format!( @@ -266,7 +266,7 @@ async fn test_did_web_lifecycle() { let create_payload = json!({ "handle": handle, "email": email, - "password": "password", + "password": "Testpass123!", "did": did, "signingKey": signing_key }); diff --git a/tests/jwt_security.rs b/tests/jwt_security.rs index 8d290f4..c8ec477 100644 --- a/tests/jwt_security.rs +++ b/tests/jwt_security.rs @@ -675,7 +675,7 @@ async fn test_refresh_token_replay_protection() { let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ "handle": handle, "email": email, "password": "test-password-123" })) + .json(&json!({ "handle": handle, "email": email, "password": "Testpass123!" })) .send() .await .unwrap(); diff --git a/tests/lifecycle_session.rs b/tests/lifecycle_session.rs index eedcdec..399d1f0 100644 --- a/tests/lifecycle_session.rs +++ b/tests/lifecycle_session.rs @@ -36,7 +36,7 @@ async fn test_session_lifecycle_multiple_sessions() { let ts = Utc::now().timestamp_millis(); let handle = format!("multi-session-{}.test", ts); let email = format!("multi-session-{}@test.com", ts); - let password = "multi-session-pw"; + let password = "Multisession123!"; let create_payload = json!({ "handle": handle, "email": email, @@ -112,7 +112,7 @@ async fn test_session_lifecycle_refresh_invalidates_old() { let ts = Utc::now().timestamp_millis(); let handle = format!("refresh-inv-{}.test", ts); let email = format!("refresh-inv-{}@test.com", ts); - let password = "refresh-inv-pw"; + let password = "Refresh123inv!"; let create_payload = json!({ "handle": handle, "email": email, @@ -180,7 +180,7 @@ async fn test_app_password_lifecycle() { let ts = Utc::now().timestamp_millis(); let handle = format!("apppass-{}.test", ts); let email = format!("apppass-{}@test.com", ts); - let password = "apppass-password"; + let password = "Apppass123!"; let create_res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", @@ -291,7 +291,7 @@ async fn test_account_deactivation_lifecycle() { let ts = Utc::now().timestamp_millis(); let handle = format!("deactivate-{}.test", ts); let email = format!("deactivate-{}@test.com", ts); - let password = "deactivate-password"; + let password = "Deactivate123!"; let create_res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", diff --git a/tests/lifecycle_social.rs b/tests/lifecycle_social.rs index 25a1799..8df2e4c 100644 --- a/tests/lifecycle_social.rs +++ b/tests/lifecycle_social.rs @@ -176,7 +176,7 @@ async fn test_account_to_post_full_lifecycle() { let ts = Utc::now().timestamp_millis(); let handle = format!("fullcycle-{}.test", ts); let email = format!("fullcycle-{}@test.com", ts); - let password = "fullcycle-password"; + let password = "Fullcycle123!"; let create_account_res = client .post(format!( "{}/xrpc/com.atproto.server.createAccount", diff --git a/tests/oauth.rs b/tests/oauth.rs index 7562ff3..6b2c17c 100644 --- a/tests/oauth.rs +++ b/tests/oauth.rs @@ -194,7 +194,7 @@ async fn test_full_oauth_flow() { let ts = Utc::now().timestamp_millis(); let handle = format!("oauth-test-{}", ts); let email = format!("oauth-test-{}@example.com", ts); - let password = "oauth-test-password"; + let password = "Oauthtest123!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) @@ -354,7 +354,7 @@ async fn test_oauth_error_cases() { let email = format!("wrong-creds-{}@example.com", ts); http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ "handle": handle, "email": email, "password": "correct-password" })) + .json(&json!({ "handle": handle, "email": email, "password": "Correct123!" })) .send() .await .unwrap(); @@ -438,7 +438,7 @@ async fn test_oauth_2fa_flow() { let ts = Utc::now().timestamp_millis(); let handle = format!("2fa-test-{}", ts); let email = format!("2fa-test-{}@example.com", ts); - let password = "2fa-test-password"; + let password = "Twofa123test!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) @@ -565,7 +565,7 @@ async fn test_oauth_2fa_lockout() { let ts = Utc::now().timestamp_millis(); let handle = format!("2fa-lockout-{}", ts); let email = format!("2fa-lockout-{}@example.com", ts); - let password = "2fa-test-password"; + let password = "Twofa123test!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) @@ -662,7 +662,7 @@ async fn test_account_selector_with_2fa() { let ts = Utc::now().timestamp_millis(); let handle = format!("selector-2fa-{}", ts); let email = format!("selector-2fa-{}@example.com", ts); - let password = "selector-2fa-password"; + let password = "Selector2fa123!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) @@ -853,7 +853,7 @@ async fn test_oauth_state_encoding() { let ts = Utc::now().timestamp_millis(); let handle = format!("state-special-{}", ts); let email = format!("state-special-{}@example.com", ts); - let password = "state-special-password"; + let password = "State123special!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) @@ -932,7 +932,7 @@ async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) { let ts = Utc::now().timestamp_millis(); let handle = format!("scope-test-{}", ts); let email = format!("scope-test-{}@example.com", ts); - let password = "scope-test-password"; + let password = "Scopetest123!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ "handle": handle, "email": email, "password": password })) diff --git a/tests/oauth_lifecycle.rs b/tests/oauth_lifecycle.rs index 651f58b..25adfd9 100644 --- a/tests/oauth_lifecycle.rs +++ b/tests/oauth_lifecycle.rs @@ -57,7 +57,7 @@ async fn create_user_and_oauth_session( let ts = Utc::now().timestamp_millis(); let handle = format!("{}-{}", handle_prefix, ts); let email = format!("{}-{}@example.com", handle_prefix, ts); - let password = format!("{}-password", handle_prefix); + let password = format!("{}Pass123!", handle_prefix); let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ @@ -577,7 +577,7 @@ async fn test_oauth_multiple_clients_same_user() { let ts = Utc::now().timestamp_millis(); let handle = format!("multi-client-{}", ts); let email = format!("multi-client-{}@example.com", ts); - let password = "multi-client-password"; + let password = "MultiClient123!"; let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) .json(&json!({ diff --git a/tests/oauth_scopes.rs b/tests/oauth_scopes.rs index b344fb4..40abc62 100644 --- a/tests/oauth_scopes.rs +++ b/tests/oauth_scopes.rs @@ -61,7 +61,7 @@ async fn create_user_and_oauth_session_with_scope( let ts = Utc::now().timestamp_millis(); let handle = format!("{}-{}", handle_prefix, ts); let email = format!("{}-{}@example.com", handle_prefix, ts); - let password = format!("{}-password", handle_prefix); + let password = format!("{}Pass123!", handle_prefix); let create_res = http_client .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) @@ -383,7 +383,7 @@ async fn test_consent_endpoint_returns_scope_info() { let ts = Utc::now().timestamp_millis(); let handle = format!("consent-test-{}", ts); let email = format!("consent-{}@example.com", ts); - let password = "consent-password"; + let password = "Consent123!"; let redirect_uri = "https://consent-test.example.com/callback"; let create_res = http_client @@ -479,7 +479,7 @@ async fn test_consent_post_generates_code() { let ts = Utc::now().timestamp_millis(); let handle = format!("consent-post-{}", ts); let email = format!("consent-post-{}@example.com", ts); - let password = "consent-post-password"; + let password = "ConsentPost123!"; let redirect_uri = "https://consent-post.example.com/callback"; let create_res = http_client @@ -593,7 +593,7 @@ async fn test_consent_post_requires_atproto_scope() { let ts = Utc::now().timestamp_millis(); let handle = format!("consent-req-{}", ts); let email = format!("consent-req-{}@example.com", ts); - let password = "consent-req-password"; + let password = "ConsentReq123!"; let redirect_uri = "https://consent-req.example.com/callback"; let create_res = http_client diff --git a/tests/oauth_security.rs b/tests/oauth_security.rs index c9f2499..b5f1653 100644 --- a/tests/oauth_security.rs +++ b/tests/oauth_security.rs @@ -44,7 +44,7 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, let ts = Utc::now().timestamp_millis(); let handle = format!("sec-test-{}", ts); let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" })) + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" })) .send().await.unwrap(); let account: Value = create_res.json().await.unwrap(); let did = account["did"].as_str().unwrap(); @@ -72,7 +72,7 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, let auth_res = http_client.post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "security-test-password", "remember_device": false})) + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "remember_device": false})) .send().await.unwrap(); let auth_body: Value = auth_res.json().await.unwrap(); let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); @@ -258,7 +258,7 @@ async fn test_pkce_security() { let ts = Utc::now().timestamp_millis(); let handle = format!("pkce-attack-{}", ts); let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" })) + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" })) .send().await.unwrap(); let account: Value = create_res.json().await.unwrap(); verify_new_account(&http_client, account["did"].as_str().unwrap()).await; @@ -283,7 +283,7 @@ async fn test_pkce_security() { let auth_res = http_client.post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "pkce-password", "remember_device": false})) + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false})) .send().await.unwrap(); assert_eq!(auth_res.status(), StatusCode::OK); let auth_body: Value = auth_res.json().await.unwrap(); @@ -329,7 +329,7 @@ async fn test_replay_attacks() { let ts = Utc::now().timestamp_millis(); let handle = format!("replay-{}", ts); let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" })) + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" })) .send().await.unwrap(); let account: Value = create_res.json().await.unwrap(); verify_new_account(&http_client, account["did"].as_str().unwrap()).await; @@ -356,7 +356,7 @@ async fn test_replay_attacks() { let auth_res = http_client.post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "replay-password", "remember_device": false})) + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false})) .send().await.unwrap(); assert_eq!(auth_res.status(), StatusCode::OK); let auth_body: Value = auth_res.json().await.unwrap(); @@ -495,7 +495,7 @@ async fn test_oauth_security_boundaries() { let ts = Utc::now().timestamp_millis(); let handle = format!("deact-{}", ts); let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "deact-password" })) + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" })) .send().await.unwrap(); let account: Value = create_res.json().await.unwrap(); let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await; @@ -524,7 +524,7 @@ async fn test_oauth_security_boundaries() { let auth_res = http_client.post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") - .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "deact-password", "remember_device": false})) + .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false})) .send().await.unwrap(); assert_eq!( auth_res.status(), @@ -539,7 +539,7 @@ async fn test_oauth_security_boundaries() { let ts2 = Utc::now().timestamp_millis(); let handle2 = format!("cross-{}", ts2); let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) - .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" })) + .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" })) .send().await.unwrap(); let account2: Value = create_res2.json().await.unwrap(); verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; @@ -563,7 +563,7 @@ async fn test_oauth_security_boundaries() { let auth_a = http_client.post(format!("{}/oauth/authorize", url)) .header("Content-Type", "application/json") .header("Accept", "application/json") - .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "cross-password", "remember_device": false})) + .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false})) .send().await.unwrap(); assert_eq!(auth_a.status(), StatusCode::OK); let auth_body_a: Value = auth_a.json().await.unwrap(); diff --git a/tests/password_reset.rs b/tests/password_reset.rs index 84f814d..64368c5 100644 --- a/tests/password_reset.rs +++ b/tests/password_reset.rs @@ -24,7 +24,7 @@ async fn test_request_password_reset_creates_code() { let payload = json!({ "handle": handle, "email": email, - "password": "oldpassword" + "password": "Oldpass123!" }); let res = client .post(format!( @@ -83,8 +83,8 @@ async fn test_reset_password_with_valid_token() { let pool = get_pool().await; let handle = format!("pwreset2_{}", uuid::Uuid::new_v4()); let email = format!("{}@example.com", handle); - let old_password = "oldpassword"; - let new_password = "newpassword123"; + let old_password = "Oldpass123!"; + let new_password = "Newpass456!"; let payload = json!({ "handle": handle, "email": email, @@ -182,7 +182,7 @@ async fn test_reset_password_with_invalid_token() { )) .json(&json!({ "token": "invalid-token", - "password": "newpassword" + "password": "Newpass123!" })) .send() .await @@ -202,7 +202,7 @@ async fn test_reset_password_with_expired_token() { let payload = json!({ "handle": handle, "email": email, - "password": "oldpassword" + "password": "Oldpass123!" }); let res = client .post(format!( @@ -246,7 +246,7 @@ async fn test_reset_password_with_expired_token() { )) .json(&json!({ "token": token, - "password": "newpassword" + "password": "Newpass123!" })) .send() .await @@ -266,7 +266,7 @@ async fn test_reset_password_invalidates_sessions() { let payload = json!({ "handle": handle, "email": email, - "password": "oldpassword" + "password": "Oldpass123!" }); let res = client .post(format!( @@ -313,7 +313,7 @@ async fn test_reset_password_invalidates_sessions() { )) .json(&json!({ "token": token, - "password": "newpassword123" + "password": "Newpass123!" })) .send() .await @@ -356,7 +356,7 @@ async fn test_reset_password_creates_notification() { let payload = json!({ "handle": handle, "email": email, - "password": "oldpassword" + "password": "Oldpass123!" }); let res = client .post(format!( diff --git a/tests/rate_limit.rs b/tests/rate_limit.rs index c55fdcb..4f9c350 100644 --- a/tests/rate_limit.rs +++ b/tests/rate_limit.rs @@ -93,7 +93,7 @@ async fn test_account_creation_rate_limiting() { let payload = json!({ "handle": format!("ratelimit_{}_{}", i, unique_id), "email": format!("ratelimit_{}_{}@example.com", i, unique_id), - "password": "testpassword123" + "password": "Testpass123!" }); let res = client .post(&url) diff --git a/tests/server.rs b/tests/server.rs index 675852f..8b7d088 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -27,7 +27,7 @@ async fn test_account_and_session_lifecycle() { let client = client(); let base = base_url().await; let handle = format!("user_{}", uuid::Uuid::new_v4()); - let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "password" }); + let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Testpass123!" }); let create_res = client .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&payload) @@ -40,7 +40,7 @@ async fn test_account_and_session_lifecycle() { let _ = verify_new_account(&client, did).await; let login = client .post(format!("{}/xrpc/com.atproto.server.createSession", base)) - .json(&json!({ "identifier": handle, "password": "password" })) + .json(&json!({ "identifier": handle, "password": "Testpass123!" })) .send() .await .unwrap(); @@ -61,7 +61,7 @@ async fn test_account_and_session_lifecycle() { assert_ne!(refresh_body["refreshJwt"].as_str().unwrap(), refresh_jwt); let missing_id = client .post(format!("{}/xrpc/com.atproto.server.createSession", base)) - .json(&json!({ "password": "password" })) + .json(&json!({ "password": "Testpass123!" })) .send() .await .unwrap(); @@ -70,7 +70,7 @@ async fn test_account_and_session_lifecycle() { || missing_id.status() == StatusCode::UNPROCESSABLE_ENTITY ); let invalid_handle = client.post(format!("{}/xrpc/com.atproto.server.createAccount", base)) - .json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "password" })) + .json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "Testpass123!" })) .send().await.unwrap(); assert_eq!(invalid_handle.status(), StatusCode::BAD_REQUEST); let unauth_session = client diff --git a/tests/session_management.rs b/tests/session_management.rs index fc62970..cd8a872 100644 --- a/tests/session_management.rs +++ b/tests/session_management.rs @@ -47,7 +47,7 @@ async fn test_list_sessions_multiple_sessions() { let ts = chrono::Utc::now().timestamp_millis(); let handle = format!("multi-list-{}.test", ts); let email = format!("multi-list-{}@test.com", ts); - let password = "test-password-123"; + let password = "Testpass123!"; let create_payload = json!({ "handle": handle, "email": email, @@ -122,7 +122,7 @@ async fn test_revoke_session_success() { let ts = chrono::Utc::now().timestamp_millis(); let handle = format!("revoke-sess-{}.test", ts); let email = format!("revoke-sess-{}@test.com", ts); - let password = "test-password-123"; + let password = "Testpass123!"; let create_payload = json!({ "handle": handle, "email": email, diff --git a/tests/signing_key.rs b/tests/signing_key.rs index 30b7bd9..94a2401 100644 --- a/tests/signing_key.rs +++ b/tests/signing_key.rs @@ -183,7 +183,7 @@ async fn test_create_account_with_reserved_signing_key() { .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "signingKey": signing_key })) .send() @@ -221,7 +221,7 @@ async fn test_create_account_with_invalid_signing_key() { .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "signingKey": "did:key:zNonExistentKey12345" })) .send() @@ -257,7 +257,7 @@ async fn test_create_account_cannot_reuse_signing_key() { .json(&json!({ "handle": handle1, "email": format!("{}@example.com", handle1), - "password": "password", + "password": "Testpass123!", "signingKey": signing_key })) .send() @@ -273,7 +273,7 @@ async fn test_create_account_cannot_reuse_signing_key() { .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), - "password": "password", + "password": "Testpass123!", "signingKey": signing_key })) .send() @@ -310,7 +310,7 @@ async fn test_reserved_key_tokens_work() { .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), - "password": "password", + "password": "Testpass123!", "signingKey": signing_key })) .send()