mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-18 23:36:06 +00:00
Security improvements
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "did",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "14693ba213bd4faff6aca2584a250a5bc1908b447b0dbba2b18de09a4e0c0e09"
|
||||
}
|
||||
+2
-1
@@ -39,7 +39,8 @@
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -47,7 +47,8 @@
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Timestamptz",
|
||||
"Bool",
|
||||
"Bool"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "301a8e352f7ebae1748ce1dc05860cef459764ca3c38b97693f00d67fd6bdd7e"
|
||||
}
|
||||
+2
-1
@@ -39,7 +39,8 @@
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n u.allow_legacy_login,\n (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR\n EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as \"has_mfa!\"\n FROM users u WHERE u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "allow_legacy_login",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "has_mfa!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5abffd8a7ba3598f986988a6f198be7b4582b70dd240f456e0c216eb953e4414"
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6159ce4146afcb2269ba1476c6bc8e3383f9f0f37a5a63470cc86bcd95d1cbb8"
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "one",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034"
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "legacy_login",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "mfa_verified",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "last_reauth_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "8402686d40c49404799cfaa834b3a86790d811632624c00de1e9b599d7b0a7fd"
|
||||
}
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
|
||||
"query": "UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -11,5 +11,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979"
|
||||
"hash": "e2b91cc27d1116fa1e30042514df0470aba3425fd55f32052a17ed00719f533f"
|
||||
}
|
||||
+2
-1
@@ -42,7 +42,8 @@
|
||||
"plc_operation",
|
||||
"two_factor_code",
|
||||
"channel_verification",
|
||||
"passkey_recovery"
|
||||
"passkey_recovery",
|
||||
"legacy_login_alert"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+34
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n k.key_bytes, k.encryption_version\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
|
||||
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -45,13 +45,40 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "allow_legacy_login",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "preferred_comms_channel: crate::comms::CommsChannel",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "comms_channel",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"email",
|
||||
"discord",
|
||||
"telegram",
|
||||
"signal"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "key_bytes",
|
||||
"type_info": "Bytea"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"ordinal": 11,
|
||||
"name": "encryption_version",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 12,
|
||||
"name": "totp_enabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -69,8 +96,11 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590"
|
||||
"hash": "fe8f204d593dce319bb4624871a3a597ba1d3d9ea32855704b18948fd6bbae38"
|
||||
}
|
||||
@@ -9,21 +9,6 @@ So like... make the thing unique, make it cool.
|
||||
- [ ] Unique "brand" style both unauthed and authed
|
||||
- [ ] Better documentation on how to sub out the entire frontend for whatever the users want
|
||||
|
||||
### Passkeys and 2FA
|
||||
Modern passwordless authentication using WebAuthn/FIDO2, plus TOTP for defense in depth.
|
||||
|
||||
- [x] passkeys table (id, did, credential_id, public_key, sign_count, created_at, last_used, friendly_name)
|
||||
- [x] user_totp table (did, secret_encrypted, verified, created_at, last_used)
|
||||
- [x] WebAuthn registration challenge generation and attestation verification
|
||||
- [x] TOTP secret generation with QR code setup flow
|
||||
- [x] Backup codes (hashed, one-time use) with recovery flow
|
||||
- [x] OAuth authorize flow: password -> 2FA (if enabled) -> passkey (as alternative)
|
||||
- [ ] Passkey-only account creation (no password)
|
||||
- [x] Settings UI for managing passkeys, TOTP, backup codes
|
||||
- [ ] Trusted devices option (remember this browser)
|
||||
- [x] Rate limit 2FA attempts
|
||||
- [ ] Re-auth for sensitive actions (email change, adding new auth methods)
|
||||
|
||||
### Delegated accounts
|
||||
Accounts controlled by other accounts rather than having their own password. When logging in as a delegated account, OAuth asks you to authenticate with a linked controller account. Uses OAuth scopes as the permission model.
|
||||
|
||||
@@ -103,3 +88,5 @@ Infrastructure: Sequencer with cursor replay, postgres repo storage with atomic
|
||||
Web UI: OAuth login, registration, email verification, password reset, multi-account selector, dashboard, sessions, app passwords, invites, notification preferences, repo browser, CAR export, admin panel, OAuth consent screen with scope selection.
|
||||
|
||||
Auth: ES256K + HS256 dual support, JTI-only token storage, refresh token family tracking, encrypted signing keys (AES-256-GCM), DPoP replay protection, constant-time comparisons.
|
||||
|
||||
Passkeys and 2FA: WebAuthn/FIDO2 passkey registration and authentication, TOTP with QR setup, backup codes (hashed, one-time use), passkey-only account creation, trusted devices (remember this browser), re-auth for sensitive actions, rate-limited 2FA attempts, settings UI for managing all auth methods.
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
activeMethod = 'totp'
|
||||
} else if (availableMethods.includes('passkey')) {
|
||||
activeMethod = 'passkey'
|
||||
if (availableMethods.length === 1) {
|
||||
handlePasskeyAuth()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+22
-1
@@ -37,7 +37,7 @@ async function xrpc<T>(method: string, options?: {
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
|
||||
throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods)
|
||||
throw new ApiError(res.status, err.error, err.message, err.did, err.reauthMethods)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
@@ -331,9 +331,23 @@ export const api = {
|
||||
return xrpc('com.tranquil.account.getPasswordStatus', { token })
|
||||
},
|
||||
|
||||
async getLegacyLoginPreference(token: string): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
|
||||
return xrpc('com.tranquil.account.getLegacyLoginPreference', { token })
|
||||
},
|
||||
|
||||
async updateLegacyLoginPreference(token: string, allowLegacyLogin: boolean): Promise<{ allowLegacyLogin: boolean }> {
|
||||
return xrpc('com.tranquil.account.updateLegacyLoginPreference', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: { allowLegacyLogin },
|
||||
})
|
||||
},
|
||||
|
||||
async listSessions(token: string): Promise<{
|
||||
sessions: Array<{
|
||||
id: string
|
||||
sessionType: string
|
||||
clientName: string | null
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
isCurrent: boolean
|
||||
@@ -350,6 +364,13 @@ export const api = {
|
||||
})
|
||||
},
|
||||
|
||||
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
|
||||
return xrpc('com.tranquil.account.revokeAllSessions', {
|
||||
method: 'POST',
|
||||
token,
|
||||
})
|
||||
},
|
||||
|
||||
async searchAccounts(token: string, options?: {
|
||||
handle?: string
|
||||
cursor?: string
|
||||
|
||||
@@ -27,12 +27,6 @@
|
||||
|
||||
const auth = getAuthState()
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
serverInfoLoaded = true
|
||||
|
||||
@@ -30,12 +30,6 @@
|
||||
let resendingCode = $state(false)
|
||||
let resendMessage = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
if (auth.session) {
|
||||
navigate('/dashboard')
|
||||
}
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
if (!serverInfoLoaded) {
|
||||
serverInfoLoaded = true
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
let showRemovePasswordForm = $state(false)
|
||||
let removePasswordLoading = $state(false)
|
||||
|
||||
let allowLegacyLogin = $state(true)
|
||||
let hasMfa = $state(false)
|
||||
let legacyLoginLoading = $state(true)
|
||||
let legacyLoginUpdating = $state(false)
|
||||
|
||||
let showReauthModal = $state(false)
|
||||
let reauthMethods = $state<string[]>(['password'])
|
||||
let pendingAction = $state<(() => Promise<void>) | null>(null)
|
||||
@@ -59,6 +64,7 @@
|
||||
loadTotpStatus()
|
||||
loadPasskeys()
|
||||
loadPasswordStatus()
|
||||
loadLegacyLoginPreference()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -75,6 +81,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLegacyLoginPreference() {
|
||||
if (!auth.session) return
|
||||
legacyLoginLoading = true
|
||||
try {
|
||||
const pref = await api.getLegacyLoginPreference(auth.session.accessJwt)
|
||||
allowLegacyLogin = pref.allowLegacyLogin
|
||||
hasMfa = pref.hasMfa
|
||||
} catch {
|
||||
allowLegacyLogin = true
|
||||
hasMfa = false
|
||||
} finally {
|
||||
legacyLoginLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleLegacyLogin() {
|
||||
if (!auth.session) return
|
||||
legacyLoginUpdating = true
|
||||
try {
|
||||
const result = await api.updateLegacyLoginPreference(auth.session.accessJwt, !allowLegacyLogin)
|
||||
allowLegacyLogin = result.allowLegacyLogin
|
||||
showMessage('success', allowLegacyLogin
|
||||
? 'Legacy app login enabled'
|
||||
: 'Legacy app login disabled - only OAuth apps can sign in')
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) {
|
||||
if (e.error === 'ReauthRequired' || e.error === 'MfaVerificationRequired') {
|
||||
reauthMethods = e.reauthMethods || ['password']
|
||||
pendingAction = handleToggleLegacyLogin
|
||||
showReauthModal = true
|
||||
} else {
|
||||
showMessage('error', e.message)
|
||||
}
|
||||
} else {
|
||||
showMessage('error', 'Failed to update preference')
|
||||
}
|
||||
} finally {
|
||||
legacyLoginUpdating = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemovePassword() {
|
||||
if (!auth.session) return
|
||||
removePasswordLoading = true
|
||||
@@ -572,9 +619,11 @@
|
||||
<button type="button" class="small secondary" onclick={() => startEditPasskey(passkey)}>
|
||||
Rename
|
||||
</button>
|
||||
<button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
|
||||
Delete
|
||||
</button>
|
||||
{#if hasPassword || passkeys.length > 1}
|
||||
<button type="button" class="small danger-outline" onclick={() => handleDeletePasskey(passkey.id)}>
|
||||
Delete
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -670,6 +719,63 @@
|
||||
Manage Trusted Devices →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
{#if hasMfa}
|
||||
<section>
|
||||
<h2>App Compatibility</h2>
|
||||
<p class="description">
|
||||
Control whether apps that don't support modern authentication (like the official Bluesky app) can sign in to your account.
|
||||
</p>
|
||||
|
||||
{#if legacyLoginLoading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else}
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-info">
|
||||
<span class="toggle-label">Allow legacy app login</span>
|
||||
<span class="toggle-description">
|
||||
{#if allowLegacyLogin}
|
||||
Legacy apps can sign in with just your password, but sensitive actions (like changing your password) will require MFA verification.
|
||||
{:else}
|
||||
Only OAuth-compatible apps can sign in. Legacy apps will be blocked.
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-button {allowLegacyLogin ? 'on' : 'off'}"
|
||||
onclick={handleToggleLegacyLogin}
|
||||
disabled={legacyLoginUpdating}
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if totpEnabled}
|
||||
<div class="warning-box">
|
||||
<strong>Important: Password changes in Bluesky app will fail</strong>
|
||||
<p>
|
||||
With TOTP enabled, changing your password from the Bluesky app (or other legacy apps) will be blocked.
|
||||
To change your password, you have two options:
|
||||
</p>
|
||||
<ol>
|
||||
<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>
|
||||
<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>
|
||||
</ol>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info-box-inline">
|
||||
<strong>What are legacy apps?</strong>
|
||||
<p>
|
||||
Some apps (like the official Bluesky app) use older authentication that only requires your password.
|
||||
When you have MFA enabled, these apps bypass your second factor.
|
||||
Disabling legacy login forces all apps to use OAuth, which properly enforces MFA.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1076,4 +1182,114 @@
|
||||
.info-box-inline li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-box-inline p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggle-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-color-light);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toggle-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.toggle-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.toggle-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
position: relative;
|
||||
width: 50px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-button.on {
|
||||
background: var(--success-text);
|
||||
}
|
||||
|
||||
.toggle-button.off {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.toggle-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border-radius: 50%;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
|
||||
.toggle-button.on .toggle-slider {
|
||||
left: 27px;
|
||||
}
|
||||
|
||||
.toggle-button.off .toggle-slider {
|
||||
left: 3px;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border, var(--border-color));
|
||||
border-left: 4px solid var(--warning-text);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.warning-box strong {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--warning-text);
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.warning-box ol {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.warning-box li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.warning-box a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
let error = $state<string | null>(null)
|
||||
let sessions = $state<Array<{
|
||||
id: string
|
||||
sessionType: string
|
||||
clientName: string | null
|
||||
createdAt: string
|
||||
expiresAt: string
|
||||
isCurrent: boolean
|
||||
@@ -51,6 +53,21 @@
|
||||
error = e instanceof ApiError ? e.message : 'Failed to revoke session'
|
||||
}
|
||||
}
|
||||
async function revokeAllSessions() {
|
||||
if (!auth.session) return
|
||||
const otherCount = sessions.filter(s => !s.isCurrent).length
|
||||
if (otherCount === 0) {
|
||||
error = 'No other sessions to revoke'
|
||||
return
|
||||
}
|
||||
if (!confirm(`This will revoke ${otherCount} other session${otherCount > 1 ? 's' : ''}. Continue?`)) return
|
||||
try {
|
||||
await api.revokeAllSessions(auth.session.accessJwt)
|
||||
sessions = sessions.filter(s => s.isCurrent)
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : 'Failed to revoke sessions'
|
||||
}
|
||||
}
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
@@ -87,9 +104,13 @@
|
||||
<div class="session-info">
|
||||
<div class="session-header">
|
||||
{#if session.isCurrent}
|
||||
<span class="badge current">Current Session</span>
|
||||
{:else}
|
||||
<span class="session-label">Session</span>
|
||||
<span class="badge current">Current</span>
|
||||
{/if}
|
||||
<span class="badge type" class:oauth={session.sessionType === 'oauth'}>
|
||||
{session.sessionType === 'oauth' ? 'OAuth' : 'Session'}
|
||||
</span>
|
||||
{#if session.clientName}
|
||||
<span class="client-name">{session.clientName}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="session-details">
|
||||
@@ -115,7 +136,12 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="refresh-btn" onclick={loadSessions}>Refresh</button>
|
||||
<div class="actions-bar">
|
||||
<button class="refresh-btn" onclick={loadSessions}>Refresh</button>
|
||||
{#if sessions.filter(s => !s.isCurrent).length > 0}
|
||||
<button class="revoke-all-btn" onclick={revokeAllSessions}>Revoke All Other Sessions</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -174,10 +200,14 @@
|
||||
}
|
||||
.session-header {
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.session-label {
|
||||
.client-name {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
@@ -190,6 +220,16 @@
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
.badge.type {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
.badge.type.oauth {
|
||||
background: #e6f4ea;
|
||||
color: #1e7e34;
|
||||
border-color: #b8d9c5;
|
||||
}
|
||||
.session-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -224,8 +264,13 @@
|
||||
.revoke-btn.danger:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
.refresh-btn {
|
||||
.actions-bar {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.refresh-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -237,4 +282,15 @@
|
||||
background: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.revoke-all-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--error-text);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--error-text);
|
||||
}
|
||||
.revoke-all-btn:hover {
|
||||
background: var(--error-bg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TABLE users ADD COLUMN allow_legacy_login BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
ALTER TABLE session_tokens ADD COLUMN mfa_verified BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE session_tokens ADD COLUMN legacy_login BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX idx_session_tokens_legacy ON session_tokens(did, legacy_login) WHERE legacy_login = TRUE;
|
||||
|
||||
ALTER TYPE comms_type ADD VALUE IF NOT EXISTS 'legacy_login_alert';
|
||||
+35
-32
@@ -4,10 +4,11 @@ use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ErrorBody {
|
||||
error: &'static str,
|
||||
struct ErrorBody<'a> {
|
||||
error: Cow<'a, str>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
message: Option<String>,
|
||||
}
|
||||
@@ -90,41 +91,43 @@ impl ApiError {
|
||||
| Self::InvalidSwap => StatusCode::BAD_REQUEST,
|
||||
}
|
||||
}
|
||||
fn error_name(&self) -> &'static str {
|
||||
fn error_name(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
Self::InternalError | Self::DatabaseError => "InternalError",
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => "UpstreamFailure",
|
||||
Self::UpstreamTimeout => "UpstreamTimeout",
|
||||
Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"),
|
||||
Self::UpstreamFailure | Self::UpstreamUnavailable(_) => Cow::Borrowed("UpstreamFailure"),
|
||||
Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"),
|
||||
Self::UpstreamError { error, .. } => {
|
||||
if let Some(e) = error {
|
||||
return Box::leak(e.clone().into_boxed_str());
|
||||
return Cow::Owned(e.clone());
|
||||
}
|
||||
"UpstreamError"
|
||||
Cow::Borrowed("UpstreamError")
|
||||
}
|
||||
Self::AuthenticationRequired => "AuthenticationRequired",
|
||||
Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => "AuthenticationFailed",
|
||||
Self::InvalidToken => "InvalidToken",
|
||||
Self::ExpiredToken | Self::ExpiredTokenMsg(_) => "ExpiredToken",
|
||||
Self::TokenRequired => "TokenRequired",
|
||||
Self::AccountDeactivated => "AccountDeactivated",
|
||||
Self::AccountTakedown => "AccountTakedown",
|
||||
Self::Forbidden => "Forbidden",
|
||||
Self::InvitesDisabled => "InvitesDisabled",
|
||||
Self::AccountNotFound => "AccountNotFound",
|
||||
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => "RepoNotFound",
|
||||
Self::RecordNotFound => "RecordNotFound",
|
||||
Self::BlobNotFound => "BlobNotFound",
|
||||
Self::AppPasswordNotFound => "AppPasswordNotFound",
|
||||
Self::InvalidRequest(_) => "InvalidRequest",
|
||||
Self::InvalidHandle => "InvalidHandle",
|
||||
Self::HandleNotAvailable => "HandleNotAvailable",
|
||||
Self::HandleTaken => "HandleTaken",
|
||||
Self::InvalidEmail => "InvalidEmail",
|
||||
Self::EmailTaken => "EmailTaken",
|
||||
Self::InvalidInviteCode => "InvalidInviteCode",
|
||||
Self::DuplicateCreate => "DuplicateCreate",
|
||||
Self::DuplicateAppPassword => "DuplicateAppPassword",
|
||||
Self::InvalidSwap => "InvalidSwap",
|
||||
Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"),
|
||||
Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => {
|
||||
Cow::Borrowed("AuthenticationFailed")
|
||||
}
|
||||
Self::InvalidToken => Cow::Borrowed("InvalidToken"),
|
||||
Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"),
|
||||
Self::TokenRequired => Cow::Borrowed("TokenRequired"),
|
||||
Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"),
|
||||
Self::AccountTakedown => Cow::Borrowed("AccountTakedown"),
|
||||
Self::Forbidden => Cow::Borrowed("Forbidden"),
|
||||
Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"),
|
||||
Self::AccountNotFound => Cow::Borrowed("AccountNotFound"),
|
||||
Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"),
|
||||
Self::RecordNotFound => Cow::Borrowed("RecordNotFound"),
|
||||
Self::BlobNotFound => Cow::Borrowed("BlobNotFound"),
|
||||
Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"),
|
||||
Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"),
|
||||
Self::InvalidHandle => Cow::Borrowed("InvalidHandle"),
|
||||
Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"),
|
||||
Self::HandleTaken => Cow::Borrowed("HandleTaken"),
|
||||
Self::InvalidEmail => Cow::Borrowed("InvalidEmail"),
|
||||
Self::EmailTaken => Cow::Borrowed("EmailTaken"),
|
||||
Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"),
|
||||
Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"),
|
||||
Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"),
|
||||
Self::InvalidSwap => Cow::Borrowed("InvalidSwap"),
|
||||
}
|
||||
}
|
||||
fn message(&self) -> Option<String> {
|
||||
|
||||
+25
-12
@@ -2,6 +2,7 @@ use super::did::verify_did_web;
|
||||
use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
|
||||
use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key};
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -124,19 +125,20 @@ pub async fn create_account(
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_migration {
|
||||
let migration_did = input.did.as_ref().unwrap();
|
||||
let auth_did = migration_auth.as_ref().unwrap();
|
||||
if migration_did != auth_did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
if let (Some(migration_did), Some(auth_did)) = (input.did.as_ref(), migration_auth.as_ref())
|
||||
{
|
||||
if migration_did != auth_did {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "AuthorizationError",
|
||||
"message": format!("Service token issuer {} does not match DID {}", auth_did, migration_did)
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
info!(did = %migration_did, "Processing account migration");
|
||||
}
|
||||
info!(did = %migration_did, "Processing account migration");
|
||||
}
|
||||
|
||||
let hostname_for_validation =
|
||||
@@ -670,6 +672,17 @@ pub async fn create_account(
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = validate_password(&input.password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let password_hash = match hash(&input.password, DEFAULT_COST) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
|
||||
@@ -304,7 +304,7 @@ pub async fn request_account_delete(
|
||||
"https://{}/xrpc/com.atproto.server.requestAccountDelete",
|
||||
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
|
||||
);
|
||||
let did = match crate::auth::validate_token_with_dpop(
|
||||
let validated = match crate::auth::validate_token_with_dpop(
|
||||
&state.db,
|
||||
&extracted.token,
|
||||
extracted.is_dpop,
|
||||
@@ -315,9 +315,15 @@ pub async fn request_account_delete(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(user) => user.did,
|
||||
Ok(user) => user,
|
||||
Err(e) => return ApiError::from(e).into_response(),
|
||||
};
|
||||
let did = validated.did.clone();
|
||||
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await;
|
||||
}
|
||||
|
||||
let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did)
|
||||
.fetch_optional(&state.db)
|
||||
.await
|
||||
|
||||
@@ -33,13 +33,15 @@ pub use password::{
|
||||
change_password, get_password_status, remove_password, request_password_reset, reset_password,
|
||||
};
|
||||
pub use reauth::{
|
||||
check_reauth_required, get_reauth_status, reauth_passkey_finish, reauth_passkey_start,
|
||||
reauth_password, reauth_required_response, reauth_totp,
|
||||
check_legacy_session_mfa, check_reauth_required, get_reauth_status, legacy_mfa_required_response,
|
||||
reauth_passkey_finish, reauth_passkey_start, reauth_password, reauth_required_response,
|
||||
reauth_totp, update_mfa_verified,
|
||||
};
|
||||
pub use service_auth::get_service_auth;
|
||||
pub use session::{
|
||||
confirm_signup, create_session, delete_session, get_session, list_sessions, refresh_session,
|
||||
resend_verification, revoke_session,
|
||||
confirm_signup, create_session, delete_session, get_legacy_login_preference, get_session,
|
||||
list_sessions, refresh_session, resend_verification, revoke_all_sessions, revoke_session,
|
||||
update_legacy_login_preference,
|
||||
};
|
||||
pub use signing_key::reserve_signing_key;
|
||||
pub use totp::{
|
||||
|
||||
@@ -16,6 +16,7 @@ use tracing::{error, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::validation::validate_password;
|
||||
|
||||
fn extract_client_ip(headers: &HeaderMap) -> String {
|
||||
if let Some(forwarded) = headers.get("x-forwarded-for")
|
||||
@@ -1108,10 +1109,13 @@ pub async fn recover_passkey_account(
|
||||
State(state): State<AppState>,
|
||||
Json(input): Json<RecoverPasskeyAccountInput>,
|
||||
) -> Response {
|
||||
if input.new_password.len() < 8 {
|
||||
if let Err(e) = validate_password(&input.new_password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "WeakPassword", "message": "Password must be at least 8 characters"})),
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
@@ -294,6 +294,15 @@ pub async fn delete_passkey(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<DeletePasskeyInput>,
|
||||
) -> Response {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
|
||||
.await;
|
||||
}
|
||||
|
||||
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
|
||||
}
|
||||
|
||||
let id: uuid::Uuid = match input.id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use crate::validation::validate_password;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -164,6 +165,16 @@ pub async fn reset_password(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = validate_password(password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let user = sqlx::query!(
|
||||
"SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1",
|
||||
token
|
||||
@@ -326,6 +337,11 @@ pub async fn change_password(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<ChangePasswordInput>,
|
||||
) -> Response {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
|
||||
.await;
|
||||
}
|
||||
|
||||
let current_password = &input.current_password;
|
||||
let new_password = &input.new_password;
|
||||
if current_password.is_empty() {
|
||||
@@ -342,10 +358,13 @@ pub async fn change_password(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if new_password.len() < 8 {
|
||||
if let Err(e) = validate_password(new_password) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Password must be at least 8 characters"})),
|
||||
Json(json!({
|
||||
"error": "InvalidPassword",
|
||||
"message": e.to_string()
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
@@ -447,6 +466,11 @@ pub async fn get_password_status(State(state): State<AppState>, auth: BearerAuth
|
||||
}
|
||||
|
||||
pub async fn remove_password(State(state): State<AppState>, auth: BearerAuth) -> Response {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
|
||||
.await;
|
||||
}
|
||||
|
||||
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
|
||||
}
|
||||
|
||||
+85
-18
@@ -11,7 +11,7 @@ use sqlx::PgPool;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use crate::auth::BearerAuth;
|
||||
use crate::state::AppState;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
|
||||
const REAUTH_WINDOW_SECONDS: i64 = 300;
|
||||
|
||||
@@ -155,6 +155,21 @@ pub async fn reauth_totp(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<TotpReauthInput>,
|
||||
) -> Response {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let valid =
|
||||
crate::api::server::totp::verify_totp_or_backup_for_user(&state, &auth.0.did, &input.code)
|
||||
.await;
|
||||
@@ -352,14 +367,29 @@ pub async fn reauth_passkey_finish(
|
||||
};
|
||||
|
||||
let cred_id_bytes = auth_result.cred_id().as_ref();
|
||||
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
|
||||
match crate::auth::webauthn::update_passkey_counter(
|
||||
&state.db,
|
||||
cred_id_bytes,
|
||||
auth_result.counter(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to update passkey counter: {:?}", e);
|
||||
Ok(false) => {
|
||||
warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key");
|
||||
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
|
||||
return (
|
||||
StatusCode::UNAUTHORIZED,
|
||||
Json(json!({
|
||||
"error": "PasskeyCounterAnomaly",
|
||||
"message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to update passkey counter: {:?}", e);
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await;
|
||||
@@ -383,7 +413,7 @@ pub async fn reauth_passkey_finish(
|
||||
async fn update_last_reauth(db: &PgPool, did: &str) -> Result<DateTime<Utc>, sqlx::Error> {
|
||||
let now = Utc::now();
|
||||
sqlx::query!(
|
||||
"UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
|
||||
"UPDATE session_tokens SET last_reauth_at = $1, mfa_verified = TRUE WHERE did = $2",
|
||||
now,
|
||||
did
|
||||
)
|
||||
@@ -419,20 +449,6 @@ async fn get_available_reauth_methods(db: &PgPool, did: &str) -> Vec<String> {
|
||||
methods.push("password".to_string());
|
||||
}
|
||||
|
||||
let has_app_password = sqlx::query_scalar!(
|
||||
"SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some();
|
||||
|
||||
if has_app_password && !methods.contains(&"password".to_string()) {
|
||||
methods.push("password".to_string());
|
||||
}
|
||||
|
||||
let has_totp = crate::api::server::totp::has_totp_enabled_db(db, did).await;
|
||||
if has_totp {
|
||||
methods.push("totp".to_string());
|
||||
@@ -480,3 +496,54 @@ pub async fn reauth_required_response(db: &PgPool, did: &str) -> Response {
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn check_legacy_session_mfa(db: &PgPool, did: &str) -> bool {
|
||||
let session = sqlx::query!(
|
||||
"SELECT legacy_login, mfa_verified, last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
did
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await;
|
||||
|
||||
match session {
|
||||
Ok(Some(row)) => {
|
||||
if !row.legacy_login {
|
||||
return true;
|
||||
}
|
||||
if row.mfa_verified {
|
||||
return true;
|
||||
}
|
||||
if let Some(last_reauth) = row.last_reauth_at {
|
||||
let elapsed = chrono::Utc::now().signed_duration_since(last_reauth);
|
||||
if elapsed.num_seconds() <= REAUTH_WINDOW_SECONDS {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_mfa_verified(db: &PgPool, did: &str) -> Result<(), sqlx::Error> {
|
||||
sqlx::query!(
|
||||
"UPDATE session_tokens SET mfa_verified = TRUE, last_reauth_at = NOW() WHERE did = $1",
|
||||
did
|
||||
)
|
||||
.execute(db)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response {
|
||||
let methods = get_available_reauth_methods(db, did).await;
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "MfaVerificationRequired",
|
||||
"message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.",
|
||||
"reauthMethods": methods
|
||||
})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
+389
-59
@@ -92,7 +92,10 @@ pub async fn create_session(
|
||||
r#"SELECT
|
||||
u.id, u.did, u.handle, u.password_hash,
|
||||
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
|
||||
k.key_bytes, k.encryption_version
|
||||
u.allow_legacy_login,
|
||||
u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel",
|
||||
k.key_bytes, k.encryption_version,
|
||||
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
|
||||
FROM users u
|
||||
JOIN user_keys k ON u.id = k.user_id
|
||||
WHERE u.handle = $1 OR u.email = $1 OR u.did = $1"#,
|
||||
@@ -161,6 +164,23 @@ pub async fn create_session(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let has_totp = row.totp_enabled.unwrap_or(false);
|
||||
let is_legacy_login = has_totp;
|
||||
if has_totp && !row.allow_legacy_login {
|
||||
warn!(
|
||||
"Legacy login blocked for TOTP-enabled account: {}",
|
||||
row.did
|
||||
);
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(json!({
|
||||
"error": "MfaRequired",
|
||||
"message": "This account requires MFA. Please use an OAuth client that supports TOTP verification.",
|
||||
"did": row.did
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
@@ -176,12 +196,14 @@ pub async fn create_session(
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
row.did,
|
||||
access_meta.jti,
|
||||
refresh_meta.jti,
|
||||
access_meta.expires_at,
|
||||
refresh_meta.expires_at
|
||||
refresh_meta.expires_at,
|
||||
is_legacy_login,
|
||||
false
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@@ -189,6 +211,25 @@ pub async fn create_session(
|
||||
error!("Failed to insert session: {:?}", e);
|
||||
return ApiError::InternalError.into_response();
|
||||
}
|
||||
if is_legacy_login {
|
||||
warn!(
|
||||
did = %row.did,
|
||||
ip = %client_ip,
|
||||
"Legacy login on TOTP-enabled account - sending notification"
|
||||
);
|
||||
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
|
||||
if let Err(e) = crate::comms::queue_legacy_login_notification(
|
||||
&state.db,
|
||||
row.id,
|
||||
&hostname,
|
||||
&client_ip,
|
||||
row.preferred_comms_channel,
|
||||
)
|
||||
.await
|
||||
{
|
||||
error!("Failed to queue legacy login notification: {:?}", e);
|
||||
}
|
||||
}
|
||||
let handle = full_handle(&row.handle, &pds_hostname);
|
||||
Json(CreateSessionOutput {
|
||||
access_jwt: access_meta.token,
|
||||
@@ -617,12 +658,14 @@ pub async fn confirm_signup(
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query!(
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at) VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified) VALUES ($1, $2, $3, $4, $5, $6, $7)",
|
||||
row.did,
|
||||
access_meta.jti,
|
||||
refresh_meta.jti,
|
||||
access_meta.expires_at,
|
||||
refresh_meta.expires_at
|
||||
refresh_meta.expires_at,
|
||||
false,
|
||||
false
|
||||
)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
@@ -746,6 +789,8 @@ pub async fn resend_verification(
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub session_type: String,
|
||||
pub client_name: Option<String>,
|
||||
pub created_at: String,
|
||||
pub expires_at: String,
|
||||
pub is_current: bool,
|
||||
@@ -767,7 +812,10 @@ pub async fn list_sessions(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
|
||||
let result = sqlx::query_as::<
|
||||
|
||||
let mut sessions: Vec<SessionInfo> = Vec::new();
|
||||
|
||||
let jwt_result = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
i32,
|
||||
@@ -786,28 +834,90 @@ pub async fn list_sessions(
|
||||
.bind(&auth.0.did)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
|
||||
match jwt_result {
|
||||
Ok(rows) => {
|
||||
let sessions: Vec<SessionInfo> = rows
|
||||
.into_iter()
|
||||
.map(|(id, access_jti, created_at, expires_at)| SessionInfo {
|
||||
id: id.to_string(),
|
||||
for (id, access_jti, created_at, expires_at) in rows {
|
||||
sessions.push(SessionInfo {
|
||||
id: format!("jwt:{}", id),
|
||||
session_type: "legacy".to_string(),
|
||||
client_name: None,
|
||||
created_at: created_at.to_rfc3339(),
|
||||
expires_at: expires_at.to_rfc3339(),
|
||||
is_current: current_jti.as_ref() == Some(&access_jti),
|
||||
})
|
||||
.collect();
|
||||
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in list_sessions: {:?}", e);
|
||||
(
|
||||
error!("DB error fetching JWT sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let oauth_result = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
i32,
|
||||
String,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
chrono::DateTime<chrono::Utc>,
|
||||
String,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT id, token_id, created_at, expires_at, client_id
|
||||
FROM oauth_token
|
||||
WHERE did = $1 AND expires_at > NOW()
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_all(&state.db)
|
||||
.await;
|
||||
|
||||
match oauth_result {
|
||||
Ok(rows) => {
|
||||
for (id, token_id, created_at, expires_at, client_id) in rows {
|
||||
let client_name = extract_client_name(&client_id);
|
||||
let is_current_oauth = auth.0.is_oauth
|
||||
&& current_jti.as_ref() == Some(&token_id);
|
||||
sessions.push(SessionInfo {
|
||||
id: format!("oauth:{}", id),
|
||||
session_type: "oauth".to_string(),
|
||||
client_name: Some(client_name),
|
||||
created_at: created_at.to_rfc3339(),
|
||||
expires_at: expires_at.to_rfc3339(),
|
||||
is_current: is_current_oauth,
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error fetching OAuth sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
(StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response()
|
||||
}
|
||||
|
||||
fn extract_client_name(client_id: &str) -> String {
|
||||
if client_id.starts_with("http://localhost") || client_id.starts_with("http://127.0.0.1") {
|
||||
"Localhost App".to_string()
|
||||
} else if let Ok(parsed) = reqwest::Url::parse(client_id) {
|
||||
parsed.host_str().unwrap_or("Unknown App").to_string()
|
||||
} else {
|
||||
client_id.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -821,57 +931,277 @@ pub async fn revoke_session(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RevokeSessionInput>,
|
||||
) -> Response {
|
||||
let session_id: i32 = match input.session_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let session = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let access_jti = match session {
|
||||
Ok(Some((jti,))) => jti,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in revoke_session: {:?}", e);
|
||||
if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") {
|
||||
let session_id: i32 = match jwt_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let session = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2",
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(&auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
let access_jti = match session {
|
||||
Ok(Some((jti,))) => jti,
|
||||
Ok(None) => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error in revoke_session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error deleting session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1")
|
||||
.bind(session_id)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error deleting session: {:?}", e);
|
||||
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache: {:?}", e);
|
||||
}
|
||||
info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked");
|
||||
} else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") {
|
||||
let session_id: i32 = match oauth_id.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2")
|
||||
.bind(session_id)
|
||||
.bind(&auth.0.did)
|
||||
.execute(&state.db)
|
||||
.await;
|
||||
match result {
|
||||
Ok(r) if r.rows_affected() == 0 => {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "SessionNotFound", "message": "Session not found"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
error!("DB error deleting OAuth session: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked");
|
||||
} else {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti);
|
||||
if let Err(e) = state.cache.delete(&cache_key).await {
|
||||
warn!("Failed to invalidate session cache: {:?}", e);
|
||||
}
|
||||
info!(did = %auth.0.did, session_id = %session_id, "Session revoked");
|
||||
(StatusCode::OK, Json(json!({}))).into_response()
|
||||
}
|
||||
|
||||
pub async fn revoke_all_sessions(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
auth: BearerAuth,
|
||||
) -> Response {
|
||||
let current_jti = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.and_then(|token| crate::auth::get_jti_from_token(token).ok());
|
||||
|
||||
if let Some(ref jti) = current_jti {
|
||||
if auth.0.is_oauth {
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1")
|
||||
.bind(&auth.0.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2")
|
||||
.bind(&auth.0.did)
|
||||
.bind(jti)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2")
|
||||
.bind(&auth.0.did)
|
||||
.bind(jti)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking JWT sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1")
|
||||
.bind(&auth.0.did)
|
||||
.execute(&state.db)
|
||||
.await
|
||||
{
|
||||
error!("DB error revoking OAuth sessions: {:?}", e);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
info!(did = %auth.0.did, "All other sessions revoked");
|
||||
(StatusCode::OK, Json(json!({"success": true}))).into_response()
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LegacyLoginPreferenceOutput {
|
||||
pub allow_legacy_login: bool,
|
||||
pub has_mfa: bool,
|
||||
}
|
||||
|
||||
pub async fn get_legacy_login_preference(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
) -> Response {
|
||||
let result = sqlx::query!(
|
||||
r#"SELECT
|
||||
u.allow_legacy_login,
|
||||
(EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR
|
||||
EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!"
|
||||
FROM users u WHERE u.did = $1"#,
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(row)) => Json(LegacyLoginPreferenceOutput {
|
||||
allow_legacy_login: row.allow_legacy_login,
|
||||
has_mfa: row.has_mfa,
|
||||
})
|
||||
.into_response(),
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateLegacyLoginInput {
|
||||
pub allow_legacy_login: bool,
|
||||
}
|
||||
|
||||
pub async fn update_legacy_login_preference(
|
||||
State(state): State<AppState>,
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<UpdateLegacyLoginInput>,
|
||||
) -> Response {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
|
||||
.await;
|
||||
}
|
||||
|
||||
if crate::api::server::reauth::check_reauth_required(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::reauth_required_response(&state.db, &auth.0.did).await;
|
||||
}
|
||||
|
||||
let result = sqlx::query!(
|
||||
"UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did",
|
||||
input.allow_legacy_login,
|
||||
auth.0.did
|
||||
)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Some(_)) => {
|
||||
info!(
|
||||
did = %auth.0.did,
|
||||
allow_legacy_login = input.allow_legacy_login,
|
||||
"Legacy login preference updated"
|
||||
);
|
||||
Json(json!({
|
||||
"allowLegacyLogin": input.allow_legacy_login
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
Ok(None) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({"error": "AccountNotFound"})),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
error!("DB error: {:?}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(json!({"error": "InternalError"})),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-1
@@ -4,7 +4,7 @@ use crate::auth::totp::{
|
||||
generate_totp_secret, generate_totp_uri, hash_backup_code, is_backup_code_format,
|
||||
verify_backup_code, verify_totp_code,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::state::{AppState, RateLimitKind};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
@@ -149,6 +149,21 @@ pub async fn enable_totp(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<EnableTotpInput>,
|
||||
) -> Response {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let totp_row = sqlx::query!(
|
||||
"SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1",
|
||||
auth.0.did
|
||||
@@ -309,6 +324,26 @@ pub async fn disable_totp(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<DisableTotpInput>,
|
||||
) -> Response {
|
||||
if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &auth.0.did).await {
|
||||
return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &auth.0.did)
|
||||
.await;
|
||||
}
|
||||
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
@@ -516,6 +551,21 @@ pub async fn regenerate_backup_codes(
|
||||
auth: BearerAuth,
|
||||
Json(input): Json<RegenerateBackupCodesInput>,
|
||||
) -> Response {
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did)
|
||||
.await
|
||||
{
|
||||
warn!(did = %auth.0.did, "TOTP verification rate limit exceeded");
|
||||
return (
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
Json(json!({
|
||||
"error": "RateLimitExceeded",
|
||||
"message": "Too many verification attempts. Please try again in a few minutes."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did)
|
||||
.fetch_optional(&state.db)
|
||||
.await;
|
||||
|
||||
@@ -64,14 +64,16 @@ pub fn validate_short_handle(handle: &str) -> Result<String, HandleValidationErr
|
||||
return Err(HandleValidationError::TooLong);
|
||||
}
|
||||
|
||||
let first_char = handle.chars().next().unwrap();
|
||||
if first_char == '-' || first_char == '_' {
|
||||
return Err(HandleValidationError::StartsWithInvalidChar);
|
||||
if let Some(first_char) = handle.chars().next() {
|
||||
if first_char == '-' || first_char == '_' {
|
||||
return Err(HandleValidationError::StartsWithInvalidChar);
|
||||
}
|
||||
}
|
||||
|
||||
let last_char = handle.chars().last().unwrap();
|
||||
if last_char == '-' || last_char == '_' {
|
||||
return Err(HandleValidationError::EndsWithInvalidChar);
|
||||
if let Some(last_char) = handle.chars().last() {
|
||||
if last_char == '-' || last_char == '_' {
|
||||
return Err(HandleValidationError::EndsWithInvalidChar);
|
||||
}
|
||||
}
|
||||
|
||||
for c in handle.chars() {
|
||||
|
||||
+17
-2
@@ -341,7 +341,22 @@ pub async fn update_passkey_counter(
|
||||
pool: &PgPool,
|
||||
credential_id: &[u8],
|
||||
new_counter: u32,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
) -> Result<bool, sqlx::Error> {
|
||||
let stored = get_passkey_by_credential_id(pool, credential_id).await?;
|
||||
let Some(stored) = stored else {
|
||||
return Err(sqlx::Error::RowNotFound);
|
||||
};
|
||||
|
||||
if new_counter > 0 && new_counter <= stored.sign_count as u32 {
|
||||
tracing::warn!(
|
||||
credential_id = ?credential_id,
|
||||
stored_counter = stored.sign_count,
|
||||
new_counter = new_counter,
|
||||
"Passkey counter did not increment - possible cloned key!"
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"UPDATE passkeys SET sign_count = $1, last_used = NOW() WHERE credential_id = $2",
|
||||
new_counter as i32,
|
||||
@@ -349,7 +364,7 @@ pub async fn update_passkey_counter(
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub async fn delete_passkey(pool: &PgPool, id: Uuid, did: &str) -> Result<bool, sqlx::Error> {
|
||||
|
||||
@@ -11,6 +11,7 @@ pub use service::{
|
||||
CommsService, channel_display_name, enqueue_2fa_code, enqueue_account_deletion, enqueue_comms,
|
||||
enqueue_email_update, enqueue_email_verification, enqueue_passkey_recovery,
|
||||
enqueue_password_reset, enqueue_plc_operation, enqueue_signup_verification, enqueue_welcome,
|
||||
queue_legacy_login_notification,
|
||||
};
|
||||
|
||||
pub use types::{CommsChannel, CommsStatus, CommsType, NewComms, QueuedComms};
|
||||
|
||||
@@ -527,3 +527,41 @@ pub async fn enqueue_signup_verification(
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn queue_legacy_login_notification(
|
||||
db: &PgPool,
|
||||
user_id: Uuid,
|
||||
hostname: &str,
|
||||
client_ip: &str,
|
||||
channel: CommsChannel,
|
||||
) -> Result<Uuid, sqlx::Error> {
|
||||
let prefs = get_user_comms_prefs(db, user_id).await?;
|
||||
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
|
||||
let body = format!(
|
||||
"Hello @{},\n\n\
|
||||
A login to your account was detected using a legacy app (like Bluesky) that doesn't support TOTP verification.\n\n\
|
||||
Details:\n\
|
||||
- Time: {}\n\
|
||||
- IP Address: {}\n\n\
|
||||
Your TOTP protection was bypassed for this login. The session has limited permissions for sensitive operations.\n\n\
|
||||
If this wasn't you, please:\n\
|
||||
1. Change your password immediately\n\
|
||||
2. Review your active sessions\n\
|
||||
3. Consider disabling legacy app logins in your security settings\n\n\
|
||||
Stay safe,\n\
|
||||
{}",
|
||||
prefs.handle, timestamp, client_ip, hostname
|
||||
);
|
||||
enqueue_comms(
|
||||
db,
|
||||
NewComms::new(
|
||||
user_id,
|
||||
channel,
|
||||
super::types::CommsType::LegacyLoginAlert,
|
||||
prefs.email.clone().unwrap_or_default(),
|
||||
Some(format!("Security Alert: Legacy Login Detected - {}", hostname)),
|
||||
body,
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ pub enum CommsType {
|
||||
PlcOperation,
|
||||
TwoFactorCode,
|
||||
PasskeyRecovery,
|
||||
LegacyLoginAlert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, FromRow)]
|
||||
|
||||
@@ -19,6 +19,7 @@ pub struct AuthConfig {
|
||||
pub signing_key_x: String,
|
||||
pub signing_key_y: String,
|
||||
key_encryption_key: [u8; 32],
|
||||
device_cookie_key: [u8; 32],
|
||||
}
|
||||
|
||||
impl AuthConfig {
|
||||
@@ -112,6 +113,10 @@ impl AuthConfig {
|
||||
hk.expand(b"tranquil-pds-user-key-encryption", &mut key_encryption_key)
|
||||
.expect("HKDF expansion failed");
|
||||
|
||||
let mut device_cookie_key = [0u8; 32];
|
||||
hk.expand(b"tranquil-pds-device-cookie-signing", &mut device_cookie_key)
|
||||
.expect("HKDF expansion failed");
|
||||
|
||||
AuthConfig {
|
||||
jwt_secret,
|
||||
dpop_secret,
|
||||
@@ -120,6 +125,7 @@ impl AuthConfig {
|
||||
signing_key_x,
|
||||
signing_key_y,
|
||||
key_encryption_key,
|
||||
device_cookie_key,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -138,6 +144,67 @@ impl AuthConfig {
|
||||
&self.dpop_secret
|
||||
}
|
||||
|
||||
pub fn sign_device_cookie(&self, device_id: &str) -> String {
|
||||
use hmac::Mac;
|
||||
type HmacSha256 = hmac::Hmac<Sha256>;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let message = format!("{}:{}", device_id, timestamp);
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key)
|
||||
.expect("HMAC key size is valid");
|
||||
mac.update(message.as_bytes());
|
||||
let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
|
||||
format!("{}.{}.{}", device_id, timestamp, signature)
|
||||
}
|
||||
|
||||
pub fn verify_device_cookie(&self, cookie_value: &str) -> Option<String> {
|
||||
use hmac::Mac;
|
||||
type HmacSha256 = hmac::Hmac<Sha256>;
|
||||
|
||||
let parts: Vec<&str> = cookie_value.splitn(3, '.').collect();
|
||||
if parts.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let device_id = parts[0];
|
||||
let timestamp_str = parts[1];
|
||||
let provided_signature = parts[2];
|
||||
|
||||
let timestamp: u64 = timestamp_str.parse().ok()?;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
let max_age_days = 400;
|
||||
if now.saturating_sub(timestamp) > max_age_days * 24 * 60 * 60 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let message = format!("{}:{}", device_id, timestamp);
|
||||
let mut mac = <HmacSha256 as Mac>::new_from_slice(&self.device_cookie_key)
|
||||
.expect("HMAC key size is valid");
|
||||
mac.update(message.as_bytes());
|
||||
let expected_signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
|
||||
|
||||
use subtle::ConstantTimeEq;
|
||||
if provided_signature
|
||||
.as_bytes()
|
||||
.ct_eq(expected_signature.as_bytes())
|
||||
.into()
|
||||
{
|
||||
Some(device_id.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encrypt_user_key(&self, plaintext: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use rand::RngCore;
|
||||
|
||||
|
||||
+12
@@ -59,6 +59,10 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.tranquil.account.revokeSession",
|
||||
post(api::server::revoke_session),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.revokeAllSessions",
|
||||
post(api::server::revoke_all_sessions),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.atproto.server.deleteSession",
|
||||
post(api::server::delete_session),
|
||||
@@ -230,6 +234,14 @@ pub fn app(state: AppState) -> Router {
|
||||
"/xrpc/com.tranquil.account.reauthPasskeyFinish",
|
||||
post(api::server::reauth_passkey_finish),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.getLegacyLoginPreference",
|
||||
get(api::server::get_legacy_login_preference),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.updateLegacyLoginPreference",
|
||||
post(api::server::update_legacy_login_preference),
|
||||
)
|
||||
.route(
|
||||
"/xrpc/com.tranquil.account.listTrustedDevices",
|
||||
get(api::server::list_trusted_devices),
|
||||
|
||||
@@ -39,7 +39,7 @@ fn extract_device_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
for cookie in cookie_str.split(';') {
|
||||
let cookie = cookie.trim();
|
||||
if let Some(value) = cookie.strip_prefix(&format!("{}=", DEVICE_COOKIE_NAME)) {
|
||||
return Some(value.to_string());
|
||||
return crate::config::AuthConfig::get().verify_device_cookie(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
@@ -69,9 +69,10 @@ fn extract_user_agent(headers: &HeaderMap) -> Option<String> {
|
||||
}
|
||||
|
||||
fn make_device_cookie(device_id: &str) -> String {
|
||||
let signed_value = crate::config::AuthConfig::get().sign_device_cookie(device_id);
|
||||
format!(
|
||||
"{}={}; Path=/oauth; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000",
|
||||
DEVICE_COOKIE_NAME, device_id
|
||||
DEVICE_COOKIE_NAME, signed_value
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1511,6 +1512,17 @@ pub async fn authorize_2fa_post(
|
||||
"No 2FA challenge found. Please start over.",
|
||||
);
|
||||
}
|
||||
if !state
|
||||
.check_rate_limit(RateLimitKind::TotpVerify, &did)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(did = %did, "TOTP verification rate limit exceeded");
|
||||
return json_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"RateLimitExceeded",
|
||||
"Too many verification attempts. Please try again in a few minutes.",
|
||||
);
|
||||
}
|
||||
let totp_valid =
|
||||
crate::api::server::verify_totp_or_backup_for_user(&state, &did, &form.code).await;
|
||||
if !totp_valid {
|
||||
@@ -2067,15 +2079,30 @@ pub async fn passkey_finish(
|
||||
tracing::warn!(error = %e, "Failed to delete authentication state");
|
||||
}
|
||||
|
||||
if auth_result.needs_update()
|
||||
&& let Err(e) = crate::auth::webauthn::update_passkey_counter(
|
||||
if auth_result.needs_update() {
|
||||
match crate::auth::webauthn::update_passkey_counter(
|
||||
&state.db,
|
||||
auth_result.cred_id(),
|
||||
auth_result.counter(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(error = %e, "Failed to update passkey counter");
|
||||
{
|
||||
Ok(false) => {
|
||||
tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key");
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Security key counter anomaly detected. This may indicate a cloned key."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "Failed to update passkey counter");
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!(did = %did, "Passkey authentication successful");
|
||||
@@ -2469,14 +2496,28 @@ pub async fn authorize_passkey_finish(
|
||||
|
||||
let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &did).await;
|
||||
|
||||
if let Err(e) = crate::auth::webauthn::update_passkey_counter(
|
||||
match crate::auth::webauthn::update_passkey_counter(
|
||||
&state.db,
|
||||
credential.id.as_ref(),
|
||||
auth_result.counter(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to update passkey counter: {:?}", e);
|
||||
Ok(false) => {
|
||||
tracing::warn!(did = %did, "Passkey counter anomaly detected - possible cloned key");
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(serde_json::json!({
|
||||
"error": "access_denied",
|
||||
"error_description": "Security key counter anomaly detected. This may indicate a cloned key."
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to update passkey counter: {:?}", e);
|
||||
}
|
||||
Ok(true) => {}
|
||||
}
|
||||
|
||||
let has_totp = crate::api::server::has_totp_enabled_db(&state.db, &did).await;
|
||||
|
||||
@@ -29,6 +29,7 @@ pub struct RateLimiters {
|
||||
pub oauth_introspect: Arc<KeyedRateLimiter>,
|
||||
pub app_password: Arc<KeyedRateLimiter>,
|
||||
pub email_update: Arc<KeyedRateLimiter>,
|
||||
pub totp_verify: Arc<KeyedRateLimiter>,
|
||||
}
|
||||
|
||||
impl Default for RateLimiters {
|
||||
@@ -73,6 +74,10 @@ impl RateLimiters {
|
||||
email_update: Arc::new(RateLimiter::keyed(Quota::per_hour(
|
||||
NonZeroU32::new(5).unwrap(),
|
||||
))),
|
||||
totp_verify: Arc::new(RateLimiter::keyed(Quota::with_period(std::time::Duration::from_secs(60))
|
||||
.unwrap()
|
||||
.allow_burst(NonZeroU32::new(5).unwrap()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ pub enum RateLimitKind {
|
||||
OAuthIntrospect,
|
||||
AppPassword,
|
||||
EmailUpdate,
|
||||
TotpVerify,
|
||||
}
|
||||
|
||||
impl RateLimitKind {
|
||||
@@ -51,6 +52,7 @@ impl RateLimitKind {
|
||||
Self::OAuthIntrospect => "oauth_introspect",
|
||||
Self::AppPassword => "app_password",
|
||||
Self::EmailUpdate => "email_update",
|
||||
Self::TotpVerify => "totp_verify",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +69,7 @@ impl RateLimitKind {
|
||||
Self::OAuthIntrospect => (30, 60_000),
|
||||
Self::AppPassword => (10, 60_000),
|
||||
Self::EmailUpdate => (5, 3_600_000),
|
||||
Self::TotpVerify => (5, 300_000),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +145,7 @@ impl AppState {
|
||||
RateLimitKind::OAuthIntrospect => &self.rate_limiters.oauth_introspect,
|
||||
RateLimitKind::AppPassword => &self.rate_limiters.app_password,
|
||||
RateLimitKind::EmailUpdate => &self.rate_limiters.email_update,
|
||||
RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify,
|
||||
};
|
||||
|
||||
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
|
||||
|
||||
@@ -409,6 +409,74 @@ pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PasswordValidationError {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PasswordValidationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.errors.join("; "))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PasswordValidationError {}
|
||||
|
||||
pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if password.len() < 8 {
|
||||
errors.push("Password must be at least 8 characters".to_string());
|
||||
}
|
||||
|
||||
if password.len() > 256 {
|
||||
errors.push("Password must be at most 256 characters".to_string());
|
||||
}
|
||||
|
||||
if !password.chars().any(|c| c.is_ascii_lowercase()) {
|
||||
errors.push("Password must contain at least one lowercase letter".to_string());
|
||||
}
|
||||
|
||||
if !password.chars().any(|c| c.is_ascii_uppercase()) {
|
||||
errors.push("Password must contain at least one uppercase letter".to_string());
|
||||
}
|
||||
|
||||
if !password.chars().any(|c| c.is_ascii_digit()) {
|
||||
errors.push("Password must contain at least one number".to_string());
|
||||
}
|
||||
|
||||
if is_common_password(password) {
|
||||
errors.push("Password is too common, please choose a different one".to_string());
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PasswordValidationError { errors })
|
||||
}
|
||||
}
|
||||
|
||||
fn is_common_password(password: &str) -> bool {
|
||||
const COMMON_PASSWORDS: &[&str] = &[
|
||||
"password", "Password1", "Password123", "Passw0rd", "Passw0rd!",
|
||||
"12345678", "123456789", "1234567890",
|
||||
"qwerty123", "Qwerty123", "qwertyui", "Qwertyui",
|
||||
"letmein1", "Letmein1", "welcome1", "Welcome1",
|
||||
"admin123", "Admin123", "password1", "Password1!",
|
||||
"iloveyou", "Iloveyou1", "monkey123", "Monkey123",
|
||||
"dragon12", "Dragon123", "master12", "Master123",
|
||||
"login123", "Login123", "abc12345", "Abc12345",
|
||||
"football", "Football1", "baseball", "Baseball1",
|
||||
"trustno1", "Trustno1", "sunshine", "Sunshine1",
|
||||
"princess", "Princess1", "computer", "Computer1",
|
||||
"whatever", "Whatever1", "nintendo", "Nintendo1",
|
||||
"bluesky1", "Bluesky1", "Bluesky123",
|
||||
];
|
||||
|
||||
let lower = password.to_lowercase();
|
||||
COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -63,7 +63,7 @@ async fn test_search_accounts_with_handle_filter() {
|
||||
let create_payload = serde_json::json!({
|
||||
"handle": unique_handle,
|
||||
"email": format!("unique-{}@searchtest.com", ts),
|
||||
"password": "test-password-123"
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
|
||||
@@ -11,8 +11,8 @@ async fn test_change_password_success() {
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("change-pw-{}.test", ts);
|
||||
let email = format!("change-pw-{}@test.com", ts);
|
||||
let old_password = "old-password-123";
|
||||
let new_password = "new-password-456";
|
||||
let old_password = "Oldpass123!";
|
||||
let new_password = "Newpass456!";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
@@ -92,8 +92,8 @@ async fn test_change_password_wrong_current() {
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": "wrong-password",
|
||||
"newPassword": "new-password-123"
|
||||
"currentPassword": "Wrongpass999!",
|
||||
"newPassword": "Newpass123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -109,7 +109,7 @@ async fn test_change_password_too_short() {
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("change-pw-short-{}.test", ts);
|
||||
let email = format!("change-pw-short-{}@test.com", ts);
|
||||
let password = "correct-password";
|
||||
let password = "Correct123!";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
@@ -158,7 +158,7 @@ async fn test_change_password_empty_current() {
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": "",
|
||||
"newPassword": "new-password-123"
|
||||
"newPassword": "Newpass123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -177,7 +177,7 @@ async fn test_change_password_empty_new() {
|
||||
))
|
||||
.bearer_auth(&jwt)
|
||||
.json(&json!({
|
||||
"currentPassword": "e2e-password-123",
|
||||
"currentPassword": "E2epass123!",
|
||||
"newPassword": ""
|
||||
}))
|
||||
.send()
|
||||
@@ -195,8 +195,8 @@ async fn test_change_password_requires_auth() {
|
||||
base_url().await
|
||||
))
|
||||
.json(&json!({
|
||||
"currentPassword": "old",
|
||||
"newPassword": "new-password-123"
|
||||
"currentPassword": "Oldpass123!",
|
||||
"newPassword": "Newpass123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
|
||||
+1
-1
@@ -418,7 +418,7 @@ async fn create_account_and_login_internal(client: &Client, make_admin: bool) ->
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password"
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = match client
|
||||
.post(format!(
|
||||
|
||||
@@ -49,7 +49,7 @@ async fn test_delete_account_full_flow() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("delete-test-{}.test", ts);
|
||||
let email = format!("delete-test-{}@test.com", ts);
|
||||
let password = "delete-password-123";
|
||||
let password = "Delete123pass!";
|
||||
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
|
||||
let request_delete_res = client
|
||||
.post(format!(
|
||||
@@ -106,7 +106,7 @@ async fn test_delete_account_wrong_password() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("delete-wrongpw-{}.test", ts);
|
||||
let email = format!("delete-wrongpw-{}@test.com", ts);
|
||||
let password = "correct-password";
|
||||
let password = "Correct123!";
|
||||
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
|
||||
let request_delete_res = client
|
||||
.post(format!(
|
||||
@@ -153,7 +153,7 @@ async fn test_delete_account_invalid_token() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("delete-badtoken-{}.test", ts);
|
||||
let email = format!("delete-badtoken-{}@test.com", ts);
|
||||
let password = "delete-password";
|
||||
let password = "Delete123!";
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
@@ -196,7 +196,7 @@ async fn test_delete_account_expired_token() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("delete-expired-{}.test", ts);
|
||||
let email = format!("delete-expired-{}@test.com", ts);
|
||||
let password = "delete-password";
|
||||
let password = "Delete123!";
|
||||
let (did, jwt) = create_verified_account(&client, &base_url, &handle, &email, password).await;
|
||||
let request_delete_res = client
|
||||
.post(format!(
|
||||
@@ -250,12 +250,12 @@ async fn test_delete_account_token_mismatch() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle1 = format!("delete-user1-{}.test", ts);
|
||||
let email1 = format!("delete-user1-{}@test.com", ts);
|
||||
let password1 = "user1-password";
|
||||
let password1 = "User1pass123!";
|
||||
let (did1, jwt1) =
|
||||
create_verified_account(&client, &base_url, &handle1, &email1, password1).await;
|
||||
let handle2 = format!("delete-user2-{}.test", ts);
|
||||
let email2 = format!("delete-user2-{}@test.com", ts);
|
||||
let password2 = "user2-password";
|
||||
let password2 = "User2pass123!";
|
||||
let (did2, _) = create_verified_account(&client, &base_url, &handle2, &email2, password2).await;
|
||||
let request_delete_res = client
|
||||
.post(format!(
|
||||
@@ -302,7 +302,7 @@ async fn test_delete_account_with_app_password() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("delete-apppw-{}.test", ts);
|
||||
let email = format!("delete-apppw-{}@test.com", ts);
|
||||
let main_password = "main-password-123";
|
||||
let main_password = "Mainpass123!";
|
||||
let (did, jwt) =
|
||||
create_verified_account(&client, &base_url, &handle, &email, main_password).await;
|
||||
let app_password_res = client
|
||||
|
||||
+6
-6
@@ -12,7 +12,7 @@ async fn test_create_self_hosted_did_web() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"didType": "web"
|
||||
});
|
||||
let res = client
|
||||
@@ -139,7 +139,7 @@ async fn test_external_did_web_no_local_doc() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"didType": "web-external",
|
||||
"did": did,
|
||||
"signingKey": signing_key
|
||||
@@ -181,7 +181,7 @@ async fn test_plc_operations_blocked_for_did_web() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"didType": "web"
|
||||
});
|
||||
let res = client
|
||||
@@ -246,7 +246,7 @@ async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"didType": "web"
|
||||
});
|
||||
let res = client
|
||||
@@ -295,7 +295,7 @@ async fn test_did_plc_still_works_with_did_type_param() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"didType": "plc"
|
||||
});
|
||||
let res = client
|
||||
@@ -324,7 +324,7 @@ async fn test_external_did_web_requires_did_field() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"didType": "web-external"
|
||||
});
|
||||
let res = client
|
||||
|
||||
@@ -26,7 +26,7 @@ async fn create_verified_account(
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
"password": "Testpass123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -10,7 +10,7 @@ pub async fn setup_new_user(handle_prefix: &str) -> (String, String) {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("{}-{}.test", handle_prefix, ts);
|
||||
let email = format!("{}-{}@test.com", handle_prefix, ts);
|
||||
let password = "e2e-password-123";
|
||||
let password = "E2epass123!";
|
||||
let create_account_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
|
||||
+4
-4
@@ -12,7 +12,7 @@ async fn test_resolve_handle_success() {
|
||||
let payload = json!({
|
||||
"handle": short_handle,
|
||||
"email": format!("{}@example.com", short_handle),
|
||||
"password": "password"
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
@@ -142,7 +142,7 @@ async fn test_create_did_web_account_and_resolve() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"did": did,
|
||||
"signingKey": signing_key
|
||||
});
|
||||
@@ -188,7 +188,7 @@ async fn test_create_account_duplicate_handle() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password"
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
@@ -266,7 +266,7 @@ async fn test_did_web_lifecycle() {
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"did": did,
|
||||
"signingKey": signing_key
|
||||
});
|
||||
|
||||
@@ -675,7 +675,7 @@ async fn test_refresh_token_replay_protection() {
|
||||
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": "test-password-123" }))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": "Testpass123!" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -36,7 +36,7 @@ async fn test_session_lifecycle_multiple_sessions() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("multi-session-{}.test", ts);
|
||||
let email = format!("multi-session-{}@test.com", ts);
|
||||
let password = "multi-session-pw";
|
||||
let password = "Multisession123!";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
@@ -112,7 +112,7 @@ async fn test_session_lifecycle_refresh_invalidates_old() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("refresh-inv-{}.test", ts);
|
||||
let email = format!("refresh-inv-{}@test.com", ts);
|
||||
let password = "refresh-inv-pw";
|
||||
let password = "Refresh123inv!";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
@@ -180,7 +180,7 @@ async fn test_app_password_lifecycle() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("apppass-{}.test", ts);
|
||||
let email = format!("apppass-{}@test.com", ts);
|
||||
let password = "apppass-password";
|
||||
let password = "Apppass123!";
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
@@ -291,7 +291,7 @@ async fn test_account_deactivation_lifecycle() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("deactivate-{}.test", ts);
|
||||
let email = format!("deactivate-{}@test.com", ts);
|
||||
let password = "deactivate-password";
|
||||
let password = "Deactivate123!";
|
||||
let create_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
|
||||
@@ -176,7 +176,7 @@ async fn test_account_to_post_full_lifecycle() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("fullcycle-{}.test", ts);
|
||||
let email = format!("fullcycle-{}@test.com", ts);
|
||||
let password = "fullcycle-password";
|
||||
let password = "Fullcycle123!";
|
||||
let create_account_res = client
|
||||
.post(format!(
|
||||
"{}/xrpc/com.atproto.server.createAccount",
|
||||
|
||||
+7
-7
@@ -194,7 +194,7 @@ async fn test_full_oauth_flow() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("oauth-test-{}", ts);
|
||||
let email = format!("oauth-test-{}@example.com", ts);
|
||||
let password = "oauth-test-password";
|
||||
let password = "Oauthtest123!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
@@ -354,7 +354,7 @@ async fn test_oauth_error_cases() {
|
||||
let email = format!("wrong-creds-{}@example.com", ts);
|
||||
http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": "correct-password" }))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": "Correct123!" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -438,7 +438,7 @@ async fn test_oauth_2fa_flow() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("2fa-test-{}", ts);
|
||||
let email = format!("2fa-test-{}@example.com", ts);
|
||||
let password = "2fa-test-password";
|
||||
let password = "Twofa123test!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
@@ -565,7 +565,7 @@ async fn test_oauth_2fa_lockout() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("2fa-lockout-{}", ts);
|
||||
let email = format!("2fa-lockout-{}@example.com", ts);
|
||||
let password = "2fa-test-password";
|
||||
let password = "Twofa123test!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
@@ -662,7 +662,7 @@ async fn test_account_selector_with_2fa() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("selector-2fa-{}", ts);
|
||||
let email = format!("selector-2fa-{}@example.com", ts);
|
||||
let password = "selector-2fa-password";
|
||||
let password = "Selector2fa123!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
@@ -853,7 +853,7 @@ async fn test_oauth_state_encoding() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("state-special-{}", ts);
|
||||
let email = format!("state-special-{}@example.com", ts);
|
||||
let password = "state-special-password";
|
||||
let password = "State123special!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
@@ -932,7 +932,7 @@ async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("scope-test-{}", ts);
|
||||
let email = format!("scope-test-{}@example.com", ts);
|
||||
let password = "scope-test-password";
|
||||
let password = "Scopetest123!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": email, "password": password }))
|
||||
|
||||
@@ -57,7 +57,7 @@ async fn create_user_and_oauth_session(
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("{}-{}", handle_prefix, ts);
|
||||
let email = format!("{}-{}@example.com", handle_prefix, ts);
|
||||
let password = format!("{}-password", handle_prefix);
|
||||
let password = format!("{}Pass123!", handle_prefix);
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({
|
||||
@@ -577,7 +577,7 @@ async fn test_oauth_multiple_clients_same_user() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("multi-client-{}", ts);
|
||||
let email = format!("multi-client-{}@example.com", ts);
|
||||
let password = "multi-client-password";
|
||||
let password = "MultiClient123!";
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({
|
||||
|
||||
@@ -61,7 +61,7 @@ async fn create_user_and_oauth_session_with_scope(
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("{}-{}", handle_prefix, ts);
|
||||
let email = format!("{}-{}@example.com", handle_prefix, ts);
|
||||
let password = format!("{}-password", handle_prefix);
|
||||
let password = format!("{}Pass123!", handle_prefix);
|
||||
|
||||
let create_res = http_client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
@@ -383,7 +383,7 @@ async fn test_consent_endpoint_returns_scope_info() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("consent-test-{}", ts);
|
||||
let email = format!("consent-{}@example.com", ts);
|
||||
let password = "consent-password";
|
||||
let password = "Consent123!";
|
||||
let redirect_uri = "https://consent-test.example.com/callback";
|
||||
|
||||
let create_res = http_client
|
||||
@@ -479,7 +479,7 @@ async fn test_consent_post_generates_code() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("consent-post-{}", ts);
|
||||
let email = format!("consent-post-{}@example.com", ts);
|
||||
let password = "consent-post-password";
|
||||
let password = "ConsentPost123!";
|
||||
let redirect_uri = "https://consent-post.example.com/callback";
|
||||
|
||||
let create_res = http_client
|
||||
@@ -593,7 +593,7 @@ async fn test_consent_post_requires_atproto_scope() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("consent-req-{}", ts);
|
||||
let email = format!("consent-req-{}@example.com", ts);
|
||||
let password = "consent-req-password";
|
||||
let password = "ConsentReq123!";
|
||||
let redirect_uri = "https://consent-req.example.com/callback";
|
||||
|
||||
let create_res = http_client
|
||||
|
||||
+10
-10
@@ -44,7 +44,7 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String,
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("sec-test-{}", ts);
|
||||
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" }))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let did = account["did"].as_str().unwrap();
|
||||
@@ -72,7 +72,7 @@ async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String,
|
||||
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "security-test-password", "remember_device": false}))
|
||||
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "remember_device": false}))
|
||||
.send().await.unwrap();
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
||||
@@ -258,7 +258,7 @@ async fn test_pkce_security() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("pkce-attack-{}", ts);
|
||||
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" }))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
||||
@@ -283,7 +283,7 @@ async fn test_pkce_security() {
|
||||
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "pkce-password", "remember_device": false}))
|
||||
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false}))
|
||||
.send().await.unwrap();
|
||||
assert_eq!(auth_res.status(), StatusCode::OK);
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
@@ -329,7 +329,7 @@ async fn test_replay_attacks() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("replay-{}", ts);
|
||||
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" }))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
||||
@@ -356,7 +356,7 @@ async fn test_replay_attacks() {
|
||||
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "replay-password", "remember_device": false}))
|
||||
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false}))
|
||||
.send().await.unwrap();
|
||||
assert_eq!(auth_res.status(), StatusCode::OK);
|
||||
let auth_body: Value = auth_res.json().await.unwrap();
|
||||
@@ -495,7 +495,7 @@ async fn test_oauth_security_boundaries() {
|
||||
let ts = Utc::now().timestamp_millis();
|
||||
let handle = format!("deact-{}", ts);
|
||||
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "deact-password" }))
|
||||
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" }))
|
||||
.send().await.unwrap();
|
||||
let account: Value = create_res.json().await.unwrap();
|
||||
let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
||||
@@ -524,7 +524,7 @@ async fn test_oauth_security_boundaries() {
|
||||
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "deact-password", "remember_device": false}))
|
||||
.json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false}))
|
||||
.send().await.unwrap();
|
||||
assert_eq!(
|
||||
auth_res.status(),
|
||||
@@ -539,7 +539,7 @@ async fn test_oauth_security_boundaries() {
|
||||
let ts2 = Utc::now().timestamp_millis();
|
||||
let handle2 = format!("cross-{}", ts2);
|
||||
let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
||||
.json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" }))
|
||||
.json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" }))
|
||||
.send().await.unwrap();
|
||||
let account2: Value = create_res2.json().await.unwrap();
|
||||
verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
|
||||
@@ -563,7 +563,7 @@ async fn test_oauth_security_boundaries() {
|
||||
let auth_a = http_client.post(format!("{}/oauth/authorize", url))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json")
|
||||
.json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "cross-password", "remember_device": false}))
|
||||
.json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false}))
|
||||
.send().await.unwrap();
|
||||
assert_eq!(auth_a.status(), StatusCode::OK);
|
||||
let auth_body_a: Value = auth_a.json().await.unwrap();
|
||||
|
||||
@@ -24,7 +24,7 @@ async fn test_request_password_reset_creates_code() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "oldpassword"
|
||||
"password": "Oldpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
@@ -83,8 +83,8 @@ async fn test_reset_password_with_valid_token() {
|
||||
let pool = get_pool().await;
|
||||
let handle = format!("pwreset2_{}", uuid::Uuid::new_v4());
|
||||
let email = format!("{}@example.com", handle);
|
||||
let old_password = "oldpassword";
|
||||
let new_password = "newpassword123";
|
||||
let old_password = "Oldpass123!";
|
||||
let new_password = "Newpass456!";
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
@@ -182,7 +182,7 @@ async fn test_reset_password_with_invalid_token() {
|
||||
))
|
||||
.json(&json!({
|
||||
"token": "invalid-token",
|
||||
"password": "newpassword"
|
||||
"password": "Newpass123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -202,7 +202,7 @@ async fn test_reset_password_with_expired_token() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "oldpassword"
|
||||
"password": "Oldpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
@@ -246,7 +246,7 @@ async fn test_reset_password_with_expired_token() {
|
||||
))
|
||||
.json(&json!({
|
||||
"token": token,
|
||||
"password": "newpassword"
|
||||
"password": "Newpass123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -266,7 +266,7 @@ async fn test_reset_password_invalidates_sessions() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "oldpassword"
|
||||
"password": "Oldpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
@@ -313,7 +313,7 @@ async fn test_reset_password_invalidates_sessions() {
|
||||
))
|
||||
.json(&json!({
|
||||
"token": token,
|
||||
"password": "newpassword123"
|
||||
"password": "Newpass123!"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
@@ -356,7 +356,7 @@ async fn test_reset_password_creates_notification() {
|
||||
let payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
"password": "oldpassword"
|
||||
"password": "Oldpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(format!(
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ async fn test_account_creation_rate_limiting() {
|
||||
let payload = json!({
|
||||
"handle": format!("ratelimit_{}_{}", i, unique_id),
|
||||
"email": format!("ratelimit_{}_{}@example.com", i, unique_id),
|
||||
"password": "testpassword123"
|
||||
"password": "Testpass123!"
|
||||
});
|
||||
let res = client
|
||||
.post(&url)
|
||||
|
||||
+4
-4
@@ -27,7 +27,7 @@ async fn test_account_and_session_lifecycle() {
|
||||
let client = client();
|
||||
let base = base_url().await;
|
||||
let handle = format!("user_{}", uuid::Uuid::new_v4());
|
||||
let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "password" });
|
||||
let payload = json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Testpass123!" });
|
||||
let create_res = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&payload)
|
||||
@@ -40,7 +40,7 @@ async fn test_account_and_session_lifecycle() {
|
||||
let _ = verify_new_account(&client, did).await;
|
||||
let login = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
|
||||
.json(&json!({ "identifier": handle, "password": "password" }))
|
||||
.json(&json!({ "identifier": handle, "password": "Testpass123!" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -61,7 +61,7 @@ async fn test_account_and_session_lifecycle() {
|
||||
assert_ne!(refresh_body["refreshJwt"].as_str().unwrap(), refresh_jwt);
|
||||
let missing_id = client
|
||||
.post(format!("{}/xrpc/com.atproto.server.createSession", base))
|
||||
.json(&json!({ "password": "password" }))
|
||||
.json(&json!({ "password": "Testpass123!" }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -70,7 +70,7 @@ async fn test_account_and_session_lifecycle() {
|
||||
|| missing_id.status() == StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
let invalid_handle = client.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
|
||||
.json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "password" }))
|
||||
.json(&json!({ "handle": "invalid!handle.com", "email": "test@example.com", "password": "Testpass123!" }))
|
||||
.send().await.unwrap();
|
||||
assert_eq!(invalid_handle.status(), StatusCode::BAD_REQUEST);
|
||||
let unauth_session = client
|
||||
|
||||
@@ -47,7 +47,7 @@ async fn test_list_sessions_multiple_sessions() {
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("multi-list-{}.test", ts);
|
||||
let email = format!("multi-list-{}@test.com", ts);
|
||||
let password = "test-password-123";
|
||||
let password = "Testpass123!";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
@@ -122,7 +122,7 @@ async fn test_revoke_session_success() {
|
||||
let ts = chrono::Utc::now().timestamp_millis();
|
||||
let handle = format!("revoke-sess-{}.test", ts);
|
||||
let email = format!("revoke-sess-{}@test.com", ts);
|
||||
let password = "test-password-123";
|
||||
let password = "Testpass123!";
|
||||
let create_payload = json!({
|
||||
"handle": handle,
|
||||
"email": email,
|
||||
|
||||
@@ -183,7 +183,7 @@ async fn test_create_account_with_reserved_signing_key() {
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
@@ -221,7 +221,7 @@ async fn test_create_account_with_invalid_signing_key() {
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"signingKey": "did:key:zNonExistentKey12345"
|
||||
}))
|
||||
.send()
|
||||
@@ -257,7 +257,7 @@ async fn test_create_account_cannot_reuse_signing_key() {
|
||||
.json(&json!({
|
||||
"handle": handle1,
|
||||
"email": format!("{}@example.com", handle1),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
@@ -273,7 +273,7 @@ async fn test_create_account_cannot_reuse_signing_key() {
|
||||
.json(&json!({
|
||||
"handle": handle2,
|
||||
"email": format!("{}@example.com", handle2),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
@@ -310,7 +310,7 @@ async fn test_reserved_key_tokens_work() {
|
||||
.json(&json!({
|
||||
"handle": handle,
|
||||
"email": format!("{}@example.com", handle),
|
||||
"password": "password",
|
||||
"password": "Testpass123!",
|
||||
"signingKey": signing_key
|
||||
}))
|
||||
.send()
|
||||
|
||||
Reference in New Issue
Block a user