diff --git a/internal/store/users.go b/internal/store/users.go index 90e71ce9..dc526a41 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -194,9 +194,9 @@ func (s *Store) SetTOTPSecret(userID, secret string) error { // TOCTOU races (e.g., a concurrent setup call overwriting the secret). func (s *Store) EnableTOTP(userID, expectedSecret, hashedRecoveryCodes string) error { result, err := s.db.Exec(s.q( - `UPDATE users SET totp_enabled = 1, recovery_codes = ?, updated_at = ? + `UPDATE users SET totp_enabled = ?, recovery_codes = ?, updated_at = ? WHERE id = ? AND totp_secret = ?`), - hashedRecoveryCodes, now(), userID, expectedSecret) + s.dialect.BoolToInt(true), hashedRecoveryCodes, now(), userID, expectedSecret) if err != nil { return fmt.Errorf("enable totp: %w", err) } @@ -209,8 +209,8 @@ func (s *Store) EnableTOTP(userID, expectedSecret, hashedRecoveryCodes string) e // DisableTOTP disables 2FA and clears the secret and recovery codes. func (s *Store) DisableTOTP(userID string) error { - _, err := s.db.Exec(s.q(`UPDATE users SET totp_enabled = 0, totp_secret = '', recovery_codes = '', updated_at = ? WHERE id = ?`), - now(), userID) + _, err := s.db.Exec(s.q(`UPDATE users SET totp_enabled = ?, totp_secret = '', recovery_codes = '', updated_at = ? WHERE id = ?`), + s.dialect.BoolToInt(false), now(), userID) if err != nil { return fmt.Errorf("disable totp: %w", err) } @@ -260,10 +260,18 @@ func (s *Store) ConsumeRecoveryCode(userID, code string) (bool, error) { return false, nil } - _, err = tx.Exec(s.q(`UPDATE users SET recovery_codes = ?, updated_at = ? WHERE id = ?`), - strings.Join(remaining, "\n"), now(), userID) + // Use optimistic locking: include the original recovery_codes in the WHERE + // clause so a concurrent transaction that already consumed a code will cause + // this UPDATE to match 0 rows, preventing double-spend. + result, err := tx.Exec(s.q(`UPDATE users SET recovery_codes = ?, updated_at = ? WHERE id = ? AND recovery_codes = ?`), + strings.Join(remaining, "\n"), now(), userID, recoveryCodes) if err != nil { return false, fmt.Errorf("consume recovery code: %w", err) } + n, _ := result.RowsAffected() + if n == 0 { + // Another request consumed or modified the codes concurrently + return false, nil + } return true, tx.Commit() } diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index cc1b4746..26145d8d 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -107,6 +107,13 @@ export interface AuthSession { user?: { id: string; email: string; name: string; role: string }; } +export interface LoginResponse { + user?: { id: string; email: string; name: string; role: string }; + token?: string; + requires_2fa?: boolean; + challenge_token?: string; +} + export const api = { // ── Health / Version ────────────────────────────────────────────────────── @@ -503,10 +510,15 @@ export const api = { auth: { session: (): Promise => fetch(BASE + '/auth/session', { credentials: 'same-origin' }).then((r) => r.json()), login: (email: string, password: string) => - request<{ user: { id: string; email: string; name: string; role: string }; token: string }>('/auth/login', { + request('/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }), + verify2FA: (challengeToken: string, code?: string, recoveryCode?: string) => + request<{ user: { id: string; email: string; name: string; role: string }; token: string }>('/auth/2fa/login-verify', { + method: 'POST', + body: JSON.stringify({ challenge_token: challengeToken, code: code || undefined, recovery_code: recoveryCode || undefined }) + }), register: (email: string, name: string, password: string, invitation_code?: string) => request<{ user: { id: string; email: string; name: string; role: string }; token: string }>('/auth/register', { method: 'POST', diff --git a/web/src/routes/join/[code]/+page.svelte b/web/src/routes/join/[code]/+page.svelte index 5d5eaef8..36252901 100644 --- a/web/src/routes/join/[code]/+page.svelte +++ b/web/src/routes/join/[code]/+page.svelte @@ -6,7 +6,7 @@ import SetupRequiredNotice from '$lib/components/auth/SetupRequiredNotice.svelte'; let code = $derived(page.params.code ?? ''); - let status = $state<'loading' | 'login' | 'register' | 'accepting' | 'error' | 'setup'>('loading'); + let status = $state<'loading' | 'login' | 'register' | 'accepting' | 'error' | 'setup' | '2fa'>('loading'); let errorMsg = $state(''); let setupMethod = $state<'local_cli' | 'docker_exec' | 'cloud' | undefined>(undefined); @@ -18,6 +18,8 @@ let confirmPassword = $state(''); let formError = $state(''); let submitting = $state(false); + let challengeToken = $state(''); + let totpCode = $state(''); onMount(async () => { try { @@ -72,7 +74,14 @@ } else { if (!email.trim()) { formError = 'Email is required'; submitting = false; return; } if (!password) { formError = 'Password is required'; submitting = false; return; } - await api.auth.login(email.trim(), password); + const response = await api.auth.login(email.trim(), password); + + if (response.requires_2fa && response.challenge_token) { + challengeToken = response.challenge_token; + status = '2fa'; + submitting = false; + return; + } } // Logged in via login — now accept the invitation await acceptInvitation(); @@ -82,8 +91,49 @@ } } + async function handleVerify2FA() { + formError = ''; + const code = totpCode.trim(); + + if (!code) { + formError = 'Please enter your authentication code.'; + return; + } + + submitting = true; + try { + const isTotp = /^\d{6}$/.test(code); + + if (isTotp) { + await api.auth.verify2FA(challengeToken, code, undefined); + } else { + await api.auth.verify2FA(challengeToken, undefined, code); + } + + // 2FA verified — now accept the invitation + await acceptInvitation(); + } catch (err: unknown) { + formError = err instanceof Error ? err.message : 'Invalid code. Please try again.'; + submitting = false; + } + } + + function handleBack2FA() { + status = 'login'; + challengeToken = ''; + totpCode = ''; + formError = ''; + submitting = false; + } + function handleKeydown(event: KeyboardEvent) { - if (event.key === 'Enter') handleSubmit(); + if (event.key === 'Enter') { + if (status === '2fa') { + handleVerify2FA(); + } else { + handleSubmit(); + } + } } @@ -105,6 +155,38 @@ {:else if status === 'error'}

{errorMsg}

Go to login + {:else if status === '2fa'} +

Two-factor authentication

+ +
+

Enter the 6-digit code from your authenticator app, or a recovery code.

+ + + + {#if formError} +

{formError}

+ {/if} + + + + +
{:else}

You've been invited to a workspace

{mode === 'register' ? 'Create an account' : 'Sign in'} to accept

@@ -209,6 +291,7 @@ color: var(--text-muted); font-size: 0.85rem; margin-bottom: var(--space-6); + line-height: 1.4; } .error-text { @@ -259,6 +342,18 @@ button:hover:not(:disabled) { opacity: 0.9; } button:disabled { opacity: 0.6; cursor: not-allowed; } + .back-button { + background: transparent; + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 400; + padding: var(--space-2) var(--space-4); + } + .back-button:hover:not(:disabled) { + color: var(--text-primary); + opacity: 1; + } + .switch-mode { margin-top: var(--space-6); font-size: 0.85rem; diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index 4050082d..849ef932 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -12,6 +12,10 @@ let setupMethod = $state<'local_cli' | 'docker_exec' | 'cloud' | undefined>(undefined); let loading = $state(false); + let step = $state<'credentials' | '2fa'>('credentials'); + let challengeToken = $state(''); + let totpCode = $state(''); + onMount(async () => { try { const session = await api.auth.session(); @@ -40,8 +44,16 @@ loading = true; try { - await api.auth.login(email, password); - await authStore.load(); // Refresh global auth state before navigating. + const response = await api.auth.login(email, password); + + if (response.requires_2fa && response.challenge_token) { + challengeToken = response.challenge_token; + step = '2fa'; + error = ''; + return; + } + + await authStore.load(); await goto('/', { replaceState: true }); } catch (err: unknown) { if (err instanceof Error) { @@ -54,9 +66,52 @@ } } + async function handleVerify2FA() { + error = ''; + const code = totpCode.trim(); + + if (!code) { + error = 'Please enter your authentication code.'; + return; + } + + loading = true; + try { + const isTotp = /^\d{6}$/.test(code); + + if (isTotp) { + await api.auth.verify2FA(challengeToken, code, undefined); + } else { + await api.auth.verify2FA(challengeToken, undefined, code); + } + + await authStore.load(); + await goto('/', { replaceState: true }); + } catch (err: unknown) { + if (err instanceof Error) { + error = err.message || 'Invalid code. Please try again.'; + } else { + error = 'Invalid code. Please try again.'; + } + } finally { + loading = false; + } + } + + function handleBack() { + step = 'credentials'; + challengeToken = ''; + totpCode = ''; + error = ''; + } + function handleKeydown(event: KeyboardEvent) { if (event.key === 'Enter') { - handleSubmit(); + if (step === 'credentials') { + handleSubmit(); + } else { + handleVerify2FA(); + } } } @@ -69,6 +124,38 @@ {setupMethod} nextStep="Once setup is complete, return here to sign in." /> + {:else if step === '2fa'} +

Two-factor authentication

+ +
+

Enter the 6-digit code from your authenticator app, or a recovery code.

+ + + + {#if error} +

{error}

+ {/if} + + + + +
{:else}

Sign in to continue

@@ -154,6 +241,13 @@ gap: var(--space-4); } + .hint { + color: var(--text-muted); + font-size: 0.85rem; + text-align: left; + line-height: 1.4; + } + input { width: 100%; padding: var(--space-3) var(--space-4); @@ -208,6 +302,19 @@ cursor: not-allowed; } + .back-button { + background: transparent; + color: var(--text-muted); + font-size: 0.85rem; + font-weight: 400; + padding: var(--space-2) var(--space-4); + } + + .back-button:hover:not(:disabled) { + color: var(--text-primary); + opacity: 1; + } + .register-link { margin-top: var(--space-6); color: var(--text-muted);