+23
.sqlx/query-14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09.json
+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
+2
-1
.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
+2
-1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
+2
-1
.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
+20
.sqlx/query-301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e.json
+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
+2
-1
.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
+28
.sqlx/query-5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414.json
+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
+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
-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
-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
+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
-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
+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
+2
-1
.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
+106
.sqlx/query-fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38.json
+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
+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
+3
frontend/src/components/ReauthModal.svelte
+22
-1
frontend/src/lib/api.ts
+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
-6
frontend/src/routes/Register.svelte
-6
frontend/src/routes/RegisterPasskey.svelte
-6
frontend/src/routes/RegisterPasskey.svelte
+219
-3
frontend/src/routes/Security.svelte
+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 →
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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+1
src/comms/types.rs
+67
src/config.rs
+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
+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",
+5
src/rate_limit.rs
+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
+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
+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
+1
-1
tests/admin_search.rs
+9
-9
tests/change_password.rs
+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
+1
-1
tests/common/mod.rs
+7
-7
tests/delete_account.rs
+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
+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
+1
-1
tests/email_update.rs
+1
-1
tests/helpers/mod.rs
+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
+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
+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
+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",
+7
-7
tests/oauth.rs
+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
+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
+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
+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
+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
+1
-1
tests/rate_limit.rs
+4
-4
tests/server.rs
+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
+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
+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()