web: prove an address with the six digits the letter carries
Signing up mailed a link and nothing else, so a visitor who signed up on one
device and read the mail on another had to leave the page the studio was open
on, or give up and stay a guest. The letter now carries six digits as well, and
the verify dialog — the face a fresh signup already lands on — takes them.
Backend, one row is both proofs. `createEmailVerification` mints the token as it
did and a `randomInt(0, 1_000_000)` code padded to six, and returns `{ token,
code }`; `sendVerification` passes both to the mailer, which puts the code first
and the link second. The code's clock is `created_at + CODE_TTL_S` (15 minutes)
and the link keeps the row's own 24-hour `expires_at`: two clocks over one row,
so the code needs no expiry column of its own. That row's `code` is NULL for
anything minted before this commit, a value no typed guess can match, so an
in-flight link from the old mail still works and its owner simply has no code to
type. `db.ts` adds both columns with `PRAGMA table_info` + `ALTER TABLE` rather
than a rebuild, and sets `attempts` to 0.
`verifyEmailCode(userId, code)` answers 'ok' | 'bad' | 'stale' | 'locked', and
the shape of the answer is the point. 'stale' is both "no live code" and "too
old", so the caller learns nothing about which; 'locked' is the spent-attempts
state, which only a fresh letter leaves. The attempt is counted BEFORE the
comparison is trusted, so an interrupted request cannot hand back a guess nobody
paid for; five (MAX_CODE_ATTEMPTS) is the cap, which is what keeps a six-digit
secret from being walked through at a hundred requests a second. The comparison
itself is `timingSafeEqual` behind a length check, the same pair the password
path uses. On success the row is deleted and `email_verified` set, so the same
row spends the link with the code — one proof, one use.
The route is `POST /api/auth/verify-code`, a POST and not a GET like the link
because a code in a query string lands in every proxy log on the way. It reads
`auth`, not `requirePro`: the whole point of it is the account that has not
passed the gate yet. Input must be exactly six digits before anything else
happens, so the counter only ever counts real guesses; a wrong or stale code is
400, a locked one 429 with `retry-after: 900`, and an already-verified caller
gets 200 without touching the row. No limiter of its own: the cap lives with the
secret on the row, and a fresh code costs one of the three resends an hour, so
five guesses per code is the budget either way.
On the web side `api.verifyCode` posts the code, and the dialog's verify face
swaps its resend button for a code box plus a smaller resend beside it: the box
is `inputMode="numeric"`, `autoComplete="one-time-code"`, `maxLength 6`, and
strips non-digits as they are typed, so the number pad comes up on a phone and
nothing can paste a password into it. The submit button is disabled until six
digits are there. The two answers a visitor can actually act on get sentences of
their own (`auth.codeBad`, `auth.codeLocked`); everything else is shown as it
comes. The link path is untouched and still works, and the dialog keeps its
"Tôi đã xác thực xong" escape in no place at all — it verified nothing, so
closing the dialog and asking again covers the same ground.
The comment in `docker/.env.example` now says the letter carries both, since a
deployment without a relay writes both to the api log.
Verified:
backend `npm test` — 180 passed, 0 failed. The new section in security.mjs
drives the route end to end against the source: signup leaves a six-digit
code beside the link, a wrong code verifies nothing and leaves the account
unproven, a five-digit body is refused, a signed-out caller cannot type one,
the mailed code verifies, spending it spends the link, five wrong guesses
lock the code out and the right code then does not help, a resent letter
hands out a fresh code that is not locked out by the old guesses, and a code
aged past its quarter hour is refused.
otp-code-probe.cjs (scratchpad) — 10 PASS, 0 FAIL on http://localhost:8090
against the rebuilt app and api, no page errors: a fresh signup lands on the
verify face with the box ready, a wrong code says so and the account stays a
guest, the mailed code unlocks PRO, and a proven address is not asked again
on the next login.
Regressions, 0 fail: landing-test.cjs 172, pro-gate-test.cjs 27,
award-column-probe.cjs 18, tone-curve-probe.cjs 33. web tsc --noEmit clean.
ponytail: the code rides `created_at` rather than an `expires_at` of its own, so
the link's 24 hours and the code's 15 minutes are one column read twice; the day
the two need to drift apart independently, the column is the thing to split. The
route carries no per-IP limiter, only the per-row cap — a stranger can burn one
account's five guesses, which costs that owner a resend, and a limiter keyed on
the address would be the next thing to add if that turns out to be cheap for an
attacker. The code is not usable from another browser: it verifies the session
that asked for it, which is the behaviour the request asked for and not a gap.
This commit is contained in:
@@ -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) =>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string | null>(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({
|
||||
<h2>{t('auth.verifyTitle')}</h2>
|
||||
<p className="hint">{t('auth.verifyBody', { email: who })}</p>
|
||||
<p className="hint">{t('auth.verifyProHint')}</p>
|
||||
<input
|
||||
className="field"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
// A one-time code, so nothing may paste a password into it: six
|
||||
// digits, and the browser puts the number pad up on a phone.
|
||||
type="text"
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
required
|
||||
placeholder={t('auth.codePlaceholder')}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
/>
|
||||
{sent ? <p className="hint ok">{t('auth.verifySent')}</p> : null}
|
||||
{error ? <p className="err">{error}</p> : null}
|
||||
<button type="submit" className="btn primary" data-key="verify-resend" disabled={busy}>
|
||||
{busy ? t('auth.busy') : t('pro.resend')}
|
||||
<button type="submit" className="btn primary" data-key="verify-code" disabled={busy || code.length < 6}>
|
||||
{busy ? t('auth.busy') : t('auth.verifyBtn')}
|
||||
</button>
|
||||
<button type="button" className="btn" data-key="verify-resend" onClick={() => void resend()} disabled={busy}>
|
||||
{t('pro.resend')}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onDone}>{t('auth.verifiedDone')}</button>
|
||||
<button type="button" className="btn ghost" onClick={onClose}>{t('auth.continueDemo')}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user