customer-presentation/src/auth.js
2026-04-22 14:39:16 +02:00

114 lines
4 KiB
JavaScript

// ─────────────────────────────────────────────────────────────
// src/auth.js — /auth/* endpoints.
//
// POST /auth/request-code { email } → 200 always (no enum leak)
// POST /auth/verify-code { email, code } → 200 on success + Set-Cookie
// POST /auth/logout → 200
// ─────────────────────────────────────────────────────────────
import { Router } from 'express';
import { q } from './db.js';
import { sendCode } from './mail.js';
import {
randomCode,
hashCode,
constantTimeEqual,
issueSession,
clearSession,
CODE_TTL_MS,
} from './sessions.js';
import { rateLimit } from './middleware.js';
const router = Router();
const MAX_VERIFY_ATTEMPTS = 5;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
// ─── POST /auth/request-code ─────────────────────────────────
// ALWAYS returns 200 (once past the format check) — we must not
// reveal whether an address is on the invite list.
router.post(
'/request-code',
rateLimit({
key: (req) => `req:${req.ip}`,
max: 5,
windowMs: 60 * 60 * 1000, // 5 requests per IP per hour
}),
async (req, res) => {
const email = String(req.body?.email || '').trim().toLowerCase();
if (!EMAIL_RE.test(email)) {
return res.status(400).json({ error: 'invalid_email' });
}
const invited = q.getInvite.get(email);
if (invited) {
const code = randomCode();
q.upsertCode.run(email, hashCode(code), Date.now() + CODE_TTL_MS);
try {
await sendCode(email, code);
} catch (err) {
// Log, but still return 200 to the client. If SMTP is misconfigured
// we want to know *immediately* in the logs, not via users.
console.error('[auth] SMTP send failed for', email, err?.message || err);
}
}
// Uniform response regardless of invite status
return res.status(200).json({ ok: true });
}
);
// ─── POST /auth/verify-code ──────────────────────────────────
router.post(
'/verify-code',
rateLimit({
key: (req) => `verify:${req.ip}`,
max: 20,
windowMs: 60 * 60 * 1000, // 20 verify attempts per IP per hour
}),
(req, res) => {
const email = String(req.body?.email || '').trim().toLowerCase();
const code = String(req.body?.code || '').trim();
if (!EMAIL_RE.test(email) || !/^\d{6}$/.test(code)) {
return res.status(401).json({ error: 'invalid' });
}
const row = q.getCode.get(email, Date.now());
if (!row) {
return res.status(401).json({ error: 'invalid_or_expired' });
}
if (row.attempts >= MAX_VERIFY_ATTEMPTS) {
q.deleteCode.run(email); // force user to request a new code
return res.status(429).json({ error: 'too_many_attempts' });
}
const submitted = hashCode(code);
if (!constantTimeEqual(submitted, row.code_hash)) {
q.incAttempts.run(email);
return res.status(401).json({ error: 'wrong_code' });
}
// Success: single-use — delete the code, issue a session
q.deleteCode.run(email);
issueSession(req, res, email);
return res.status(200).json({ ok: true });
}
);
// ─── POST /auth/logout ───────────────────────────────────────
router.post('/logout', (req, res) => {
clearSession(req, res);
return res.status(200).json({ ok: true });
});
// ─── GET /auth/me ─ convenience for debugging, returns current email or 401
router.get('/me', (req, res) => {
const s = req.cookies?.fenja_session ? q.getSession.get(req.cookies.fenja_session, Date.now()) : null;
if (!s) return res.status(401).end();
return res.json({ email: s.email });
});
export default router;