PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.
fork

Configure Feed

Select the types of activity you want to include in your feed.

Security improvements

+1551 -379
+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()