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 @@
+
+
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"