diff --git a/docker/.env.example b/docker/.env.example index 9df20f2..d40b736 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -6,12 +6,14 @@ WEB_PORT=8090 # Leave empty to make nobody an admin. ADMIN_EMAILS= -# The mail relay that sends the address-verification link. A new account is a -# guest until it follows that link, so a deployment without a relay can only -# ever hand out guest access. +# The mail relay that sends the address-verification mail — the 6-digit code +# plus the link. A new account is a guest until it proves the address with one +# of the two, so a deployment without a relay can only ever hand out guest +# access. # -# Leave SMTP_HOST empty and the link is written to the api container's log -# instead (`docker compose logs api`), which is enough for local work. +# Leave SMTP_HOST empty and both the code and the link are written to the api +# container's log instead (`docker compose logs api`), which is enough for local +# work. SMTP_HOST= # 587 upgrades to TLS (STARTTLS); 465 is TLS from the first byte. Set # SMTP_SECURE=true to force the latter on an unusual port. diff --git a/docker/backend/src/db.ts b/docker/backend/src/db.ts index bd47fd8..eaa6dda 100644 --- a/docker/backend/src/db.ts +++ b/docker/backend/src/db.ts @@ -1,7 +1,7 @@ import Database from 'better-sqlite3'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; -import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'; +import { randomBytes, randomInt, scryptSync, timingSafeEqual } from 'node:crypto'; export const SESSION_COOKIE = 'rc_session'; export const SESSION_MAX_AGE_S = 30 * 24 * 60 * 60; // 30 days @@ -86,7 +86,12 @@ CREATE TABLE IF NOT EXISTS email_verifications ( token TEXT PRIMARY KEY, user_id INTEGER NOT NULL, expires_at TEXT NOT NULL, - created_at TEXT NOT NULL + created_at TEXT NOT NULL, + -- The six digits the same letter carries, so a visitor can prove the address + -- without leaving the page they signed up on. NULL for a row minted before + -- codes existed; attempts counts only the guesses at it. + code TEXT, + attempts INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS places ( key TEXT PRIMARY KEY, @@ -165,6 +170,19 @@ export const serializeSlots = (slots: readonly PhotoSlot[]): string => } } +// The mailed code, added once visitors were expected to prove an address +// without leaving the page. Rows minted before it keep working as links: their +// `code` is NULL, which no typed guess can match (see verifyEmailCode). +{ + const cols = db.prepare('PRAGMA table_info(email_verifications)').all() as { name: string }[]; + if (!cols.some((c) => c.name === 'code')) { + db.exec(`ALTER TABLE email_verifications ADD COLUMN code TEXT`); + } + if (!cols.some((c) => c.name === 'attempts')) { + db.exec(`ALTER TABLE email_verifications ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0`); + } +} + // The strip's own labels, added after the first contributions were on disk: the // tagline burned/overlaid on the frame (`#KODAK_PORTRA_400`), the artwork title // and the technical line (`ISO 400 · GRAIN 35 · WARMTH +18`). All three are @@ -277,18 +295,30 @@ export function findUserById(id: number): User | undefined { // mail is the only link that works and the table cannot grow past the user // count. 24 hours is long enough to find the mail in a spam folder. export const VERIFY_TTL_S = 24 * 60 * 60; +// The typed code is the same proof over a shorter clock: six digits are worth +// guessing for a quarter of an hour, not for a day. It rides the same row — +// the link's `expires_at` and the code's `created_at + CODE_TTL_S` are two +// clocks over one row, which is why the code needs no expiry column of its own. +export const CODE_TTL_S = 15 * 60; +// Five guesses at a million, then the row is spent: the count is what keeps a +// six-digit secret from being walked through a hundred requests a second. +export const MAX_CODE_ATTEMPTS = 5; -export function createEmailVerification(userId: number): string { +export interface Verification { + token: string; + code: string; +} + +export function createEmailVerification(userId: number): Verification { const token = randomBytes(32).toString('hex'); + // randomInt, not Math.random: the code is the one value here worth predicting. + const code = String(randomInt(0, 1_000_000)).padStart(6, '0'); const at = now(); db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId); - db.prepare('INSERT INTO email_verifications (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)').run( - token, - userId, - new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(), - at, - ); - return token; + db.prepare( + 'INSERT INTO email_verifications (token, code, user_id, expires_at, created_at, attempts) VALUES (?, ?, ?, ?, ?, 0)', + ).run(token, code, userId, new Date(Date.now() + VERIFY_TTL_S * 1000).toISOString(), at); + return { token, code }; } // The account the token proves, or null when it is unknown or expired — the @@ -305,6 +335,29 @@ export function verifyEmailToken(token: string): number | null { return row.userId; } +// What a typed code did. 'stale' covers both "no live code" and "too old", so +// the caller learns nothing about which — the two are one answer, "ask for +// another letter". 'locked' is the spent-attempts state, which the visitor can +// only leave by having a new code mailed. +export type CodeResult = 'ok' | 'bad' | 'stale' | 'locked'; + +export function verifyEmailCode(userId: number, code: string): CodeResult { + const row = db + .prepare('SELECT code, attempts, created_at AS createdAt FROM email_verifications WHERE user_id = ?') + .get(userId) as { code: string | null; attempts: number; createdAt: string } | undefined; + if (!row || !row.code) return 'stale'; + if (new Date(row.createdAt).getTime() + CODE_TTL_S * 1000 <= Date.now()) return 'stale'; + if (row.attempts >= MAX_CODE_ATTEMPTS) return 'locked'; + // The attempt is counted before the answer is given, so an interrupted + // request cannot hand back a guess nobody paid for. + db.prepare('UPDATE email_verifications SET attempts = attempts + 1 WHERE user_id = ?').run(userId); + if (code.length !== row.code.length || !timingSafeEqual(Buffer.from(code), Buffer.from(row.code))) return 'bad'; + // One row is both proofs, so the code being spent spends the link with it. + db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId); + db.prepare('UPDATE users SET email_verified = 1 WHERE id = ?').run(userId); + return 'ok'; +} + export function deleteEmailVerifications(userId: number): void { db.prepare('DELETE FROM email_verifications WHERE user_id = ?').run(userId); } diff --git a/docker/backend/src/mailer.ts b/docker/backend/src/mailer.ts index 0935373..cf5526a 100644 --- a/docker/backend/src/mailer.ts +++ b/docker/backend/src/mailer.ts @@ -33,26 +33,36 @@ const transporter: Transporter | null = mailConfigured : null; // Both languages, because the account's language is not known before it exists. -const body = (url: string) => +// The code comes first and the link second: the code is the one that works +// without leaving the page the visitor signed up on, so it is what most of them +// will use — but the link stays, for the phone that owns the address, and +// because a letter that only carries digits is a letter a spam filter can +// decide is a phishing attempt. +const body = (url: string, code: string) => [ 'RecipesCam — xác thực địa chỉ email / verify your email address', '', + `Mã xác thực của bạn / your verification code: ${code}`, + '', + 'Mã có hiệu lực 15 phút. Nhập mã vào trang đăng ký để bật các tính năng PRO.', + 'The code is valid for 15 minutes. Type it into the signup page to unlock PRO.', + '', + 'Hoặc mở liên kết này / or open this link (hiệu lực 24 giờ / valid 24 hours):', url, '', - 'Liên kết hết hạn sau 24 giờ. Nếu bạn không đăng ký, hãy bỏ qua thư này.', - 'The link expires in 24 hours. If you did not sign up, ignore this mail.', + 'Nếu bạn không đăng ký, hãy bỏ qua thư này. / If you did not sign up, ignore this mail.', ].join('\r\n'); // Fire and forget: the account already exists, so a relay that is slow, out of // quota or misconfigured may not fail the signup that asked for it. The owner // can ask for another link from the studio; the operator sees the error here. -export function sendVerificationMail(to: string, url: string, log: (msg: string) => void): void { +export function sendVerificationMail(to: string, url: string, code: string, log: (msg: string) => void): void { if (!transporter) { - log(`[verify] SMTP not configured — verification link for ${to}: ${url}`); + log(`[verify] SMTP not configured — code ${code} and verification link for ${to}: ${url}`); return; } transporter - .sendMail({ from: SMTP_FROM, to, subject: 'RecipesCam — verify your email', text: body(url) }) - .then(() => log(`[verify] link sent to ${to}`)) + .sendMail({ from: SMTP_FROM, to, subject: `RecipesCam — ${code} is your verification code`, text: body(url, code) }) + .then(() => log(`[verify] code and link sent to ${to}`)) .catch((err: unknown) => log(`[verify] could not mail ${to}: ${String(err)}`)); } diff --git a/docker/backend/src/server.ts b/docker/backend/src/server.ts index fa9200b..205eca0 100644 --- a/docker/backend/src/server.ts +++ b/docker/backend/src/server.ts @@ -53,6 +53,7 @@ import { updateRecipe, updateUserEmail, userAvatar, + verifyEmailCode, verifyEmailToken, verifyPassword, type PhotoMeta, @@ -267,13 +268,13 @@ function originOf(req: FastifyRequest): string { return host ? `${proto}://${host}` : ''; } -// Mints the single live token and hands the link to the mailer. The URL is the -// API's own route, so a click needs no page of its own (see the redirect -// there). A relay that cannot send is not an error here: the link is in the -// log, and the account can ask again. +// Mints the single live token and code and hands both to the mailer. The URL is +// the API's own route, so a click needs no page of its own (see the redirect +// there). A relay that cannot send is not an error here: the code and the link +// are in the log, and the account can ask again. function sendVerification(req: FastifyRequest, user: User): void { - const token = createEmailVerification(user.id); - sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, (msg) => req.log.info(msg)); + const { token, code } = createEmailVerification(user.id); + sendVerificationMail(user.email, `${originOf(req)}/api/auth/verify?token=${token}`, code, (msg) => req.log.info(msg)); } // ---- analytics ------------------------------------------------------------ @@ -475,6 +476,29 @@ app.get('/api/auth/verify', async (req, reply) => { return reply.redirect(`${originOf(req)}/?verified=${userId ? 1 : 0}`, 303); }); +// The code from the same letter, typed into the page the visitor signed up on. +// A POST, unlike the link: it is a mutation the visitor submits on purpose, and +// a code in a GET query string would land in every proxy log on the way. +// +// No limiter of its own: the row carries the cap (MAX_CODE_ATTEMPTS) and a +// fresh code costs one of the three resends an hour, so five guesses per code +// is the budget an attacker has either way — and the cap lives with the secret. +app.post('/api/auth/verify-code', async (req, reply) => { + // `auth`, not `requirePro`: the whole point of the route is the account that + // has not passed the gate yet. + const user = auth(req); + if (!user) return reply.status(401).send({ error: 'unauthorized' }); + if (isVerified(user)) return reply.status(200).send({ ok: true, verified: true }); + const b = bodyOf(req); + const code = b && typeof b.code === 'string' ? b.code.trim() : ''; + // Six digits or nothing, so the counter only ever counts real guesses. + if (!/^\d{6}$/.test(code)) return reply.status(400).send({ error: 'invalid code' }); + const result = verifyEmailCode(user.id, code); + if (result === 'locked') return reply.header('retry-after', '900').status(429).send({ error: 'too_many_attempts' }); + if (result !== 'ok') return reply.status(400).send({ error: 'invalid code' }); + return reply.status(200).send({ ok: true, verified: true }); +}); + // The banner's own button. Capped like signup and keyed on the address, so the // route is not a way to mail a stranger repeatedly. const allowResend = limiter(3, 60 * 60_000); diff --git a/docker/backend/test/security.mjs b/docker/backend/test/security.mjs index b2e2576..0d35f03 100644 --- a/docker/backend/test/security.mjs +++ b/docker/backend/test/security.mjs @@ -100,6 +100,25 @@ async function activeSignup(a, email) { await followVerifyLink(email); return res; } +// The same letter carries a code, and the suite reaches it the same way — the +// database is the only reader of the mail here. +function codeFor(email) { + const db = new Database(join(DATA_DIR, 'recipescam.db'), { readonly: true }); + const row = db + .prepare('SELECT code FROM email_verifications WHERE user_id = (SELECT id FROM users WHERE email = ?)') + .get(email); + db.close(); + return row?.code; +} +// The code's own clock starts at `created_at`, so an old one is made here +// rather than waited for. +function ageCode(email, minutes) { + const db = new Database(join(DATA_DIR, 'recipescam.db')); + db.prepare( + 'UPDATE email_verifications SET created_at = ? WHERE user_id = (SELECT id FROM users WHERE email = ?)', + ).run(new Date(Date.now() - minutes * 60_000).toISOString(), email); + db.close(); +} // Run the sources, not a possibly stale build: the point of this suite is to // test the code as written. @@ -225,6 +244,49 @@ try { check('a spent link cannot be followed twice', replay.headers.get('location')?.endsWith('/?verified=0'), String(replay.headers.get('location'))); check('the allowlisted admin needs no letter', (await admin.req('/auth/me')).body?.user?.verified === true); + // ---- the code in the same letter ---------------------------------------- + // The link is not the only way to prove an address any more: the letter also + // carries six digits the visitor types into the dialog they signed up on. The + // code is the shorter-lived of the two proofs and the one worth guessing, so + // the checks below are mostly about what a wrong guess costs. + const typed = actor(); + const typedEmail = `typed${stamp}@test.local`; + await typed.signup(typedEmail); + const code = codeFor(typedEmail); + check('signup leaves a six-digit code beside the link', /^\d{6}$/.test(String(code)), String(code)); + const wrongCode = code === '000000' ? '111111' : '000000'; + const type = (a, value) => + a.req('/auth/verify-code', { method: 'POST', headers: jsonHdr, body: JSON.stringify({ code: value }) }); + check('a wrong code verifies nothing', (await type(typed, wrongCode)).status === 400); + check('and leaves the account unproven', (await typed.req('/auth/me')).body?.user?.verified === false); + check('a code of the wrong shape is refused', (await type(typed, '12345')).status === 400); + check('a signed-out caller cannot type a code', (await type(actor(), code)).status === 401); + check('the mailed code verifies the account', (await type(typed, code)).status === 200); + check('the account is verified from then on', (await typed.req('/auth/me')).body?.user?.verified === true); + check('the code being spent spends the link with it', tokenFor(typedEmail) === undefined, String(tokenFor(typedEmail))); + + const locked = actor(); + const lockedEmail = `locked${stamp}@test.local`; + await locked.signup(lockedEmail); + const lockedCode = codeFor(lockedEmail); + const otherCode = lockedCode === '000000' ? '111111' : '000000'; + for (let i = 0; i < 5; i++) await type(locked, otherCode); + const afterLock = await type(locked, lockedCode); + check('five wrong guesses lock the code out', afterLock.status === 429, `got ${afterLock.status}`); + check('and the right code no longer helps', (await locked.req('/auth/me')).body?.user?.verified === false); + await locked.req('/auth/resend-verification', { method: 'POST' }); + const freshCode = codeFor(lockedEmail); + check('a resent letter hands out a fresh code', freshCode !== lockedCode && /^\d{6}$/.test(String(freshCode))); + check('that fresh code is not locked out by the old guesses', (await type(locked, freshCode)).status === 200); + + const stale = actor(); + const staleEmail = `stale${stamp}@test.local`; + await stale.signup(staleEmail); + const staleCode = codeFor(staleEmail); + ageCode(staleEmail, 16); // past CODE_TTL_S + check('a code older than its quarter hour is refused', (await type(stale, staleCode)).status === 400); + check('and leaves the account unproven', (await stale.req('/auth/me')).body?.user?.verified === false); + // ---- rate limiting ------------------------------------------------------ const brute = actor(); const bruteEmail = `brute${stamp}@test.local`; diff --git a/docker/frontend/src/api.ts b/docker/frontend/src/api.ts index 424fb61..d69fa9d 100644 --- a/docker/frontend/src/api.ts +++ b/docker/frontend/src/api.ts @@ -221,6 +221,11 @@ export const api = { // Mail the verification link to the signed-in address again. Works while // unverified (that is the whole point); 429 once the hourly cap is spent. resendVerification: () => call<{ ok: boolean; verified?: boolean }>('/auth/resend-verification', { method: 'POST' }), + // The six digits from the same letter, typed instead of clicking the link. + // The route answers to the session the signup already granted, so the code + // proves the address to the browser that asked for it. + verifyCode: (code: string) => + call<{ ok: boolean; verified?: boolean }>('/auth/verify-code', { method: 'POST', body: JSON.stringify({ code }) }), listRecipes: () => call<{ recipes: SavedRecipe[] }>('/recipes'), createRecipe: (name: string, recipe: Recipe) => diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 13d2a97..afc7fe9 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -119,11 +119,14 @@ export const en: Dict = { 'auth.busy': 'Working…', 'auth.loggedInAs': 'Signed in as {email}', 'auth.verifyTitle': 'Verify your email', - 'auth.verifyBody': 'We sent a verification link to {email}. Open it to unlock the PRO features.', + 'auth.verifyBody': 'We sent a six-digit code to {email}. Type it below — or open the link in the mail — to unlock the PRO features.', 'auth.verifyProHint': 'Until then the account works exactly like a guest: exports stay 2048px with a watermark, and nothing can be saved.', - 'auth.verifySent': 'Verification email sent again.', - 'auth.verifiedDone': 'I have verified', - 'auth.signupVerifyHint': 'After signing up we email you a verification link — open it to unlock the PRO features.', + 'auth.verifySent': 'A new code has been sent.', + 'auth.codePlaceholder': 'The six digits from the mail', + 'auth.verifyBtn': 'VERIFY', + 'auth.codeBad': 'That code is wrong or has expired (15 minutes).', + 'auth.codeLocked': 'Too many wrong tries — ask for a new code by email.', + 'auth.signupVerifyHint': 'After signing up we email you a six-digit code — type it in to unlock the PRO features.', // The PRO gate. Signed in but unproven is served as a guest, so the studio // needs one place that says why and one way to ask for the letter again. diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index fd604b1..3895a7b 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -127,11 +127,14 @@ export const vi = { 'auth.busy': 'Đang xử lý…', 'auth.loggedInAs': 'Đã đăng nhập: {email}', 'auth.verifyTitle': 'Xác thực email', - 'auth.verifyBody': 'Đã gửi một liên kết xác thực tới {email}. Mở liên kết đó để dùng được các tính năng PRO.', + 'auth.verifyBody': 'Đã gửi mã xác thực 6 số tới {email}. Nhập mã vào ô dưới đây — hoặc mở liên kết trong email — để dùng được các tính năng PRO.', 'auth.verifyProHint': 'Khi chưa xác thực, tài khoản dùng y như khách: ảnh xuất ra tối đa 2048px kèm watermark và không lưu được gì.', - 'auth.verifySent': 'Đã gửi lại email xác thực.', - 'auth.verifiedDone': 'Tôi đã xác thực xong', - 'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một email xác thực — mở liên kết trong đó để bật các tính năng PRO.', + 'auth.verifySent': 'Đã gửi lại mã xác thực.', + 'auth.codePlaceholder': 'Mã 6 số trong email', + 'auth.verifyBtn': 'XÁC THỰC', + 'auth.codeBad': 'Mã không đúng hoặc đã hết hạn (15 phút).', + 'auth.codeLocked': 'Sai quá nhiều lần — hãy gửi lại email để nhận mã mới.', + 'auth.signupVerifyHint': 'Sau khi đăng ký, hệ thống gửi một mã xác thực 6 số tới email — nhập mã để bật các tính năng PRO.', // The PRO gate. Signed in but unproven is served as a guest, so the studio // needs one place that says why and one way to ask for the letter again. diff --git a/docker/frontend/src/ui/AuthModal.tsx b/docker/frontend/src/ui/AuthModal.tsx index 4d2b08e..d259729 100644 --- a/docker/frontend/src/ui/AuthModal.tsx +++ b/docker/frontend/src/ui/AuthModal.tsx @@ -7,10 +7,11 @@ import { api } from '../api'; // out, and only the PRO half is gated (see config/tiers.ts). // // Three faces, one form. `verify` is the third: the account exists but the -// address it gave has not been confirmed, so until the visitor opens the link -// it is served exactly like a guest. A fresh signup lands on that face by -// itself — telling someone their account is ready when it is not is the one -// thing this dialog must not do. +// address it gave has not been confirmed, so until the visitor proves it — by +// typing the code from the letter, or by opening the link in it — it is served +// exactly like a guest. A fresh signup lands on that face by itself, with the +// code box already waiting: telling someone their account is ready when it is +// not is the one thing this dialog must not do. export function AuthModal({ initialMode = 'login', email, @@ -28,18 +29,45 @@ export function AuthModal({ const [mode, setMode] = useState<'login' | 'signup' | 'verify'>(initialMode); const [address, setAddress] = useState(''); const [password, setPassword] = useState(''); + const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [sent, setSent] = useState(false); + // The API answers in codes; the two a visitor can actually reach are worth a + // sentence of their own, and the rest are shown as they come. + const complain = (err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + setError( + msg === 'invalid code' + ? t('auth.codeBad') + : msg === 'too_many_attempts' + ? t('auth.codeLocked') + : msg, + ); + }; + + const resend = async () => { + setBusy(true); + setError(null); + try { + await api.resendVerification(); + setSent(true); + } catch (err) { + complain(err); + } finally { + setBusy(false); + } + }; + const submit = async (e: React.FormEvent) => { e.preventDefault(); setBusy(true); setError(null); try { if (mode === 'verify') { - await api.resendVerification(); - setSent(true); + await api.verifyCode(code); + onDone(); return; } const { user } = await (mode === 'login' @@ -47,13 +75,13 @@ export function AuthModal({ : api.signup(address, password)); setAddress(user.email); if (!user.verified) { - if (mode === 'signup') setSent(true); // signup mails the first link itself + if (mode === 'signup') setSent(true); // signup mails the first code itself setMode('verify'); return; } onDone(); } catch (err) { - setError(err instanceof Error ? err.message : String(err)); + complain(err); } finally { setBusy(false); } @@ -67,12 +95,28 @@ export function AuthModal({

{t('auth.verifyTitle')}

{t('auth.verifyBody', { email: who })}

{t('auth.verifyProHint')}

+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + /> {sent ?

{t('auth.verifySent')}

: null} {error ?

{error}

: null} - + -