From 6a8e6ba8a24ed7ef4327756dac03778dd318e2f7 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 14:08:12 +0000 Subject: [PATCH] feat(portal): two-factor step at sign-in and password change An account that turned on two-factor sign-in (VoltiusApp/server#105) gets 428 TWO_FACTOR_REQUIRED from /v1/auth/login. The portal showed that as a raw JSON error, so those accounts could not reach billing. Sign-in now moves to a code step when the server asks for one and finishes through /v1/auth/login/2fa, with a switch to a recovery code. Changing the master password asks for a code when the account has two-factor on, since the server refuses the change without one. ApiError exposes the parsed `{error: CODE}` body so a refusal can be told apart without matching on text. --- portal/app/TwoFactorField.tsx | 56 +++++++++++++ portal/app/account/ChangePasswordModal.tsx | 22 ++++- portal/app/signin/page.tsx | 95 ++++++++++++++++++---- portal/lib/api.ts | 67 ++++++++++++++- 4 files changed, 218 insertions(+), 22 deletions(-) create mode 100644 portal/app/TwoFactorField.tsx diff --git a/portal/app/TwoFactorField.tsx b/portal/app/TwoFactorField.tsx new file mode 100644 index 0000000..701db88 --- /dev/null +++ b/portal/app/TwoFactorField.tsx @@ -0,0 +1,56 @@ +"use client"; + +import type { TwoFactorProof } from "../lib/api"; + +export interface TwoFactorEntry { + recovery: boolean; + value: string; +} + +export const EMPTY_TWO_FACTOR_ENTRY: TwoFactorEntry = { recovery: false, value: "" }; + +export function entryProof(entry: TwoFactorEntry): TwoFactorProof | null { + const value = entry.value.trim(); + if (!value) return null; + return entry.recovery ? { recoveryCode: value } : { code: value }; +} + +const ALL_METHODS = ["totp", "recovery"]; + +export function TwoFactorField({ entry, onChange, methods = ALL_METHODS, autoFocus }: { + entry: TwoFactorEntry; + onChange: (entry: TwoFactorEntry) => void; + /** What the server will accept; one that lost its seed key offers recovery codes only. */ + methods?: string[]; + autoFocus?: boolean; +}) { + const recovery = entry.recovery || !methods.includes("totp"); + const canSwitch = methods.includes("totp") && methods.includes("recovery"); + return ( +
+ onChange({ recovery, value: e.target.value })} + autoFocus={autoFocus} + autoComplete={recovery ? "off" : "one-time-code"} + inputMode={recovery ? "text" : "numeric"} + autoCapitalize="none" + spellCheck={false} + maxLength={recovery ? 32 : 8} + placeholder={recovery ? "Recovery code" : "6-digit code"} + className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm font-mono tracking-wider text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" + /> + {canSwitch && ( + + )} +
+ ); +} diff --git a/portal/app/account/ChangePasswordModal.tsx b/portal/app/account/ChangePasswordModal.tsx index b1f764e..ed5f134 100644 --- a/portal/app/account/ChangePasswordModal.tsx +++ b/portal/app/account/ChangePasswordModal.tsx @@ -2,7 +2,8 @@ import { useState, type FormEvent } from "react"; import { deriveAuthKey, deriveKek, wrapUserSecrets, base64ToBytes, bytesToBase64, unwrapUserSecretsDek, unwrapUserSecretsX25519 } from "../../lib/crypto"; -import { getMe, updatePassword, ApiError } from "../../lib/api"; +import { getMe, updatePassword, twoFactorMessage, ApiError } from "../../lib/api"; +import { EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry } from "../TwoFactorField"; interface Props { accountId: string; @@ -18,6 +19,8 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [done, setDone] = useState(false); + const [needsCode, setNeedsCode] = useState(false); + const [twoFactor, setTwoFactor] = useState(EMPTY_TWO_FACTOR_ENTRY); async function handleSubmit(e: FormEvent) { e.preventDefault(); @@ -35,6 +38,13 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce setLoading(false); return; } + const proof = entryProof(twoFactor); + if (me.two_factor_enabled && !proof) { + setNeedsCode(true); + setError("Enter a code from your authenticator app to confirm the change."); + setLoading(false); + return; + } const oldKek = await deriveKek(currentPassword, accountId); const wrappedBytes = base64ToBytes(me.wrapped_user_secrets); @@ -52,11 +62,16 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce const newWrappedBytes = await wrapUserSecrets(newKek, dek, x25519Private); const newWrappedUserSecrets = bytesToBase64(newWrappedBytes); - const auth = await updatePassword(oldAuthKey, newAuthKey, newWrappedUserSecrets, token); + const auth = await updatePassword(oldAuthKey, newAuthKey, newWrappedUserSecrets, token, proof); setDone(true); onSuccess(auth.jwt_token, auth.refresh_token); } catch (err) { - if (err instanceof ApiError && err.status === 401) { + const refusal = twoFactorMessage(err); + if (refusal) { + setNeedsCode(true); + setError(refusal); + setTwoFactor((entry) => ({ ...entry, value: "" })); + } else if (err instanceof ApiError && err.status === 401) { setError("Current password is incorrect."); } else { setError(err instanceof Error ? err.message : "Password change failed."); @@ -84,6 +99,7 @@ export default function ChangePasswordModal({ accountId, token, onClose, onSucce className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" /> setConfirmPassword(e.target.value)} className="w-full px-3.5 py-2.5 rounded-xl bg-[#0a0a0f] border border-[#1e1e2e] text-sm text-white placeholder:text-zinc-600 focus:outline-none focus:border-cyan-500/50 focus:ring-1 focus:ring-cyan-500/20 transition-colors" /> + {needsCode && } {error &&

{error}

}
diff --git a/portal/app/signin/page.tsx b/portal/app/signin/page.tsx index c248f64..0e3deb0 100644 --- a/portal/app/signin/page.tsx +++ b/portal/app/signin/page.tsx @@ -4,7 +4,11 @@ import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import Image from "next/image"; import { deriveAuthKey } from "../../lib/crypto"; -import { getChallenge, login } from "../../lib/api"; +import { + ApiError, getChallenge, login, loginSecondFactor, twoFactorMessage, + type AuthResponse, type TwoFactorChallenge, +} from "../../lib/api"; +import { EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry } from "../TwoFactorField"; export default function SigninPage() { const router = useRouter(); @@ -12,32 +16,64 @@ export default function SigninPage() { const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); + const [challenge, setChallenge] = useState(null); + const [twoFactor, setTwoFactor] = useState(EMPTY_TWO_FACTOR_ENTRY); const emailRef = useRef(null); useEffect(() => { emailRef.current?.focus(); }, []); - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); + function enter(auth: AuthResponse) { + sessionStorage.setItem("access_token", auth.jwt_token); + sessionStorage.setItem("refresh_token", auth.refresh_token); + sessionStorage.setItem("tier", auth.tier); + if (auth.trial_ends_at) sessionStorage.setItem("trial_ends_at", String(auth.trial_ends_at)); + + router.push("/account"); + } + + function backToPassword(message = "") { + setChallenge(null); + setTwoFactor(EMPTY_TWO_FACTOR_ENTRY); + setError(message); + } + + async function attempt(work: () => Promise) { setError(""); setLoading(true); try { - const { account_id } = await getChallenge(email); - const authKey = await deriveAuthKey(password, account_id); - const auth = await login(authKey, account_id); - - sessionStorage.setItem("access_token", auth.jwt_token); - sessionStorage.setItem("refresh_token", auth.refresh_token); - sessionStorage.setItem("tier", auth.tier); - if (auth.trial_ends_at) sessionStorage.setItem("trial_ends_at", String(auth.trial_ends_at)); - - router.push("/account"); + await work(); } catch (err) { - setError(err instanceof Error ? err.message : "Sign in failed."); + const refusal = twoFactorMessage(err); + // A bare 401 at the code step is the five-minute pending sign-in running out. + if (!refusal && challenge && err instanceof ApiError && err.status === 401) { + backToPassword("This sign-in took too long. Start again."); + } else { + setError(refusal ?? (err instanceof Error ? err.message : "Sign in failed.")); + setTwoFactor((entry) => ({ ...entry, value: "" })); + } } finally { setLoading(false); } } + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + return attempt(async () => { + const { account_id } = await getChallenge(email); + const authKey = await deriveAuthKey(password, account_id); + const result = await login(authKey, account_id); + if ("challenge" in result) setChallenge(result.challenge); + else enter(result.session); + }); + } + + function handleCode(e: React.FormEvent) { + e.preventDefault(); + const proof = entryProof(twoFactor); + if (!challenge || !proof) return; + return attempt(async () => enter(await loginSecondFactor(challenge.pending_token, proof))); + } + return (
{/* Ambient glow */} @@ -56,11 +92,37 @@ export default function SigninPage() { {/* Card */}
-

Welcome back

+

{challenge ? "Two-factor authentication" : "Welcome back"}

- Your password never leaves your device. + {challenge + ? "Enter the code from your authenticator app, or one of your recovery codes." + : "Your password never leaves your device."}

+ {challenge ? ( +
+ + + {error && ( +

{error}

+ )} + + + + + ) : (
@@ -77,6 +139,7 @@ export default function SigninPage() { {loading ? "Signing in…" : "Sign in"} + )}

diff --git a/portal/lib/api.ts b/portal/lib/api.ts index f923c53..41df15f 100644 --- a/portal/lib/api.ts +++ b/portal/lib/api.ts @@ -8,6 +8,16 @@ export class ApiError extends Error { super(message); this.name = "ApiError"; } + + /** The `{"error": CODE, ...}` body the server sends with a refusal it wants told apart. */ + get body(): Record | null { + try { + const parsed: unknown = JSON.parse(this.message); + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } + } } async function request( @@ -55,8 +65,37 @@ export interface MeResponse { handle: string; handle_is_custom: boolean; handle_managed?: boolean; + two_factor_enabled?: boolean; +} + +export type TwoFactorProof = { code: string } | { recoveryCode: string }; + +function proofBody(proof?: TwoFactorProof | null): { code?: string; recovery_code?: string } { + if (!proof) return {}; + return "code" in proof ? { code: proof.code.trim() } : { recovery_code: proof.recoveryCode.trim() }; +} + +const TWO_FACTOR_MESSAGES: Record = { + TWO_FACTOR_REQUIRED: "Enter the code from your authenticator app.", + TWO_FACTOR_INVALID: "Wrong code. Try the next one your app shows.", + TWO_FACTOR_LOCKED: "Too many wrong codes. Try again in 15 minutes.", + TWO_FACTOR_UNAVAILABLE: "This server cannot check authenticator codes right now. Use a recovery code.", +}; + +/** What to tell the user when a refusal is about the second factor, else null. */ +export function twoFactorMessage(err: unknown): string | null { + const code = err instanceof ApiError ? err.body?.error : null; + return typeof code === "string" ? (TWO_FACTOR_MESSAGES[code] ?? null) : null; +} + +export interface TwoFactorChallenge { + pending_token: string; + /** "totp" and/or "recovery". */ + methods: string[]; } +export type LoginResult = { session: AuthResponse } | { challenge: TwoFactorChallenge }; + export interface CheckoutResponse { checkout_url: string; } @@ -115,6 +154,7 @@ export function updatePassword( newAuthKey: string, newWrappedUserSecrets: string, token: string, + proof?: TwoFactorProof | null, ): Promise { return request( "/v1/auth/password", @@ -124,16 +164,37 @@ export function updatePassword( old_auth_key: oldAuthKey, new_auth_key: newAuthKey, new_wrapped_user_secrets: newWrappedUserSecrets, + ...proofBody(proof), }), }, token, ); } -export function login(authKey: string, accountId: string): Promise { - return request("/v1/auth/login", { +/** A session, or the challenge an account with two-factor sign-in answers the password with. */ +export async function login(authKey: string, accountId: string): Promise { + try { + const session = await request("/v1/auth/login", { + method: "POST", + body: JSON.stringify({ auth_key: authKey, account_id: accountId }), + }); + return { session }; + } catch (err) { + const body = err instanceof ApiError && err.status === 428 ? err.body : null; + if (body?.error !== "TWO_FACTOR_REQUIRED" || typeof body.pending_token !== "string") throw err; + return { + challenge: { + pending_token: body.pending_token, + methods: Array.isArray(body.methods) ? body.methods.filter((m): m is string => typeof m === "string") : ["totp"], + }, + }; + } +} + +export function loginSecondFactor(pendingToken: string, proof: TwoFactorProof): Promise { + return request("/v1/auth/login/2fa", { method: "POST", - body: JSON.stringify({ auth_key: authKey, account_id: accountId }), + body: JSON.stringify({ pending_token: pendingToken, ...proofBody(proof) }), }); }