diff --git a/apps/web/package.json b/apps/web/package.json index c8c40c1..ed44344 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,6 +21,7 @@ "@revenuecat/purchases-js": "^1.32.0", "astro": "^5.7.0", "dompurify": "^3.1.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "tailwindcss": "^3.4.0" @@ -32,6 +33,7 @@ "@testing-library/svelte": "^5.3.1", "@testing-library/user-event": "^14.6.1", "@types/dompurify": "^3.0.5", + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "happy-dom": "^20.8.9", diff --git a/apps/web/src/components/AccountView.svelte b/apps/web/src/components/AccountView.svelte index 42df7f2..c734aa9 100644 --- a/apps/web/src/components/AccountView.svelte +++ b/apps/web/src/components/AccountView.svelte @@ -27,6 +27,7 @@ import VanityInboxForm from "./VanityInboxForm.svelte"; import DomainManager from "./DomainManager.svelte"; import LocaleToggle from "./LocaleToggle.svelte"; + import QRCode from "./QRCode.svelte"; const loc = useLocale(); const user = useUser(); @@ -143,6 +144,57 @@ } catch { /* silent */ } } + let twoFactorSetup = $state<{ secret: string; uri: string } | null>(null); + let twoFactorCode = $state(""); + let twoFactorBusy = $state(false); + let twoFactorError = $state(""); + + async function startTwoFactorSetup() { + if (!user.apiKey) return; + twoFactorBusy = true; + twoFactorError = ""; + try { + twoFactorSetup = await api.setupTwoFactor(user.apiKey); + twoFactorCode = ""; + } catch (e: any) { + twoFactorError = e?.message || t("genericError"); + } finally { + twoFactorBusy = false; + } + } + + async function confirmTwoFactor() { + if (!user.apiKey || !twoFactorCode.trim()) return; + twoFactorBusy = true; + twoFactorError = ""; + try { + await api.enableTwoFactor(twoFactorCode.trim(), user.apiKey); + twoFactorSetup = null; + twoFactorCode = ""; + await refreshUser(); + } catch (e: any) { + twoFactorError = t("twoFactorBadCode"); + } finally { + twoFactorBusy = false; + } + } + + async function disableTwoFactor() { + if (!user.apiKey) return; + twoFactorBusy = true; + twoFactorError = ""; + try { + await api.disableTwoFactor(twoFactorCode.trim(), user.apiKey); + twoFactorCode = ""; + twoFactorSetup = null; + await refreshUser(); + } catch (e: any) { + twoFactorError = t("twoFactorBadCode"); + } finally { + twoFactorBusy = false; + } + } + async function loadPlanData() { if (!user.info?.user_id) return; try { @@ -485,6 +537,79 @@ + +
+
+

{t("twoFactor")}

+ + {user.info?.two_factor_enabled ? t("twoFactorOn") : t("twoFactorOff")} + +
+

{t("twoFactorDescription")}

+ + {#if twoFactorSetup} +

{t("twoFactorScan")}

+
+
+ +
+
+
SECRET
+ {twoFactorSetup.secret} +
+
+
+ + +
+ {#if twoFactorError}
{twoFactorError}
{/if} + {:else if user.info?.two_factor_enabled} +
+ + +
+ {#if twoFactorError}
{twoFactorError}
{/if} + {:else} + + {#if twoFactorError}
{twoFactorError}
{/if} + {/if} +
+
diff --git a/apps/web/src/components/QRCode.svelte b/apps/web/src/components/QRCode.svelte new file mode 100644 index 0000000..d3a877e --- /dev/null +++ b/apps/web/src/components/QRCode.svelte @@ -0,0 +1,30 @@ + + + diff --git a/apps/web/src/pages/auth/verify.astro b/apps/web/src/pages/auth/verify.astro index 6e112cd..347f700 100644 --- a/apps/web/src/pages/auth/verify.astro +++ b/apps/web/src/pages/auth/verify.astro @@ -24,7 +24,6 @@ import Layout from "../../layouts/Layout.astro"; diff --git a/packages/shared/src/api-client.ts b/packages/shared/src/api-client.ts index 45c120d..fda494d 100644 --- a/packages/shared/src/api-client.ts +++ b/packages/shared/src/api-client.ts @@ -8,6 +8,7 @@ import type { RegisterDomainRequest, DomainInfo, WebhookConfig, + TwoFactorSetup, } from "./types"; export function createApiClient(baseUrl: string) { @@ -178,5 +179,45 @@ export function createApiClient(baseUrl: string) { headers: { Authorization: `Bearer ${userApiKey}` }, }); }, + + setupTwoFactor: (userApiKey: string) => + request("/user/2fa/setup", { method: "POST" }, userApiKey), + + enableTwoFactor: async (code: string, userApiKey: string): Promise => { + const res = await fetch(`${baseUrl}/user/2fa/enable`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${userApiKey}` }, + body: JSON.stringify({ code }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `enable failed: ${res.status}`); + } + }, + + disableTwoFactor: async (code: string, userApiKey: string): Promise => { + const res = await fetch(`${baseUrl}/user/2fa/disable`, { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${userApiKey}` }, + body: JSON.stringify({ code }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `disable failed: ${res.status}`); + } + }, + + verifyTwoFactor: async (pendingToken: string, code: string) => { + const res = await fetch(`${baseUrl}/auth/2fa/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pending_token: pendingToken, code }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `verify failed: ${res.status}`); + } + return res.json() as Promise<{ user_id: string; api_key: string; tier: string; no_ads: boolean }>; + }, }; } diff --git a/packages/shared/src/i18n/locales/en.ts b/packages/shared/src/i18n/locales/en.ts index e83bb44..f961e50 100644 --- a/packages/shared/src/i18n/locales/en.ts +++ b/packages/shared/src/i18n/locales/en.ts @@ -187,4 +187,18 @@ export const en: Record = { sharedWith: "Shared with", shareUserNotFound: "No user with that email", shareSelf: "You can't share with yourself", + + // Two-factor + twoFactor: "Two-factor authentication", + twoFactorDescription: "Require a TOTP code from your authenticator app when confirming an email login.", + twoFactorEnable: "Enable 2FA", + twoFactorDisable: "Disable 2FA", + twoFactorOn: "Active", + twoFactorOff: "Inactive", + twoFactorScan: "Scan with your authenticator (Google Authenticator, Authy, 1Password) or copy the secret manually.", + twoFactorCodePlaceholder: "6-digit code", + twoFactorVerify: "Verify", + twoFactorBadCode: "Invalid code", + twoFactorRequired: "Enter the code from your authenticator", + twoFactorPendingExpired: "Session expired — request a new login link", }; diff --git a/packages/shared/src/i18n/locales/es.ts b/packages/shared/src/i18n/locales/es.ts index 818de91..9068aca 100644 --- a/packages/shared/src/i18n/locales/es.ts +++ b/packages/shared/src/i18n/locales/es.ts @@ -187,4 +187,18 @@ export const es: Record = { sharedWith: "Compartido con", shareUserNotFound: "No hay usuario con ese email", shareSelf: "No puedes compartir contigo mismo", + + // Two-factor + twoFactor: "Autenticación en dos pasos", + twoFactorDescription: "Requiere un código TOTP de tu app autenticadora al confirmar el inicio de sesión por email.", + twoFactorEnable: "Activar 2FA", + twoFactorDisable: "Desactivar 2FA", + twoFactorOn: "Activo", + twoFactorOff: "Inactivo", + twoFactorScan: "Escanea con tu autenticador (Google Authenticator, Authy, 1Password) o copia el secreto manualmente.", + twoFactorCodePlaceholder: "código de 6 dígitos", + twoFactorVerify: "Verificar", + twoFactorBadCode: "Código inválido", + twoFactorRequired: "Introduce el código de tu autenticador", + twoFactorPendingExpired: "Sesión expirada — solicita un nuevo enlace de acceso", }; diff --git a/packages/shared/src/i18n/locales/fr.ts b/packages/shared/src/i18n/locales/fr.ts index 5741f63..61becca 100644 --- a/packages/shared/src/i18n/locales/fr.ts +++ b/packages/shared/src/i18n/locales/fr.ts @@ -187,4 +187,18 @@ export const fr: Record = { sharedWith: "Partagé avec", shareUserNotFound: "Aucun utilisateur avec cet email", shareSelf: "Vous ne pouvez pas partager avec vous-même", + + // Two-factor + twoFactor: "Authentification à deux facteurs", + twoFactorDescription: "Exiger un code TOTP de votre application d'authentification lors de la confirmation d'une connexion par email.", + twoFactorEnable: "Activer 2FA", + twoFactorDisable: "Désactiver 2FA", + twoFactorOn: "Actif", + twoFactorOff: "Inactif", + twoFactorScan: "Scannez avec votre application (Google Authenticator, Authy, 1Password) ou copiez le secret manuellement.", + twoFactorCodePlaceholder: "code à 6 chiffres", + twoFactorVerify: "Vérifier", + twoFactorBadCode: "Code invalide", + twoFactorRequired: "Entrez le code de votre authentificateur", + twoFactorPendingExpired: "Session expirée — demandez un nouveau lien de connexion", }; diff --git a/packages/shared/src/i18n/locales/pt-BR.ts b/packages/shared/src/i18n/locales/pt-BR.ts index 893425b..fa5c9b3 100644 --- a/packages/shared/src/i18n/locales/pt-BR.ts +++ b/packages/shared/src/i18n/locales/pt-BR.ts @@ -185,6 +185,20 @@ export const ptBR = { sharedWith: "Compartilhado com", shareUserNotFound: "Nenhum usuário com esse email", shareSelf: "Você não pode compartilhar consigo mesmo", + + // Two-factor + twoFactor: "Autenticação em duas etapas", + twoFactorDescription: "Exige um código TOTP do seu app de autenticação ao confirmar o login por email.", + twoFactorEnable: "Ativar 2FA", + twoFactorDisable: "Desativar 2FA", + twoFactorOn: "Ativo", + twoFactorOff: "Inativo", + twoFactorScan: "Escaneie no app autenticador (Google Authenticator, Authy, 1Password) ou copie o segredo manualmente.", + twoFactorCodePlaceholder: "código de 6 dígitos", + twoFactorVerify: "Verificar", + twoFactorBadCode: "Código inválido", + twoFactorRequired: "Digite o código do seu autenticador", + twoFactorPendingExpired: "Sessão expirou — gere um novo link de acesso", } as const; export type TranslationKey = keyof typeof ptBR; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index f4eb51d..99b2a46 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -51,9 +51,15 @@ export interface UserInfo { role?: string; no_ads: boolean; inbox_count: number; + two_factor_enabled?: boolean; created_at: number; } +export interface TwoFactorSetup { + secret: string; + uri: string; +} + export interface VanityInboxRequest { local_part: string; domain?: string; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c324569..cd7f6ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: dompurify: specifier: ^3.1.0 version: 3.3.3 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.0.0 version: 19.2.4 @@ -151,6 +154,9 @@ importers: '@types/dompurify': specifier: ^3.0.5 version: 3.2.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.0.0 version: 19.2.14 @@ -2180,6 +2186,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -2803,6 +2812,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -2993,6 +3005,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -3085,6 +3101,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dir-glob@3.0.1: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} @@ -4807,6 +4826,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4925,6 +4948,11 @@ packages: resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} @@ -5160,6 +5188,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requireg@0.2.2: resolution: {integrity: sha512-nYzyjnFcPNGR3lx9lwPPPnuQxv6JWEZd2Ci0u9opN7N5zUEPIhY/GbL3vMGOr2UXwEg9WwSyV9X9Y/kLFgPsOg==} engines: {node: '>= 4.0.0'} @@ -5296,6 +5327,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6064,6 +6098,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -6093,6 +6130,10 @@ packages: wonka@6.3.6: resolution: {integrity: sha512-MXH+6mDHAZ2GuMpgKS055FR6v0xVP3XwquxIMYXgiW+FejHQlMGlvVRZT4qMCxR+bEo/FCtIdKxwej9WV3YQag==} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -6180,6 +6221,9 @@ packages: xxhash-wasm@1.1.0: resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6199,10 +6243,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -8626,6 +8678,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.5.2 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -9385,6 +9441,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9573,6 +9635,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.6.0: optional: true @@ -9652,6 +9716,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + dir-glob@3.0.1: dependencies: path-type: 4.0.0 @@ -11787,6 +11853,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-import@15.1.0(postcss@8.5.8): @@ -11890,6 +11958,12 @@ snapshots: qrcode-terminal@0.11.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + query-string@7.1.3: dependencies: decode-uri-component: 0.2.2 @@ -12231,6 +12305,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requireg@0.2.2: dependencies: nested-error-stacks: 2.0.1 @@ -12410,6 +12486,8 @@ snapshots: server-only@0.0.1: {} + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -13137,6 +13215,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which-pm-runs@1.1.0: {} which-typed-array@1.1.20: @@ -13168,6 +13248,12 @@ snapshots: wonka@6.3.6: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -13231,6 +13317,8 @@ snapshots: xxhash-wasm@1.1.0: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} @@ -13241,8 +13329,27 @@ snapshots: yaml@2.8.3: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/services/internal/handler/api.go b/services/internal/handler/api.go index 868b8f1..5ee2b5c 100644 --- a/services/internal/handler/api.go +++ b/services/internal/handler/api.go @@ -148,6 +148,16 @@ func (a *API) routeInternal(method string, parts []string) http.HandlerFunc { return a.SendMagicLink case method == "GET" && len(parts) == 2 && parts[0] == "auth" && parts[1] == "verify": return a.VerifyMagicLink + case method == "POST" && len(parts) == 3 && parts[0] == "auth" && parts[1] == "2fa" && parts[2] == "verify": + return a.VerifyTwoFactor + + // User 2FA management + case method == "POST" && len(parts) == 3 && parts[0] == "user" && parts[1] == "2fa" && parts[2] == "setup": + return a.SetupTwoFactor + case method == "POST" && len(parts) == 3 && parts[0] == "user" && parts[1] == "2fa" && parts[2] == "enable": + return a.EnableTwoFactor + case method == "POST" && len(parts) == 3 && parts[0] == "user" && parts[1] == "2fa" && parts[2] == "disable": + return a.DisableTwoFactor // User email case method == "POST" && len(parts) == 2 && parts[0] == "user" && parts[1] == "email": @@ -952,6 +962,96 @@ func (a *API) ListUserInboxes(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"inboxes": addresses}) } +func (a *API) SetupTwoFactor(w http.ResponseWriter, r *http.Request) { + u, err := a.authenticateUser(r) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + setup, err := a.userSvc.SetupTwoFactor(r.Context(), u.UserID, "Ephemask") + if err != nil { + if errors.Is(err, user.ErrPremiumOnly) { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "premium feature"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to setup 2fa"}) + return + } + writeJSON(w, http.StatusOK, setup) +} + +func (a *API) EnableTwoFactor(w http.ResponseWriter, r *http.Request) { + u, err := a.authenticateUser(r) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + var body struct { + Code string `json:"code"` + } + if err := readJSON(r, &body); err != nil || body.Code == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code is required"}) + return + } + if err := a.userSvc.EnableTwoFactor(r.Context(), u.UserID, body.Code); err != nil { + switch { + case errors.Is(err, user.ErrTwoFactorBad): + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid code"}) + case errors.Is(err, user.ErrTwoFactorOff): + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "run setup first"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to enable 2fa"}) + } + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) DisableTwoFactor(w http.ResponseWriter, r *http.Request) { + u, err := a.authenticateUser(r) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + var body struct { + Code string `json:"code"` + } + _ = readJSON(r, &body) + if err := a.userSvc.DisableTwoFactor(r.Context(), u.UserID, body.Code); err != nil { + if errors.Is(err, user.ErrTwoFactorBad) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid code"}) + return + } + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to disable 2fa"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (a *API) VerifyTwoFactor(w http.ResponseWriter, r *http.Request) { + var body struct { + PendingToken string `json:"pending_token"` + Code string `json:"code"` + } + if err := readJSON(r, &body); err != nil || body.PendingToken == "" || body.Code == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "pending_token and code are required"}) + return + } + resp, err := a.userSvc.VerifyTwoFactor(r.Context(), body.PendingToken, body.Code) + if err != nil { + switch { + case errors.Is(err, user.ErrTokenExpired): + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token expired"}) + case errors.Is(err, user.ErrTwoFactorBad): + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid code"}) + default: + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "verify failed"}) + } + return + } + writeJSON(w, http.StatusOK, resp) +} + func (a *API) SetUserWebhook(w http.ResponseWriter, r *http.Request) { u, err := a.authenticateUser(r) if err != nil { diff --git a/services/internal/twofactor/totp.go b/services/internal/twofactor/totp.go new file mode 100644 index 0000000..63ad6d7 --- /dev/null +++ b/services/internal/twofactor/totp.go @@ -0,0 +1,104 @@ +// Package twofactor implements RFC 6238 TOTP with a small built-in API +// (Generate, Verify, NewSecret, OtpauthURI). Wide library compatibility: +// 6-digit codes, SHA-1, 30-second period — what every authenticator app +// (Google, Authy, 1Password, Bitwarden) defaults to. +package twofactor + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base32" + "encoding/binary" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +const ( + period = 30 + digits = 6 + // drift allows ±1 step (30s) on either side to tolerate clock skew. + drift = 1 +) + +var ( + ErrBadSecret = errors.New("invalid 2fa secret") + ErrBadCode = errors.New("invalid 2fa code") + + b32 = base32.StdEncoding.WithPadding(base32.NoPadding) +) + +// NewSecret returns a fresh 20-byte TOTP secret encoded in base32 (no padding), +// the standard format authenticator apps expect. +func NewSecret() (string, error) { + raw := make([]byte, 20) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return b32.EncodeToString(raw), nil +} + +// OtpauthURI builds the otpauth:// provisioning URI for QR encoding. +func OtpauthURI(issuer, account, secret string) string { + v := url.Values{} + v.Set("secret", secret) + v.Set("issuer", issuer) + v.Set("algorithm", "SHA1") + v.Set("digits", fmt.Sprintf("%d", digits)) + v.Set("period", fmt.Sprintf("%d", period)) + label := url.PathEscape(issuer + ":" + account) + return "otpauth://totp/" + label + "?" + v.Encode() +} + +// Verify returns true when code matches the current TOTP for secret, allowing +// ±drift steps for clock skew. +func Verify(secret, code string) bool { + code = strings.TrimSpace(code) + if len(code) != digits { + return false + } + key, err := decodeSecret(secret) + if err != nil { + return false + } + now := time.Now().Unix() / period + for offset := -drift; offset <= drift; offset++ { + if hmac.Equal([]byte(generate(key, now+int64(offset))), []byte(code)) { + return true + } + } + return false +} + +func decodeSecret(secret string) ([]byte, error) { + secret = strings.ToUpper(strings.ReplaceAll(secret, " ", "")) + if secret == "" { + return nil, ErrBadSecret + } + key, err := b32.DecodeString(secret) + if err != nil { + return nil, ErrBadSecret + } + return key, nil +} + +func generate(key []byte, counter int64) string { + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, uint64(counter)) + m := hmac.New(sha1.New, key) + m.Write(buf) + h := m.Sum(nil) + offset := h[len(h)-1] & 0x0f + binCode := (uint32(h[offset])&0x7f)<<24 | + uint32(h[offset+1])<<16 | + uint32(h[offset+2])<<8 | + uint32(h[offset+3]) + mod := uint32(1) + for i := 0; i < digits; i++ { + mod *= 10 + } + return fmt.Sprintf("%0*d", digits, binCode%mod) +} diff --git a/services/internal/twofactor/totp_test.go b/services/internal/twofactor/totp_test.go new file mode 100644 index 0000000..c83021e --- /dev/null +++ b/services/internal/twofactor/totp_test.go @@ -0,0 +1,39 @@ +package twofactor + +import "testing" + +// RFC 6238 reference values use the secret "12345678901234567890" (20 ASCII +// bytes), which encodes as GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ in base32. +func TestVerifyKnownVectors(t *testing.T) { + const secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + key, err := decodeSecret(secret) + if err != nil { + t.Fatalf("decode: %v", err) + } + + cases := []struct { + t int64 + want string + }{ + {59, "287082"}, + {1111111109, "081804"}, + {1111111111, "050471"}, + {1234567890, "005924"}, + } + for _, c := range cases { + got := generate(key, c.t/period) + if got != c.want { + t.Errorf("generate(t=%d) = %s, want %s", c.t, got, c.want) + } + } +} + +func TestNewSecretRoundTrip(t *testing.T) { + s, err := NewSecret() + if err != nil { + t.Fatalf("NewSecret: %v", err) + } + if _, err := decodeSecret(s); err != nil { + t.Errorf("decodeSecret rejected freshly generated secret: %v", err) + } +} diff --git a/services/internal/user/model.go b/services/internal/user/model.go index 534b998..3aa2999 100644 --- a/services/internal/user/model.go +++ b/services/internal/user/model.go @@ -26,17 +26,19 @@ const ( // UserRecord represents a registered user in DynamoDB. type UserRecord struct { - InboxAddress string `dynamodbav:"inbox_address" json:"-"` - MessageID string `dynamodbav:"message_id" json:"-"` - UserID string `dynamodbav:"user_id,omitempty" json:"user_id"` - ApiKey string `dynamodbav:"api_key" json:"-"` - Email string `dynamodbav:"email,omitempty" json:"email,omitempty"` - Tier string `dynamodbav:"tier" json:"tier"` - Role string `dynamodbav:"role,omitempty" json:"role,omitempty"` - NoAds bool `dynamodbav:"no_ads" json:"no_ads"` - WebhookURL string `dynamodbav:"webhook_url,omitempty" json:"-"` - WebhookSecret string `dynamodbav:"webhook_secret,omitempty" json:"-"` - CreatedAt int64 `dynamodbav:"received_at" json:"created_at"` + InboxAddress string `dynamodbav:"inbox_address" json:"-"` + MessageID string `dynamodbav:"message_id" json:"-"` + UserID string `dynamodbav:"user_id,omitempty" json:"user_id"` + ApiKey string `dynamodbav:"api_key" json:"-"` + Email string `dynamodbav:"email,omitempty" json:"email,omitempty"` + Tier string `dynamodbav:"tier" json:"tier"` + Role string `dynamodbav:"role,omitempty" json:"role,omitempty"` + NoAds bool `dynamodbav:"no_ads" json:"no_ads"` + WebhookURL string `dynamodbav:"webhook_url,omitempty" json:"-"` + WebhookSecret string `dynamodbav:"webhook_secret,omitempty" json:"-"` + TwoFactorSecret string `dynamodbav:"twofa_secret,omitempty" json:"-"` + TwoFactorEnabled bool `dynamodbav:"twofa_enabled,omitempty" json:"-"` + CreatedAt int64 `dynamodbav:"received_at" json:"created_at"` } // WebhookConfig is what /user/webhook returns to the API consumer. @@ -65,13 +67,14 @@ type RegisterResponse struct { // UserInfo is returned for GET /user/me. type UserInfo struct { - UserID string `json:"user_id"` - Email string `json:"email,omitempty"` - Tier string `json:"tier"` - Role string `json:"role,omitempty"` - NoAds bool `json:"no_ads"` - InboxCount int `json:"inbox_count"` - CreatedAt int64 `json:"created_at"` + UserID string `json:"user_id"` + Email string `json:"email,omitempty"` + Tier string `json:"tier"` + Role string `json:"role,omitempty"` + NoAds bool `json:"no_ads"` + InboxCount int `json:"inbox_count"` + TwoFactorEnabled bool `json:"two_factor_enabled"` + CreatedAt int64 `json:"created_at"` } // AdminUserSummary is one row in the admin user list. We deliberately omit @@ -91,10 +94,37 @@ type MagicLinkResponse struct { Message string `json:"message"` } -// VerifyResponse is returned after verifying a magic link. +// VerifyResponse is returned after verifying a magic link. When TwoFactorRequired +// is true the api_key is empty and the client must POST PendingToken + TOTP code +// to /auth/2fa/verify to receive the real api_key. type VerifyResponse struct { - UserID string `json:"user_id"` - ApiKey string `json:"api_key"` - Tier string `json:"tier"` - NoAds bool `json:"no_ads"` + UserID string `json:"user_id,omitempty"` + ApiKey string `json:"api_key,omitempty"` + Tier string `json:"tier,omitempty"` + NoAds bool `json:"no_ads,omitempty"` + TwoFactorRequired bool `json:"two_factor_required,omitempty"` + PendingToken string `json:"pending_token,omitempty"` +} + +// PendingAuth is a short-lived item that the client trades for a real api_key +// after submitting a valid TOTP code. +type PendingAuth struct { + InboxAddress string `dynamodbav:"inbox_address"` // __pending2fa__: + MessageID string `dynamodbav:"message_id"` + UserID string `dynamodbav:"user_id"` + Token string `dynamodbav:"token"` + TTL int64 `dynamodbav:"ttl"` +} + +const ( + PendingAuthSentinel = "__pending2fa__" + PendingAuthPrefix = "__pending2fa__:" + PendingAuthTTL = 300 // 5 minutes +) + +// TwoFactorSetup is returned by POST /user/2fa/setup with the unverified +// secret and provisioning URI. Setup is finalized by POST /user/2fa/enable. +type TwoFactorSetup struct { + Secret string `json:"secret"` + URI string `json:"uri"` } diff --git a/services/internal/user/repository.go b/services/internal/user/repository.go index 57fc55b..3789327 100644 --- a/services/internal/user/repository.go +++ b/services/internal/user/repository.go @@ -140,6 +140,100 @@ func (r *Repository) UpdateTier(ctx context.Context, userID, tier string, noAds return err } +// SetTwoFactorSecret saves a (still-unverified) TOTP secret. The enabled flag +// is set by EnableTwoFactor once the user proves they can read it. +func (r *Repository) SetTwoFactorSecret(ctx context.Context, userID, secret string) error { + _, err := r.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{ + TableName: &r.tableName, + Key: map[string]types.AttributeValue{ + "inbox_address": &types.AttributeValueMemberS{Value: UserPKPrefix + userID}, + "message_id": &types.AttributeValueMemberS{Value: UserSentinel}, + }, + UpdateExpression: aws.String("SET twofa_secret = :s, twofa_enabled = :off"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":s": &types.AttributeValueMemberS{Value: secret}, + ":off": &types.AttributeValueMemberBOOL{Value: false}, + }, + }) + return err +} + +// EnableTwoFactor flips the verified flag. +func (r *Repository) EnableTwoFactor(ctx context.Context, userID string) error { + _, err := r.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{ + TableName: &r.tableName, + Key: map[string]types.AttributeValue{ + "inbox_address": &types.AttributeValueMemberS{Value: UserPKPrefix + userID}, + "message_id": &types.AttributeValueMemberS{Value: UserSentinel}, + }, + UpdateExpression: aws.String("SET twofa_enabled = :on"), + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":on": &types.AttributeValueMemberBOOL{Value: true}, + }, + }) + return err +} + +// ClearTwoFactor removes both the secret and the enabled flag. +func (r *Repository) ClearTwoFactor(ctx context.Context, userID string) error { + _, err := r.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{ + TableName: &r.tableName, + Key: map[string]types.AttributeValue{ + "inbox_address": &types.AttributeValueMemberS{Value: UserPKPrefix + userID}, + "message_id": &types.AttributeValueMemberS{Value: UserSentinel}, + }, + UpdateExpression: aws.String("REMOVE twofa_secret, twofa_enabled"), + }) + return err +} + +// CreatePendingAuth stores a short-lived placeholder for a 2FA-gated login. +func (r *Repository) CreatePendingAuth(ctx context.Context, p *PendingAuth) error { + item, err := attributevalue.MarshalMap(p) + if err != nil { + return err + } + _, err = r.client.PutItem(ctx, &dynamodb.PutItemInput{ + TableName: &r.tableName, + Item: item, + }) + return err +} + +// GetPendingAuth retrieves a pending 2FA auth by token. +func (r *Repository) GetPendingAuth(ctx context.Context, token string) (*PendingAuth, error) { + out, err := r.client.GetItem(ctx, &dynamodb.GetItemInput{ + TableName: &r.tableName, + Key: map[string]types.AttributeValue{ + "inbox_address": &types.AttributeValueMemberS{Value: PendingAuthPrefix + token}, + "message_id": &types.AttributeValueMemberS{Value: PendingAuthSentinel}, + }, + }) + if err != nil { + return nil, err + } + if out.Item == nil { + return nil, nil + } + var p PendingAuth + if err := attributevalue.UnmarshalMap(out.Item, &p); err != nil { + return nil, err + } + return &p, nil +} + +// DeletePendingAuth removes a consumed token. +func (r *Repository) DeletePendingAuth(ctx context.Context, token string) error { + _, err := r.client.DeleteItem(ctx, &dynamodb.DeleteItemInput{ + TableName: &r.tableName, + Key: map[string]types.AttributeValue{ + "inbox_address": &types.AttributeValueMemberS{Value: PendingAuthPrefix + token}, + "message_id": &types.AttributeValueMemberS{Value: PendingAuthSentinel}, + }, + }) + return err +} + // SetWebhook stores the webhook URL and HMAC secret for a user. func (r *Repository) SetWebhook(ctx context.Context, userID, url, secret string) error { _, err := r.client.UpdateItem(ctx, &dynamodb.UpdateItemInput{ diff --git a/services/internal/user/service.go b/services/internal/user/service.go index 2345b4a..d772848 100644 --- a/services/internal/user/service.go +++ b/services/internal/user/service.go @@ -9,15 +9,18 @@ import ( "time" "github.com/ephemask/services/internal/mailer" + "github.com/ephemask/services/internal/twofactor" ) var ( - ErrUnauthorized = errors.New("unauthorized") - ErrInboxLimit = errors.New("inbox limit reached") - ErrPremiumOnly = errors.New("premium feature") - ErrAlreadyExists = errors.New("already exists") - ErrTokenExpired = errors.New("token expired or invalid") - ErrEmailTaken = errors.New("email already in use") + ErrUnauthorized = errors.New("unauthorized") + ErrInboxLimit = errors.New("inbox limit reached") + ErrPremiumOnly = errors.New("premium feature") + ErrAlreadyExists = errors.New("already exists") + ErrTokenExpired = errors.New("token expired or invalid") + ErrEmailTaken = errors.New("email already in use") + ErrTwoFactorBad = errors.New("invalid 2fa code") + ErrTwoFactorOff = errors.New("two-factor not configured") ) // Service contains business logic for user operations. @@ -121,13 +124,14 @@ func (s *Service) GetInfo(ctx context.Context, userID string) (*UserInfo, error) count := len(addresses) return &UserInfo{ - UserID: user.UserID, - Email: user.Email, - Tier: user.Tier, - Role: user.Role, - NoAds: user.NoAds, - InboxCount: count, - CreatedAt: user.CreatedAt, + UserID: user.UserID, + Email: user.Email, + Tier: user.Tier, + Role: user.Role, + NoAds: user.NoAds, + InboxCount: count, + TwoFactorEnabled: user.TwoFactorEnabled, + CreatedAt: user.CreatedAt, }, nil } @@ -179,7 +183,9 @@ func (s *Service) SendMagicLink(ctx context.Context, email string) error { return s.mailer.SendMagicLink(ctx, email, magicLink) } -// VerifyMagicLink validates a magic token and returns the user's API key. +// VerifyMagicLink validates a magic token. If the user has 2FA enabled the +// caller receives a short-lived pending token that must be exchanged via +// VerifyTwoFactor for the real api_key. func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*VerifyResponse, error) { mt, err := s.repo.GetMagicToken(ctx, token) if err != nil { @@ -189,32 +195,147 @@ func (s *Service) VerifyMagicLink(ctx context.Context, token string) (*VerifyRes return nil, ErrTokenExpired } - // Check TTL if time.Now().Unix() > mt.TTL { _ = s.repo.DeleteMagicToken(ctx, token) return nil, ErrTokenExpired } - // Get the user - user, err := s.repo.GetUserByID(ctx, mt.UserID) + u, err := s.repo.GetUserByID(ctx, mt.UserID) if err != nil { return nil, err } - if user == nil { + if u == nil { return nil, ErrTokenExpired } - // Consume the token _ = s.repo.DeleteMagicToken(ctx, token) + if u.TwoFactorEnabled { + pending, err := generateToken() + if err != nil { + return nil, err + } + p := &PendingAuth{ + InboxAddress: PendingAuthPrefix + pending, + MessageID: PendingAuthSentinel, + UserID: u.UserID, + Token: pending, + TTL: time.Now().Unix() + PendingAuthTTL, + } + if err := s.repo.CreatePendingAuth(ctx, p); err != nil { + return nil, err + } + return &VerifyResponse{ + TwoFactorRequired: true, + PendingToken: pending, + }, nil + } + + return &VerifyResponse{ + UserID: u.UserID, + ApiKey: u.ApiKey, + Tier: u.Tier, + NoAds: u.NoAds, + }, nil +} + +// VerifyTwoFactor exchanges a pending token + TOTP code for a real api_key. +func (s *Service) VerifyTwoFactor(ctx context.Context, pendingToken, code string) (*VerifyResponse, error) { + p, err := s.repo.GetPendingAuth(ctx, pendingToken) + if err != nil { + return nil, err + } + if p == nil || time.Now().Unix() > p.TTL { + _ = s.repo.DeletePendingAuth(ctx, pendingToken) + return nil, ErrTokenExpired + } + u, err := s.repo.GetUserByID(ctx, p.UserID) + if err != nil { + return nil, err + } + if u == nil || !u.TwoFactorEnabled || u.TwoFactorSecret == "" { + _ = s.repo.DeletePendingAuth(ctx, pendingToken) + return nil, ErrUnauthorized + } + if !twofactor.Verify(u.TwoFactorSecret, code) { + return nil, ErrTwoFactorBad + } + _ = s.repo.DeletePendingAuth(ctx, pendingToken) return &VerifyResponse{ - UserID: user.UserID, - ApiKey: user.ApiKey, - Tier: user.Tier, - NoAds: user.NoAds, + UserID: u.UserID, + ApiKey: u.ApiKey, + Tier: u.Tier, + NoAds: u.NoAds, + }, nil +} + +// SetupTwoFactor stores a fresh (still-disabled) TOTP secret and returns the +// provisioning data to display in the account UI. +func (s *Service) SetupTwoFactor(ctx context.Context, userID, issuer string) (*TwoFactorSetup, error) { + u, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return nil, err + } + if u == nil { + return nil, ErrUnauthorized + } + if u.Tier != TierPremium { + return nil, ErrPremiumOnly + } + secret, err := twofactor.NewSecret() + if err != nil { + return nil, err + } + if err := s.repo.SetTwoFactorSecret(ctx, userID, secret); err != nil { + return nil, err + } + account := u.Email + if account == "" { + account = u.UserID + } + return &TwoFactorSetup{ + Secret: secret, + URI: twofactor.OtpauthURI(issuer, account, secret), }, nil } +// EnableTwoFactor finalizes setup by verifying the user can produce a code. +func (s *Service) EnableTwoFactor(ctx context.Context, userID, code string) error { + u, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return err + } + if u == nil { + return ErrUnauthorized + } + if u.TwoFactorSecret == "" { + return ErrTwoFactorOff + } + if !twofactor.Verify(u.TwoFactorSecret, code) { + return ErrTwoFactorBad + } + return s.repo.EnableTwoFactor(ctx, userID) +} + +// DisableTwoFactor turns 2FA off (requires a valid code so a stolen api key +// can't quietly drop the second factor). +func (s *Service) DisableTwoFactor(ctx context.Context, userID, code string) error { + u, err := s.repo.GetUserByID(ctx, userID) + if err != nil { + return err + } + if u == nil { + return ErrUnauthorized + } + if !u.TwoFactorEnabled { + return s.repo.ClearTwoFactor(ctx, userID) + } + if !twofactor.Verify(u.TwoFactorSecret, code) { + return ErrTwoFactorBad + } + return s.repo.ClearTwoFactor(ctx, userID) +} + // AddEmail adds an email to an existing anonymous user and sends verification. func (s *Service) AddEmail(ctx context.Context, userID, email string) error { // Check if email is already taken by another user diff --git a/terraform/api_gateway.tf b/terraform/api_gateway.tf index e11e24a..c521036 100644 --- a/terraform/api_gateway.tf +++ b/terraform/api_gateway.tf @@ -181,6 +181,30 @@ resource "aws_apigatewayv2_route" "get_auth_verify" { target = "integrations/${aws_apigatewayv2_integration.api.id}" } +resource "aws_apigatewayv2_route" "post_auth_2fa_verify" { + api_id = aws_apigatewayv2_api.main.id + route_key = "POST /auth/2fa/verify" + target = "integrations/${aws_apigatewayv2_integration.api.id}" +} + +resource "aws_apigatewayv2_route" "post_user_2fa_setup" { + api_id = aws_apigatewayv2_api.main.id + route_key = "POST /user/2fa/setup" + target = "integrations/${aws_apigatewayv2_integration.api.id}" +} + +resource "aws_apigatewayv2_route" "post_user_2fa_enable" { + api_id = aws_apigatewayv2_api.main.id + route_key = "POST /user/2fa/enable" + target = "integrations/${aws_apigatewayv2_integration.api.id}" +} + +resource "aws_apigatewayv2_route" "post_user_2fa_disable" { + api_id = aws_apigatewayv2_api.main.id + route_key = "POST /user/2fa/disable" + target = "integrations/${aws_apigatewayv2_integration.api.id}" +} + resource "aws_apigatewayv2_route" "put_user_webhook" { api_id = aws_apigatewayv2_api.main.id route_key = "PUT /user/webhook"