From 55ec70bb45d609108ac272d32ed675c20fe58d6e Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 14:36:00 +0000 Subject: [PATCH] feat(account): optional two-factor sign-in and revoked-session handling (#615) Client half of VoltiusApp/server#104 and #105. Two-factor sign-in - Settings > Account gets a two-factor row on servers that offer it: password, QR code and key, first code, then the recovery codes with copy and save-to-file. Managing it offers new recovery codes and turning it off. Changing the master password asks for a code. - Every sign-in path (first launch, lock screen, cloud sign-in, add account) falls into a code step when the server answers 428. The step takes an authenticator code or a recovery code and then runs the follow-up of the sign-in it interrupted. - Unlocking is no longer a sign-in: when the password opens the vault from this device's cached secrets and the server still honours the session, login() does not post to /auth/login. Without that a two-factor account would be asked for a code on every unlock. - login() writes nothing to the keychain until the server has accepted the sign-in, so one abandoned at the code step does not leave an account that reads as local. Revoked sessions - /v1/auth/refresh had three callers and none handled a refusal: the sync loop retried every five seconds for good. There is now one refreshAccessToken. A 401 is remembered, so the loop stops asking, and a toast offers to sign in again, which asks for the master password of the current account and nothing else. - A 401 for a refresh token that was replaced while the request was in flight is retried with the new one. The server announces a version bump before it answers the request that caused it, so enabling two-factor made this device report itself signed out. signInServerAccount and the re-auth branch of login() posted the login separately with different error mapping; both go through serverLogin. --- package.json | 1 + pnpm-lock.yaml | 8 + src/components/layout/AuthPage.tsx | 44 ++- .../layout/AuthPage.twoFactor.test.tsx | 75 +++++ src/components/layout/CloudAuthModal.tsx | 98 ++++-- .../layout/CloudAuthModal.twoFactor.test.tsx | 137 ++++++++ .../settings/sections/AccountSection.tsx | 6 +- .../sections/ChangeMasterPasswordModal.tsx | 14 +- .../sections/TwoFactorSettings.test.tsx | 123 ++++++++ .../settings/sections/TwoFactorSettings.tsx | 243 ++++++++++++++ src/components/shared/TwoFactorStep.tsx | 136 ++++++++ src/i18n/locales/cs/common.json | 13 +- src/i18n/locales/cs/layout.json | 13 +- src/i18n/locales/cs/settings.json | 27 ++ src/i18n/locales/en/common.json | 13 +- src/i18n/locales/en/layout.json | 13 +- src/i18n/locales/en/settings.json | 27 ++ src/i18n/locales/fr/common.json | 13 +- src/i18n/locales/fr/layout.json | 13 +- src/i18n/locales/fr/settings.json | 27 ++ src/i18n/locales/ru/common.json | 13 +- src/i18n/locales/ru/layout.json | 13 +- src/i18n/locales/ru/settings.json | 27 ++ src/i18n/locales/tr/common.json | 13 +- src/i18n/locales/tr/layout.json | 13 +- src/i18n/locales/tr/settings.json | 27 ++ src/i18n/locales/zh/common.json | 13 +- src/i18n/locales/zh/layout.json | 13 +- src/i18n/locales/zh/settings.json | 27 ++ src/services/account.serverAuth.test.ts | 199 ++++++++++++ src/services/account.ts | 297 +++++++++++------- src/services/authTokens.ts | 71 ++++- src/services/serverCapabilities.ts | 20 +- src/services/sync.ts | 21 +- src/services/twoFactor.test.ts | 86 +++++ src/services/twoFactor.ts | 55 ++++ src/services/twoFactorProof.ts | 42 +++ src/stores/uiStore.ts | 4 +- 38 files changed, 1798 insertions(+), 200 deletions(-) create mode 100644 src/components/layout/AuthPage.twoFactor.test.tsx create mode 100644 src/components/layout/CloudAuthModal.twoFactor.test.tsx create mode 100644 src/components/settings/sections/TwoFactorSettings.test.tsx create mode 100644 src/components/settings/sections/TwoFactorSettings.tsx create mode 100644 src/components/shared/TwoFactorStep.tsx create mode 100644 src/services/twoFactor.test.ts create mode 100644 src/services/twoFactor.ts create mode 100644 src/services/twoFactorProof.ts diff --git a/package.json b/package.json index 74b374393..29571d399 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "country-flag-emoji-polyfill": "^0.1.8", "es-module-lexer": "^2.3.1", "i18next": "^26.3.3", + "qrcode-generator": "2.0.4", "react": "^19.2.7", "react-dom": "^19.2.7", "react-i18next": "^17.0.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2beb8f183..231b80b42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,9 @@ importers: i18next: specifier: ^26.3.3 version: 26.3.3(typescript@5.9.3) + qrcode-generator: + specifier: 2.0.4 + version: 2.0.4 react: specifier: ^19.2.7 version: 19.2.7 @@ -1619,6 +1622,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode-generator@2.0.4: + resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==} + react-dom@19.2.7: resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} peerDependencies: @@ -3589,6 +3595,8 @@ snapshots: punycode@2.3.1: {} + qrcode-generator@2.0.4: {} + react-dom@19.2.7(react@19.2.7): dependencies: react: 19.2.7 diff --git a/src/components/layout/AuthPage.tsx b/src/components/layout/AuthPage.tsx index 74f3509b5..e13cf383f 100644 --- a/src/components/layout/AuthPage.tsx +++ b/src/components/layout/AuthPage.tsx @@ -11,6 +11,7 @@ import { useNotificationStore } from "@/stores/notificationStore"; import { VaultUnreadableError } from "@/services/vaultErrors"; import { VaultBackups } from "@/components/shared/VaultBackups"; import { ServerUrlField } from "@/components/shared/ServerUrlField"; +import { TwoFactorStep, useTwoFactorChallenge } from "@/components/shared/TwoFactorStep"; import { lastServerUrl } from "@/utils/serverInstance"; import { useSystemAuthPrompt } from "@/hooks/useSystemAuthPrompt"; import { rebindAfterPassword, unlockWithSystemAuth } from "@/services/vaultBinding"; @@ -56,16 +57,29 @@ export default function AuthPage({ isLocked, vaultUnreadable, systemAuth, onRead setConfirm(""); }; - const wrap = async (fn: () => Promise) => { + const twoFactor = useTwoFactorChallenge(); + + // Not a bad password, so not the password form. + const unlessUnreadable = async (work: () => Promise) => { + try { + await work(); + } catch (e) { + if (!(e instanceof VaultUnreadableError)) throw e; + twoFactor.cancel(); + setUnreadable("wrong-key"); + } + }; + + const wrap = async (fn: () => Promise, after?: () => Promise) => { setLoading(true); setError(""); try { - await fn(); - onReady(); + await unlessUnreadable(() => twoFactor.run(fn, async () => { + await after?.(); + onReady(); + })); } catch (e) { - // Not a bad password, so not the password form. - if (e instanceof VaultUnreadableError) setUnreadable("wrong-key"); - else setError(e instanceof Error ? e.message : String(e)); + setError(e instanceof Error ? e.message : String(e)); } finally { setLoading(false); } @@ -130,13 +144,27 @@ export default function AuthPage({ isLocked, vaultUnreadable, systemAuth, onRead ); } + // ── Second factor (the server accepted the password) ───────────────────── + + if (twoFactor.challenge) { + const { methods, resume } = twoFactor.challenge; + return ( + + unlessUnreadable(() => resume(proof)) }} + onCancel={twoFactor.cancel} + inputClassName={INPUT_CLASS} + /> + + ); + } + // ── Locked (vault exists, need password) ───────────────────────────────── if (isLocked) { const submit = async (e: React.FormEvent) => { e.preventDefault(); - await wrap(async () => { - await login(password); + await wrap(() => login(password), async () => { if (bindingLost) await rebindAfterPassword(password, t("layout.appLock.sealReason")); }); }; diff --git a/src/components/layout/AuthPage.twoFactor.test.tsx b/src/components/layout/AuthPage.twoFactor.test.tsx new file mode 100644 index 000000000..60a59e9f0 --- /dev/null +++ b/src/components/layout/AuthPage.twoFactor.test.tsx @@ -0,0 +1,75 @@ +import { test, expect, vi, beforeEach } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const h = vi.hoisted(() => ({ login: vi.fn() })); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openUrl: vi.fn() })); +vi.mock("./LogoBadge", () => ({ default: () => null })); +vi.mock("@/services/account", () => ({ + createLocalAccountNoPassword: vi.fn(), + createServerAccount: vi.fn(), + login: h.login, +})); +vi.mock("@/stores/notificationStore", () => ({ + useNotificationStore: (sel: (s: unknown) => unknown) => sel({ addToast: vi.fn() }), +})); +vi.mock("@/components/shared/VaultBackups", () => ({ + VaultBackups: () =>
, +})); + +import AuthPage from "./AuthPage"; +import { TwoFactorRequiredError, type TwoFactorProof } from "@/services/twoFactorProof"; +import { VaultUnreadableError } from "@/services/vaultErrors"; + +beforeEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const submit = () => userEvent.click(document.querySelector('button[type="submit"]')!); + +async function reachCodeStep(complete: (proof: TwoFactorProof) => Promise, onReady = vi.fn()) { + h.login.mockRejectedValue(new TwoFactorRequiredError(["totp", "recovery"], complete)); + render(); + fireEvent.change(screen.getByPlaceholderText("layout.auth.masterPasswordPlaceholder"), { target: { value: "pw" } }); + await submit(); + const code = await screen.findByPlaceholderText("layout.twoFactor.codePlaceholder"); + expect(onReady).not.toHaveBeenCalled(); + return { code, onReady }; +} + +test("unlocking an account that needs a code opens the app only once the code is accepted", async () => { + const complete = vi.fn(async (_proof: TwoFactorProof) => {}); + const { code, onReady } = await reachCodeStep(complete); + + fireEvent.change(code, { target: { value: "123456" } }); + await submit(); + + await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)); + expect(complete).toHaveBeenCalledWith({ code: "123456" }); +}); + +test("cancelling the code step returns to the password form", async () => { + await reachCodeStep(async () => {}); + await userEvent.click(screen.getByRole("button", { name: "common.action.cancel" })); + expect(screen.getByPlaceholderText("layout.auth.masterPasswordPlaceholder")).toBeTruthy(); +}); + +test("an unreadable vault found after the code lands on recovery, not on a code error", async () => { + const { code, onReady } = await reachCodeStep(async () => { + throw new VaultUnreadableError(); + }); + + fireEvent.change(code, { target: { value: "123456" } }); + await submit(); + + expect(await screen.findByText("layout.auth.vaultUnreadableTitle")).toBeTruthy(); + expect(onReady).not.toHaveBeenCalled(); +}); diff --git a/src/components/layout/CloudAuthModal.tsx b/src/components/layout/CloudAuthModal.tsx index e937147ca..7c7a6dd28 100644 --- a/src/components/layout/CloudAuthModal.tsx +++ b/src/components/layout/CloudAuthModal.tsx @@ -4,10 +4,13 @@ import { Icon } from "@iconify/react"; import { Modal, ModalCard } from "@/components/shared/Modal"; import { useUIStore, type CloudAuthMode } from "@/stores/uiStore"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; -import { authenticateServerAccount, getAccountMode, linkToCloud, setMasterPassword, signInToCloud } from "@/services/account"; +import { + authenticateServerAccount, getAccountMode, getCurrentUserEmail, linkToCloud, login, setMasterPassword, signInToCloud, +} from "@/services/account"; import { addAccount } from "@/services/savedAccounts"; -import { startRealtimeSync, syncOnLogin, syncOnLoginReplace } from "@/services/sync"; +import { startRealtimeSync, syncNow, syncOnLogin, syncOnLoginReplace } from "@/services/sync"; import { ServerUrlField } from "@/components/shared/ServerUrlField"; +import { TwoFactorStep, useTwoFactorChallenge } from "@/components/shared/TwoFactorStep"; import { lastServerUrl } from "@/utils/serverInstance"; const AUTH_INPUT_CLASS = @@ -16,8 +19,11 @@ const AUTH_INPUT_CLASS = export default function CloudAuthModal() { const { t } = useTranslation(); const open = useUIStore((s) => s.cloudAuthOpen); - const mode = useUIStore((s) => s.cloudAuthMode); - const isAdd = useUIStore((s) => s.cloudAuthPurpose === "add"); + const purpose = useUIStore((s) => s.cloudAuthPurpose); + const isAdd = purpose === "add"; + // The session this device held was ended by the server; the account itself is unchanged. + const isReauth = purpose === "reauth"; + const mode = useUIStore((s) => (s.cloudAuthPurpose === "reauth" ? "signin" : s.cloudAuthMode)); const setMode = useUIStore((s) => s.setCloudAuthMode); const onClose = useUIStore((s) => s.closeCloudAuth); const reloadSubscription = useSubscriptionStore((s) => s.load); @@ -29,12 +35,15 @@ export default function CloudAuthModal() { const [serverUrl, setServerUrl] = useState(lastServerUrl); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const twoFactor = useTwoFactorChallenge(); useEffect(() => { if (!open) return; getAccountMode().then(setAccountMode).catch(() => setAccountMode(null)); + if (isReauth) getCurrentUserEmail().then((current) => setEmail(current ?? "")).catch(() => {}); setError(""); - }, [open]); + twoFactor.cancel(); + }, [open, isReauth]); if (!open) return null; @@ -64,30 +73,37 @@ export default function CloudAuthModal() { if (password !== confirm) { setError(t("layout.auth.errorPasswordMismatch")); return; } } + const connected = async (firstSync: () => Promise) => { + firstSync().catch(() => {}); + startRealtimeSync(); + await reloadSubscription().catch(() => {}); + onClose(); + setEmail(""); + setPassword(""); + setConfirm(""); + }; + setLoading(true); setError(""); try { - if (isAdd) { + if (isReauth) { + await twoFactor.run(() => login(password), () => connected(syncNow)); + } else if (isAdd) { // Nothing of the current session is touched until the server accepts these. - const session = await authenticateServerAccount(isRegister ? "register" : "signin", email, password, normalizedUrl); - await addAccount(session); - onClose(); - return; - } - if (isRegister) { + await twoFactor.run( + () => authenticateServerAccount(isRegister ? "register" : "signin", email, password, normalizedUrl), + async (session) => { + await addAccount(session); + onClose(); + }, + ); + } else if (isRegister) { if (isLocalNoPassword) await setMasterPassword(password); await linkToCloud(email, normalizedUrl); - syncOnLogin().catch(() => {}); + await connected(syncOnLogin); } else { - await signInToCloud(email, password, normalizedUrl); - syncOnLoginReplace().catch(() => {}); + await twoFactor.run(() => signInToCloud(email, password, normalizedUrl), () => connected(syncOnLoginReplace)); } - startRealtimeSync(); - await reloadSubscription().catch(() => {}); - onClose(); - setEmail(""); - setPassword(""); - setConfirm(""); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -107,19 +123,31 @@ export default function CloudAuthModal() {

- {isAdd - ? t("layout.cloudAuthModal.addTitle") - : isRegister ? t("layout.cloudAuthModal.registerTitle") : t("layout.cloudAuthModal.signinTitle")} + {isReauth + ? t("layout.cloudAuthModal.reauthTitle") + : isAdd + ? t("layout.cloudAuthModal.addTitle") + : isRegister ? t("layout.cloudAuthModal.registerTitle") : t("layout.cloudAuthModal.signinTitle")}

- {isAdd - ? t("layout.cloudAuthModal.addDesc") - : isRegister ? t("layout.cloudAuthModal.registerDesc") : t("layout.cloudAuthModal.signinDesc")} + {isReauth + ? t("layout.cloudAuthModal.reauthDesc") + : isAdd + ? t("layout.cloudAuthModal.addDesc") + : isRegister ? t("layout.cloudAuthModal.registerDesc") : t("layout.cloudAuthModal.signinDesc")}

-
+ {twoFactor.challenge ? ( + + ) : (<> + {!isReauth &&
{(["signin", "register"] as CloudAuthMode[]).map((next) => { const active = mode === next; return ( @@ -134,12 +162,14 @@ export default function CloudAuthModal() { ); })} -
+
}
- + {!isRegister && ( - + )} {needsNewPassword && ( <> @@ -148,7 +178,7 @@ export default function CloudAuthModal() { )} - + {!isReauth && } {error &&

{error}

} @@ -167,6 +197,7 @@ export default function CloudAuthModal() { {t("common.action.cancel")} + )} ); @@ -178,12 +209,14 @@ function AuthInput({ value, onChange, autoFocus, + disabled, }: { type: string; placeholder: string; value: string; onChange: (value: string) => void; autoFocus?: boolean; + disabled?: boolean; }) { return ( onChange(e.target.value)} className={AUTH_INPUT_CLASS} /> diff --git a/src/components/layout/CloudAuthModal.twoFactor.test.tsx b/src/components/layout/CloudAuthModal.twoFactor.test.tsx new file mode 100644 index 000000000..bdafcfc04 --- /dev/null +++ b/src/components/layout/CloudAuthModal.twoFactor.test.tsx @@ -0,0 +1,137 @@ +import { test, expect, vi, beforeEach } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const h = vi.hoisted(() => ({ + login: vi.fn(), + signInToCloud: vi.fn(), + syncNow: vi.fn(async () => {}), + syncOnLoginReplace: vi.fn(async () => {}), + startRealtimeSync: vi.fn(), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/services/account", () => ({ + authenticateServerAccount: vi.fn(), + getAccountMode: vi.fn(async () => "server"), + getCurrentUserEmail: vi.fn(async () => "me@x.io"), + linkToCloud: vi.fn(), + login: h.login, + setMasterPassword: vi.fn(), + signInToCloud: h.signInToCloud, +})); +vi.mock("@/services/savedAccounts", () => ({ addAccount: vi.fn() })); +vi.mock("@/services/sync", () => ({ + startRealtimeSync: h.startRealtimeSync, + syncNow: h.syncNow, + syncOnLogin: vi.fn(async () => {}), + syncOnLoginReplace: h.syncOnLoginReplace, +})); +vi.mock("@/stores/subscriptionStore", () => ({ + useSubscriptionStore: (sel: (s: unknown) => unknown) => sel({ load: vi.fn(async () => {}) }), +})); + +import CloudAuthModal from "./CloudAuthModal"; +import { useUIStore } from "@/stores/uiStore"; +import { TwoFactorRequiredError, type TwoFactorMethod, type TwoFactorProof } from "@/services/twoFactorProof"; + +beforeEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const submit = () => userEvent.click(document.querySelector('button[type="submit"]')!); +const type = (placeholder: string, value: string) => + fireEvent.change(screen.getByPlaceholderText(placeholder), { target: { value } }); + +type Complete = (proof: TwoFactorProof) => Promise; + +function asksForCode(complete: Complete, methods: TwoFactorMethod[] = ["totp", "recovery"]) { + return new TwoFactorRequiredError(methods, complete); +} + +test("a sign-in that needs a code shows the code step and finishes from it", async () => { + const complete = vi.fn(async () => {}); + h.signInToCloud.mockRejectedValue(asksForCode(complete)); + useUIStore.getState().openCloudAuth("signin", "link"); + render(); + + type("layout.auth.emailPlaceholder", "a@x.io"); + type("layout.auth.masterPasswordPlaceholder", "pw"); + await submit(); + + const code = await screen.findByPlaceholderText("layout.twoFactor.codePlaceholder"); + expect(screen.queryByPlaceholderText("layout.auth.masterPasswordPlaceholder")).toBeNull(); + expect(h.syncOnLoginReplace).not.toHaveBeenCalled(); + expect(useUIStore.getState().cloudAuthOpen).toBe(true); + + fireEvent.change(code, { target: { value: "123456" } }); + await submit(); + + await waitFor(() => expect(useUIStore.getState().cloudAuthOpen).toBe(false)); + expect(complete).toHaveBeenCalledWith({ code: "123456" }); + expect(h.syncOnLoginReplace).toHaveBeenCalled(); + expect(h.startRealtimeSync).toHaveBeenCalled(); +}); + +test("a refused code keeps the step open with the reason, and a recovery code can be used instead", async () => { + const complete = vi.fn() + .mockRejectedValueOnce(new Error("common.error.twoFactorInvalid")) + .mockResolvedValueOnce(undefined); + h.signInToCloud.mockRejectedValue(asksForCode(complete)); + useUIStore.getState().openCloudAuth("signin", "link"); + render(); + + type("layout.auth.emailPlaceholder", "a@x.io"); + type("layout.auth.masterPasswordPlaceholder", "pw"); + await submit(); + fireEvent.change(await screen.findByPlaceholderText("layout.twoFactor.codePlaceholder"), { target: { value: "000000" } }); + await submit(); + + expect(await screen.findByText("common.error.twoFactorInvalid")).toBeTruthy(); + expect(useUIStore.getState().cloudAuthOpen).toBe(true); + + await userEvent.click(screen.getByRole("button", { name: "layout.twoFactor.useRecovery" })); + type("layout.twoFactor.recoveryPlaceholder", "abcd-efgh-jkmn-pqrs"); + await submit(); + + await waitFor(() => expect(useUIStore.getState().cloudAuthOpen).toBe(false)); + expect(complete).toHaveBeenLastCalledWith({ recoveryCode: "abcd-efgh-jkmn-pqrs" }); +}); + +test("a server that lost its seed key is only offered a recovery code", async () => { + h.signInToCloud.mockRejectedValue(asksForCode(async () => {}, ["recovery"])); + useUIStore.getState().openCloudAuth("signin", "link"); + render(); + + type("layout.auth.emailPlaceholder", "a@x.io"); + type("layout.auth.masterPasswordPlaceholder", "pw"); + await submit(); + + expect(await screen.findByPlaceholderText("layout.twoFactor.recoveryPlaceholder")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "layout.twoFactor.useCode" })).toBeNull(); +}); + +test("signing in again after a revoked session asks only for the password of the current account", async () => { + h.login.mockResolvedValue(undefined); + useUIStore.getState().openCloudAuth("signin", "reauth"); + render(); + + const email = await screen.findByDisplayValue("me@x.io"); + expect((email as HTMLInputElement).disabled).toBe(true); + expect(screen.queryByRole("button", { name: "layout.auth.createAccount" })).toBeNull(); + expect(screen.getByText("layout.cloudAuthModal.reauthTitle")).toBeTruthy(); + + type("layout.auth.masterPasswordPlaceholder", "pw"); + await submit(); + + await waitFor(() => expect(useUIStore.getState().cloudAuthOpen).toBe(false)); + expect(h.login).toHaveBeenCalledWith("pw"); + expect(h.signInToCloud).not.toHaveBeenCalled(); + expect(h.syncNow).toHaveBeenCalled(); +}); diff --git a/src/components/settings/sections/AccountSection.tsx b/src/components/settings/sections/AccountSection.tsx index 9df9eead5..8f7472bc6 100644 --- a/src/components/settings/sections/AccountSection.tsx +++ b/src/components/settings/sections/AccountSection.tsx @@ -19,6 +19,7 @@ import { canLockApp } from "@/utils/accountMode"; import EditEmailModal from "./EditEmailModal"; import { SessionSecuritySettings } from "./SessionSecuritySettings"; import ChangeMasterPasswordModal from "./ChangeMasterPasswordModal"; +import { TwoFactorSettings } from "./TwoFactorSettings"; import { formatOptionalDate, SHORT_DATE } from "@/utils/localeFormat"; type AccountStep = "idle" | "set-password" | "loading" | "confirm-wipe"; @@ -116,6 +117,7 @@ export default function AccountSection() { const [handle, setHandle] = useState(null); const [handleIsCustom, setHandleIsCustom] = useState(false); const [handleManaged, setHandleManaged] = useState(false); + const [twoFactorEnabled, setTwoFactorEnabled] = useState(null); // `null` until /auth/me answers — the claim control renders in neither state // until then, so a verified user never sees the "verify your email" row flash. const [emailVerified, setEmailVerified] = useState(null); @@ -159,6 +161,7 @@ export default function AccountSection() { setHandleIsCustom(!!me.handle_is_custom); setEmailVerified(!!me.email_verified); setHandleManaged(!!me.handle_managed); + setTwoFactorEnabled(!!me.two_factor_enabled); if (typeof me.allow_stranger_invites === "boolean") setAllowStrangerInvites(me.allow_stranger_invites); }).catch(() => {}); setStep("idle"); @@ -346,6 +349,7 @@ export default function AccountSection() { onClick={() => setShowChangePassword(true)} /> )} + {mode === "server" && } {mode === "local-nopassword" && ( setShowChangePassword(false)} /> + setShowChangePassword(false)} /> )} {step === "confirm-wipe" && ( diff --git a/src/components/settings/sections/ChangeMasterPasswordModal.tsx b/src/components/settings/sections/ChangeMasterPasswordModal.tsx index 549e0902e..b33a09844 100644 --- a/src/components/settings/sections/ChangeMasterPasswordModal.tsx +++ b/src/components/settings/sections/ChangeMasterPasswordModal.tsx @@ -2,17 +2,24 @@ import { useState, type FormEvent } from "react"; import { Icon } from "@iconify/react"; import { useTranslation } from "react-i18next"; import { changeMasterPassword } from "@/services/account"; +import { INPUT_CLASS } from "@/components/layout/authParts"; +import { + EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry, +} from "@/components/shared/TwoFactorStep"; import { FormButtons, SettingsDialog, SettingsInput } from "./shared"; interface Props { + /** The server asks for a code with the change once the account has a second factor. */ + twoFactorEnabled?: boolean; onClose: () => void; } -export default function ChangeMasterPasswordModal({ onClose }: Props) { +export default function ChangeMasterPasswordModal({ twoFactorEnabled, onClose }: Props) { const { t } = useTranslation(); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [twoFactor, setTwoFactor] = useState(EMPTY_TWO_FACTOR_ENTRY); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); const [done, setDone] = useState(false); @@ -39,7 +46,7 @@ export default function ChangeMasterPasswordModal({ onClose }: Props) { setLoading(true); setError(""); try { - await changeMasterPassword(currentPassword, newPassword); + await changeMasterPassword(currentPassword, newPassword, twoFactorEnabled ? entryProof(twoFactor) : null); setDone(true); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -87,6 +94,9 @@ export default function ChangeMasterPasswordModal({ onClose }: Props) { value={confirmPassword} onChange={setConfirmPassword} /> + {twoFactorEnabled && ( + + )} {error &&

{error}

} ({ + offered: true, + beginTwoFactorSetup: vi.fn(), + enableTwoFactor: vi.fn(), + disableTwoFactor: vi.fn(), + regenerateRecoveryCodes: vi.fn(), + saveTextFile: vi.fn(async () => true), + writeClipboard: vi.fn(async () => {}), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (k: string) => k }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@iconify/react", () => ({ Icon: () => null })); +vi.mock("@/services/serverCapabilities", () => ({ useServerOffersTwoFactor: () => h.offered })); +vi.mock("@/services/twoFactor", () => ({ + beginTwoFactorSetup: h.beginTwoFactorSetup, + enableTwoFactor: h.enableTwoFactor, + disableTwoFactor: h.disableTwoFactor, + regenerateRecoveryCodes: h.regenerateRecoveryCodes, +})); +vi.mock("@/services/saveFile", () => ({ saveTextFile: h.saveTextFile })); +vi.mock("@/utils/clipboard", () => ({ writeClipboard: h.writeClipboard })); + +import { TwoFactorSettings } from "./TwoFactorSettings"; + +const CODES = ["aaaa-bbbb-cccc-dddd", "eeee-ffff-gggg-hhhh"]; +const onChange = vi.fn(); + +beforeEach(() => { + cleanup(); + vi.clearAllMocks(); + h.offered = true; +}); + +const open = () => userEvent.click(screen.getByRole("button", { name: /settings\.account\.twoFactor\.label/ })); +const submit = () => userEvent.click(document.querySelector('button[type="submit"]')!); +const type = (placeholder: string, value: string) => + fireEvent.change(screen.getByPlaceholderText(placeholder), { target: { value } }); + +test("nothing is shown until the account answers, or on a server that does not offer it", () => { + const { rerender, container } = render(); + expect(container.innerHTML).toBe(""); + + h.offered = false; + rerender(); + expect(container.innerHTML).toBe(""); + + rerender(); + expect(screen.getByText("settings.account.twoFactor.subOn")).toBeTruthy(); +}); + +test("turning it on goes password, QR and first code, then the recovery codes", async () => { + h.beginTwoFactorSetup.mockResolvedValue({ secret: "SEEDSEED", otpauth_uri: "otpauth://totp/Voltius:a?secret=SEEDSEED" }); + h.enableTwoFactor.mockResolvedValue(CODES); + render(); + await open(); + + expect(screen.getByText("settings.account.twoFactor.introDevices")).toBeTruthy(); + type("layout.auth.masterPasswordPlaceholder", "pw"); + await submit(); + + expect(h.beginTwoFactorSetup).toHaveBeenCalledWith("pw"); + expect(await screen.findByRole("img", { name: "settings.account.twoFactor.qrLabel" })).toBeTruthy(); + expect(screen.getByText("SEEDSEED")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "layout.twoFactor.useRecovery" })).toBeNull(); + + type("layout.twoFactor.codePlaceholder", "123456"); + await submit(); + + expect(h.enableTwoFactor).toHaveBeenCalledWith("123456"); + for (const code of CODES) expect(await screen.findByText(code)).toBeTruthy(); + expect(onChange).toHaveBeenCalledWith(true); + + await userEvent.click(screen.getByRole("button", { name: "settings.account.twoFactor.copy" })); + expect(h.writeClipboard).toHaveBeenCalledWith(CODES.join("\n")); + await userEvent.click(screen.getByRole("button", { name: "settings.account.twoFactor.save" })); + expect(h.saveTextFile).toHaveBeenCalledWith("voltius-recovery-codes.txt", `${CODES.join("\n")}\n`); +}); + +test("the recovery codes stay up until the user says they are saved", async () => { + h.regenerateRecoveryCodes.mockResolvedValue(CODES); + render(); + await open(); + await userEvent.click(screen.getByRole("button", { name: /settings\.account\.twoFactor\.newCodes/ })); + type("layout.twoFactor.codePlaceholder", "123456"); + await submit(); + + expect(h.regenerateRecoveryCodes).toHaveBeenCalledWith({ code: "123456" }); + await screen.findByText(CODES[0]); + // The scrim and the header close button are the two ways out of every other view. + fireEvent.click(document.querySelector(".fixed.inset-0")!); + expect(screen.getByText(CODES[0])).toBeTruthy(); + + await userEvent.click(screen.getByRole("button", { name: "settings.account.twoFactor.saved" })); + expect(screen.queryByText(CODES[0])).toBeNull(); +}); + +test("turning it off takes the password and a code, and a refusal stays on the form", async () => { + h.disableTwoFactor.mockRejectedValueOnce(new Error("common.error.twoFactorInvalid")).mockResolvedValueOnce(undefined); + render(); + await open(); + await userEvent.click(screen.getByRole("button", { name: /settings\.account\.twoFactor\.turnOff/ })); + + type("layout.auth.masterPasswordPlaceholder", "pw"); + type("layout.twoFactor.codePlaceholder", "000000"); + await submit(); + expect(await screen.findByText("common.error.twoFactorInvalid")).toBeTruthy(); + expect(onChange).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("button", { name: "layout.twoFactor.useRecovery" })); + type("layout.twoFactor.recoveryPlaceholder", "aaaa-bbbb-cccc-dddd"); + await submit(); + + expect(h.disableTwoFactor).toHaveBeenLastCalledWith("pw", { recoveryCode: "aaaa-bbbb-cccc-dddd" }); + expect(onChange).toHaveBeenCalledWith(false); +}); diff --git a/src/components/settings/sections/TwoFactorSettings.tsx b/src/components/settings/sections/TwoFactorSettings.tsx new file mode 100644 index 000000000..037ff465a --- /dev/null +++ b/src/components/settings/sections/TwoFactorSettings.tsx @@ -0,0 +1,243 @@ +import { useMemo, useState, type FormEvent, type ReactNode } from "react"; +import { Icon } from "@iconify/react"; +import { useTranslation } from "react-i18next"; +import qrcode from "qrcode-generator"; +import { INPUT_CLASS } from "@/components/layout/authParts"; +import { + EMPTY_TWO_FACTOR_ENTRY, TwoFactorField, entryProof, type TwoFactorEntry, +} from "@/components/shared/TwoFactorStep"; +import { useCopiedFlash } from "@/hooks/useCopiedFlash"; +import { saveTextFile } from "@/services/saveFile"; +import { useServerOffersTwoFactor } from "@/services/serverCapabilities"; +import { + beginTwoFactorSetup, disableTwoFactor, enableTwoFactor, regenerateRecoveryCodes, type TwoFactorSetup, +} from "@/services/twoFactor"; +import { writeClipboard } from "@/utils/clipboard"; +import { ActionItem, FormButtons, SettingsDialog, SettingsInput } from "./shared"; + +const QUIET_ZONE = 4; + +function QrCode({ value, label }: { value: string; label: string }) { + const { size, path } = useMemo(() => { + const qr = qrcode(0, "M"); + qr.addData(value); + qr.make(); + const count = qr.getModuleCount(); + let d = ""; + for (let row = 0; row < count; row++) { + for (let col = 0; col < count; col++) { + if (qr.isDark(row, col)) d += `M${col + QUIET_ZONE} ${row + QUIET_ZONE}h1v1h-1z`; + } + } + return { size: count + QUIET_ZONE * 2, path: d }; + }, [value]); + // Black on white whatever the theme: scanners need the contrast. + return ( + + + + + ); +} + +type View = + | { kind: "intro" } + | { kind: "confirm"; setup: TwoFactorSetup } + | { kind: "codes"; codes: string[] } + | { kind: "manage" } + | { kind: "regenerate" } + | { kind: "disable" }; + +function RecoveryCodes({ codes, onDone }: { codes: string[]; onDone: () => void }) { + const { t } = useTranslation(); + const { copied, flash } = useCopiedFlash(1500); + const text = codes.join("\n"); + return ( +
+

{t("settings.account.twoFactor.codesBody")}

+
    + {codes.map((code) =>
  • {code}
  • )} +
+
+ + +
+ +
+ ); +} + +function TwoFactorDialog({ enabled, onChange, onClose }: { + enabled: boolean; + onChange: (enabled: boolean) => void; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [view, setView] = useState({ kind: enabled ? "manage" : "intro" }); + const [password, setPassword] = useState(""); + const [entry, setEntry] = useState(EMPTY_TWO_FACTOR_ENTRY); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const { copied: keyCopied, flash: flashKey } = useCopiedFlash(1500); + + const go = (next: View) => { + setView(next); + setEntry(EMPTY_TWO_FACTOR_ENTRY); + setError(""); + }; + + const attempt = (work: () => Promise) => async (e: FormEvent) => { + e.preventDefault(); + setBusy(true); + setError(""); + try { + await work(); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setEntry((current) => ({ ...current, value: "" })); + } finally { + setBusy(false); + } + }; + + const withProof = (work: (proof: NonNullable>) => Promise) => + attempt(async () => { + const proof = entryProof(entry); + if (proof) await work(proof); + }); + + const form = (onSubmit: (e: FormEvent) => void, submitLabel: string, onCancel: () => void, children: ReactNode) => ( +
+ {children} + {error &&

{error}

} + + + ); + + const hint = (text: string) =>

{text}

; + const passwordInput = ( + + ); + + let body: ReactNode; + switch (view.kind) { + case "intro": + body = form( + attempt(async () => go({ kind: "confirm", setup: await beginTwoFactorSetup(password) })), + t("settings.account.twoFactor.continue"), onClose, + <> + {hint(t("settings.account.twoFactor.intro"))} + {hint(t("settings.account.twoFactor.introDevices"))} + {passwordInput} + , + ); + break; + case "confirm": + body = form( + attempt(async () => { + const codes = await enableTwoFactor(entry.value); + onChange(true); + go({ kind: "codes", codes }); + }), + t("settings.account.twoFactor.turnOn"), onClose, + <> + {hint(t("settings.account.twoFactor.scan"))} + + + {hint(t("settings.account.twoFactor.confirmPrompt"))} + + , + ); + break; + case "codes": + body = ; + break; + case "manage": + body = ( +
+ {hint(t("settings.account.twoFactor.manageBody"))} + go({ kind: "regenerate" })} /> + go({ kind: "disable" })} /> +
+ ); + break; + case "regenerate": + body = form( + withProof(async (proof) => go({ kind: "codes", codes: await regenerateRecoveryCodes(proof) })), + t("settings.account.twoFactor.generate"), () => go({ kind: "manage" }), + <> + {hint(t("settings.account.twoFactor.regeneratePrompt"))} + + , + ); + break; + case "disable": + body = form( + withProof(async (proof) => { + await disableTwoFactor(password, proof); + onChange(false); + onClose(); + }), + t("settings.account.twoFactor.turnOff"), () => go({ kind: "manage" }), + <> + {hint(t("settings.account.twoFactor.disablePrompt"))} + {passwordInput} + + , + ); + break; + } + + return ( + {} : onClose} + > + {body} + + ); +} + +/** Settings row and dialogs for the account's second factor. `enabled` is null until /me answers. */ +export function TwoFactorSettings({ enabled, onChange }: { + enabled: boolean | null; + onChange: (enabled: boolean) => void; +}) { + const { t } = useTranslation(); + const offered = useServerOffersTwoFactor(); + const [open, setOpen] = useState(false); + // An account already enrolled keeps the row on a server that stopped offering it: turning it off still works. + if (enabled === null || (!enabled && !offered)) return null; + return ( + <> + setOpen(true)} + /> + {open && setOpen(false)} />} + + ); +} diff --git a/src/components/shared/TwoFactorStep.tsx b/src/components/shared/TwoFactorStep.tsx new file mode 100644 index 000000000..8a9adb564 --- /dev/null +++ b/src/components/shared/TwoFactorStep.tsx @@ -0,0 +1,136 @@ +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Icon } from "@iconify/react"; +import { TwoFactorRequiredError, type TwoFactorMethod, type TwoFactorProof } from "@/services/twoFactorProof"; + +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: TwoFactorMethod[] = ["totp", "recovery"]; + +export function TwoFactorField({ entry, onChange, inputClassName, autoFocus, methods = ALL_METHODS }: { + entry: TwoFactorEntry; + onChange: (entry: TwoFactorEntry) => void; + inputClassName: string; + autoFocus?: boolean; + /** What the server will accept; a server that lost its seed key offers recovery codes only. */ + methods?: TwoFactorMethod[]; +}) { + const { t } = useTranslation(); + 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 ? t("layout.twoFactor.recoveryPlaceholder") : t("layout.twoFactor.codePlaceholder")} + className={`${inputClassName} font-mono tracking-wider`} + /> + {canSwitch && ( + + )} + + ); +} + +export interface TwoFactorChallenge { + methods: TwoFactorMethod[]; + /** Rejects with the message to show; resolving means the sign-in went through. */ + resume: (proof: TwoFactorProof) => Promise; +} + +/** + * Runs a sign-in and what follows it. When the server asks for a code in + * between, `challenge` is set and resuming it runs the same follow-up. + */ +export function useTwoFactorChallenge() { + const [challenge, setChallenge] = useState(null); + const run = async (work: () => Promise, after: (result: T) => Promise | void): Promise => { + try { + await after(await work()); + setChallenge(null); + } catch (e) { + if (!(e instanceof TwoFactorRequiredError)) throw e; + const step = e as TwoFactorRequiredError; + setChallenge({ methods: step.methods, resume: (proof) => run(() => step.complete(proof), after) }); + } + }; + return { challenge, run, cancel: () => setChallenge(null) }; +} + +/** The code form a sign-in falls into when the account has two-factor on. */ +export function TwoFactorStep({ challenge, onCancel, inputClassName, buttonClassName = "py-2" }: { + challenge: TwoFactorChallenge; + onCancel: () => void; + inputClassName: string; + buttonClassName?: string; +}) { + const { t } = useTranslation(); + const [entry, setEntry] = useState(EMPTY_TWO_FACTOR_ENTRY); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const { methods } = challenge; + const recovery = entry.recovery || !methods.includes("totp"); + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + const proof = entryProof({ recovery, value: entry.value }); + if (!proof) return; + setLoading(true); + setError(""); + try { + await challenge.resume(proof); + } catch (err) { + setError(err instanceof Error ? err.message : String(err)); + setEntry({ recovery, value: "" }); + } finally { + setLoading(false); + } + }; + + return ( +
+

+ {recovery ? t("layout.twoFactor.recoveryPrompt") : t("layout.twoFactor.prompt")} +

+ + {error &&

{error}

} + + + + ); +} diff --git a/src/i18n/locales/cs/common.json b/src/i18n/locales/cs/common.json index 608da36c4..e4f8467b7 100644 --- a/src/i18n/locales/cs/common.json +++ b/src/i18n/locales/cs/common.json @@ -64,7 +64,9 @@ "undoFailed": "Vrácení zpět se nezdařilo: {{error}}", "redoFailed": "Opakování se nezdařilo: {{error}}", "fileSaved": "Uloženo do {{path}}", - "fileSaveFailed": "Soubor se nepodařilo uložit: {{error}}" + "fileSaveFailed": "Soubor se nepodařilo uložit: {{error}}", + "sessionRevoked": "Toto zařízení bylo odhlášeno z vašeho účtu Voltius. Přihlaste se znovu, aby synchronizace pokračovala.", + "signInAgain": "Přihlásit se" }, "error": { "identityPickUnavailable": "Vaše identita pro {{host}} není k dispozici", @@ -205,7 +207,14 @@ "inviteCodeMalformed": "Tento kód nemá správný formát", "inviteCodeNotFound": "Platnost kódu vypršela nebo je nesprávný", "inviteCodeTooManyAttempts": "Příliš mnoho pokusů — chvíli počkejte a zkuste to znovu", - "businessPlanRequired": "To vyžaduje týmový tarif Business." + "businessPlanRequired": "To vyžaduje týmový tarif Business.", + "twoFactorRequired": "Zadejte kód z ověřovací aplikace.", + "twoFactorInvalid": "Nesprávný kód. Zkuste další, který aplikace zobrazí.", + "twoFactorLocked": "Příliš mnoho nesprávných kódů. Zkuste to znovu za 15 minut.", + "twoFactorUnavailable": "Tento server teď nemůže ověřovat kódy z ověřovací aplikace. Použijte záložní kód.", + "twoFactorAlreadyEnabled": "Dvoufázové ověření je už zapnuté.", + "twoFactorNotEnabled": "Dvoufázové ověření není zapnuté.", + "twoFactorExpired": "Přihlášení trvalo příliš dlouho. Začněte znovu." }, "clipboard": { "cut_one": "Vyjmuta {{count}} položka", diff --git a/src/i18n/locales/cs/layout.json b/src/i18n/locales/cs/layout.json index 8bc9a1525..b587605fd 100644 --- a/src/i18n/locales/cs/layout.json +++ b/src/i18n/locales/cs/layout.json @@ -60,7 +60,18 @@ "confirmMasterPasswordPlaceholder": "Potvrďte hlavní heslo", "errorPasswordRequired": "Heslo je povinné", "errorMinLength4": "Alespoň 4 znaky", - "pleaseWait": "Čekejte prosím…" + "pleaseWait": "Čekejte prosím…", + "reauthTitle": "Přihlaste se znovu", + "reauthDesc": "Relace na tomto zařízení skončila. Zadejte hlavní heslo a připojte se znovu." + }, + "twoFactor": { + "prompt": "Zadejte šestimístný kód z ověřovací aplikace.", + "recoveryPrompt": "Zadejte jeden ze záložních kódů. Každý funguje jen jednou.", + "codePlaceholder": "Šestimístný kód", + "recoveryPlaceholder": "Záložní kód", + "useRecovery": "Použít záložní kód", + "useCode": "Použít kód z ověřovací aplikace", + "verify": "Ověřit" }, "mainPanel": { "noVaultSelectedTitle": "Nejsou vybrány žádné trezory", diff --git a/src/i18n/locales/cs/settings.json b/src/i18n/locales/cs/settings.json index a48436643..50d67dc22 100644 --- a/src/i18n/locales/cs/settings.json +++ b/src/i18n/locales/cs/settings.json @@ -341,6 +341,33 @@ "changing": "Změna…", "changeBtn": "Změnit heslo" }, + "twoFactor": { + "label": "Dvoufázové ověření", + "subOff": "Při přihlášení na novém zařízení vyžadovat kód z ověřovací aplikace", + "subOn": "Zapnuto. Přihlášení na novém zařízení vyžaduje kód", + "intro": "Po hlavním hesle bude přihlášení na novém zařízení vyžadovat šestimístný kód z ověřovací aplikace.", + "introDevices": "Zapnutím odhlásíte svá ostatní zařízení. K opětovnému přihlášení potřebují aktuální verzi Voltiusu.", + "continue": "Pokračovat", + "scan": "Naskenujte kód ověřovací aplikací, nebo do ní opište klíč níže.", + "qrLabel": "QR kód pro ověřovací aplikaci", + "copyKey": "Zkopírovat klíč", + "confirmPrompt": "Potom zadejte šestimístný kód, který aplikace zobrazí.", + "turnOn": "Zapnout", + "codesTitle": "Uložte si záložní kódy", + "codesBody": "Každý kód vás jednou přihlásí, pokud přijdete o ověřovací aplikaci. Znovu se už nezobrazí.", + "copy": "Kopírovat", + "copied": "Zkopírováno", + "save": "Uložit do souboru", + "saved": "Mám je uložené", + "manageBody": "Přihlášení na novém zařízení vyžaduje kód z ověřovací aplikace.", + "newCodes": "Nové záložní kódy", + "newCodesSub": "Stávající přestanou platit", + "turnOff": "Vypnout", + "turnOffSub": "Přihlašovat se jen hlavním heslem", + "generate": "Vygenerovat", + "regeneratePrompt": "Zadejte kód, kterým nahradíte záložní kódy.", + "disablePrompt": "Zadejte hlavní heslo a kód, kterým dvoufázové ověření vypnete." + }, "setMasterPassword": { "label": "Nastavit hlavní heslo", "sub": "Chraňte trezor lokálně bez synchronizace s cloudem" diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 5050114fd..b92be5e98 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -64,7 +64,9 @@ "undoFailed": "Undo failed: {{error}}", "redoFailed": "Redo failed: {{error}}", "fileSaved": "Saved to {{path}}", - "fileSaveFailed": "Couldn't save the file: {{error}}" + "fileSaveFailed": "Couldn't save the file: {{error}}", + "sessionRevoked": "This device was signed out of your Voltius account. Sign in again to keep syncing.", + "signInAgain": "Sign in" }, "error": { "identityPickUnavailable": "Your identity for {{host}} isn't available", @@ -205,7 +207,14 @@ "inviteCodeMalformed": "That code is not in the right format", "inviteCodeNotFound": "That code has expired or is incorrect", "inviteCodeTooManyAttempts": "Too many attempts — wait a moment and try again", - "businessPlanRequired": "This needs the team's Business plan." + "businessPlanRequired": "This needs the team's Business plan.", + "twoFactorRequired": "Enter the code from your authenticator app.", + "twoFactorInvalid": "Wrong code. Try the next one your app shows.", + "twoFactorLocked": "Too many wrong codes. Try again in 15 minutes.", + "twoFactorUnavailable": "This server cannot check authenticator codes right now. Use a recovery code.", + "twoFactorAlreadyEnabled": "Two-factor authentication is already on.", + "twoFactorNotEnabled": "Two-factor authentication is not on.", + "twoFactorExpired": "This sign-in took too long. Start again." }, "clipboard": { "cut_one": "{{count}} item cut", diff --git a/src/i18n/locales/en/layout.json b/src/i18n/locales/en/layout.json index 05866fa26..52cc92dcd 100644 --- a/src/i18n/locales/en/layout.json +++ b/src/i18n/locales/en/layout.json @@ -60,7 +60,18 @@ "confirmMasterPasswordPlaceholder": "Confirm master password", "errorPasswordRequired": "Password required", "errorMinLength4": "At least 4 characters", - "pleaseWait": "Please wait…" + "pleaseWait": "Please wait…", + "reauthTitle": "Sign in again", + "reauthDesc": "Your session on this device has ended. Enter your master password to reconnect." + }, + "twoFactor": { + "prompt": "Enter the 6-digit code from your authenticator app.", + "recoveryPrompt": "Enter one of your recovery codes. Each one works once.", + "codePlaceholder": "6-digit code", + "recoveryPlaceholder": "Recovery code", + "useRecovery": "Use a recovery code", + "useCode": "Use an authenticator code", + "verify": "Verify" }, "mainPanel": { "noVaultSelectedTitle": "No vaults selected", diff --git a/src/i18n/locales/en/settings.json b/src/i18n/locales/en/settings.json index 4ee125d06..56d11873e 100644 --- a/src/i18n/locales/en/settings.json +++ b/src/i18n/locales/en/settings.json @@ -341,6 +341,33 @@ "changing": "Changing…", "changeBtn": "Change password" }, + "twoFactor": { + "label": "Two-factor authentication", + "subOff": "Ask for a code from an authenticator app when signing in on a new device", + "subOn": "On. Signing in on a new device asks for a code", + "intro": "After your master password, signing in on a new device will ask for a 6-digit code from an authenticator app.", + "introDevices": "Turning this on signs out your other devices. They need a recent version of Voltius to sign back in.", + "continue": "Continue", + "scan": "Scan this with your authenticator app, or type the key below into it.", + "qrLabel": "QR code for your authenticator app", + "copyKey": "Copy key", + "confirmPrompt": "Then enter the 6-digit code it shows.", + "turnOn": "Turn on", + "codesTitle": "Save your recovery codes", + "codesBody": "Each code signs you in once if you lose your authenticator. They are not shown again.", + "copy": "Copy", + "copied": "Copied", + "save": "Save to file", + "saved": "I saved them", + "manageBody": "Signing in on a new device asks for a code from your authenticator app.", + "newCodes": "New recovery codes", + "newCodesSub": "The current ones stop working", + "turnOff": "Turn off", + "turnOffSub": "Sign in with the master password alone", + "generate": "Generate", + "regeneratePrompt": "Enter a code to replace your recovery codes.", + "disablePrompt": "Enter your master password and a code to turn two-factor authentication off." + }, "setMasterPassword": { "label": "Set a master password", "sub": "Protect your vault locally without cloud sync" diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 77c0e32e8..2a8d33ace 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -64,7 +64,9 @@ "undoFailed": "Échec de l'annulation : {{error}}", "redoFailed": "Échec du rétablissement : {{error}}", "fileSaved": "Enregistré dans {{path}}", - "fileSaveFailed": "Impossible d'enregistrer le fichier : {{error}}" + "fileSaveFailed": "Impossible d'enregistrer le fichier : {{error}}", + "sessionRevoked": "Cet appareil a été déconnecté de votre compte Voltius. Reconnectez-vous pour continuer la synchronisation.", + "signInAgain": "Se connecter" }, "error": { "identityPickUnavailable": "Votre identité pour {{host}} n'est pas disponible", @@ -205,7 +207,14 @@ "inviteCodeMalformed": "Ce code n'a pas le bon format", "inviteCodeNotFound": "Ce code a expiré ou est incorrect", "inviteCodeTooManyAttempts": "Trop de tentatives — patientez un instant et réessayez", - "businessPlanRequired": "Cela nécessite le forfait Business de l'équipe." + "businessPlanRequired": "Cela nécessite le forfait Business de l'équipe.", + "twoFactorRequired": "Saisissez le code de votre application d'authentification.", + "twoFactorInvalid": "Code incorrect. Essayez le suivant affiché par votre application.", + "twoFactorLocked": "Trop de codes incorrects. Réessayez dans 15 minutes.", + "twoFactorUnavailable": "Ce serveur ne peut pas vérifier les codes d'authentification pour le moment. Utilisez un code de récupération.", + "twoFactorAlreadyEnabled": "L'authentification à deux facteurs est déjà activée.", + "twoFactorNotEnabled": "L'authentification à deux facteurs n'est pas activée.", + "twoFactorExpired": "Cette connexion a pris trop de temps. Recommencez." }, "clipboard": { "cut_one": "{{count}} élément coupé", diff --git a/src/i18n/locales/fr/layout.json b/src/i18n/locales/fr/layout.json index 94b8cf775..1db0c001b 100644 --- a/src/i18n/locales/fr/layout.json +++ b/src/i18n/locales/fr/layout.json @@ -60,7 +60,18 @@ "confirmMasterPasswordPlaceholder": "Confirmer le mot de passe principal", "errorPasswordRequired": "Mot de passe requis", "errorMinLength4": "Au moins 4 caractères", - "pleaseWait": "Veuillez patienter…" + "pleaseWait": "Veuillez patienter…", + "reauthTitle": "Se reconnecter", + "reauthDesc": "Votre session sur cet appareil a pris fin. Saisissez votre mot de passe principal pour vous reconnecter." + }, + "twoFactor": { + "prompt": "Saisissez le code à 6 chiffres de votre application d'authentification.", + "recoveryPrompt": "Saisissez l'un de vos codes de récupération. Chacun ne fonctionne qu'une fois.", + "codePlaceholder": "Code à 6 chiffres", + "recoveryPlaceholder": "Code de récupération", + "useRecovery": "Utiliser un code de récupération", + "useCode": "Utiliser un code d'authentification", + "verify": "Vérifier" }, "mainPanel": { "noVaultSelectedTitle": "Aucun coffre sélectionné", diff --git a/src/i18n/locales/fr/settings.json b/src/i18n/locales/fr/settings.json index 317d6b3a9..26e348193 100644 --- a/src/i18n/locales/fr/settings.json +++ b/src/i18n/locales/fr/settings.json @@ -341,6 +341,33 @@ "changing": "Modification…", "changeBtn": "Changer le mot de passe" }, + "twoFactor": { + "label": "Authentification à deux facteurs", + "subOff": "Demander un code d'une application d'authentification lors de la connexion sur un nouvel appareil", + "subOn": "Activée. La connexion sur un nouvel appareil demande un code", + "intro": "Après votre mot de passe principal, la connexion sur un nouvel appareil demandera un code à 6 chiffres d'une application d'authentification.", + "introDevices": "L'activation déconnecte vos autres appareils. Il leur faut une version récente de Voltius pour se reconnecter.", + "continue": "Continuer", + "scan": "Scannez ce code avec votre application d'authentification, ou saisissez-y la clé ci-dessous.", + "qrLabel": "Code QR pour votre application d'authentification", + "copyKey": "Copier la clé", + "confirmPrompt": "Saisissez ensuite le code à 6 chiffres qu'elle affiche.", + "turnOn": "Activer", + "codesTitle": "Enregistrez vos codes de récupération", + "codesBody": "Chaque code vous connecte une fois si vous perdez votre application d'authentification. Ils ne seront plus affichés.", + "copy": "Copier", + "copied": "Copié", + "save": "Enregistrer dans un fichier", + "saved": "Je les ai enregistrés", + "manageBody": "La connexion sur un nouvel appareil demande un code de votre application d'authentification.", + "newCodes": "Nouveaux codes de récupération", + "newCodesSub": "Les codes actuels cessent de fonctionner", + "turnOff": "Désactiver", + "turnOffSub": "Se connecter avec le mot de passe principal seul", + "generate": "Générer", + "regeneratePrompt": "Saisissez un code pour remplacer vos codes de récupération.", + "disablePrompt": "Saisissez votre mot de passe principal et un code pour désactiver l'authentification à deux facteurs." + }, "setMasterPassword": { "label": "Définir un mot de passe maître", "sub": "Protégez votre coffre-fort localement sans synchronisation cloud" diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index b5cba31eb..a4551410a 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -64,7 +64,9 @@ "undoFailed": "Не удалось отменить действие: {{error}}", "redoFailed": "Не удалось повторить действие: {{error}}", "fileSaved": "Сохранено в {{path}}", - "fileSaveFailed": "Не удалось сохранить файл: {{error}}" + "fileSaveFailed": "Не удалось сохранить файл: {{error}}", + "sessionRevoked": "На этом устройстве выполнен выход из учётной записи Voltius. Войдите снова, чтобы продолжить синхронизацию.", + "signInAgain": "Войти" }, "error": { "identityPickUnavailable": "Ваша личность для {{host}} недоступна", @@ -205,7 +207,14 @@ "inviteCodeMalformed": "Код указан в неверном формате", "inviteCodeNotFound": "Код истёк или указан неверно", "inviteCodeTooManyAttempts": "Слишком много попыток — подождите и попробуйте снова", - "businessPlanRequired": "Для этого нужен тариф Business команды." + "businessPlanRequired": "Для этого нужен тариф Business команды.", + "twoFactorRequired": "Введите код из приложения-аутентификатора.", + "twoFactorInvalid": "Неверный код. Попробуйте следующий код из приложения.", + "twoFactorLocked": "Слишком много неверных кодов. Повторите попытку через 15 минут.", + "twoFactorUnavailable": "Этот сервер сейчас не может проверять коды аутентификатора. Используйте резервный код.", + "twoFactorAlreadyEnabled": "Двухфакторная аутентификация уже включена.", + "twoFactorNotEnabled": "Двухфакторная аутентификация не включена.", + "twoFactorExpired": "Вход занял слишком много времени. Начните заново." }, "clipboard": { "cut_one": "Вырезан {{count}} элемент", diff --git a/src/i18n/locales/ru/layout.json b/src/i18n/locales/ru/layout.json index 687eb3eb8..befee31db 100644 --- a/src/i18n/locales/ru/layout.json +++ b/src/i18n/locales/ru/layout.json @@ -60,7 +60,18 @@ "confirmMasterPasswordPlaceholder": "Подтвердите мастер-пароль", "errorPasswordRequired": "Требуется пароль", "errorMinLength4": "Не менее 4 символов", - "pleaseWait": "Пожалуйста, подождите…" + "pleaseWait": "Пожалуйста, подождите…", + "reauthTitle": "Войдите снова", + "reauthDesc": "Сессия на этом устройстве завершена. Введите мастер-пароль, чтобы подключиться снова." + }, + "twoFactor": { + "prompt": "Введите 6-значный код из приложения-аутентификатора.", + "recoveryPrompt": "Введите один из резервных кодов. Каждый действует один раз.", + "codePlaceholder": "6-значный код", + "recoveryPlaceholder": "Резервный код", + "useRecovery": "Использовать резервный код", + "useCode": "Использовать код аутентификатора", + "verify": "Подтвердить" }, "mainPanel": { "noVaultSelectedTitle": "Хранилища не выбраны", diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index f30407e5d..943df2669 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -341,6 +341,33 @@ "changing": "Изменение…", "changeBtn": "Изменить пароль" }, + "twoFactor": { + "label": "Двухфакторная аутентификация", + "subOff": "Запрашивать код из приложения-аутентификатора при входе на новом устройстве", + "subOn": "Включена. При входе на новом устройстве запрашивается код", + "intro": "После мастер-пароля при входе на новом устройстве потребуется 6-значный код из приложения-аутентификатора.", + "introDevices": "При включении на других устройствах будет выполнен выход. Чтобы войти на них снова, нужна свежая версия Voltius.", + "continue": "Продолжить", + "scan": "Отсканируйте код приложением-аутентификатором или введите в него ключ ниже.", + "qrLabel": "QR-код для приложения-аутентификатора", + "copyKey": "Скопировать ключ", + "confirmPrompt": "Затем введите 6-значный код, который оно покажет.", + "turnOn": "Включить", + "codesTitle": "Сохраните резервные коды", + "codesBody": "Каждый код позволяет войти один раз, если вы потеряете аутентификатор. Больше они не будут показаны.", + "copy": "Копировать", + "copied": "Скопировано", + "save": "Сохранить в файл", + "saved": "Я их сохранил", + "manageBody": "При входе на новом устройстве запрашивается код из приложения-аутентификатора.", + "newCodes": "Новые резервные коды", + "newCodesSub": "Текущие перестанут действовать", + "turnOff": "Отключить", + "turnOffSub": "Входить только по мастер-паролю", + "generate": "Создать", + "regeneratePrompt": "Введите код, чтобы заменить резервные коды.", + "disablePrompt": "Введите мастер-пароль и код, чтобы отключить двухфакторную аутентификацию." + }, "setMasterPassword": { "label": "Установить мастер-пароль", "sub": "Защитите хранилище локально без облачной синхронизации" diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index fc855d3c9..c826ba5b1 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -64,7 +64,9 @@ "undoFailed": "Geri alınamadı: {{error}}", "redoFailed": "Yinelenemedi: {{error}}", "fileSaved": "{{path}} konumuna kaydedildi", - "fileSaveFailed": "Dosya kaydedilemedi: {{error}}" + "fileSaveFailed": "Dosya kaydedilemedi: {{error}}", + "sessionRevoked": "Bu cihazda Voltius hesabınızın oturumu kapatıldı. Eşitlemeye devam etmek için yeniden oturum açın.", + "signInAgain": "Oturum aç" }, "error": { "identityPickUnavailable": "{{host}} için kimliğiniz kullanılamıyor", @@ -205,7 +207,14 @@ "inviteCodeMalformed": "Bu kodun biçimi hatalı", "inviteCodeNotFound": "Bu kodun süresi dolmuş veya kod yanlış", "inviteCodeTooManyAttempts": "Çok fazla deneme — biraz bekleyip yeniden deneyin", - "businessPlanRequired": "Bu, ekibin Business planını gerektirir." + "businessPlanRequired": "Bu, ekibin Business planını gerektirir.", + "twoFactorRequired": "Kimlik doğrulama uygulamanızdaki kodu girin.", + "twoFactorInvalid": "Kod yanlış. Uygulamanızın gösterdiği bir sonraki kodu deneyin.", + "twoFactorLocked": "Çok fazla yanlış kod girildi. 15 dakika sonra yeniden deneyin.", + "twoFactorUnavailable": "Bu sunucu şu anda kimlik doğrulama kodlarını denetleyemiyor. Bir kurtarma kodu kullanın.", + "twoFactorAlreadyEnabled": "İki adımlı doğrulama zaten açık.", + "twoFactorNotEnabled": "İki adımlı doğrulama açık değil.", + "twoFactorExpired": "Bu oturum açma işlemi çok uzun sürdü. Baştan başlayın." }, "clipboard": { "cut_one": "{{count}} öğe kesildi", diff --git a/src/i18n/locales/tr/layout.json b/src/i18n/locales/tr/layout.json index a73404c96..a71130686 100644 --- a/src/i18n/locales/tr/layout.json +++ b/src/i18n/locales/tr/layout.json @@ -60,7 +60,18 @@ "confirmMasterPasswordPlaceholder": "Ana parolayı onaylayın", "errorPasswordRequired": "Parola gerekli", "errorMinLength4": "En az 4 karakter", - "pleaseWait": "Lütfen bekleyin…" + "pleaseWait": "Lütfen bekleyin…", + "reauthTitle": "Yeniden oturum açın", + "reauthDesc": "Bu cihazdaki oturumunuz sona erdi. Yeniden bağlanmak için ana parolanızı girin." + }, + "twoFactor": { + "prompt": "Kimlik doğrulama uygulamanızdaki 6 haneli kodu girin.", + "recoveryPrompt": "Kurtarma kodlarınızdan birini girin. Her biri bir kez kullanılabilir.", + "codePlaceholder": "6 haneli kod", + "recoveryPlaceholder": "Kurtarma kodu", + "useRecovery": "Kurtarma kodu kullan", + "useCode": "Kimlik doğrulama kodu kullan", + "verify": "Doğrula" }, "mainPanel": { "noVaultSelectedTitle": "Seçili kasa yok", diff --git a/src/i18n/locales/tr/settings.json b/src/i18n/locales/tr/settings.json index 0c89fea41..4098c1cea 100644 --- a/src/i18n/locales/tr/settings.json +++ b/src/i18n/locales/tr/settings.json @@ -347,6 +347,33 @@ "changing": "Değiştiriliyor…", "changeBtn": "Parolayı değiştir" }, + "twoFactor": { + "label": "İki adımlı doğrulama", + "subOff": "Yeni bir cihazda oturum açarken kimlik doğrulama uygulamasından kod iste", + "subOn": "Açık. Yeni bir cihazda oturum açarken kod istenir", + "intro": "Ana parolanızdan sonra, yeni bir cihazda oturum açarken kimlik doğrulama uygulamasından 6 haneli bir kod istenir.", + "introDevices": "Bunu açmak diğer cihazlarınızdaki oturumları kapatır. Yeniden oturum açmaları için güncel bir Voltius sürümü gerekir.", + "continue": "Devam", + "scan": "Bunu kimlik doğrulama uygulamanızla tarayın veya aşağıdaki anahtarı uygulamaya yazın.", + "qrLabel": "Kimlik doğrulama uygulamanız için QR kodu", + "copyKey": "Anahtarı kopyala", + "confirmPrompt": "Ardından uygulamanın gösterdiği 6 haneli kodu girin.", + "turnOn": "Aç", + "codesTitle": "Kurtarma kodlarınızı kaydedin", + "codesBody": "Kimlik doğrulama uygulamanızı kaybederseniz her kod bir kez oturum açmanızı sağlar. Bir daha gösterilmezler.", + "copy": "Kopyala", + "copied": "Kopyalandı", + "save": "Dosyaya kaydet", + "saved": "Kaydettim", + "manageBody": "Yeni bir cihazda oturum açarken kimlik doğrulama uygulamanızdan kod istenir.", + "newCodes": "Yeni kurtarma kodları", + "newCodesSub": "Mevcut kodlar geçersiz olur", + "turnOff": "Kapat", + "turnOffSub": "Yalnızca ana parolayla oturum aç", + "generate": "Oluştur", + "regeneratePrompt": "Kurtarma kodlarınızı değiştirmek için bir kod girin.", + "disablePrompt": "İki adımlı doğrulamayı kapatmak için ana parolanızı ve bir kod girin." + }, "setMasterPassword": { "label": "Ana parola belirle", "sub": "Kasanızı bulut eşitleme olmadan yerel olarak koruyun" diff --git a/src/i18n/locales/zh/common.json b/src/i18n/locales/zh/common.json index e29131e66..b4be1aa3b 100644 --- a/src/i18n/locales/zh/common.json +++ b/src/i18n/locales/zh/common.json @@ -64,7 +64,9 @@ "undoFailed": "撤销失败:{{error}}", "redoFailed": "重做失败:{{error}}", "fileSaved": "已保存到 {{path}}", - "fileSaveFailed": "无法保存文件:{{error}}" + "fileSaveFailed": "无法保存文件:{{error}}", + "sessionRevoked": "此设备已退出您的 Voltius 账户。请重新登录以继续同步。", + "signInAgain": "登录" }, "error": { "identityPickUnavailable": "你在 {{host}} 的身份不可用", @@ -205,7 +207,14 @@ "inviteCodeMalformed": "该邀请码格式不正确", "inviteCodeNotFound": "该邀请码已过期或不正确", "inviteCodeTooManyAttempts": "尝试次数过多 — 请稍后重试", - "businessPlanRequired": "此操作需要团队的 Business 套餐。" + "businessPlanRequired": "此操作需要团队的 Business 套餐。", + "twoFactorRequired": "请输入身份验证器应用中的验证码。", + "twoFactorInvalid": "验证码错误。请尝试应用显示的下一个验证码。", + "twoFactorLocked": "错误的验证码次数过多。请在 15 分钟后重试。", + "twoFactorUnavailable": "此服务器目前无法校验身份验证器验证码。请使用恢复码。", + "twoFactorAlreadyEnabled": "双重验证已开启。", + "twoFactorNotEnabled": "双重验证未开启。", + "twoFactorExpired": "本次登录耗时过长。请重新开始。" }, "clipboard": { "cut_one": "已剪切 {{count}} 个项目", diff --git a/src/i18n/locales/zh/layout.json b/src/i18n/locales/zh/layout.json index 4a33f351f..f9151dcf3 100644 --- a/src/i18n/locales/zh/layout.json +++ b/src/i18n/locales/zh/layout.json @@ -60,7 +60,18 @@ "confirmMasterPasswordPlaceholder": "确认主密码", "errorPasswordRequired": "需要密码", "errorMinLength4": "至少 4 个字符", - "pleaseWait": "请稍候…" + "pleaseWait": "请稍候…", + "reauthTitle": "重新登录", + "reauthDesc": "此设备上的会话已结束。请输入主密码以重新连接。" + }, + "twoFactor": { + "prompt": "请输入身份验证器应用中的 6 位验证码。", + "recoveryPrompt": "请输入一个恢复码。每个恢复码只能使用一次。", + "codePlaceholder": "6 位验证码", + "recoveryPlaceholder": "恢复码", + "useRecovery": "使用恢复码", + "useCode": "使用身份验证器验证码", + "verify": "验证" }, "mainPanel": { "noVaultSelectedTitle": "未选择保险库", diff --git a/src/i18n/locales/zh/settings.json b/src/i18n/locales/zh/settings.json index 88fa9e62f..9c9cfa554 100644 --- a/src/i18n/locales/zh/settings.json +++ b/src/i18n/locales/zh/settings.json @@ -235,6 +235,33 @@ "changing": "正在更改…", "changeBtn": "更改密码" }, + "twoFactor": { + "label": "双重验证", + "subOff": "在新设备上登录时要求输入身份验证器应用的验证码", + "subOn": "已开启。在新设备上登录时需要验证码", + "intro": "输入主密码后,在新设备上登录还需要身份验证器应用中的 6 位验证码。", + "introDevices": "开启后,您的其他设备将被退出登录。它们需要较新版本的 Voltius 才能重新登录。", + "continue": "继续", + "scan": "使用身份验证器应用扫描此二维码,或在应用中输入下方的密钥。", + "qrLabel": "用于身份验证器应用的二维码", + "copyKey": "复制密钥", + "confirmPrompt": "然后输入应用显示的 6 位验证码。", + "turnOn": "开启", + "codesTitle": "保存您的恢复码", + "codesBody": "如果您丢失了身份验证器,每个恢复码可用于登录一次。它们不会再次显示。", + "copy": "复制", + "copied": "已复制", + "save": "保存到文件", + "saved": "我已保存", + "manageBody": "在新设备上登录时需要身份验证器应用中的验证码。", + "newCodes": "新的恢复码", + "newCodesSub": "当前的恢复码将失效", + "turnOff": "关闭", + "turnOffSub": "仅使用主密码登录", + "generate": "生成", + "regeneratePrompt": "请输入验证码以更换恢复码。", + "disablePrompt": "请输入主密码和验证码以关闭双重验证。" + }, "setMasterPassword": { "label": "设置主密码", "sub": "在本地保护保险库,无需云同步" diff --git a/src/services/account.serverAuth.test.ts b/src/services/account.serverAuth.test.ts index 4097bf874..a6d21c8d4 100644 --- a/src/services/account.serverAuth.test.ts +++ b/src/services/account.serverAuth.test.ts @@ -72,6 +72,8 @@ import { resendVerificationEmail, } from "./account"; import { VaultUnreadableError } from "./vaultErrors"; +import { clearSessionRevoked, tryRefreshJwt } from "./authTokens"; +import { TwoFactorRequiredError } from "./twoFactorProof"; import { EmailUndeliverableError } from "@/utils/emailVerification"; import { DEFAULT_SERVER_URL, lastServerUrl } from "@/utils/serverInstance"; import { GLOBAL_PROXY_PASSWORD_KEY } from "./teamVaultSecretKeys"; @@ -142,6 +144,7 @@ const ok = (body: unknown = {}) => ({ ok: true, status: 200, body }); const err = (status: number, body: unknown = {}) => ({ ok: false, status, body }); beforeEach(() => { + clearSessionRevoked(); for (const m of [h.invoke, h.appFetch, h.setVaultKey, h.wipeLocalConfig, h.load, h.keysSet]) m.mockReset(); h.readLocalSecrets.mockReset().mockResolvedValue({}); h.getVaultStatus.mockReset(); @@ -810,3 +813,199 @@ test("authenticateServerAccount rejects bad credentials and leaves the session a .rejects.toThrow("common.error.invalidEmailOrPassword"); expect(h.store).toEqual(before); }); + +// ─── two-factor sign-in ────────────────────────────────────────────────────── + +const CHALLENGE = err(428, { error: "TWO_FACTOR_REQUIRED", pending_token: "PENDING", methods: ["totp", "recovery"] }); + +function serverAccount() { + h.store.account_id = "acc"; + h.store.mode = "server"; + h.store.email = "a@b.co"; + h.store.server_url = S; +} + +/** Registered longest path first: routeHttp matches by substring, and "/auth/login" is one of "/auth/login/2fa". */ +function secondStep(answer: { ok: boolean; status: number; body?: unknown }) { + h.http["/auth/login/2fa"] = answer; + h.http["/auth/login"] = CHALLENGE; +} + +async function challengeFrom(work: Promise): Promise> { + const error = await work.then(() => null, (e: unknown) => e); + expect(error).toBeInstanceOf(TwoFactorRequiredError); + return error as TwoFactorRequiredError; +} + +test("login stops at the code step without touching the keychain, then finishes with the code", async () => { + h.store.server_url = S; + h.http["/auth/challenge"] = ok({ account_id: "acc" }); + secondStep(ok({ ...TOKENS, wrapped_user_secrets: "WRAPPED" })); + + const challenge = await challengeFrom(login("pw", "a@b.co", S)); + expect(challenge.methods).toEqual(["totp", "recovery"]); + // A sign-in abandoned here must not leave something that reads as a local account. + expect(h.store.mode).toBeUndefined(); + expect(h.store.account_id).toBeUndefined(); + expect(h.setVaultKey).not.toHaveBeenCalled(); + + await challenge.complete({ code: " 123456 " }); + expect(h.sent["/auth/login/2fa"]).toEqual({ pending_token: "PENDING", code: "123456" }); + expect(h.store).toMatchObject({ mode: "server", account_id: "acc", jwt: "JWT", refresh_token: "RT" }); + expect(h.setVaultKey).toHaveBeenLastCalledWith([1, 1, 1]); +}); + +test("a recovery code is sent under its own name", async () => { + serverAccount(); + secondStep(ok({ ...TOKENS, wrapped_user_secrets: "WRAPPED" })); + + const challenge = await challengeFrom(login("pw")); + await challenge.complete({ recoveryCode: "abcd-efgh-jkmn-pqrs" }); + expect(h.sent["/auth/login/2fa"]).toEqual({ pending_token: "PENDING", recovery_code: "abcd-efgh-jkmn-pqrs" }); +}); + +test.each([ + [err(401, { error: "TWO_FACTOR_INVALID" }), "common.error.twoFactorInvalid"], + [err(429, { error: "TWO_FACTOR_LOCKED" }), "common.error.twoFactorLocked"], + [err(503, { error: "TWO_FACTOR_UNAVAILABLE" }), "common.error.twoFactorUnavailable"], + [err(401), "common.error.twoFactorExpired"], + [err(500), "common.error.serverLoginFailed"], +])("a refused code says why and can be retried (%#)", async (refusal, message) => { + serverAccount(); + secondStep(refusal); + + const challenge = await challengeFrom(login("pw")); + await expect(challenge.complete({ code: "000000" })).rejects.toThrow(message); + expect(h.store.jwt).toBeUndefined(); + + h.http["/auth/login/2fa"] = ok({ ...TOKENS, wrapped_user_secrets: "WRAPPED" }); + await challenge.complete({ code: "123456" }); + expect(h.store.jwt).toBe("JWT"); +}); + +test("a 428 that is not a two-factor challenge is an ordinary failure", async () => { + serverAccount(); + h.http["/auth/login"] = err(428, {}); + await expect(login("pw")).rejects.toThrow("common.error.serverLoginFailed"); +}); + +test("signInToCloud wipes nothing until the code is accepted", async () => { + h.http["/auth/challenge"] = ok({ account_id: "acc" }); + secondStep(ok({ ...TOKENS, wrapped_user_secrets: "WRAPPED" })); + + const challenge = await challengeFrom(signInToCloud("a@b.co", "pw", S)); + expect(h.wipeLocalConfig).not.toHaveBeenCalled(); + expect(h.store.jwt).toBeUndefined(); + + await challenge.complete({ code: "123456" }); + expect(h.wipeLocalConfig).toHaveBeenCalled(); + expect(h.store).toMatchObject({ mode: "server", jwt: "JWT", wrapped_user_secrets: "WRAPPED" }); +}); + +test("authenticateServerAccount hands back the session once the code is accepted", async () => { + h.http["/auth/challenge"] = ok({ account_id: "acc" }); + secondStep(ok({ ...TOKENS, wrapped_user_secrets: "WRAPPED" })); + + const challenge = await challengeFrom(authenticateServerAccount("signin", "a@b.co", "pw", S)); + await expect(challenge.complete({ code: "123456" })).resolves.toMatchObject({ + account_id: "acc", jwt: "JWT", refresh_token: "RT", wrapped_user_secrets: "WRAPPED", master_password: "pw", + }); + expect(h.store.jwt).toBeUndefined(); +}); + +// ─── unlock is not a sign-in ───────────────────────────────────────────────── + +function lockedCloudVault() { + serverAccount(); + h.store.refresh_token = "RT"; + h.store.wrapped_user_secrets = "WRAPPED"; + existingVaultOpenedBy([1, 1, 1]); +} + +test("unlocking a vault whose session the server still honours does not sign in again", async () => { + lockedCloudVault(); + h.http["/auth/refresh"] = ok({ jwt_token: "JWT2" }); + h.http["/auth/login"] = CHALLENGE; + + await login("pw"); + expect(h.setVaultKey).toHaveBeenLastCalledWith([1, 1, 1]); + expect(h.store.jwt).toBe("JWT2"); + expect(h.sent["/auth/login"]).toBeUndefined(); +}); + +test("unlocking after the session was revoked signs in again, and says nothing about it", async () => { + lockedCloudVault(); + h.http["/auth/refresh"] = err(401); + h.http["/auth/login"] = ok({ ...TOKENS, refresh_token: "RT2", wrapped_user_secrets: "WRAPPED" }); + + await login("pw"); + expect(h.store.refresh_token).toBe("RT2"); + // The fresh pair lifts the revocation: the next refresh reaches the server again. + h.http["/auth/refresh"] = ok({ jwt_token: "JWT3" }); + await expect(tryRefreshJwt()).resolves.toBe("JWT3"); +}); + +test("a password that did not open the cached secrets is still proven by the server", async () => { + serverAccount(); + h.store.refresh_token = "RT"; + h.http["/auth/refresh"] = ok({ jwt_token: "JWT2" }); + h.http["/auth/login"] = err(401); + + await expect(login("pw")).rejects.toThrow("common.error.serverLoginFailed"); +}); + +// ─── revoked session ───────────────────────────────────────────────────────── + +test("a revoked session is asked about once, then left alone until a new sign-in", async () => { + h.store.refresh_token = "RT"; + h.store.server_url = S; + h.http["/auth/refresh"] = err(401); + + await expect(tryRefreshJwt()).resolves.toBeNull(); + await expect(tryRefreshJwt()).resolves.toBeNull(); + await expect(refreshSession()).rejects.toThrow("common.error.sessionRefreshFailed"); + expect(h.seq.filter((entry) => entry.includes("/auth/refresh"))).toHaveLength(1); +}); + +// The server announces a version bump before it answers the request that caused it, +// so this device's own sync loop can refresh with the pair it is about to replace. +test("a 401 for a refresh token replaced mid-flight is retried with the new one", async () => { + h.store.refresh_token = "OLD"; + h.store.server_url = S; + h.appFetch.mockImplementation(async (_url: string, init?: { body?: string }) => { + const sent = JSON.parse(init!.body!).refresh_token; + if (sent === "OLD") { + h.store.refresh_token = "NEW"; + return { ok: false, status: 401, json: async () => ({}) }; + } + return { ok: true, status: 200, json: async () => ({ jwt_token: `JWT_FOR_${sent}` }) }; + }); + + await expect(tryRefreshJwt()).resolves.toBe("JWT_FOR_NEW"); + await expect(tryRefreshJwt()).resolves.toBe("JWT_FOR_NEW"); +}); + +test("a refresh the server could not answer is not a revocation", async () => { + h.store.refresh_token = "RT"; + h.store.server_url = S; + h.http["/auth/refresh"] = err(503); + await expect(tryRefreshJwt()).resolves.toBeNull(); + + h.http["/auth/refresh"] = ok({ jwt_token: "JWT2" }); + await expect(tryRefreshJwt()).resolves.toBe("JWT2"); +}); + +test("changeMasterPassword sends the code and names a refused one", async () => { + h.store.account_id = "acc"; + h.store.jwt = "JWT0"; + h.store.server_url = S; + h.dek = [1, 1, 1]; + h.x25519 = [2, 2, 2]; + h.http["/auth/password"] = err(401, { error: "TWO_FACTOR_INVALID" }); + + await expect(changeMasterPassword("pw", "new", { code: "000000" })).rejects.toThrow("common.error.twoFactorInvalid"); + expect(h.sent["/auth/password"]).toMatchObject({ code: "000000" }); + + h.http["/auth/password"] = err(428, { error: "TWO_FACTOR_REQUIRED" }); + await expect(changeMasterPassword("pw", "new")).rejects.toThrow("common.error.twoFactorRequired"); +}); diff --git a/src/services/account.ts b/src/services/account.ts index c8bc2c763..fe8123519 100644 --- a/src/services/account.ts +++ b/src/services/account.ts @@ -12,6 +12,8 @@ import { rememberServer } from "@/utils/serverInstance"; import { base64ToBytes, hexToBytes } from "@/utils/base64"; import { EmailUndeliverableError, isEmailUndeliverable } from "@/utils/emailVerification"; import type { SavedAccount } from "./savedAccounts"; +import { clearSessionRevoked, refreshAccessToken } from "./authTokens"; +import { proofBody, twoFactorError, TwoFactorRequiredError, type TwoFactorProof } from "./twoFactorProof"; function reloadSubscription() { useSubscriptionStore.getState().load().catch(() => {}); @@ -204,12 +206,66 @@ async function persistServerSession(session: { if (session.accountId) await keychainSet("account_id", session.accountId); await keychainSet("mode", "server"); await keychainSet("email", session.email); - if (session.jwt) await keychainSet("jwt", session.jwt); - if (session.refreshToken) await keychainSet("refresh_token", session.refreshToken); await keychainSet("server_url", session.serverUrl); + if (session.jwt && session.refreshToken) { + await adoptSessionTokens({ jwt_token: session.jwt, refresh_token: session.refreshToken }); + } rememberServer(session.serverUrl); } +/** Store a token pair the server just issued; it supersedes whatever session was revoked. */ +export async function adoptSessionTokens(tokens: { jwt_token: string; refresh_token: string }): Promise { + await keychainSet("jwt", tokens.jwt_token); + await keychainSet("refresh_token", tokens.refresh_token); + clearSessionRevoked(); +} + +interface LoginData { + jwt_token: string; + refresh_token: string; + wrapped_user_secrets?: string | null; +} + +async function postJson(url: string, body: unknown): Promise { + return fetchWithTimeout(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** + * Sign in and hand the session to `then`. An account with two-factor on throws + * TwoFactorRequiredError instead, whose `complete` runs the same `then` once a + * code is accepted, so every caller's follow-up survives the extra step. + */ +async function serverLogin( + serverUrl: string, + accountId: string, + authKey: string, + refused: (status: number) => Error, + then: (data: LoginData) => Promise, +): Promise { + const res = await postJson(`${serverUrl}/v1/auth/login`, { account_id: accountId, auth_key: authKey }); + if (res.ok) return then(await res.json()); + if (res.status !== 428) throw refused(res.status); + + const challenge = await res.json().catch(() => null); + if (challenge?.error !== "TWO_FACTOR_REQUIRED" || !challenge.pending_token) throw refused(res.status); + throw new TwoFactorRequiredError(challenge.methods ?? ["totp"], async (proof: TwoFactorProof) => { + const second = await postJson(`${serverUrl}/v1/auth/login/2fa`, { + pending_token: challenge.pending_token, + ...proofBody(proof), + }); + if (second.ok) return then(await second.json()); + const refusal = await twoFactorError(second); + if (refusal) throw refusal; + // A bare 401 here is the pending token: expired, or the account moved on. + if (second.status === 401) throw new Error(i18n.t("common.error.twoFactorExpired")); + throw refused(second.status); + }); +} + export type LockKind = "screen" | "vault"; export async function getAppLock(): Promise { @@ -329,6 +385,7 @@ export async function login(password: string, email?: string, serverUrl?: string : null; let encKey: number[]; + let cachedSecretsOpen = false; if (mode === "local-nopassword") { // password IS the stored hex key — convert back to bytes encKey = hexToBytes(password); @@ -338,48 +395,69 @@ export async function login(password: string, email?: string, serverUrl?: string const opened = await passwordVaultKey(kek, false); // Without a cached dek only the server can supply a cloud vault's key. if (!opened && !reauth) throw new Error(i18n.t("common.error.incorrectPassword")); + // Only the dek unwrapped from this device's cached secrets proves the password without the server. + cachedSecretsOpen = !!opened && opened !== kek; encKey = opened ?? kek; } - setVaultKey(encKey); + const openLocally = async () => { + setVaultKey(encKey); + await rememberPassword(password); + await keychainSet("account_id", accountId); + if (!mode) { + // Heal missing mode for local accounts (e.g. Windows after mock-keychain loss). + // Server mode is corrected below if server auth succeeds. + await keychainSet("mode", isHexEncoded32ByteKey(password) ? "local-nopassword" : "local"); + } + }; - await rememberPassword(password); - await keychainSet("account_id", accountId); - if (!mode) { - // Heal missing mode for local accounts (e.g. Windows after mock-keychain loss). - // Server mode is corrected below if server auth succeeds. - await keychainSet("mode", isHexEncoded32ByteKey(password) ? "local-nopassword" : "local"); + if (!reauth) return openLocally(); + + // An unlock is not a sign-in: while the server still honours this device's + // session, a password that opened the vault has nothing left to prove. + if (cachedSecretsOpen && (await sessionStillHonoured())) { + await openLocally(); + await persistServerSession({ serverUrl: reauth.serverUrl, email: reauth.email }); + reloadSubscription(); + return; } - if (reauth) { - const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); - const res = await fetchWithTimeout(`${reauth.serverUrl}/v1/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ account_id: accountId, auth_key }), - }); - if (!res.ok) throw new Error(i18n.t("common.error.serverLoginFailed")); - const data = await res.json(); - await persistServerSession({ - serverUrl: reauth.serverUrl, email: reauth.email, - jwt: data.jwt_token, refreshToken: data.refresh_token, - }); + const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); + // The local writes wait for the server: a sign-in left at the code step must not look like a local account. + await serverLogin( + reauth.serverUrl, accountId, auth_key, + () => new Error(i18n.t("common.error.serverLoginFailed")), + async (data) => { + await openLocally(); + await persistServerSession({ + serverUrl: reauth.serverUrl, email: reauth.email, + jwt: data.jwt_token, refreshToken: data.refresh_token, + }); + + if (data.wrapped_user_secrets) { + const unwrapped = await unwrapUserSecrets(kek, data.wrapped_user_secrets); + useVaultKeysStore.getState().set({ dek: unwrapped.dek, x25519Private: unwrapped.x25519_private, kek }); + // This device's secrets.enc may predate the split and still be kek-encrypted. + const opens = await keyThatOpensVault(unwrapped.dek, kek); + // Server login proved the password, so this is an unreadable file, not a bad one. + if (!opens) throw new VaultUnreadableError(); + setVaultKey(opens); + await keychainSet("wrapped_user_secrets", data.wrapped_user_secrets); + } else { + // Legacy account — trigger one-time migration + await migrateToWrappedUserSecrets(password, accountId, kek, reauth.serverUrl, data.jwt_token); + } - if (data.wrapped_user_secrets) { - const unwrapped = await unwrapUserSecrets(kek, data.wrapped_user_secrets); - useVaultKeysStore.getState().set({ dek: unwrapped.dek, x25519Private: unwrapped.x25519_private, kek }); - // This device's secrets.enc may predate the split and still be kek-encrypted. - const opens = await keyThatOpensVault(unwrapped.dek, kek); - // Server login proved the password, so this is an unreadable file, not a bad one. - if (!opens) throw new VaultUnreadableError(); - setVaultKey(opens); - await keychainSet("wrapped_user_secrets", data.wrapped_user_secrets); - } else { - // Legacy account — trigger one-time migration - await migrateToWrappedUserSecrets(password, accountId, kek, reauth.serverUrl, data.jwt_token); - } + reloadSubscription(); + }, + ); +} - reloadSubscription(); +async function sessionStillHonoured(): Promise { + try { + return typeof (await refreshAccessToken({ fetchImpl: fetchWithTimeout, quiet: true })) !== "string"; + } catch { + return false; } } @@ -497,6 +575,7 @@ export interface MeResponse { email_verified?: boolean; email_undeliverable?: boolean; handle_managed?: boolean; + two_factor_enabled?: boolean; } export const EMAIL_CHANGED_EVENT = "voltius:email-changed"; @@ -538,21 +617,9 @@ export async function getMyHandle(): Promise { } async function refreshJwt(): Promise { - const [refreshToken, serverUrl] = await Promise.all([ - keychainGet("refresh_token"), - keychainGet("server_url"), - ]); - if (!refreshToken || !serverUrl) throw new Error(i18n.t("common.error.sessionExpired")); - - const res = await fetchWithTimeout(`${serverUrl}/v1/auth/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); - if (!res.ok) throw new Error(i18n.t("common.error.sessionRefreshFailed")); - - const { jwt_token } = await res.json(); - await keychainSet("jwt", jwt_token); + const outcome = await refreshAccessToken({ fetchImpl: fetchWithTimeout }); + if (outcome === "no-session") throw new Error(i18n.t("common.error.sessionExpired")); + if (typeof outcome === "string") throw new Error(i18n.t("common.error.sessionRefreshFailed")); } export async function refreshSession(): Promise { @@ -631,55 +698,63 @@ function authFailure(status: number, expected: string): Error { return new Error(i18n.t("common.error.serverError", { status })); } -async function signInServerAccount(email: string, password: string, serverUrl: string) { +interface ServerSignIn { + accountId: string; + kek: number[]; + data: LoginData; +} + +async function signInServerAccount( + email: string, + password: string, + serverUrl: string, + then: (signedIn: ServerSignIn) => Promise, +): Promise { const res = await fetchWithTimeout(`${serverUrl}/v1/auth/challenge?email=${encodeURIComponent(email)}`); if (!res.ok) throw authFailure(res.status, "common.error.accountNotFound"); const { account_id: accountId } = await res.json(); const { auth_key, enc_key: kek } = await deriveKeys(password, accountId); - const loginRes = await fetchWithTimeout(`${serverUrl}/v1/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ account_id: accountId, auth_key }), - }); - if (!loginRes.ok) throw authFailure(loginRes.status, "common.error.invalidEmailOrPassword"); - const data: { jwt_token: string; refresh_token: string; wrapped_user_secrets?: string | null } = await loginRes.json(); - return { accountId: accountId as string, kek, data }; + return serverLogin( + serverUrl, accountId, auth_key, + (status) => authFailure(status, "common.error.invalidEmailOrPassword"), + (data) => then({ accountId, kek, data }), + ); } -/** Sign in to an existing cloud account (any local mode — replaces local identity). */ export async function signInToCloud( email: string, password: string, serverUrl: string, ): Promise { serverUrl = normalizeServerUrl(serverUrl); - const { accountId, kek, data } = await signInServerAccount(email, password, serverUrl); - // Read with the outgoing key, before setVaultKey swaps it. - const carried = await readLocalSecrets(deviceScopedSecretKeys()); - - let vaultKey = kek; - if (data.wrapped_user_secrets) { - const unwrapped = await unwrapUserSecrets(kek, data.wrapped_user_secrets); - useVaultKeysStore.getState().set({ dek: unwrapped.dek, x25519Private: unwrapped.x25519_private, kek }); - vaultKey = unwrapped.dek; - await keychainSet("wrapped_user_secrets", data.wrapped_user_secrets); - } + await signInServerAccount(email, password, serverUrl, async ({ accountId, kek, data }) => { + // Read with the outgoing key, before setVaultKey swaps it. + const carried = await readLocalSecrets(deviceScopedSecretKeys()); - setVaultKey(vaultKey); + let vaultKey = kek; + if (data.wrapped_user_secrets) { + const unwrapped = await unwrapUserSecrets(kek, data.wrapped_user_secrets); + useVaultKeysStore.getState().set({ dek: unwrapped.dek, x25519Private: unwrapped.x25519_private, kek }); + vaultKey = unwrapped.dek; + await keychainSet("wrapped_user_secrets", data.wrapped_user_secrets); + } - await persistServerSession({ - serverUrl, email, accountId, password, - jwt: data.jwt_token, refreshToken: data.refresh_token, - }); - reloadSubscription(); + setVaultKey(vaultKey); - // Delete the old secrets.enc (encrypted with the previous account's key — the new - // key cannot open it and secrets_unlock would fail with "wrong key or corrupted file"). - // config_wipe also clears the JSON entity files; clearLocalEntityState will repopulate - // them with empty arrays so syncOnLogin starts from a clean slate. - await wipeLocalConfig(carried); + await persistServerSession({ + serverUrl, email, accountId, password, + jwt: data.jwt_token, refreshToken: data.refresh_token, + }); + reloadSubscription(); + + // Delete the old secrets.enc (encrypted with the previous account's key — the new + // key cannot open it and secrets_unlock would fail with "wrong key or corrupted file"). + // config_wipe also clears the JSON entity files; clearLocalEntityState will repopulate + // them with empty arrays so syncOnLogin starts from a clean slate. + await wipeLocalConfig(carried); + }); } /** Sign in to, or register, another cloud account without touching the active session. */ @@ -690,21 +765,24 @@ export async function authenticateServerAccount( serverUrl: string, ): Promise> { serverUrl = normalizeServerUrl(serverUrl); - const { accountId, data, wrapped_user_secrets } = kind === "register" - ? await registerServerAccount(email, password, serverUrl) - : await signInServerAccount(email, password, serverUrl).then((r) => ({ - ...r, wrapped_user_secrets: r.data.wrapped_user_secrets ?? null, - })); - rememberServer(serverUrl); - return { - account_id: accountId, - email, - server_url: serverUrl, - master_password: password, - jwt: data.jwt_token, - refresh_token: data.refresh_token, - wrapped_user_secrets, + const saved = (accountId: string, data: { jwt_token: string; refresh_token: string }, wrapped: string | null) => { + rememberServer(serverUrl); + return { + account_id: accountId, + email, + server_url: serverUrl, + master_password: password, + jwt: data.jwt_token, + refresh_token: data.refresh_token, + wrapped_user_secrets: wrapped, + }; }; + if (kind === "register") { + const { accountId, data, wrapped_user_secrets } = await registerServerAccount(email, password, serverUrl); + return saved(accountId, data, wrapped_user_secrets); + } + return signInServerAccount(email, password, serverUrl, async ({ accountId, data }) => + saved(accountId, data, data.wrapped_user_secrets ?? null)); } /** Link an existing local account to a cloud server — registers and enables sync. */ @@ -785,6 +863,7 @@ async function passwordChangeMayHaveLanded(res: Response | null, newWrapped: str export async function changeMasterPassword( currentPassword: string, newPassword: string, + proof?: TwoFactorProof | null, ): Promise { const [accountId, jwt, serverUrl] = await Promise.all([ keychainGet("account_id"), @@ -824,37 +903,39 @@ export async function changeMasterPassword( const res = await fetchWithTimeout(`${serverUrl}/v1/auth/password`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, - body: JSON.stringify({ old_auth_key, new_auth_key, new_wrapped_user_secrets }), + body: JSON.stringify({ old_auth_key, new_auth_key, new_wrapped_user_secrets, ...proofBody(proof) }), }).catch(async (e) => { await undoMoveUnlessLanded(null); throw e; }); if (!res.ok) { await undoMoveUnlessLanded(res); + const refusal = await twoFactorError(res); + if (refusal) throw refusal; if (res.status === 401) throw new Error(i18n.t("common.error.currentPasswordIncorrect")); throw new Error(i18n.t("common.error.passwordChangeFailed", { status: res.status })); } const data = await res.json(); await replacePassword(newPassword, i18n.t("layout.appLock.sealReason")); - await keychainSet("jwt", data.jwt_token); - await keychainSet("refresh_token", data.refresh_token); + await adoptSessionTokens(data); await keychainSet("wrapped_user_secrets", new_wrapped_user_secrets); useVaultKeysStore.getState().set({ dek: cachedDek, x25519Private: cachedX25519, kek: new_kek }); if (undoMove) pushUnderNewVaultKey(); reloadSubscription(); } -export async function changeEmail(newEmail: string, currentPassword: string): Promise { - const [accountId, jwt, serverUrl] = await Promise.all([ - keychainGet("account_id"), - keychainGet("jwt"), - keychainGet("server_url"), - ]); +/** The login key for the signed-in account, as the server expects it to re-prove a password. */ +export async function deriveAuthKey(password: string): Promise { + const accountId = await keychainGet("account_id"); if (!accountId) throw new Error(i18n.t("common.error.noAccountFound")); - if (!jwt || !serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); + return (await deriveKeys(password, accountId)).auth_key; +} - const { auth_key } = await deriveKeys(currentPassword, accountId); +export async function changeEmail(newEmail: string, currentPassword: string): Promise { + const auth_key = await deriveAuthKey(currentPassword); + const [jwt, serverUrl] = await Promise.all([keychainGet("jwt"), keychainGet("server_url")]); + if (!jwt || !serverUrl) throw new Error(i18n.t("common.error.notConnectedToServer")); const res = await fetchWithTimeout(`${serverUrl}/v1/auth/email`, { method: "PUT", diff --git a/src/services/authTokens.ts b/src/services/authTokens.ts index 6fb40b17a..58cb64ebb 100644 --- a/src/services/authTokens.ts +++ b/src/services/authTokens.ts @@ -19,21 +19,80 @@ export function isJwtExpiredOrExpiring(jwt: string): boolean { } } -/** Try to refresh the access token using the stored refresh_token. Returns new JWT or null. */ -export async function tryRefreshJwt(): Promise { +let revoked = false; +let revokedToast: string | null = null; + +export function clearSessionRevoked(): void { + revoked = false; + if (!revokedToast) return; + const id = revokedToast; + revokedToast = null; + void import("@/stores/notificationStore").then(({ useNotificationStore }) => + useNotificationStore.getState().dismissToast(id)); +} + +async function announceSessionRevoked(): Promise { + const [{ useNotificationStore }, { useUIStore }, { default: i18n }] = await Promise.all([ + import("@/stores/notificationStore"), + import("@/stores/uiStore"), + import("@/i18n"), + ]); + if (!revoked || revokedToast) return; + revokedToast = useNotificationStore.getState().addToast({ + source: { kind: "plugin", id: "system", name: "Voltius" }, + type: "toast", + message: i18n.t("common.toast.sessionRevoked"), + severity: "warning", + duration: 0, + action: { + label: i18n.t("common.toast.signInAgain"), + onClick: () => useUIStore.getState().openCloudAuth("signin", "reauth"), + }, + }); +} + +export type RefreshOutcome = { jwt: string } | "revoked" | "failed" | "no-session"; + +/** + * The one caller of /v1/auth/refresh. A 401 means the server ended this session; + * it is remembered so the sync loop stops asking, and the user is told once. + */ +export async function refreshAccessToken( + { fetchImpl = appFetch, quiet = false }: { + fetchImpl?: (url: string, init?: RequestInit) => Promise; + quiet?: boolean; + } = {}, +): Promise { const [refreshToken, serverUrl] = await Promise.all([ invoke("keychain_get", { key: "refresh_token" }), getServerUrl(), ]); - if (!refreshToken || !serverUrl) return null; - const res = await appFetch(`${serverUrl}/v1/auth/refresh`, { + if (!refreshToken || !serverUrl) return "no-session"; + if (revoked) return "revoked"; + const res = await fetchImpl(`${serverUrl}/v1/auth/refresh`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ refresh_token: refreshToken }), }); - if (!res.ok) return null; + if (res.status === 401) { + // A pair adopted while this request was in flight (password change, two-factor) supersedes its answer. + const current = await invoke("keychain_get", { key: "refresh_token" }); + if (current !== refreshToken) return refreshAccessToken({ fetchImpl, quiet }); + revoked = true; + // `quiet` for a caller that is about to sign in again by itself. + if (!quiet) void announceSessionRevoked().catch(() => undefined); + return "revoked"; + } + if (!res.ok) return "failed"; const { jwt_token } = await res.json(); await invoke("keychain_set", { key: "jwt", value: jwt_token }); + return { jwt: jwt_token }; +} + +/** Try to refresh the access token using the stored refresh_token. Returns new JWT or null. */ +export async function tryRefreshJwt(): Promise { + const outcome = await refreshAccessToken(); + if (typeof outcome === "string") return null; await useSubscriptionStore.getState().load().catch(() => undefined); - return jwt_token; + return outcome.jwt; } diff --git a/src/services/serverCapabilities.ts b/src/services/serverCapabilities.ts index 14b23fcb7..a2aa0478e 100644 --- a/src/services/serverCapabilities.ts +++ b/src/services/serverCapabilities.ts @@ -11,9 +11,10 @@ const REFRESH_INTERVAL_MS = 5 * 60_000; interface ServerCapabilities { advertisedSecretTypes: string[] | null; refusedSecretTypes: TeamSecretType[]; + twoFactor: boolean; } -const UNKNOWN: ServerCapabilities = { advertisedSecretTypes: null, refusedSecretTypes: [] }; +const UNKNOWN: ServerCapabilities = { advertisedSecretTypes: null, refusedSecretTypes: [], twoFactor: false }; const useServerCapabilities = create(() => UNKNOWN); let fetchedAt: number | null = null; @@ -21,13 +22,14 @@ let inFlight: Promise | null = null; async function loadCapabilities(): Promise { try { - const { team_secret_types: types } = await fetchServerMeta("common.error.failedToLoadServerCapabilities"); + const { team_secret_types: types, two_factor } = await fetchServerMeta("common.error.failedToLoadServerCapabilities"); fetchedAt = Date.now(); - useServerCapabilities.setState( - Array.isArray(types) + useServerCapabilities.setState({ + twoFactor: two_factor === true, + ...(Array.isArray(types) ? { advertisedSecretTypes: types.filter((t): t is string => typeof t === "string"), refusedSecretTypes: [] } - : { advertisedSecretTypes: null }, - ); + : { advertisedSecretTypes: null }), + }); } catch (e) { logFailure("server capabilities")(e); } finally { @@ -65,3 +67,9 @@ export function useServerLacksSecretType(type: TeamSecretType): boolean { useEffect(() => void refreshServerCapabilities(), []); return useServerCapabilities((s) => lacks(s, type)); } + +/** Whether this server lets an account enrol a second factor; false until it says so. */ +export function useServerOffersTwoFactor(): boolean { + useEffect(() => void refreshServerCapabilities(), []); + return useServerCapabilities((s) => s.twoFactor); +} diff --git a/src/services/sync.ts b/src/services/sync.ts index eb5ee83f4..34f5a9096 100644 --- a/src/services/sync.ts +++ b/src/services/sync.ts @@ -2,7 +2,7 @@ import { invoke } from "@/lib/invoke"; import i18n from "@/i18n"; import { logFailure } from "@/lib/logger"; import { useSubscriptionStore } from "@/stores/subscriptionStore"; -import { getJwt, getServerUrl, isJwtExpiredOrExpiring } from "@/services/authTokens"; +import { getJwt, getServerUrl, isJwtExpiredOrExpiring, refreshAccessToken } from "@/services/authTokens"; import { fetchAuthRateLimited } from "@/services/authFetch"; import { getVaultKey, unlockVaultIfNeeded } from "@/services/vault"; import { buildUserDataBundle, mergeUserDataBundle, applyUserDataBundle } from "@/services/user-data/registry"; @@ -28,7 +28,6 @@ import { initTeamVaultKey } from "@/services/teamVaultSync"; import { onTeamLogin } from "@/services/teamDataManager"; import { handleMembershipChangedEvent, parseMembershipChangedEvent } from "@/services/teamMembershipEvents"; import * as teamService from "@/services/teamService"; -import { appFetch } from "@/services/http"; import { SseDataLineParser } from "@/services/realtimeSseEvents"; import { connectNativeSse } from "@/services/nativeSseStream"; import { useCrossDeviceSessionsStore } from "@/stores/crossDeviceSessionsStore"; @@ -124,21 +123,9 @@ export { getServerUrl }; /** Try to refresh the access token using the stored refresh_token. Returns new JWT or null. */ async function tryRefreshJwt(): Promise { - const [refreshToken, serverUrl] = await Promise.all([ - invoke("keychain_get", { key: "refresh_token" }), - getServerUrl(), - ]); - if (!refreshToken || !serverUrl) return null; - - const res = await appFetch(`${serverUrl}/v1/auth/refresh`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ refresh_token: refreshToken }), - }); - if (!res.ok) return null; - - const { jwt_token } = await res.json(); - await invoke("keychain_set", { key: "jwt", value: jwt_token }); + const outcome = await refreshAccessToken(); + if (typeof outcome === "string") return null; + const jwt_token = outcome.jwt; const wasProBefore = useSubscriptionStore.getState().isPro; const wasTeamsBefore = useSubscriptionStore.getState().isTeams; diff --git a/src/services/twoFactor.test.ts b/src/services/twoFactor.test.ts new file mode 100644 index 000000000..aa2f25e93 --- /dev/null +++ b/src/services/twoFactor.test.ts @@ -0,0 +1,86 @@ +import { test, expect, vi, beforeEach } from "vitest"; + +const h = vi.hoisted(() => ({ + appFetch: vi.fn(), + adoptSessionTokens: vi.fn(async () => {}), + tryRefreshJwt: vi.fn(async () => "FRESH" as string | null), + jwt: "JWT" as string | null, + expiring: false, +})); + +vi.mock("@/i18n", () => ({ default: { t: (k: string) => k } })); +vi.mock("@/services/http", () => ({ appFetch: h.appFetch })); +vi.mock("@/services/authTokens", () => ({ + getJwt: async () => h.jwt, + getServerUrl: async () => "https://srv", + isJwtExpiredOrExpiring: () => h.expiring, + tryRefreshJwt: h.tryRefreshJwt, +})); +vi.mock("@/services/account", () => ({ + adoptSessionTokens: h.adoptSessionTokens, + deriveAuthKey: async (password: string) => `AUTH(${password})`, +})); + +import { beginTwoFactorSetup, disableTwoFactor, enableTwoFactor, regenerateRecoveryCodes } from "./twoFactor"; + +const answer = (status: number, body: unknown = {}) => ({ ok: status < 400, status, json: async () => body }); +const sent = () => { + const [url, init] = h.appFetch.mock.lastCall!; + return { url, auth: init.headers.Authorization, body: JSON.parse(init.body) }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + h.jwt = "JWT"; + h.expiring = false; +}); + +test("setup proves the password with the auth key, never the password", async () => { + h.appFetch.mockResolvedValue(answer(200, { secret: "SEED", otpauth_uri: "otpauth://x" })); + await expect(beginTwoFactorSetup("pw")).resolves.toEqual({ secret: "SEED", otpauth_uri: "otpauth://x" }); + expect(sent()).toEqual({ url: "https://srv/v1/auth/2fa/setup", auth: "Bearer JWT", body: { auth_key: "AUTH(pw)" } }); +}); + +test("enabling adopts the session the server issued and returns the codes", async () => { + h.appFetch.mockResolvedValue(answer(200, { recovery_codes: ["a", "b"], jwt_token: "J2", refresh_token: "R2" })); + await expect(enableTwoFactor(" 123456 ")).resolves.toEqual(["a", "b"]); + expect(sent().body).toEqual({ code: "123456" }); + expect(h.adoptSessionTokens).toHaveBeenCalledWith(expect.objectContaining({ jwt_token: "J2", refresh_token: "R2" })); +}); + +test("disabling sends the auth key with the proof and adopts the new session", async () => { + h.appFetch.mockResolvedValue(answer(200, { jwt_token: "J2", refresh_token: "R2" })); + await disableTwoFactor("pw", { recoveryCode: "abcd-efgh" }); + expect(sent().body).toEqual({ auth_key: "AUTH(pw)", recovery_code: "abcd-efgh" }); + expect(h.adoptSessionTokens).toHaveBeenCalledWith({ jwt_token: "J2", refresh_token: "R2" }); +}); + +test("a wrong code is sent once: a retry would count twice against the lockout", async () => { + h.appFetch.mockResolvedValue(answer(401, { error: "TWO_FACTOR_INVALID" })); + await expect(regenerateRecoveryCodes({ code: "000000" })).rejects.toThrow("common.error.twoFactorInvalid"); + expect(h.appFetch).toHaveBeenCalledTimes(1); + expect(h.tryRefreshJwt).not.toHaveBeenCalled(); +}); + +test.each([ + [answer(401), "common.error.incorrectPassword"], + [answer(429, { error: "TWO_FACTOR_LOCKED" }), "common.error.twoFactorLocked"], + [answer(429), "common.error.tooManyAttempts"], + [answer(503, { error: "TWO_FACTOR_UNAVAILABLE" }), "common.error.twoFactorUnavailable"], + [answer(409, { error: "TWO_FACTOR_ALREADY_ENABLED" }), "common.error.twoFactorAlreadyEnabled"], + [answer(500), "common.error.serverError"], +])("a refusal is named (%#)", async (refusal, message) => { + h.appFetch.mockResolvedValue(refusal); + await expect(beginTwoFactorSetup("pw")).rejects.toThrow(message); +}); + +test("an expiring token is refreshed first, and no session is not a request", async () => { + h.expiring = true; + h.appFetch.mockResolvedValue(answer(200, { recovery_codes: [] })); + await regenerateRecoveryCodes({ code: "123456" }); + expect(sent().auth).toBe("Bearer FRESH"); + + h.tryRefreshJwt.mockResolvedValueOnce(null); + await expect(regenerateRecoveryCodes({ code: "123456" })).rejects.toThrow("common.error.notConnectedToServer"); + expect(h.appFetch).toHaveBeenCalledTimes(1); +}); diff --git a/src/services/twoFactor.ts b/src/services/twoFactor.ts new file mode 100644 index 000000000..e4bef88d0 --- /dev/null +++ b/src/services/twoFactor.ts @@ -0,0 +1,55 @@ +import i18n from "@/i18n"; +import { appFetch } from "@/services/http"; +import { getJwt, getServerUrl, isJwtExpiredOrExpiring, tryRefreshJwt } from "@/services/authTokens"; +import { adoptSessionTokens, deriveAuthKey } from "@/services/account"; +import { proofBody, twoFactorError, type TwoFactorProof } from "@/services/twoFactorProof"; + +interface SessionTokens { + jwt_token: string; + refresh_token: string; +} + +// Not fetchAuth: it retries a 401, and here a 401 is a wrong code that must be counted once. +async function post(path: string, body: unknown): Promise { + const serverUrl = await getServerUrl(); + let jwt = await getJwt(); + if (jwt && isJwtExpiredOrExpiring(jwt)) jwt = await tryRefreshJwt(); + if (!serverUrl || !jwt) throw new Error(i18n.t("common.error.notConnectedToServer")); + + const res = await appFetch(`${serverUrl}/v1/auth/2fa/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, + body: JSON.stringify(body), + }); + if (res.ok) return res.json(); + const refusal = await twoFactorError(res); + if (refusal) throw refusal; + if (res.status === 401) throw new Error(i18n.t("common.error.incorrectPassword")); + if (res.status === 429) throw new Error(i18n.t("common.error.tooManyAttempts")); + throw new Error(i18n.t("common.error.serverError", { status: res.status })); +} + +export interface TwoFactorSetup { + secret: string; + otpauth_uri: string; +} + +export async function beginTwoFactorSetup(password: string): Promise { + return post("setup", { auth_key: await deriveAuthKey(password) }); +} + +/** Turns two-factor on and returns the recovery codes, which the server never shows again. */ +export async function enableTwoFactor(code: string): Promise { + const enabled = await post("enable", { code: code.trim() }); + await adoptSessionTokens(enabled); + return enabled.recovery_codes; +} + +export async function disableTwoFactor(password: string, proof: TwoFactorProof): Promise { + const auth_key = await deriveAuthKey(password); + await adoptSessionTokens(await post("disable", { auth_key, ...proofBody(proof) })); +} + +export async function regenerateRecoveryCodes(proof: TwoFactorProof): Promise { + return (await post<{ recovery_codes: string[] }>("recovery-codes", proofBody(proof))).recovery_codes; +} diff --git a/src/services/twoFactorProof.ts b/src/services/twoFactorProof.ts new file mode 100644 index 000000000..2f5f70cad --- /dev/null +++ b/src/services/twoFactorProof.ts @@ -0,0 +1,42 @@ +import i18n from "@/i18n"; + +export type TwoFactorMethod = "totp" | "recovery"; + +export type TwoFactorProof = { code: string } | { recoveryCode: string }; + +export 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() }; +} + +/** The server accepted the password and wants a second factor; `complete` finishes the interrupted sign-in. */ +export class TwoFactorRequiredError extends Error { + constructor( + readonly methods: TwoFactorMethod[], + readonly complete: (proof: TwoFactorProof) => Promise, + ) { + super(i18n.t("common.error.twoFactorRequired")); + this.name = "TwoFactorRequiredError"; + } +} + +const MESSAGE_KEYS: Record = { + TWO_FACTOR_REQUIRED: "common.error.twoFactorRequired", + TWO_FACTOR_INVALID: "common.error.twoFactorInvalid", + TWO_FACTOR_LOCKED: "common.error.twoFactorLocked", + TWO_FACTOR_UNAVAILABLE: "common.error.twoFactorUnavailable", + TWO_FACTOR_ALREADY_ENABLED: "common.error.twoFactorAlreadyEnabled", + TWO_FACTOR_NOT_ENABLED: "common.error.twoFactorNotEnabled", +}; + +export async function twoFactorCode(res: Response): Promise { + const body = await res.json().catch(() => null); + const code = body && typeof body === "object" ? (body as { error?: unknown }).error : null; + return typeof code === "string" && code in MESSAGE_KEYS ? code : null; +} + +/** The translated refusal when a failed response carries a two-factor code, else null. Reads the body. */ +export async function twoFactorError(res: Response): Promise { + const code = await twoFactorCode(res); + return code ? new Error(i18n.t(MESSAGE_KEYS[code])) : null; +} diff --git a/src/stores/uiStore.ts b/src/stores/uiStore.ts index e80f87557..46e263a8f 100644 --- a/src/stores/uiStore.ts +++ b/src/stores/uiStore.ts @@ -77,8 +77,8 @@ export type KeychainPendingAction = | { action: "edit-identity"; id: string } | null; export type CloudAuthMode = "signin" | "register"; -/** `link` moves the current local account to the cloud; `add` signs in to another one beside it. */ -export type CloudAuthPurpose = "link" | "add"; +/** `link` moves the current local account to the cloud; `add` signs in to another one beside it; `reauth` renews the current one after the server ended its session. */ +export type CloudAuthPurpose = "link" | "add" | "reauth"; export type MembersPanel = "invite" | "roles" | "security";