this repo has no description

Security improvements

lewis 18b49421 36f6be7f

Changed files
+1551 -379
.sqlx
frontend
migrations
src
tests
+23
.sqlx/query-14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "did", 9 + "type_info": "Text" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Bool", 15 + "Text" 16 + ] 17 + }, 18 + "nullable": [ 19 + false 20 + ] 21 + }, 22 + "hash": "14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09" 23 + }
+2 -1
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
··· 39 39 "plc_operation", 40 40 "two_factor_code", 41 41 "channel_verification", 42 - "passkey_recovery" 42 + "passkey_recovery", 43 + "legacy_login_alert" 43 44 ] 44 45 } 45 46 }
+2 -1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
··· 47 47 "plc_operation", 48 48 "two_factor_code", 49 49 "channel_verification", 50 - "passkey_recovery" 50 + "passkey_recovery", 51 + "legacy_login_alert" 51 52 ] 52 53 } 53 54 }
+20
.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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)", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text", 9 + "Text", 10 + "Text", 11 + "Timestamptz", 12 + "Timestamptz", 13 + "Bool", 14 + "Bool" 15 + ] 16 + }, 17 + "nullable": [] 18 + }, 19 + "hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e" 20 + }
+2 -1
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
··· 39 39 "plc_operation", 40 40 "two_factor_code", 41 41 "channel_verification", 42 - "passkey_recovery" 42 + "passkey_recovery", 43 + "legacy_login_alert" 43 44 ] 44 45 } 45 46 }
+28
.sqlx/query-5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "allow_legacy_login", 9 + "type_info": "Bool" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "has_mfa!", 14 + "type_info": "Bool" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + null 25 + ] 26 + }, 27 + "hash": "5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414" 28 + }
+14
.sqlx/query-6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Text" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8" 14 + }
-22
.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "one", 9 - "type_info": "Int4" 10 - } 11 - ], 12 - "parameters": { 13 - "Left": [ 14 - "Text" 15 - ] 16 - }, 17 - "nullable": [ 18 - null 19 - ] 20 - }, 21 - "hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034" 22 - }
-15
.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Timestamptz", 9 - "Text" 10 - ] 11 - }, 12 - "nullable": [] 13 - }, 14 - "hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979" 15 - }
+34
.sqlx/query-8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "legacy_login", 9 + "type_info": "Bool" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "mfa_verified", 14 + "type_info": "Bool" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "last_reauth_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + true 31 + ] 32 + }, 33 + "hash": "8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd" 34 + }
-76
.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "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", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "id", 9 - "type_info": "Uuid" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "did", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "handle", 19 - "type_info": "Text" 20 - }, 21 - { 22 - "ordinal": 3, 23 - "name": "password_hash", 24 - "type_info": "Text" 25 - }, 26 - { 27 - "ordinal": 4, 28 - "name": "email_verified", 29 - "type_info": "Bool" 30 - }, 31 - { 32 - "ordinal": 5, 33 - "name": "discord_verified", 34 - "type_info": "Bool" 35 - }, 36 - { 37 - "ordinal": 6, 38 - "name": "telegram_verified", 39 - "type_info": "Bool" 40 - }, 41 - { 42 - "ordinal": 7, 43 - "name": "signal_verified", 44 - "type_info": "Bool" 45 - }, 46 - { 47 - "ordinal": 8, 48 - "name": "key_bytes", 49 - "type_info": "Bytea" 50 - }, 51 - { 52 - "ordinal": 9, 53 - "name": "encryption_version", 54 - "type_info": "Int4" 55 - } 56 - ], 57 - "parameters": { 58 - "Left": [ 59 - "Text" 60 - ] 61 - }, 62 - "nullable": [ 63 - false, 64 - false, 65 - false, 66 - true, 67 - false, 68 - false, 69 - false, 70 - false, 71 - false, 72 - true 73 - ] 74 - }, 75 - "hash": "c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590" 76 - }
+15
.sqlx/query-e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Timestamptz", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f" 15 + }
+2 -1
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
··· 42 42 "plc_operation", 43 43 "two_factor_code", 44 44 "channel_verification", 45 - "passkey_recovery" 45 + "passkey_recovery", 46 + "legacy_login_alert" 46 47 ] 47 48 } 48 49 }
+106
.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "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", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "handle", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "password_hash", 24 + "type_info": "Text" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "email_verified", 29 + "type_info": "Bool" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "discord_verified", 34 + "type_info": "Bool" 35 + }, 36 + { 37 + "ordinal": 6, 38 + "name": "telegram_verified", 39 + "type_info": "Bool" 40 + }, 41 + { 42 + "ordinal": 7, 43 + "name": "signal_verified", 44 + "type_info": "Bool" 45 + }, 46 + { 47 + "ordinal": 8, 48 + "name": "allow_legacy_login", 49 + "type_info": "Bool" 50 + }, 51 + { 52 + "ordinal": 9, 53 + "name": "preferred_comms_channel: crate::comms::CommsChannel", 54 + "type_info": { 55 + "Custom": { 56 + "name": "comms_channel", 57 + "kind": { 58 + "Enum": [ 59 + "email", 60 + "discord", 61 + "telegram", 62 + "signal" 63 + ] 64 + } 65 + } 66 + } 67 + }, 68 + { 69 + "ordinal": 10, 70 + "name": "key_bytes", 71 + "type_info": "Bytea" 72 + }, 73 + { 74 + "ordinal": 11, 75 + "name": "encryption_version", 76 + "type_info": "Int4" 77 + }, 78 + { 79 + "ordinal": 12, 80 + "name": "totp_enabled", 81 + "type_info": "Bool" 82 + } 83 + ], 84 + "parameters": { 85 + "Left": [ 86 + "Text" 87 + ] 88 + }, 89 + "nullable": [ 90 + false, 91 + false, 92 + false, 93 + true, 94 + false, 95 + false, 96 + false, 97 + false, 98 + false, 99 + false, 100 + false, 101 + true, 102 + null 103 + ] 104 + }, 105 + "hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38" 106 + }
+2 -15
TODO.md
··· 9 9 - [ ] Unique "brand" style both unauthed and authed 10 10 - [ ] Better documentation on how to sub out the entire frontend for whatever the users want 11 11 12 - ### Passkeys and 2FA 13 - Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth. 14 - 15 - - [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name) 16 - - [x] user_totp table (did, secret_encrypted, verified, created_at, last_used) 17 - - [x] WebAuthn registration challenge generation and attestation verification 18 - - [x] TOTP secret generation with QR code setup flow 19 - - [x] Backup codes (hashed, one-time use) with recovery flow 20 - - [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative) 21 - - [ ] Passkey-only account creation (no password) 22 - - [x] Settings UI for managing passkeys, TOTP, backup codes 23 - - [ ] Trusted devices option (remember this browser) 24 - - [x] Rate limit 2FA attempts 25 - - [ ] Re-auth for sensitive actions (email change, adding new auth methods) 26 - 27 12 ### Delegated accounts 28 13 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. 29 14 ··· 103 88 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. 104 89 105 90 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. 91 + 92 + 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.
+3
frontend/src/components/ReauthModal.svelte
··· 29 29 activeMethod = 'totp' 30 30 } else if (availableMethods.includes('passkey')) { 31 31 activeMethod = 'passkey' 32 + if (availableMethods.length === 1) { 33 + handlePasskeyAuth() 34 + } 32 35 } 33 36 } 34 37 })
+22 -1
frontend/src/lib/api.ts
··· 37 37 }) 38 38 if (!res.ok) { 39 39 const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText })) 40 - throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods) 40 + throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods) 41 41 } 42 42 return res.json() 43 43 } ··· 331 331 return xrpc('com.tranquil.account.getPasswordStatus', { token }) 332 332 }, 333 333 334 + async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 335 + return xrpc('com.tranquil.account.getLegacyLoginPreference', { token }) 336 + }, 337 + 338 + async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> { 339 + return xrpc('com.tranquil.account.updateLegacyLoginPreference', { 340 + method: 'POST', 341 + token, 342 + body: { allowLegacyLogin }, 343 + }) 344 + }, 345 + 334 346 async listSessions(token: string): Promise<{ 335 347 sessions: Array<{ 336 348 id: string 349 + sessionType: string 350 + clientName: string | null 337 351 createdAt: string 338 352 expiresAt: string 339 353 isCurrent: boolean ··· 347 361 method: 'POST', 348 362 token, 349 363 body: { sessionId }, 364 + }) 365 + }, 366 + 367 + async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 368 + return xrpc('com.tranquil.account.revokeAllSessions', { 369 + method: 'POST', 370 + token, 350 371 }) 351 372 }, 352 373
-6
frontend/src/routes/Register.svelte
··· 28 28 const auth = getAuthState() 29 29 30 30 $effect(() => { 31 - if (auth.session) { 32 - navigate('/dashboard') 33 - } 34 - }) 35 - 36 - $effect(() => { 37 31 if (!serverInfoLoaded) { 38 32 serverInfoLoaded = true 39 33 loadServerInfo()
-6
frontend/src/routes/RegisterPasskey.svelte
··· 31 31 let resendMessage = $state<string | null>(null) 32 32 33 33 $effect(() => { 34 - if (auth.session) { 35 - navigate('/dashboard') 36 - } 37 - }) 38 - 39 - $effect(() => { 40 34 if (!serverInfoLoaded) { 41 35 serverInfoLoaded = true 42 36 loadServerInfo()
+219 -3
frontend/src/routes/Security.svelte
··· 44 44 let showRemovePasswordForm = $state(false) 45 45 let removePasswordLoading = $state(false) 46 46 47 + let allowLegacyLogin = $state(true) 48 + let hasMfa = $state(false) 49 + let legacyLoginLoading = $state(true) 50 + let legacyLoginUpdating = $state(false) 51 + 47 52 let showReauthModal = $state(false) 48 53 let reauthMethods = $state<string[]>(['password']) 49 54 let pendingAction = $state<(() => Promise<void>) | null>(null) ··· 59 64 loadTotpStatus() 60 65 loadPasskeys() 61 66 loadPasswordStatus() 67 + loadLegacyLoginPreference() 62 68 } 63 69 }) 64 70 ··· 72 78 hasPassword = true 73 79 } finally { 74 80 passwordLoading = false 81 + } 82 + } 83 + 84 + async function loadLegacyLoginPreference() { 85 + if (!auth.session) return 86 + legacyLoginLoading = true 87 + try { 88 + const pref = await api.getLegacyLoginPreference(auth.session.accessJwt) 89 + allowLegacyLogin = pref.allowLegacyLogin 90 + hasMfa = pref.hasMfa 91 + } catch { 92 + allowLegacyLogin = true 93 + hasMfa = false 94 + } finally { 95 + legacyLoginLoading = false 96 + } 97 + } 98 + 99 + async function handleToggleLegacyLogin() { 100 + if (!auth.session) return 101 + legacyLoginUpdating = true 102 + try { 103 + const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin) 104 + allowLegacyLogin = result.allowLegacyLogin 105 + showMessage('success', allowLegacyLogin 106 + ? 'Legacy app login enabled' 107 + : 'Legacy app login disabled - only OAuth apps can sign in') 108 + } catch (e) { 109 + if (e instanceof ApiError) { 110 + if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') { 111 + reauthMethods = e.reauthMethods || ['password'] 112 + pendingAction = handleToggleLegacyLogin 113 + showReauthModal = true 114 + } else { 115 + showMessage('error', e.message) 116 + } 117 + } else { 118 + showMessage('error', 'Failed to update preference') 119 + } 120 + } finally { 121 + legacyLoginUpdating = false 75 122 } 76 123 } 77 124 ··· 572 619 <button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}> 573 620 Rename 574 621 </button> 575 - <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 576 - Delete 577 - </button> 622 + {#if hasPassword || passkeys.length > 1} 623 + <button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}> 624 + Delete 625 + </button> 626 + {/if} 578 627 </div> 579 628 {/if} 580 629 </div> ··· 670 719 Manage Trusted Devices &rarr; 671 720 </a> 672 721 </section> 722 + 723 + {#if hasMfa} 724 + <section> 725 + <h2>App Compatibility</h2> 726 + <p class="description"> 727 + Control whether apps that don't support modern authentication (like the official Bluesky app) can sign in to your account. 728 + </p> 729 + 730 + {#if legacyLoginLoading} 731 + <div class="loading">Loading...</div> 732 + {:else} 733 + <div class="toggle-row"> 734 + <div class="toggle-info"> 735 + <span class="toggle-label">Allow legacy app login</span> 736 + <span class="toggle-description"> 737 + {#if allowLegacyLogin} 738 + Legacy apps can sign in with just your password, but sensitive actions (like changing your password) will require MFA verification. 739 + {:else} 740 + Only OAuth-compatible apps can sign in. Legacy apps will be blocked. 741 + {/if} 742 + </span> 743 + </div> 744 + <button 745 + type="button" 746 + class="toggle-button {allowLegacyLogin ? 'on' : 'off'}" 747 + onclick={handleToggleLegacyLogin} 748 + disabled={legacyLoginUpdating} 749 + > 750 + <span class="toggle-slider"></span> 751 + </button> 752 + </div> 753 + 754 + {#if totpEnabled} 755 + <div class="warning-box"> 756 + <strong>Important: Password changes in Bluesky app will fail</strong> 757 + <p> 758 + With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked. 759 + To change your password, you have two options: 760 + </p> 761 + <ol> 762 + <li><strong>Change it here:</strong> Use this website's <a href="#/settings">Settings page</a> where you can verify with your authenticator app.</li> 763 + <li><strong>Verify your session first:</strong> Use the <a href="#/settings">re-authenticate option</a> to verify your Bluesky session with TOTP, then password changes will work temporarily.</li> 764 + </ol> 765 + </div> 766 + {/if} 767 + 768 + <div class="info-box-inline"> 769 + <strong>What are legacy apps?</strong> 770 + <p> 771 + Some apps (like the official Bluesky app) use older authentication that only requires your password. 772 + When you have MFA enabled, these apps bypass your second factor. 773 + Disabling legacy login forces all apps to use OAuth, which properly enforces MFA. 774 + </p> 775 + </div> 776 + {/if} 777 + </section> 778 + {/if} 673 779 {/if} 674 780 </div> 675 781 ··· 1075 1181 1076 1182 .info-box-inline li { 1077 1183 margin-bottom: 0.25rem; 1184 + } 1185 + 1186 + .info-box-inline p { 1187 + margin: 0; 1188 + color: var(--text-secondary); 1189 + } 1190 + 1191 + .toggle-row { 1192 + display: flex; 1193 + justify-content: space-between; 1194 + align-items: flex-start; 1195 + gap: 1rem; 1196 + padding: 1rem; 1197 + background: var(--bg-card); 1198 + border: 1px solid var(--border-color-light); 1199 + border-radius: 6px; 1200 + margin-bottom: 1rem; 1201 + } 1202 + 1203 + .toggle-info { 1204 + display: flex; 1205 + flex-direction: column; 1206 + gap: 0.25rem; 1207 + } 1208 + 1209 + .toggle-label { 1210 + font-weight: 500; 1211 + } 1212 + 1213 + .toggle-description { 1214 + font-size: 0.875rem; 1215 + color: var(--text-secondary); 1216 + } 1217 + 1218 + .toggle-button { 1219 + position: relative; 1220 + width: 50px; 1221 + height: 26px; 1222 + padding: 0; 1223 + border: none; 1224 + border-radius: 13px; 1225 + cursor: pointer; 1226 + transition: background 0.2s; 1227 + flex-shrink: 0; 1228 + } 1229 + 1230 + .toggle-button.on { 1231 + background: var(--success-text); 1232 + } 1233 + 1234 + .toggle-button.off { 1235 + background: var(--text-secondary); 1236 + } 1237 + 1238 + .toggle-button:disabled { 1239 + opacity: 0.6; 1240 + cursor: not-allowed; 1241 + } 1242 + 1243 + .toggle-slider { 1244 + position: absolute; 1245 + top: 3px; 1246 + width: 20px; 1247 + height: 20px; 1248 + background: white; 1249 + border-radius: 50%; 1250 + transition: left 0.2s; 1251 + } 1252 + 1253 + .toggle-button.on .toggle-slider { 1254 + left: 27px; 1255 + } 1256 + 1257 + .toggle-button.off .toggle-slider { 1258 + left: 3px; 1259 + } 1260 + 1261 + .warning-box { 1262 + background: var(--warning-bg); 1263 + border: 1px solid var(--warning-border, var(--border-color)); 1264 + border-left: 4px solid var(--warning-text); 1265 + border-radius: 6px; 1266 + padding: 1rem; 1267 + margin-bottom: 1rem; 1268 + } 1269 + 1270 + .warning-box strong { 1271 + display: block; 1272 + margin-bottom: 0.5rem; 1273 + color: var(--warning-text); 1274 + } 1275 + 1276 + .warning-box p { 1277 + margin: 0 0 0.75rem 0; 1278 + font-size: 0.875rem; 1279 + color: var(--text-primary); 1280 + } 1281 + 1282 + .warning-box ol { 1283 + margin: 0; 1284 + padding-left: 1.25rem; 1285 + font-size: 0.875rem; 1286 + } 1287 + 1288 + .warning-box li { 1289 + margin-bottom: 0.5rem; 1290 + } 1291 + 1292 + .warning-box a { 1293 + color: var(--accent); 1078 1294 } 1079 1295 </style>
+63 -7
frontend/src/routes/Sessions.svelte
··· 7 7 let error = $state<string | null>(null) 8 8 let sessions = $state<Array<{ 9 9 id: string 10 + sessionType: string 11 + clientName: string | null 10 12 createdAt: string 11 13 expiresAt: string 12 14 isCurrent: boolean ··· 51 53 error = e instanceof ApiError ? e.message : 'Failed to revoke session' 52 54 } 53 55 } 56 + async function revokeAllSessions() { 57 + if (!auth.session) return 58 + const otherCount = sessions.filter(s => !s.isCurrent).length 59 + if (otherCount === 0) { 60 + error = 'No other sessions to revoke' 61 + return 62 + } 63 + if (!confirm(`This will revoke ${otherCount} other session${otherCount > 1 ? 's' : ''}. Continue?`)) return 64 + try { 65 + await api.revokeAllSessions(auth.session.accessJwt) 66 + sessions = sessions.filter(s => s.isCurrent) 67 + } catch (e) { 68 + error = e instanceof ApiError ? e.message : 'Failed to revoke sessions' 69 + } 70 + } 54 71 function formatDate(dateStr: string): string { 55 72 return new Date(dateStr).toLocaleString() 56 73 } ··· 87 104 <div class="session-info"> 88 105 <div class="session-header"> 89 106 {#if session.isCurrent} 90 - <span class="badge current">Current Session</span> 91 - {:else} 92 - <span class="session-label">Session</span> 107 + <span class="badge current">Current</span> 108 + {/if} 109 + <span class="badge type" class:oauth={session.sessionType === 'oauth'}> 110 + {session.sessionType === 'oauth' ? 'OAuth' : 'Session'} 111 + </span> 112 + {#if session.clientName} 113 + <span class="client-name">{session.clientName}</span> 93 114 {/if} 94 115 </div> 95 116 <div class="session-details"> ··· 115 136 </div> 116 137 {/each} 117 138 </div> 118 - <button class="refresh-btn" onclick={loadSessions}>Refresh</button> 139 + <div class="actions-bar"> 140 + <button class="refresh-btn" onclick={loadSessions}>Refresh</button> 141 + {#if sessions.filter(s => !s.isCurrent).length > 0} 142 + <button class="revoke-all-btn" onclick={revokeAllSessions}>Revoke All Other Sessions</button> 143 + {/if} 144 + </div> 119 145 {/if} 120 146 {/if} 121 147 </div> ··· 174 200 } 175 201 .session-header { 176 202 margin-bottom: 0.5rem; 203 + display: flex; 204 + align-items: center; 205 + gap: 0.5rem; 206 + flex-wrap: wrap; 177 207 } 178 - .session-label { 208 + .client-name { 179 209 font-weight: 500; 180 - color: var(--text-secondary); 210 + color: var(--text-primary); 181 211 } 182 212 .badge { 183 213 display: inline-block; ··· 189 219 .badge.current { 190 220 background: var(--accent); 191 221 color: white; 222 + } 223 + .badge.type { 224 + background: var(--bg-secondary); 225 + color: var(--text-secondary); 226 + border: 1px solid var(--border-color); 227 + } 228 + .badge.type.oauth { 229 + background: #e6f4ea; 230 + color: #1e7e34; 231 + border-color: #b8d9c5; 192 232 } 193 233 .session-details { 194 234 display: flex; ··· 224 264 .revoke-btn.danger:hover { 225 265 background: var(--error-bg); 226 266 } 227 - .refresh-btn { 267 + .actions-bar { 228 268 margin-top: 1rem; 269 + display: flex; 270 + gap: 0.5rem; 271 + flex-wrap: wrap; 272 + } 273 + .refresh-btn { 229 274 padding: 0.5rem 1rem; 230 275 background: transparent; 231 276 border: 1px solid var(--border-color); ··· 236 281 .refresh-btn:hover { 237 282 background: var(--bg-card); 238 283 border-color: var(--accent); 284 + } 285 + .revoke-all-btn { 286 + padding: 0.5rem 1rem; 287 + background: transparent; 288 + border: 1px solid var(--error-text); 289 + border-radius: 4px; 290 + cursor: pointer; 291 + color: var(--error-text); 292 + } 293 + .revoke-all-btn:hover { 294 + background: var(--error-bg); 239 295 } 240 296 </style>
+8
migrations/20251229_legacy_login_security.sql
··· 1 + ALTER TABLE users ADD COLUMN allow_legacy_login BOOLEAN NOT NULL DEFAULT TRUE; 2 + 3 + ALTER TABLE session_tokens ADD COLUMN mfa_verified BOOLEAN NOT NULL DEFAULT FALSE; 4 + ALTER TABLE session_tokens ADD COLUMN legacy_login BOOLEAN NOT NULL DEFAULT FALSE; 5 + 6 + CREATE INDEX idx_session_tokens_legacy ON session_tokens(did, legacy_login) WHERE legacy_login = TRUE; 7 + 8 + ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'legacy_login_alert';
+35 -32
src/api/error.rs
··· 4 4 response::{IntoResponse, Response}, 5 5 }; 6 6 use serde::Serialize; 7 + use std::borrow::Cow; 7 8 8 9 #[derive(Debug, Serialize)] 9 - struct ErrorBody { 10 - error: &'static str, 10 + struct ErrorBody<'a> { 11 + error: Cow<'a, str>, 11 12 #[serde(skip_serializing_if = "Option::is_none")] 12 13 message: Option<String>, 13 14 } ··· 90 91 | Self::InvalidSwap => StatusCode::BAD_REQUEST, 91 92 } 92 93 } 93 - fn error_name(&self) -> &'static str { 94 + fn error_name(&self) -> Cow<'static, str> { 94 95 match self { 95 - Self::InternalError | Self::DatabaseError => "InternalError", 96 - Self::UpstreamFailure | Self::UpstreamUnavailable(_) => "UpstreamFailure", 97 - Self::UpstreamTimeout => "UpstreamTimeout", 96 + Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"), 97 + Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"), 98 + Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"), 98 99 Self::UpstreamError { error, .. } => { 99 100 if let Some(e) = error { 100 - return Box::leak(e.clone().into_boxed_str()); 101 + return Cow::Owned(e.clone()); 101 102 } 102 - "UpstreamError" 103 + Cow::Borrowed("UpstreamError") 103 104 } 104 - Self::AuthenticationRequired => "AuthenticationRequired", 105 - Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => "AuthenticationFailed", 106 - Self::InvalidToken => "InvalidToken", 107 - Self::ExpiredToken | Self::ExpiredTokenMsg(_) => "ExpiredToken", 108 - Self::TokenRequired => "TokenRequired", 109 - Self::AccountDeactivated => "AccountDeactivated", 110 - Self::AccountTakedown => "AccountTakedown", 111 - Self::Forbidden => "Forbidden", 112 - Self::InvitesDisabled => "InvitesDisabled", 113 - Self::AccountNotFound => "AccountNotFound", 114 - Self::RepoNotFound | Self::RepoNotFoundMsg(_) => "RepoNotFound", 115 - Self::RecordNotFound => "RecordNotFound", 116 - Self::BlobNotFound => "BlobNotFound", 117 - Self::AppPasswordNotFound => "AppPasswordNotFound", 118 - Self::InvalidRequest(_) => "InvalidRequest", 119 - Self::InvalidHandle => "InvalidHandle", 120 - Self::HandleNotAvailable => "HandleNotAvailable", 121 - Self::HandleTaken => "HandleTaken", 122 - Self::InvalidEmail => "InvalidEmail", 123 - Self::EmailTaken => "EmailTaken", 124 - Self::InvalidInviteCode => "InvalidInviteCode", 125 - Self::DuplicateCreate => "DuplicateCreate", 126 - Self::DuplicateAppPassword => "DuplicateAppPassword", 127 - Self::InvalidSwap => "InvalidSwap", 105 + Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), 106 + Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => { 107 + Cow::Borrowed("AuthenticationFailed") 108 + } 109 + Self::InvalidToken => Cow::Borrowed("InvalidToken"), 110 + Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"), 111 + Self::TokenRequired => Cow::Borrowed("TokenRequired"), 112 + Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), 113 + Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), 114 + Self::Forbidden => Cow::Borrowed("Forbidden"), 115 + Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"), 116 + Self::AccountNotFound => Cow::Borrowed("AccountNotFound"), 117 + Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"), 118 + Self::RecordNotFound => Cow::Borrowed("RecordNotFound"), 119 + Self::BlobNotFound => Cow::Borrowed("BlobNotFound"), 120 + Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"), 121 + Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"), 122 + Self::InvalidHandle => Cow::Borrowed("InvalidHandle"), 123 + Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"), 124 + Self::HandleTaken => Cow::Borrowed("HandleTaken"), 125 + Self::InvalidEmail => Cow::Borrowed("InvalidEmail"), 126 + Self::EmailTaken => Cow::Borrowed("EmailTaken"), 127 + Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"), 128 + Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"), 129 + Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"), 130 + Self::InvalidSwap => Cow::Borrowed("InvalidSwap"), 128 131 } 129 132 } 130 133 fn message(&self) -> Option<String> {
+25 -12
src/api/identity/account.rs
··· 2 2 use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; 3 3 use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; 4 4 use crate::state::{AppState, RateLimitKind}; 5 + use crate::validation::validate_password; 5 6 use axum::{ 6 7 Json, 7 8 extract::State, ··· 124 125 .unwrap_or(false); 125 126 126 127 if is_migration { 127 - let migration_did = input.did.as_ref().unwrap(); 128 - let auth_did = migration_auth.as_ref().unwrap(); 129 - if migration_did != auth_did { 130 - return ( 131 - StatusCode::FORBIDDEN, 132 - Json(json!({ 133 - "error": "AuthorizationError", 134 - "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) 135 - })), 136 - ) 137 - .into_response(); 128 + if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref()) 129 + { 130 + if migration_did != auth_did { 131 + return ( 132 + StatusCode::FORBIDDEN, 133 + Json(json!({ 134 + "error": "AuthorizationError", 135 + "message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did) 136 + })), 137 + ) 138 + .into_response(); 139 + } 140 + info!(did = %migration_did, "Processing account migration"); 138 141 } 139 - info!(did = %migration_did, "Processing account migration"); 140 142 } 141 143 142 144 let hostname_for_validation = ··· 670 672 } 671 673 } 672 674 } 675 + if let Err(e) = validate_password(&input.password) { 676 + return ( 677 + StatusCode::BAD_REQUEST, 678 + Json(json!({ 679 + "error": "InvalidPassword", 680 + "message": e.to_string() 681 + })), 682 + ) 683 + .into_response(); 684 + } 685 + 673 686 let password_hash = match hash(&input.password, DEFAULT_COST) { 674 687 Ok(h) => h, 675 688 Err(e) => {
+8 -2
src/api/server/account_status.rs
··· 304 304 "https://{}/xrpc/com.atproto.server.requestAccountDelete", 305 305 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 306 306 ); 307 - let did = match crate::auth::validate_token_with_dpop( 307 + let validated = match crate::auth::validate_token_with_dpop( 308 308 &state.db, 309 309 &extracted.token, 310 310 extracted.is_dpop, ··· 315 315 ) 316 316 .await 317 317 { 318 - Ok(user) => user.did, 318 + Ok(user) => user, 319 319 Err(e) => return ApiError::from(e).into_response(), 320 320 }; 321 + let did = validated.did.clone(); 322 + 323 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await { 324 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await; 325 + } 326 + 321 327 let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) 322 328 .fetch_optional(&state.db) 323 329 .await
+6 -4
src/api/server/mod.rs
··· 33 33 change_password, get_password_status, remove_password, request_password_reset, reset_password, 34 34 }; 35 35 pub use reauth::{ 36 - check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start, 37 - reauth_password, reauth_required_response, reauth_totp, 36 + check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response, 37 + reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response, 38 + reauth_totp, update_mfa_verified, 38 39 }; 39 40 pub use service_auth::get_service_auth; 40 41 pub use session::{ 41 - confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session, 42 - resend_verification, revoke_session, 42 + confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session, 43 + list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session, 44 + update_legacy_login_preference, 43 45 }; 44 46 pub use signing_key::reserve_signing_key; 45 47 pub use totp::{
+6 -2
src/api/server/passkey_account.rs
··· 16 16 use uuid::Uuid; 17 17 18 18 use crate::state::{AppState, RateLimitKind}; 19 + use crate::validation::validate_password; 19 20 20 21 fn extract_client_ip(headers: &HeaderMap) -> String { 21 22 if let Some(forwarded) = headers.get("x-forwarded-for") ··· 1108 1109 State(state): State<AppState>, 1109 1110 Json(input): Json<RecoverPasskeyAccountInput>, 1110 1111 ) -> Response { 1111 - if input.new_password.len() < 8 { 1112 + if let Err(e) = validate_password(&input.new_password) { 1112 1113 return ( 1113 1114 StatusCode::BAD_REQUEST, 1114 - Json(json!({"error": "WeakPassword", "message": "Password must be at least 8 characters"})), 1115 + Json(json!({ 1116 + "error": "InvalidPassword", 1117 + "message": e.to_string() 1118 + })), 1115 1119 ) 1116 1120 .into_response(); 1117 1121 }
+9
src/api/server/passkeys.rs
··· 294 294 auth: BearerAuth, 295 295 Json(input): Json<DeletePasskeyInput>, 296 296 ) -> Response { 297 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 298 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 299 + .await; 300 + } 301 + 302 + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 303 + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 304 + } 305 + 297 306 let id: uuid::Uuid = match input.id.parse() { 298 307 Ok(id) => id, 299 308 Err(_) => {
+26 -2
src/api/server/password.rs
··· 1 1 use crate::auth::BearerAuth; 2 2 use crate::state::{AppState, RateLimitKind}; 3 + use crate::validation::validate_password; 3 4 use axum::{ 4 5 Json, 5 6 extract::State, ··· 164 165 ) 165 166 .into_response(); 166 167 } 168 + if let Err(e) = validate_password(password) { 169 + return ( 170 + StatusCode::BAD_REQUEST, 171 + Json(json!({ 172 + "error": "InvalidPassword", 173 + "message": e.to_string() 174 + })), 175 + ) 176 + .into_response(); 177 + } 167 178 let user = sqlx::query!( 168 179 "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", 169 180 token ··· 326 337 auth: BearerAuth, 327 338 Json(input): Json<ChangePasswordInput>, 328 339 ) -> Response { 340 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 341 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 342 + .await; 343 + } 344 + 329 345 let current_password = &input.current_password; 330 346 let new_password = &input.new_password; 331 347 if current_password.is_empty() { ··· 342 358 ) 343 359 .into_response(); 344 360 } 345 - if new_password.len() < 8 { 361 + if let Err(e) = validate_password(new_password) { 346 362 return ( 347 363 StatusCode::BAD_REQUEST, 348 - Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})), 364 + Json(json!({ 365 + "error": "InvalidPassword", 366 + "message": e.to_string() 367 + })), 349 368 ) 350 369 .into_response(); 351 370 } ··· 447 466 } 448 467 449 468 pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response { 469 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 470 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 471 + .await; 472 + } 473 + 450 474 if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 451 475 return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 452 476 }
+85 -18
src/api/server/reauth.rs
··· 11 11 use tracing::{error, info, warn}; 12 12 13 13 use crate::auth::BearerAuth; 14 - use crate::state::AppState; 14 + use crate::state::{AppState, RateLimitKind}; 15 15 16 16 const REAUTH_WINDOW_SECONDS: i64 = 300; 17 17 ··· 155 155 auth: BearerAuth, 156 156 Json(input): Json<TotpReauthInput>, 157 157 ) -> Response { 158 + if !state 159 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 160 + .await 161 + { 162 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 163 + return ( 164 + StatusCode::TOO_MANY_REQUESTS, 165 + Json(json!({ 166 + "error": "RateLimitExceeded", 167 + "message": "Too many verification attempts. Please try again in a few minutes." 168 + })), 169 + ) 170 + .into_response(); 171 + } 172 + 158 173 let valid = 159 174 crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code) 160 175 .await; ··· 352 367 }; 353 368 354 369 let cred_id_bytes = auth_result.cred_id().as_ref(); 355 - if let Err(e) = crate::auth::webauthn::update_passkey_counter( 370 + match crate::auth::webauthn::update_passkey_counter( 356 371 &state.db, 357 372 cred_id_bytes, 358 373 auth_result.counter(), 359 374 ) 360 375 .await 361 376 { 362 - error!("Failed to update passkey counter: {:?}", e); 377 + Ok(false) => { 378 + warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key"); 379 + let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; 380 + return ( 381 + StatusCode::UNAUTHORIZED, 382 + Json(json!({ 383 + "error": "PasskeyCounterAnomaly", 384 + "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key." 385 + })), 386 + ) 387 + .into_response(); 388 + } 389 + Err(e) => { 390 + error!("Failed to update passkey counter: {:?}", e); 391 + } 392 + Ok(true) => {} 363 393 } 364 394 365 395 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; ··· 383 413 async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> { 384 414 let now = Utc::now(); 385 415 sqlx::query!( 386 - "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2", 416 + "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2", 387 417 now, 388 418 did 389 419 ) ··· 416 446 .unwrap_or(Some(false)); 417 447 418 448 if has_password == Some(true) { 419 - methods.push("password".to_string()); 420 - } 421 - 422 - let has_app_password = sqlx::query_scalar!( 423 - "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1", 424 - did 425 - ) 426 - .fetch_optional(db) 427 - .await 428 - .ok() 429 - .flatten() 430 - .is_some(); 431 - 432 - if has_app_password && !methods.contains(&"password".to_string()) { 433 449 methods.push("password".to_string()); 434 450 } 435 451 ··· 480 496 ) 481 497 .into_response() 482 498 } 499 + 500 + pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool { 501 + let session = sqlx::query!( 502 + "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", 503 + did 504 + ) 505 + .fetch_optional(db) 506 + .await; 507 + 508 + match session { 509 + Ok(Some(row)) => { 510 + if !row.legacy_login { 511 + return true; 512 + } 513 + if row.mfa_verified { 514 + return true; 515 + } 516 + if let Some(last_reauth) = row.last_reauth_at { 517 + let elapsed = chrono::Utc::now().signed_duration_since(last_reauth); 518 + if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS { 519 + return true; 520 + } 521 + } 522 + false 523 + } 524 + _ => true, 525 + } 526 + } 527 + 528 + pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> { 529 + sqlx::query!( 530 + "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1", 531 + did 532 + ) 533 + .execute(db) 534 + .await?; 535 + Ok(()) 536 + } 537 + 538 + pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response { 539 + let methods = get_available_reauth_methods(db, did).await; 540 + ( 541 + StatusCode::FORBIDDEN, 542 + Json(serde_json::json!({ 543 + "error": "MfaVerificationRequired", 544 + "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.", 545 + "reauthMethods": methods 546 + })), 547 + ) 548 + .into_response() 549 + }
+385 -55
src/api/server/session.rs
··· 92 92 r#"SELECT 93 93 u.id, u.did, u.handle, u.password_hash, 94 94 u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified, 95 - k.key_bytes, k.encryption_version 95 + u.allow_legacy_login, 96 + u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel", 97 + k.key_bytes, k.encryption_version, 98 + (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled 96 99 FROM users u 97 100 JOIN user_keys k ON u.id = k.user_id 98 101 WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#, ··· 161 164 ) 162 165 .into_response(); 163 166 } 167 + let has_totp = row.totp_enabled.unwrap_or(false); 168 + let is_legacy_login = has_totp; 169 + if has_totp && !row.allow_legacy_login { 170 + warn!( 171 + "Legacy login blocked for TOTP-enabled account: {}", 172 + row.did 173 + ); 174 + return ( 175 + StatusCode::FORBIDDEN, 176 + Json(json!({ 177 + "error": "MfaRequired", 178 + "message": "This account requires MFA. Please use an OAuth client that supports TOTP verification.", 179 + "did": row.did 180 + })), 181 + ) 182 + .into_response(); 183 + } 164 184 let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { 165 185 Ok(m) => m, 166 186 Err(e) => { ··· 176 196 } 177 197 }; 178 198 if let Err(e) = sqlx::query!( 179 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 199 + "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)", 180 200 row.did, 181 201 access_meta.jti, 182 202 refresh_meta.jti, 183 203 access_meta.expires_at, 184 - refresh_meta.expires_at 204 + refresh_meta.expires_at, 205 + is_legacy_login, 206 + false 185 207 ) 186 208 .execute(&state.db) 187 209 .await 188 210 { 189 211 error!("Failed to insert session: {:?}", e); 190 212 return ApiError::InternalError.into_response(); 213 + } 214 + if is_legacy_login { 215 + warn!( 216 + did = %row.did, 217 + ip = %client_ip, 218 + "Legacy login on TOTP-enabled account - sending notification" 219 + ); 220 + let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); 221 + if let Err(e) = crate::comms::queue_legacy_login_notification( 222 + &state.db, 223 + row.id, 224 + &hostname, 225 + &client_ip, 226 + row.preferred_comms_channel, 227 + ) 228 + .await 229 + { 230 + error!("Failed to queue legacy login notification: {:?}", e); 231 + } 191 232 } 192 233 let handle = full_handle(&row.handle, &pds_hostname); 193 234 Json(CreateSessionOutput { ··· 617 658 } 618 659 }; 619 660 if let Err(e) = sqlx::query!( 620 - "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)", 661 + "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)", 621 662 row.did, 622 663 access_meta.jti, 623 664 refresh_meta.jti, 624 665 access_meta.expires_at, 625 - refresh_meta.expires_at 666 + refresh_meta.expires_at, 667 + false, 668 + false 626 669 ) 627 670 .execute(&state.db) 628 671 .await ··· 746 789 #[serde(rename_all = "camelCase")] 747 790 pub struct SessionInfo { 748 791 pub id: String, 792 + pub session_type: String, 793 + pub client_name: Option<String>, 749 794 pub created_at: String, 750 795 pub expires_at: String, 751 796 pub is_current: bool, ··· 767 812 .and_then(|v| v.to_str().ok()) 768 813 .and_then(|v| v.strip_prefix("Bearer ")) 769 814 .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 770 - let result = sqlx::query_as::< 815 + 816 + let mut sessions: Vec<SessionInfo> = Vec::new(); 817 + 818 + let jwt_result = sqlx::query_as::< 771 819 _, 772 820 ( 773 821 i32, ··· 786 834 .bind(&auth.0.did) 787 835 .fetch_all(&state.db) 788 836 .await; 789 - match result { 837 + 838 + match jwt_result { 790 839 Ok(rows) => { 791 - let sessions: Vec<SessionInfo> = rows 792 - .into_iter() 793 - .map(|(id, access_jti, created_at, expires_at)| SessionInfo { 794 - id: id.to_string(), 840 + for (id, access_jti, created_at, expires_at) in rows { 841 + sessions.push(SessionInfo { 842 + id: format!("jwt:{}", id), 843 + session_type: "legacy".to_string(), 844 + client_name: None, 795 845 created_at: created_at.to_rfc3339(), 796 846 expires_at: expires_at.to_rfc3339(), 797 847 is_current: current_jti.as_ref() == Some(&access_jti), 798 - }) 799 - .collect(); 800 - (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() 848 + }); 849 + } 801 850 } 802 851 Err(e) => { 803 - error!("DB error in list_sessions: {:?}", e); 804 - ( 852 + error!("DB error fetching JWT sessions: {:?}", e); 853 + return ( 805 854 StatusCode::INTERNAL_SERVER_ERROR, 806 855 Json(json!({"error": "InternalError"})), 807 856 ) 808 - .into_response() 857 + .into_response(); 809 858 } 810 859 } 860 + 861 + let oauth_result = sqlx::query_as::< 862 + _, 863 + ( 864 + i32, 865 + String, 866 + chrono::DateTime<chrono::Utc>, 867 + chrono::DateTime<chrono::Utc>, 868 + String, 869 + ), 870 + >( 871 + r#" 872 + SELECT id, token_id, created_at, expires_at, client_id 873 + FROM oauth_token 874 + WHERE did = $1 AND expires_at > NOW() 875 + ORDER BY created_at DESC 876 + "#, 877 + ) 878 + .bind(&auth.0.did) 879 + .fetch_all(&state.db) 880 + .await; 881 + 882 + match oauth_result { 883 + Ok(rows) => { 884 + for (id, token_id, created_at, expires_at, client_id) in rows { 885 + let client_name = extract_client_name(&client_id); 886 + let is_current_oauth = auth.0.is_oauth 887 + && current_jti.as_ref() == Some(&token_id); 888 + sessions.push(SessionInfo { 889 + id: format!("oauth:{}", id), 890 + session_type: "oauth".to_string(), 891 + client_name: Some(client_name), 892 + created_at: created_at.to_rfc3339(), 893 + expires_at: expires_at.to_rfc3339(), 894 + is_current: is_current_oauth, 895 + }); 896 + } 897 + } 898 + Err(e) => { 899 + error!("DB error fetching OAuth sessions: {:?}", e); 900 + return ( 901 + StatusCode::INTERNAL_SERVER_ERROR, 902 + Json(json!({"error": "InternalError"})), 903 + ) 904 + .into_response(); 905 + } 906 + } 907 + 908 + sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); 909 + 910 + (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() 911 + } 912 + 913 + fn extract_client_name(client_id: &str) -> String { 914 + if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") { 915 + "Localhost App".to_string() 916 + } else if let Ok(parsed) = reqwest::Url::parse(client_id) { 917 + parsed.host_str().unwrap_or("Unknown App").to_string() 918 + } else { 919 + client_id.to_string() 920 + } 811 921 } 812 922 813 923 #[derive(Deserialize)] ··· 821 931 auth: BearerAuth, 822 932 Json(input): Json<RevokeSessionInput>, 823 933 ) -> Response { 824 - let session_id: i32 = match input.session_id.parse() { 825 - Ok(id) => id, 826 - Err(_) => { 934 + if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { 935 + let session_id: i32 = match jwt_id.parse() { 936 + Ok(id) => id, 937 + Err(_) => { 938 + return ( 939 + StatusCode::BAD_REQUEST, 940 + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), 941 + ) 942 + .into_response(); 943 + } 944 + }; 945 + let session = sqlx::query_as::<_, (String,)>( 946 + "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 947 + ) 948 + .bind(session_id) 949 + .bind(&auth.0.did) 950 + .fetch_optional(&state.db) 951 + .await; 952 + let access_jti = match session { 953 + Ok(Some((jti,))) => jti, 954 + Ok(None) => { 955 + return ( 956 + StatusCode::NOT_FOUND, 957 + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), 958 + ) 959 + .into_response(); 960 + } 961 + Err(e) => { 962 + error!("DB error in revoke_session: {:?}", e); 963 + return ( 964 + StatusCode::INTERNAL_SERVER_ERROR, 965 + Json(json!({"error": "InternalError"})), 966 + ) 967 + .into_response(); 968 + } 969 + }; 970 + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") 971 + .bind(session_id) 972 + .execute(&state.db) 973 + .await 974 + { 975 + error!("DB error deleting session: {:?}", e); 827 976 return ( 828 - StatusCode::BAD_REQUEST, 829 - Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), 977 + StatusCode::INTERNAL_SERVER_ERROR, 978 + Json(json!({"error": "InternalError"})), 830 979 ) 831 980 .into_response(); 832 981 } 833 - }; 834 - let session = sqlx::query_as::<_, (String,)>( 835 - "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", 982 + let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); 983 + if let Err(e) = state.cache.delete(&cache_key).await { 984 + warn!("Failed to invalidate session cache: {:?}", e); 985 + } 986 + info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked"); 987 + } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { 988 + let session_id: i32 = match oauth_id.parse() { 989 + Ok(id) => id, 990 + Err(_) => { 991 + return ( 992 + StatusCode::BAD_REQUEST, 993 + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), 994 + ) 995 + .into_response(); 996 + } 997 + }; 998 + let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2") 999 + .bind(session_id) 1000 + .bind(&auth.0.did) 1001 + .execute(&state.db) 1002 + .await; 1003 + match result { 1004 + Ok(r) if r.rows_affected() == 0 => { 1005 + return ( 1006 + StatusCode::NOT_FOUND, 1007 + Json(json!({"error": "SessionNotFound", "message": "Session not found"})), 1008 + ) 1009 + .into_response(); 1010 + } 1011 + Err(e) => { 1012 + error!("DB error deleting OAuth session: {:?}", e); 1013 + return ( 1014 + StatusCode::INTERNAL_SERVER_ERROR, 1015 + Json(json!({"error": "InternalError"})), 1016 + ) 1017 + .into_response(); 1018 + } 1019 + _ => {} 1020 + } 1021 + info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked"); 1022 + } else { 1023 + return ( 1024 + StatusCode::BAD_REQUEST, 1025 + Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})), 1026 + ) 1027 + .into_response(); 1028 + } 1029 + (StatusCode::OK, Json(json!({}))).into_response() 1030 + } 1031 + 1032 + pub async fn revoke_all_sessions( 1033 + State(state): State<AppState>, 1034 + headers: HeaderMap, 1035 + auth: BearerAuth, 1036 + ) -> Response { 1037 + let current_jti = headers 1038 + .get("authorization") 1039 + .and_then(|v| v.to_str().ok()) 1040 + .and_then(|v| v.strip_prefix("Bearer ")) 1041 + .and_then(|token| crate::auth::get_jti_from_token(token).ok()); 1042 + 1043 + if let Some(ref jti) = current_jti { 1044 + if auth.0.is_oauth { 1045 + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1") 1046 + .bind(&auth.0.did) 1047 + .execute(&state.db) 1048 + .await 1049 + { 1050 + error!("DB error revoking JWT sessions: {:?}", e); 1051 + return ( 1052 + StatusCode::INTERNAL_SERVER_ERROR, 1053 + Json(json!({"error": "InternalError"})), 1054 + ) 1055 + .into_response(); 1056 + } 1057 + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2") 1058 + .bind(&auth.0.did) 1059 + .bind(jti) 1060 + .execute(&state.db) 1061 + .await 1062 + { 1063 + error!("DB error revoking OAuth sessions: {:?}", e); 1064 + return ( 1065 + StatusCode::INTERNAL_SERVER_ERROR, 1066 + Json(json!({"error": "InternalError"})), 1067 + ) 1068 + .into_response(); 1069 + } 1070 + } else { 1071 + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") 1072 + .bind(&auth.0.did) 1073 + .bind(jti) 1074 + .execute(&state.db) 1075 + .await 1076 + { 1077 + error!("DB error revoking JWT sessions: {:?}", e); 1078 + return ( 1079 + StatusCode::INTERNAL_SERVER_ERROR, 1080 + Json(json!({"error": "InternalError"})), 1081 + ) 1082 + .into_response(); 1083 + } 1084 + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") 1085 + .bind(&auth.0.did) 1086 + .execute(&state.db) 1087 + .await 1088 + { 1089 + error!("DB error revoking OAuth sessions: {:?}", e); 1090 + return ( 1091 + StatusCode::INTERNAL_SERVER_ERROR, 1092 + Json(json!({"error": "InternalError"})), 1093 + ) 1094 + .into_response(); 1095 + } 1096 + } 1097 + } else { 1098 + return ( 1099 + StatusCode::BAD_REQUEST, 1100 + Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})), 1101 + ) 1102 + .into_response(); 1103 + } 1104 + 1105 + info!(did = %auth.0.did, "All other sessions revoked"); 1106 + (StatusCode::OK, Json(json!({"success": true}))).into_response() 1107 + } 1108 + 1109 + #[derive(Serialize)] 1110 + #[serde(rename_all = "camelCase")] 1111 + pub struct LegacyLoginPreferenceOutput { 1112 + pub allow_legacy_login: bool, 1113 + pub has_mfa: bool, 1114 + } 1115 + 1116 + pub async fn get_legacy_login_preference( 1117 + State(state): State<AppState>, 1118 + auth: BearerAuth, 1119 + ) -> Response { 1120 + let result = sqlx::query!( 1121 + r#"SELECT 1122 + u.allow_legacy_login, 1123 + (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR 1124 + EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!" 1125 + FROM users u WHERE u.did = $1"#, 1126 + auth.0.did 836 1127 ) 837 - .bind(session_id) 838 - .bind(&auth.0.did) 839 1128 .fetch_optional(&state.db) 840 1129 .await; 841 - let access_jti = match session { 842 - Ok(Some((jti,))) => jti, 843 - Ok(None) => { 844 - return ( 845 - StatusCode::NOT_FOUND, 846 - Json(json!({"error": "SessionNotFound", "message": "Session not found"})), 1130 + 1131 + match result { 1132 + Ok(Some(row)) => Json(LegacyLoginPreferenceOutput { 1133 + allow_legacy_login: row.allow_legacy_login, 1134 + has_mfa: row.has_mfa, 1135 + }) 1136 + .into_response(), 1137 + Ok(None) => ( 1138 + StatusCode::NOT_FOUND, 1139 + Json(json!({"error": "AccountNotFound"})), 1140 + ) 1141 + .into_response(), 1142 + Err(e) => { 1143 + error!("DB error: {:?}", e); 1144 + ( 1145 + StatusCode::INTERNAL_SERVER_ERROR, 1146 + Json(json!({"error": "InternalError"})), 847 1147 ) 848 - .into_response(); 1148 + .into_response() 849 1149 } 1150 + } 1151 + } 1152 + 1153 + #[derive(Deserialize)] 1154 + #[serde(rename_all = "camelCase")] 1155 + pub struct UpdateLegacyLoginInput { 1156 + pub allow_legacy_login: bool, 1157 + } 1158 + 1159 + pub async fn update_legacy_login_preference( 1160 + State(state): State<AppState>, 1161 + auth: BearerAuth, 1162 + Json(input): Json<UpdateLegacyLoginInput>, 1163 + ) -> Response { 1164 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 1165 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 1166 + .await; 1167 + } 1168 + 1169 + if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await { 1170 + return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await; 1171 + } 1172 + 1173 + let result = sqlx::query!( 1174 + "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", 1175 + input.allow_legacy_login, 1176 + auth.0.did 1177 + ) 1178 + .fetch_optional(&state.db) 1179 + .await; 1180 + 1181 + match result { 1182 + Ok(Some(_)) => { 1183 + info!( 1184 + did = %auth.0.did, 1185 + allow_legacy_login = input.allow_legacy_login, 1186 + "Legacy login preference updated" 1187 + ); 1188 + Json(json!({ 1189 + "allowLegacyLogin": input.allow_legacy_login 1190 + })) 1191 + .into_response() 1192 + } 1193 + Ok(None) => ( 1194 + StatusCode::NOT_FOUND, 1195 + Json(json!({"error": "AccountNotFound"})), 1196 + ) 1197 + .into_response(), 850 1198 Err(e) => { 851 - error!("DB error in revoke_session: {:?}", e); 852 - return ( 1199 + error!("DB error: {:?}", e); 1200 + ( 853 1201 StatusCode::INTERNAL_SERVER_ERROR, 854 1202 Json(json!({"error": "InternalError"})), 855 1203 ) 856 - .into_response(); 1204 + .into_response() 857 1205 } 858 - }; 859 - if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") 860 - .bind(session_id) 861 - .execute(&state.db) 862 - .await 863 - { 864 - error!("DB error deleting session: {:?}", e); 865 - return ( 866 - StatusCode::INTERNAL_SERVER_ERROR, 867 - Json(json!({"error": "InternalError"})), 868 - ) 869 - .into_response(); 870 1206 } 871 - let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); 872 - if let Err(e) = state.cache.delete(&cache_key).await { 873 - warn!("Failed to invalidate session cache: {:?}", e); 874 - } 875 - info!(did = %auth.0.did, session_id = %session_id, "Session revoked"); 876 - (StatusCode::OK, Json(json!({}))).into_response() 877 1207 }
+51 -1
src/api/server/totp.rs
··· 4 4 generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format, 5 5 verify_backup_code, verify_totp_code, 6 6 }; 7 - use crate::state::AppState; 7 + use crate::state::{AppState, RateLimitKind}; 8 8 use axum::{ 9 9 Json, 10 10 extract::State, ··· 149 149 auth: BearerAuth, 150 150 Json(input): Json<EnableTotpInput>, 151 151 ) -> Response { 152 + if !state 153 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 154 + .await 155 + { 156 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 157 + return ( 158 + StatusCode::TOO_MANY_REQUESTS, 159 + Json(json!({ 160 + "error": "RateLimitExceeded", 161 + "message": "Too many verification attempts. Please try again in a few minutes." 162 + })), 163 + ) 164 + .into_response(); 165 + } 166 + 152 167 let totp_row = sqlx::query!( 153 168 "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", 154 169 auth.0.did ··· 309 324 auth: BearerAuth, 310 325 Json(input): Json<DisableTotpInput>, 311 326 ) -> Response { 327 + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await { 328 + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did) 329 + .await; 330 + } 331 + 332 + if !state 333 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 334 + .await 335 + { 336 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 337 + return ( 338 + StatusCode::TOO_MANY_REQUESTS, 339 + Json(json!({ 340 + "error": "RateLimitExceeded", 341 + "message": "Too many verification attempts. Please try again in a few minutes." 342 + })), 343 + ) 344 + .into_response(); 345 + } 346 + 312 347 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 313 348 .fetch_optional(&state.db) 314 349 .await; ··· 516 551 auth: BearerAuth, 517 552 Json(input): Json<RegenerateBackupCodesInput>, 518 553 ) -> Response { 554 + if !state 555 + .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) 556 + .await 557 + { 558 + warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); 559 + return ( 560 + StatusCode::TOO_MANY_REQUESTS, 561 + Json(json!({ 562 + "error": "RateLimitExceeded", 563 + "message": "Too many verification attempts. Please try again in a few minutes." 564 + })), 565 + ) 566 + .into_response(); 567 + } 568 + 519 569 let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) 520 570 .fetch_optional(&state.db) 521 571 .await;
+8 -6
src/api/validation.rs
··· 64 64 return Err(HandleValidationError::TooLong); 65 65 } 66 66 67 - let first_char = handle.chars().next().unwrap(); 68 - if first_char == '-' || first_char == '_' { 69 - return Err(HandleValidationError::StartsWithInvalidChar); 67 + if let Some(first_char) = handle.chars().next() { 68 + if first_char == '-' || first_char == '_' { 69 + return Err(HandleValidationError::StartsWithInvalidChar); 70 + } 70 71 } 71 72 72 - let last_char = handle.chars().last().unwrap(); 73 - if last_char == '-' || last_char == '_' { 74 - return Err(HandleValidationError::EndsWithInvalidChar); 73 + if let Some(last_char) = handle.chars().last() { 74 + if last_char == '-' || last_char == '_' { 75 + return Err(HandleValidationError::EndsWithInvalidChar); 76 + } 75 77 } 76 78 77 79 for c in handle.chars() {
+17 -2
src/auth/webauthn.rs
··· 341 341 pool: &PgPool, 342 342 credential_id: &[u8], 343 343 new_counter: u32, 344 - ) -> Result<(), sqlx::Error> { 344 + ) -> Result<bool, sqlx::Error> { 345 + let stored = get_passkey_by_credential_id(pool, credential_id).await?; 346 + let Some(stored) = stored else { 347 + return Err(sqlx::Error::RowNotFound); 348 + }; 349 + 350 + if new_counter > 0 && new_counter <= stored.sign_count as u32 { 351 + tracing::warn!( 352 + credential_id = ?credential_id, 353 + stored_counter = stored.sign_count, 354 + new_counter = new_counter, 355 + "Passkey counter did not increment - possible cloned key!" 356 + ); 357 + return Ok(false); 358 + } 359 + 345 360 sqlx::query!( 346 361 "UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2", 347 362 new_counter as i32, ··· 349 364 ) 350 365 .execute(pool) 351 366 .await?; 352 - Ok(()) 367 + Ok(true) 353 368 } 354 369 355 370 pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> {
+1
src/comms/mod.rs
··· 11 11 CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms, 12 12 enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery, 13 13 enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome, 14 + queue_legacy_login_notification, 14 15 }; 15 16 16 17 pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
+38
src/comms/service.rs
··· 527 527 ) 528 528 .await 529 529 } 530 + 531 + pub async fn queue_legacy_login_notification( 532 + db: &PgPool, 533 + user_id: Uuid, 534 + hostname: &str, 535 + client_ip: &str, 536 + channel: CommsChannel, 537 + ) -> Result<Uuid, sqlx::Error> { 538 + let prefs = get_user_comms_prefs(db, user_id).await?; 539 + let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"); 540 + let body = format!( 541 + "Hello @{},\n\n\ 542 + A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\n\ 543 + Details:\n\ 544 + - Time: {}\n\ 545 + - IP Address: {}\n\n\ 546 + Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\n\ 547 + If this wasn't you, please:\n\ 548 + 1. Change your password immediately\n\ 549 + 2. Review your active sessions\n\ 550 + 3. Consider disabling legacy app logins in your security settings\n\n\ 551 + Stay safe,\n\ 552 + {}", 553 + prefs.handle, timestamp, client_ip, hostname 554 + ); 555 + enqueue_comms( 556 + db, 557 + NewComms::new( 558 + user_id, 559 + channel, 560 + super::types::CommsType::LegacyLoginAlert, 561 + prefs.email.clone().unwrap_or_default(), 562 + Some(format!("Security Alert: Legacy Login Detected - {}", hostname)), 563 + body, 564 + ), 565 + ) 566 + .await 567 + }
+1
src/comms/types.rs
··· 33 33 PlcOperation, 34 34 TwoFactorCode, 35 35 PasskeyRecovery, 36 + LegacyLoginAlert, 36 37 } 37 38 38 39 #[derive(Debug, Clone, FromRow)]
+67
src/config.rs
··· 19 19 pub signing_key_x: String, 20 20 pub signing_key_y: String, 21 21 key_encryption_key: [u8; 32], 22 + device_cookie_key: [u8; 32], 22 23 } 23 24 24 25 impl AuthConfig { ··· 112 113 hk.expand(b"tranquil-pds-user-key-encryption", &mut key_encryption_key) 113 114 .expect("HKDF expansion failed"); 114 115 116 + let mut device_cookie_key = [0u8; 32]; 117 + hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key) 118 + .expect("HKDF expansion failed"); 119 + 115 120 AuthConfig { 116 121 jwt_secret, 117 122 dpop_secret, ··· 120 125 signing_key_x, 121 126 signing_key_y, 122 127 key_encryption_key, 128 + device_cookie_key, 123 129 } 124 130 }) 125 131 } ··· 136 142 137 143 pub fn dpop_secret(&self) -> &str { 138 144 &self.dpop_secret 145 + } 146 + 147 + pub fn sign_device_cookie(&self, device_id: &str) -> String { 148 + use hmac::Mac; 149 + type HmacSha256 = hmac::Hmac<Sha256>; 150 + 151 + let timestamp = std::time::SystemTime::now() 152 + .duration_since(std::time::UNIX_EPOCH) 153 + .unwrap_or_default() 154 + .as_secs(); 155 + 156 + let message = format!("{}:{}", device_id, timestamp); 157 + let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key) 158 + .expect("HMAC key size is valid"); 159 + mac.update(message.as_bytes()); 160 + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); 161 + 162 + format!("{}.{}.{}", device_id, timestamp, signature) 163 + } 164 + 165 + pub fn verify_device_cookie(&self, cookie_value: &str) -> Option<String> { 166 + use hmac::Mac; 167 + type HmacSha256 = hmac::Hmac<Sha256>; 168 + 169 + let parts: Vec<&str> = cookie_value.splitn(3, '.').collect(); 170 + if parts.len() != 3 { 171 + return None; 172 + } 173 + 174 + let device_id = parts[0]; 175 + let timestamp_str = parts[1]; 176 + let provided_signature = parts[2]; 177 + 178 + let timestamp: u64 = timestamp_str.parse().ok()?; 179 + 180 + let now = std::time::SystemTime::now() 181 + .duration_since(std::time::UNIX_EPOCH) 182 + .unwrap_or_default() 183 + .as_secs(); 184 + 185 + let max_age_days = 400; 186 + if now.saturating_sub(timestamp) > max_age_days * 24 * 60 * 60 { 187 + return None; 188 + } 189 + 190 + let message = format!("{}:{}", device_id, timestamp); 191 + let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key) 192 + .expect("HMAC key size is valid"); 193 + mac.update(message.as_bytes()); 194 + let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); 195 + 196 + use subtle::ConstantTimeEq; 197 + if provided_signature 198 + .as_bytes() 199 + .ct_eq(expected_signature.as_bytes()) 200 + .into() 201 + { 202 + Some(device_id.to_string()) 203 + } else { 204 + None 205 + } 139 206 } 140 207 141 208 pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
+12
src/lib.rs
··· 60 60 post(api::server::revoke_session), 61 61 ) 62 62 .route( 63 + "/xrpc/com.tranquil.account.revokeAllSessions", 64 + post(api::server::revoke_all_sessions), 65 + ) 66 + .route( 63 67 "/xrpc/com.atproto.server.deleteSession", 64 68 post(api::server::delete_session), 65 69 ) ··· 229 233 .route( 230 234 "/xrpc/com.tranquil.account.reauthPasskeyFinish", 231 235 post(api::server::reauth_passkey_finish), 236 + ) 237 + .route( 238 + "/xrpc/com.tranquil.account.getLegacyLoginPreference", 239 + get(api::server::get_legacy_login_preference), 240 + ) 241 + .route( 242 + "/xrpc/com.tranquil.account.updateLegacyLoginPreference", 243 + post(api::server::update_legacy_login_preference), 232 244 ) 233 245 .route( 234 246 "/xrpc/com.tranquil.account.listTrustedDevices",
+49 -8
src/oauth/endpoints/authorize.rs
··· 39 39 for cookie in cookie_str.split(';') { 40 40 let cookie = cookie.trim(); 41 41 if let Some(value) = cookie.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) { 42 - return Some(value.to_string()); 42 + return crate::config::AuthConfig::get().verify_device_cookie(value); 43 43 } 44 44 } 45 45 None ··· 69 69 } 70 70 71 71 fn make_device_cookie(device_id: &str) -> String { 72 + let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id); 72 73 format!( 73 74 "{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000", 74 - DEVICE_COOKIE_NAME, device_id 75 + DEVICE_COOKIE_NAME, signed_value 75 76 ) 76 77 } 77 78 ··· 1511 1512 "No 2FA challenge found. Please start over.", 1512 1513 ); 1513 1514 } 1515 + if !state 1516 + .check_rate_limit(RateLimitKind::TotpVerify, &did) 1517 + .await 1518 + { 1519 + tracing::warn!(did = %did, "TOTP verification rate limit exceeded"); 1520 + return json_error( 1521 + StatusCode::TOO_MANY_REQUESTS, 1522 + "RateLimitExceeded", 1523 + "Too many verification attempts. Please try again in a few minutes.", 1524 + ); 1525 + } 1514 1526 let totp_valid = 1515 1527 crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await; 1516 1528 if !totp_valid { ··· 2067 2079 tracing::warn!(error = %e, "Failed to delete authentication state"); 2068 2080 } 2069 2081 2070 - if auth_result.needs_update() 2071 - && let Err(e) = crate::auth::webauthn::update_passkey_counter( 2082 + if auth_result.needs_update() { 2083 + match crate::auth::webauthn::update_passkey_counter( 2072 2084 &state.db, 2073 2085 auth_result.cred_id(), 2074 2086 auth_result.counter(), 2075 2087 ) 2076 2088 .await 2077 - { 2078 - tracing::warn!(error = %e, "Failed to update passkey counter"); 2089 + { 2090 + Ok(false) => { 2091 + tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key"); 2092 + return ( 2093 + StatusCode::FORBIDDEN, 2094 + Json(serde_json::json!({ 2095 + "error": "access_denied", 2096 + "error_description": "Security key counter anomaly detected. This may indicate a cloned key." 2097 + })), 2098 + ) 2099 + .into_response(); 2100 + } 2101 + Err(e) => { 2102 + tracing::warn!(error = %e, "Failed to update passkey counter"); 2103 + } 2104 + Ok(true) => {} 2105 + } 2079 2106 } 2080 2107 2081 2108 tracing::info!(did = %did, "Passkey authentication successful"); ··· 2469 2496 2470 2497 let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await; 2471 2498 2472 - if let Err(e) = crate::auth::webauthn::update_passkey_counter( 2499 + match crate::auth::webauthn::update_passkey_counter( 2473 2500 &state.db, 2474 2501 credential.id.as_ref(), 2475 2502 auth_result.counter(), 2476 2503 ) 2477 2504 .await 2478 2505 { 2479 - tracing::warn!("Failed to update passkey counter: {:?}", e); 2506 + Ok(false) => { 2507 + tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key"); 2508 + return ( 2509 + StatusCode::FORBIDDEN, 2510 + Json(serde_json::json!({ 2511 + "error": "access_denied", 2512 + "error_description": "Security key counter anomaly detected. This may indicate a cloned key." 2513 + })), 2514 + ) 2515 + .into_response(); 2516 + } 2517 + Err(e) => { 2518 + tracing::warn!("Failed to update passkey counter: {:?}", e); 2519 + } 2520 + Ok(true) => {} 2480 2521 } 2481 2522 2482 2523 let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await;
+5
src/rate_limit.rs
··· 29 29 pub oauth_introspect: Arc<KeyedRateLimiter>, 30 30 pub app_password: Arc<KeyedRateLimiter>, 31 31 pub email_update: Arc<KeyedRateLimiter>, 32 + pub totp_verify: Arc<KeyedRateLimiter>, 32 33 } 33 34 34 35 impl Default for RateLimiters { ··· 73 74 email_update: Arc::new(RateLimiter::keyed(Quota::per_hour( 74 75 NonZeroU32::new(5).unwrap(), 75 76 ))), 77 + totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60)) 78 + .unwrap() 79 + .allow_burst(NonZeroU32::new(5).unwrap()), 80 + )), 76 81 } 77 82 } 78 83
+4
src/state.rs
··· 35 35 OAuthIntrospect, 36 36 AppPassword, 37 37 EmailUpdate, 38 + TotpVerify, 38 39 } 39 40 40 41 impl RateLimitKind { ··· 51 52 Self::OAuthIntrospect => "oauth_introspect", 52 53 Self::AppPassword => "app_password", 53 54 Self::EmailUpdate => "email_update", 55 + Self::TotpVerify => "totp_verify", 54 56 } 55 57 } 56 58 ··· 67 69 Self::OAuthIntrospect => (30, 60_000), 68 70 Self::AppPassword => (10, 60_000), 69 71 Self::EmailUpdate => (5, 3_600_000), 72 + Self::TotpVerify => (5, 300_000), 70 73 } 71 74 } 72 75 } ··· 142 145 RateLimitKind::OAuthIntrospect => &self.rate_limiters.oauth_introspect, 143 146 RateLimitKind::AppPassword => &self.rate_limiters.app_password, 144 147 RateLimitKind::EmailUpdate => &self.rate_limiters.email_update, 148 + RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 145 149 }; 146 150 147 151 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+68
src/validation/mod.rs
··· 409 409 Ok(()) 410 410 } 411 411 412 + #[derive(Debug)] 413 + pub struct PasswordValidationError { 414 + pub errors: Vec<String>, 415 + } 416 + 417 + impl std::fmt::Display for PasswordValidationError { 418 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 419 + write!(f, "{}", self.errors.join("; ")) 420 + } 421 + } 422 + 423 + impl std::error::Error for PasswordValidationError {} 424 + 425 + pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> { 426 + let mut errors = Vec::new(); 427 + 428 + if password.len() < 8 { 429 + errors.push("Password must be at least 8 characters".to_string()); 430 + } 431 + 432 + if password.len() > 256 { 433 + errors.push("Password must be at most 256 characters".to_string()); 434 + } 435 + 436 + if !password.chars().any(|c| c.is_ascii_lowercase()) { 437 + errors.push("Password must contain at least one lowercase letter".to_string()); 438 + } 439 + 440 + if !password.chars().any(|c| c.is_ascii_uppercase()) { 441 + errors.push("Password must contain at least one uppercase letter".to_string()); 442 + } 443 + 444 + if !password.chars().any(|c| c.is_ascii_digit()) { 445 + errors.push("Password must contain at least one number".to_string()); 446 + } 447 + 448 + if is_common_password(password) { 449 + errors.push("Password is too common, please choose a different one".to_string()); 450 + } 451 + 452 + if errors.is_empty() { 453 + Ok(()) 454 + } else { 455 + Err(PasswordValidationError { errors }) 456 + } 457 + } 458 + 459 + fn is_common_password(password: &str) -> bool { 460 + const COMMON_PASSWORDS: &[&str] = &[ 461 + "password", "Password1", "Password123", "Passw0rd", "Passw0rd!", 462 + "12345678", "123456789", "1234567890", 463 + "qwerty123", "Qwerty123", "qwertyui", "Qwertyui", 464 + "letmein1", "Letmein1", "welcome1", "Welcome1", 465 + "admin123", "Admin123", "password1", "Password1!", 466 + "iloveyou", "Iloveyou1", "monkey123", "Monkey123", 467 + "dragon12", "Dragon123", "master12", "Master123", 468 + "login123", "Login123", "abc12345", "Abc12345", 469 + "football", "Football1", "baseball", "Baseball1", 470 + "trustno1", "Trustno1", "sunshine", "Sunshine1", 471 + "princess", "Princess1", "computer", "Computer1", 472 + "whatever", "Whatever1", "nintendo", "Nintendo1", 473 + "bluesky1", "Bluesky1", "Bluesky123", 474 + ]; 475 + 476 + let lower = password.to_lowercase(); 477 + COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower) 478 + } 479 + 412 480 #[cfg(test)] 413 481 mod tests { 414 482 use super::*;
+1 -1
tests/admin_search.rs
··· 63 63 let create_payload = serde_json::json!({ 64 64 "handle": unique_handle, 65 65 "email": format!("unique-{}@searchtest.com", ts), 66 - "password": "test-password-123" 66 + "password": "Testpass123!" 67 67 }); 68 68 let create_res = client 69 69 .post(format!(
+9 -9
tests/change_password.rs
··· 11 11 let ts = chrono::Utc::now().timestamp_millis(); 12 12 let handle = format!("change-pw-{}.test", ts); 13 13 let email = format!("change-pw-{}@test.com", ts); 14 - let old_password = "old-password-123"; 15 - let new_password = "new-password-456"; 14 + let old_password = "Oldpass123!"; 15 + let new_password = "Newpass456!"; 16 16 let create_payload = json!({ 17 17 "handle": handle, 18 18 "email": email, ··· 92 92 )) 93 93 .bearer_auth(&jwt) 94 94 .json(&json!({ 95 - "currentPassword": "wrong-password", 96 - "newPassword": "new-password-123" 95 + "currentPassword": "Wrongpass999!", 96 + "newPassword": "Newpass123!" 97 97 })) 98 98 .send() 99 99 .await ··· 109 109 let ts = chrono::Utc::now().timestamp_millis(); 110 110 let handle = format!("change-pw-short-{}.test", ts); 111 111 let email = format!("change-pw-short-{}@test.com", ts); 112 - let password = "correct-password"; 112 + let password = "Correct123!"; 113 113 let create_payload = json!({ 114 114 "handle": handle, 115 115 "email": email, ··· 158 158 .bearer_auth(&jwt) 159 159 .json(&json!({ 160 160 "currentPassword": "", 161 - "newPassword": "new-password-123" 161 + "newPassword": "Newpass123!" 162 162 })) 163 163 .send() 164 164 .await ··· 177 177 )) 178 178 .bearer_auth(&jwt) 179 179 .json(&json!({ 180 - "currentPassword": "e2e-password-123", 180 + "currentPassword": "E2epass123!", 181 181 "newPassword": "" 182 182 })) 183 183 .send() ··· 195 195 base_url().await 196 196 )) 197 197 .json(&json!({ 198 - "currentPassword": "old", 199 - "newPassword": "new-password-123" 198 + "currentPassword": "Oldpass123!", 199 + "newPassword": "Newpass123!" 200 200 })) 201 201 .send() 202 202 .await
+1 -1
tests/common/mod.rs
··· 418 418 let payload = json!({ 419 419 "handle": handle, 420 420 "email": format!("{}@example.com", handle), 421 - "password": "password" 421 + "password": "Testpass123!" 422 422 }); 423 423 let res = match client 424 424 .post(format!(
+7 -7
tests/delete_account.rs
··· 49 49 let ts = Utc::now().timestamp_millis(); 50 50 let handle = format!("delete-test-{}.test", ts); 51 51 let email = format!("delete-test-{}@test.com", ts); 52 - let password = "delete-password-123"; 52 + let password = "Delete123pass!"; 53 53 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 54 54 let request_delete_res = client 55 55 .post(format!( ··· 106 106 let ts = Utc::now().timestamp_millis(); 107 107 let handle = format!("delete-wrongpw-{}.test", ts); 108 108 let email = format!("delete-wrongpw-{}@test.com", ts); 109 - let password = "correct-password"; 109 + let password = "Correct123!"; 110 110 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 111 111 let request_delete_res = client 112 112 .post(format!( ··· 153 153 let ts = Utc::now().timestamp_millis(); 154 154 let handle = format!("delete-badtoken-{}.test", ts); 155 155 let email = format!("delete-badtoken-{}@test.com", ts); 156 - let password = "delete-password"; 156 + let password = "Delete123!"; 157 157 let create_res = client 158 158 .post(format!( 159 159 "{}/xrpc/com.atproto.server.createAccount", ··· 196 196 let ts = Utc::now().timestamp_millis(); 197 197 let handle = format!("delete-expired-{}.test", ts); 198 198 let email = format!("delete-expired-{}@test.com", ts); 199 - let password = "delete-password"; 199 + let password = "Delete123!"; 200 200 let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await; 201 201 let request_delete_res = client 202 202 .post(format!( ··· 250 250 let ts = Utc::now().timestamp_millis(); 251 251 let handle1 = format!("delete-user1-{}.test", ts); 252 252 let email1 = format!("delete-user1-{}@test.com", ts); 253 - let password1 = "user1-password"; 253 + let password1 = "User1pass123!"; 254 254 let (did1, jwt1) = 255 255 create_verified_account(&client, &base_url, &handle1, &email1, password1).await; 256 256 let handle2 = format!("delete-user2-{}.test", ts); 257 257 let email2 = format!("delete-user2-{}@test.com", ts); 258 - let password2 = "user2-password"; 258 + let password2 = "User2pass123!"; 259 259 let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await; 260 260 let request_delete_res = client 261 261 .post(format!( ··· 302 302 let ts = Utc::now().timestamp_millis(); 303 303 let handle = format!("delete-apppw-{}.test", ts); 304 304 let email = format!("delete-apppw-{}@test.com", ts); 305 - let main_password = "main-password-123"; 305 + let main_password = "Mainpass123!"; 306 306 let (did, jwt) = 307 307 create_verified_account(&client, &base_url, &handle, &email, main_password).await; 308 308 let app_password_res = client
+6 -6
tests/did_web.rs
··· 12 12 let payload = json!({ 13 13 "handle": handle, 14 14 "email": format!("{}@example.com", handle), 15 - "password": "password", 15 + "password": "Testpass123!", 16 16 "didType": "web" 17 17 }); 18 18 let res = client ··· 139 139 let payload = json!({ 140 140 "handle": handle, 141 141 "email": format!("{}@example.com", handle), 142 - "password": "password", 142 + "password": "Testpass123!", 143 143 "didType": "web-external", 144 144 "did": did, 145 145 "signingKey": signing_key ··· 181 181 let payload = json!({ 182 182 "handle": handle, 183 183 "email": format!("{}@example.com", handle), 184 - "password": "password", 184 + "password": "Testpass123!", 185 185 "didType": "web" 186 186 }); 187 187 let res = client ··· 246 246 let payload = json!({ 247 247 "handle": handle, 248 248 "email": format!("{}@example.com", handle), 249 - "password": "password", 249 + "password": "Testpass123!", 250 250 "didType": "web" 251 251 }); 252 252 let res = client ··· 295 295 let payload = json!({ 296 296 "handle": handle, 297 297 "email": format!("{}@example.com", handle), 298 - "password": "password", 298 + "password": "Testpass123!", 299 299 "didType": "plc" 300 300 }); 301 301 let res = client ··· 324 324 let payload = json!({ 325 325 "handle": handle, 326 326 "email": format!("{}@example.com", handle), 327 - "password": "password", 327 + "password": "Testpass123!", 328 328 "didType": "web-external" 329 329 }); 330 330 let res = client
+1 -1
tests/email_update.rs
··· 26 26 .json(&json!({ 27 27 "handle": handle, 28 28 "email": email, 29 - "password": "password" 29 + "password": "Testpass123!" 30 30 })) 31 31 .send() 32 32 .await
+1 -1
tests/helpers/mod.rs
··· 10 10 let ts = Utc::now().timestamp_millis(); 11 11 let handle = format!("{}-{}.test", handle_prefix, ts); 12 12 let email = format!("{}-{}@test.com", handle_prefix, ts); 13 - let password = "e2e-password-123"; 13 + let password = "E2epass123!"; 14 14 let create_account_payload = json!({ 15 15 "handle": handle, 16 16 "email": email,
+4 -4
tests/identity.rs
··· 12 12 let payload = json!({ 13 13 "handle": short_handle, 14 14 "email": format!("{}@example.com", short_handle), 15 - "password": "password" 15 + "password": "Testpass123!" 16 16 }); 17 17 let res = client 18 18 .post(format!( ··· 142 142 let payload = json!({ 143 143 "handle": handle, 144 144 "email": format!("{}@example.com", handle), 145 - "password": "password", 145 + "password": "Testpass123!", 146 146 "did": did, 147 147 "signingKey": signing_key 148 148 }); ··· 188 188 let payload = json!({ 189 189 "handle": handle, 190 190 "email": email, 191 - "password": "password" 191 + "password": "Testpass123!" 192 192 }); 193 193 let res = client 194 194 .post(format!( ··· 266 266 let create_payload = json!({ 267 267 "handle": handle, 268 268 "email": email, 269 - "password": "password", 269 + "password": "Testpass123!", 270 270 "did": did, 271 271 "signingKey": signing_key 272 272 });
+1 -1
tests/jwt_security.rs
··· 675 675 676 676 let create_res = http_client 677 677 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 678 - .json(&json!({ "handle": handle, "email": email, "password": "test-password-123" })) 678 + .json(&json!({ "handle": handle, "email": email, "password": "Testpass123!" })) 679 679 .send() 680 680 .await 681 681 .unwrap();
+4 -4
tests/lifecycle_session.rs
··· 36 36 let ts = Utc::now().timestamp_millis(); 37 37 let handle = format!("multi-session-{}.test", ts); 38 38 let email = format!("multi-session-{}@test.com", ts); 39 - let password = "multi-session-pw"; 39 + let password = "Multisession123!"; 40 40 let create_payload = json!({ 41 41 "handle": handle, 42 42 "email": email, ··· 112 112 let ts = Utc::now().timestamp_millis(); 113 113 let handle = format!("refresh-inv-{}.test", ts); 114 114 let email = format!("refresh-inv-{}@test.com", ts); 115 - let password = "refresh-inv-pw"; 115 + let password = "Refresh123inv!"; 116 116 let create_payload = json!({ 117 117 "handle": handle, 118 118 "email": email, ··· 180 180 let ts = Utc::now().timestamp_millis(); 181 181 let handle = format!("apppass-{}.test", ts); 182 182 let email = format!("apppass-{}@test.com", ts); 183 - let password = "apppass-password"; 183 + let password = "Apppass123!"; 184 184 let create_res = client 185 185 .post(format!( 186 186 "{}/xrpc/com.atproto.server.createAccount", ··· 291 291 let ts = Utc::now().timestamp_millis(); 292 292 let handle = format!("deactivate-{}.test", ts); 293 293 let email = format!("deactivate-{}@test.com", ts); 294 - let password = "deactivate-password"; 294 + let password = "Deactivate123!"; 295 295 let create_res = client 296 296 .post(format!( 297 297 "{}/xrpc/com.atproto.server.createAccount",
+1 -1
tests/lifecycle_social.rs
··· 176 176 let ts = Utc::now().timestamp_millis(); 177 177 let handle = format!("fullcycle-{}.test", ts); 178 178 let email = format!("fullcycle-{}@test.com", ts); 179 - let password = "fullcycle-password"; 179 + let password = "Fullcycle123!"; 180 180 let create_account_res = client 181 181 .post(format!( 182 182 "{}/xrpc/com.atproto.server.createAccount",
+7 -7
tests/oauth.rs
··· 194 194 let ts = Utc::now().timestamp_millis(); 195 195 let handle = format!("oauth-test-{}", ts); 196 196 let email = format!("oauth-test-{}@example.com", ts); 197 - let password = "oauth-test-password"; 197 + let password = "Oauthtest123!"; 198 198 let create_res = http_client 199 199 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 200 200 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 354 354 let email = format!("wrong-creds-{}@example.com", ts); 355 355 http_client 356 356 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 357 - .json(&json!({ "handle": handle, "email": email, "password": "correct-password" })) 357 + .json(&json!({ "handle": handle, "email": email, "password": "Correct123!" })) 358 358 .send() 359 359 .await 360 360 .unwrap(); ··· 438 438 let ts = Utc::now().timestamp_millis(); 439 439 let handle = format!("2fa-test-{}", ts); 440 440 let email = format!("2fa-test-{}@example.com", ts); 441 - let password = "2fa-test-password"; 441 + let password = "Twofa123test!"; 442 442 let create_res = http_client 443 443 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 444 444 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 565 565 let ts = Utc::now().timestamp_millis(); 566 566 let handle = format!("2fa-lockout-{}", ts); 567 567 let email = format!("2fa-lockout-{}@example.com", ts); 568 - let password = "2fa-test-password"; 568 + let password = "Twofa123test!"; 569 569 let create_res = http_client 570 570 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 571 571 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 662 662 let ts = Utc::now().timestamp_millis(); 663 663 let handle = format!("selector-2fa-{}", ts); 664 664 let email = format!("selector-2fa-{}@example.com", ts); 665 - let password = "selector-2fa-password"; 665 + let password = "Selector2fa123!"; 666 666 let create_res = http_client 667 667 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 668 668 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 853 853 let ts = Utc::now().timestamp_millis(); 854 854 let handle = format!("state-special-{}", ts); 855 855 let email = format!("state-special-{}@example.com", ts); 856 - let password = "state-special-password"; 856 + let password = "State123special!"; 857 857 let create_res = http_client 858 858 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 859 859 .json(&json!({ "handle": handle, "email": email, "password": password })) ··· 932 932 let ts = Utc::now().timestamp_millis(); 933 933 let handle = format!("scope-test-{}", ts); 934 934 let email = format!("scope-test-{}@example.com", ts); 935 - let password = "scope-test-password"; 935 + let password = "Scopetest123!"; 936 936 let create_res = http_client 937 937 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 938 938 .json(&json!({ "handle": handle, "email": email, "password": password }))
+2 -2
tests/oauth_lifecycle.rs
··· 57 57 let ts = Utc::now().timestamp_millis(); 58 58 let handle = format!("{}-{}", handle_prefix, ts); 59 59 let email = format!("{}-{}@example.com", handle_prefix, ts); 60 - let password = format!("{}-password", handle_prefix); 60 + let password = format!("{}Pass123!", handle_prefix); 61 61 let create_res = http_client 62 62 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 63 63 .json(&json!({ ··· 577 577 let ts = Utc::now().timestamp_millis(); 578 578 let handle = format!("multi-client-{}", ts); 579 579 let email = format!("multi-client-{}@example.com", ts); 580 - let password = "multi-client-password"; 580 + let password = "MultiClient123!"; 581 581 let create_res = http_client 582 582 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 583 583 .json(&json!({
+4 -4
tests/oauth_scopes.rs
··· 61 61 let ts = Utc::now().timestamp_millis(); 62 62 let handle = format!("{}-{}", handle_prefix, ts); 63 63 let email = format!("{}-{}@example.com", handle_prefix, ts); 64 - let password = format!("{}-password", handle_prefix); 64 + let password = format!("{}Pass123!", handle_prefix); 65 65 66 66 let create_res = http_client 67 67 .post(format!("{}/xrpc/com.atproto.server.createAccount", url)) ··· 383 383 let ts = Utc::now().timestamp_millis(); 384 384 let handle = format!("consent-test-{}", ts); 385 385 let email = format!("consent-{}@example.com", ts); 386 - let password = "consent-password"; 386 + let password = "Consent123!"; 387 387 let redirect_uri = "https://consent-test.example.com/callback"; 388 388 389 389 let create_res = http_client ··· 479 479 let ts = Utc::now().timestamp_millis(); 480 480 let handle = format!("consent-post-{}", ts); 481 481 let email = format!("consent-post-{}@example.com", ts); 482 - let password = "consent-post-password"; 482 + let password = "ConsentPost123!"; 483 483 let redirect_uri = "https://consent-post.example.com/callback"; 484 484 485 485 let create_res = http_client ··· 593 593 let ts = Utc::now().timestamp_millis(); 594 594 let handle = format!("consent-req-{}", ts); 595 595 let email = format!("consent-req-{}@example.com", ts); 596 - let password = "consent-req-password"; 596 + let password = "ConsentReq123!"; 597 597 let redirect_uri = "https://consent-req.example.com/callback"; 598 598 599 599 let create_res = http_client
+10 -10
tests/oauth_security.rs
··· 44 44 let ts = Utc::now().timestamp_millis(); 45 45 let handle = format!("sec-test-{}", ts); 46 46 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 47 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" })) 47 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" })) 48 48 .send().await.unwrap(); 49 49 let account: Value = create_res.json().await.unwrap(); 50 50 let did = account["did"].as_str().unwrap(); ··· 72 72 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 73 73 .header("Content-Type", "application/json") 74 74 .header("Accept", "application/json") 75 - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "security-test-password", "remember_device": false})) 75 + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "remember_device": false})) 76 76 .send().await.unwrap(); 77 77 let auth_body: Value = auth_res.json().await.unwrap(); 78 78 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string(); ··· 258 258 let ts = Utc::now().timestamp_millis(); 259 259 let handle = format!("pkce-attack-{}", ts); 260 260 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 261 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" })) 261 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" })) 262 262 .send().await.unwrap(); 263 263 let account: Value = create_res.json().await.unwrap(); 264 264 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 283 283 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 284 284 .header("Content-Type", "application/json") 285 285 .header("Accept", "application/json") 286 - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "pkce-password", "remember_device": false})) 286 + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false})) 287 287 .send().await.unwrap(); 288 288 assert_eq!(auth_res.status(), StatusCode::OK); 289 289 let auth_body: Value = auth_res.json().await.unwrap(); ··· 329 329 let ts = Utc::now().timestamp_millis(); 330 330 let handle = format!("replay-{}", ts); 331 331 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 332 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" })) 332 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" })) 333 333 .send().await.unwrap(); 334 334 let account: Value = create_res.json().await.unwrap(); 335 335 verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 356 356 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 357 357 .header("Content-Type", "application/json") 358 358 .header("Accept", "application/json") 359 - .json(&json!({"request_uri": request_uri, "username": &handle, "password": "replay-password", "remember_device": false})) 359 + .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false})) 360 360 .send().await.unwrap(); 361 361 assert_eq!(auth_res.status(), StatusCode::OK); 362 362 let auth_body: Value = auth_res.json().await.unwrap(); ··· 495 495 let ts = Utc::now().timestamp_millis(); 496 496 let handle = format!("deact-{}", ts); 497 497 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 498 - .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "deact-password" })) 498 + .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" })) 499 499 .send().await.unwrap(); 500 500 let account: Value = create_res.json().await.unwrap(); 501 501 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await; ··· 524 524 let auth_res = http_client.post(format!("{}/oauth/authorize", url)) 525 525 .header("Content-Type", "application/json") 526 526 .header("Accept", "application/json") 527 - .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "deact-password", "remember_device": false})) 527 + .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false})) 528 528 .send().await.unwrap(); 529 529 assert_eq!( 530 530 auth_res.status(), ··· 539 539 let ts2 = Utc::now().timestamp_millis(); 540 540 let handle2 = format!("cross-{}", ts2); 541 541 let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url)) 542 - .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" })) 542 + .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" })) 543 543 .send().await.unwrap(); 544 544 let account2: Value = create_res2.json().await.unwrap(); 545 545 verify_new_account(&http_client, account2["did"].as_str().unwrap()).await; ··· 563 563 let auth_a = http_client.post(format!("{}/oauth/authorize", url)) 564 564 .header("Content-Type", "application/json") 565 565 .header("Accept", "application/json") 566 - .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "cross-password", "remember_device": false})) 566 + .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false})) 567 567 .send().await.unwrap(); 568 568 assert_eq!(auth_a.status(), StatusCode::OK); 569 569 let auth_body_a: Value = auth_a.json().await.unwrap();
+9 -9
tests/password_reset.rs
··· 24 24 let payload = json!({ 25 25 "handle": handle, 26 26 "email": email, 27 - "password": "oldpassword" 27 + "password": "Oldpass123!" 28 28 }); 29 29 let res = client 30 30 .post(format!( ··· 83 83 let pool = get_pool().await; 84 84 let handle = format!("pwreset2_{}", uuid::Uuid::new_v4()); 85 85 let email = format!("{}@example.com", handle); 86 - let old_password = "oldpassword"; 87 - let new_password = "newpassword123"; 86 + let old_password = "Oldpass123!"; 87 + let new_password = "Newpass456!"; 88 88 let payload = json!({ 89 89 "handle": handle, 90 90 "email": email, ··· 182 182 )) 183 183 .json(&json!({ 184 184 "token": "invalid-token", 185 - "password": "newpassword" 185 + "password": "Newpass123!" 186 186 })) 187 187 .send() 188 188 .await ··· 202 202 let payload = json!({ 203 203 "handle": handle, 204 204 "email": email, 205 - "password": "oldpassword" 205 + "password": "Oldpass123!" 206 206 }); 207 207 let res = client 208 208 .post(format!( ··· 246 246 )) 247 247 .json(&json!({ 248 248 "token": token, 249 - "password": "newpassword" 249 + "password": "Newpass123!" 250 250 })) 251 251 .send() 252 252 .await ··· 266 266 let payload = json!({ 267 267 "handle": handle, 268 268 "email": email, 269 - "password": "oldpassword" 269 + "password": "Oldpass123!" 270 270 }); 271 271 let res = client 272 272 .post(format!( ··· 313 313 )) 314 314 .json(&json!({ 315 315 "token": token, 316 - "password": "newpassword123" 316 + "password": "Newpass123!" 317 317 })) 318 318 .send() 319 319 .await ··· 356 356 let payload = json!({ 357 357 "handle": handle, 358 358 "email": email, 359 - "password": "oldpassword" 359 + "password": "Oldpass123!" 360 360 }); 361 361 let res = client 362 362 .post(format!(
+1 -1
tests/rate_limit.rs
··· 93 93 let payload = json!({ 94 94 "handle": format!("ratelimit_{}_{}", i, unique_id), 95 95 "email": format!("ratelimit_{}_{}@example.com", i, unique_id), 96 - "password": "testpassword123" 96 + "password": "Testpass123!" 97 97 }); 98 98 let res = client 99 99 .post(&url)
+4 -4
tests/server.rs
··· 27 27 let client = client(); 28 28 let base = base_url().await; 29 29 let handle = format!("user_{}", uuid::Uuid::new_v4()); 30 - let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "password" }); 30 + let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Testpass123!" }); 31 31 let create_res = client 32 32 .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 33 33 .json(&payload) ··· 40 40 let _ = verify_new_account(&client, did).await; 41 41 let login = client 42 42 .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 43 - .json(&json!({ "identifier": handle, "password": "password" })) 43 + .json(&json!({ "identifier": handle, "password": "Testpass123!" })) 44 44 .send() 45 45 .await 46 46 .unwrap(); ··· 61 61 assert_ne!(refresh_body["refreshJwt"].as_str().unwrap(), refresh_jwt); 62 62 let missing_id = client 63 63 .post(format!("{}/xrpc/com.atproto.server.createSession", base)) 64 - .json(&json!({ "password": "password" })) 64 + .json(&json!({ "password": "Testpass123!" })) 65 65 .send() 66 66 .await 67 67 .unwrap(); ··· 70 70 || missing_id.status() == StatusCode::UNPROCESSABLE_ENTITY 71 71 ); 72 72 let invalid_handle = client.post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 73 - .json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "password" })) 73 + .json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "Testpass123!" })) 74 74 .send().await.unwrap(); 75 75 assert_eq!(invalid_handle.status(), StatusCode::BAD_REQUEST); 76 76 let unauth_session = client
+2 -2
tests/session_management.rs
··· 47 47 let ts = chrono::Utc::now().timestamp_millis(); 48 48 let handle = format!("multi-list-{}.test", ts); 49 49 let email = format!("multi-list-{}@test.com", ts); 50 - let password = "test-password-123"; 50 + let password = "Testpass123!"; 51 51 let create_payload = json!({ 52 52 "handle": handle, 53 53 "email": email, ··· 122 122 let ts = chrono::Utc::now().timestamp_millis(); 123 123 let handle = format!("revoke-sess-{}.test", ts); 124 124 let email = format!("revoke-sess-{}@test.com", ts); 125 - let password = "test-password-123"; 125 + let password = "Testpass123!"; 126 126 let create_payload = json!({ 127 127 "handle": handle, 128 128 "email": email,
+5 -5
tests/signing_key.rs
··· 183 183 .json(&json!({ 184 184 "handle": handle, 185 185 "email": format!("{}@example.com", handle), 186 - "password": "password", 186 + "password": "Testpass123!", 187 187 "signingKey": signing_key 188 188 })) 189 189 .send() ··· 221 221 .json(&json!({ 222 222 "handle": handle, 223 223 "email": format!("{}@example.com", handle), 224 - "password": "password", 224 + "password": "Testpass123!", 225 225 "signingKey": "did:key:zNonExistentKey12345" 226 226 })) 227 227 .send() ··· 257 257 .json(&json!({ 258 258 "handle": handle1, 259 259 "email": format!("{}@example.com", handle1), 260 - "password": "password", 260 + "password": "Testpass123!", 261 261 "signingKey": signing_key 262 262 })) 263 263 .send() ··· 273 273 .json(&json!({ 274 274 "handle": handle2, 275 275 "email": format!("{}@example.com", handle2), 276 - "password": "password", 276 + "password": "Testpass123!", 277 277 "signingKey": signing_key 278 278 })) 279 279 .send() ··· 310 310 .json(&json!({ 311 311 "handle": handle, 312 312 "email": format!("{}@example.com", handle), 313 - "password": "password", 313 + "password": "Testpass123!", 314 314 "signingKey": signing_key 315 315 })) 316 316 .send()