114 lines
4 KiB
JavaScript
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;
|