divan/api/src/auth.ts
Anas Rashid e409adc668 Accounts: email and password sign-up, sign-in/out, change password, delete account (#18)
- API (api/src/auth.ts): scrypt password hashes; random session tokens stored only as SHA-256;
  30-day sliding sessions; rate limits on sign-in (per IP and per email) and sign-up; changing the
  password signs out other devices; deleting the account removes its data.
- Site: /signup, /signin (returns to the page the reader came from), /account; header link; plain
  forms, no JavaScript needed. Session in an HTTP-only, SameSite=Lax cookie (Secure over HTTPS).
- CSRF: Astro's origin check, with the site's hostnames listed (SITE_HOSTS) so its own form posts
  pass and other sites' are refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:53:51 +02:00

132 lines
7.8 KiB
TypeScript

// Accounts: email address and password only (no email is sent; owner decision 2026-10-08).
// Passwords: scrypt with a per-user salt. Sessions: a random token held by the site in an HTTP-only
// cookie; the database stores only its SHA-256, so a database leak does not give working sessions.
// The API is private (only the site calls it), so the site passes the reader's IP in x-client-ip.
// POST /api/auth/signup {email, password} -> {token, user}
// POST /api/auth/signin {email, password} -> {token, user}
// GET /api/auth/me Bearer token -> {user}
// POST /api/auth/signout Bearer token
// POST /api/auth/password Bearer token {current, next} -> other sessions signed out
// POST /api/auth/delete Bearer token {password} -> account and its data deleted
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { pool } from './db.ts';
const SESSION_DAYS = 30;
const KDF = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
const derive = (password: string, salt: Buffer) =>
new Promise<Buffer>((ok, fail) => scrypt(password.normalize('NFC'), salt, 32, KDF, (e, k) => (e ? fail(e) : ok(k))));
// "scrypt$N$r$p$salt$hash" (base64url), so the cost can be raised later without breaking old hashes
export async function hashPassword(password: string) {
const salt = randomBytes(16);
return `scrypt$${KDF.N}$${KDF.r}$${KDF.p}$${salt.toString('base64url')}$${(await derive(password, salt)).toString('base64url')}`;
}
export async function verifyPassword(password: string, stored: string) {
const [alg, N, r, p, salt, hash] = stored.split('$');
if (alg !== 'scrypt') return false;
const key = await new Promise<Buffer>((ok, fail) =>
scrypt(password.normalize('NFC'), Buffer.from(salt, 'base64url'), 32, { N: +N, r: +r, p: +p, maxmem: KDF.maxmem }, (e, k) => (e ? fail(e) : ok(k))));
return timingSafeEqual(key, Buffer.from(hash, 'base64url'));
}
// compared against when the email is unknown, so a wrong email takes as long as a wrong password
const DUMMY = await hashPassword(randomBytes(16).toString('hex'));
export const normaliseEmail = (e: unknown) => (typeof e === 'string' ? e.trim().toLowerCase() : '');
export const validEmail = (e: string) => e.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);
export const passwordProblem = (p: unknown) =>
typeof p !== 'string' ? 'پاس ورڈ درکار ہے' : p.length < 8 ? 'پاس ورڈ کم از کم ۸ حروف کا ہو' : p.length > 200 ? 'پاس ورڈ بہت لمبا ہے' : null;
// fixed-window limits per key (IP or email); ponytail: in-process memory, fine for one API process;
// move to PostgreSQL or Redis if the API ever runs as several processes
export function limiter(max: number, windowMs: number) {
const hits = new Map<string, { n: number; until: number }>();
return (key: string, now = Date.now()) => {
const h = hits.get(key);
if (!h || h.until <= now) {
hits.set(key, { n: 1, until: now + windowMs });
if (hits.size > 10_000) for (const [k, v] of hits) if (v.until <= now) hits.delete(k);
return true;
}
return ++h.n <= max;
};
}
const signinByIp = limiter(20, 15 * 60_000), signinByEmail = limiter(8, 15 * 60_000), signupByIp = limiter(5, 60 * 60_000);
const sha = (t: string) => createHash('sha256').update(t).digest('hex');
const publicUser = (u: any) => ({ id: Number(u.id), email: u.email, created_at: u.created_at });
async function newSession(userId: number) {
const token = randomBytes(32).toString('base64url');
await pool.query(`INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, now() + interval '${SESSION_DAYS} days')`, [sha(token), userId]);
return token;
}
// the signed-in user for a Bearer token; sliding expiry (renewed when less than half is left)
export async function sessionUser(req: FastifyRequest) {
const token = req.headers.authorization?.match(/^Bearer (\S+)$/)?.[1];
if (!token) return null;
const { rows } = await pool.query(
`SELECT u.*, s.id AS sid, s.expires_at < now() + interval '${SESSION_DAYS / 2} days' AS renew
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = $1 AND s.expires_at > now()`, [sha(token)]);
const u = rows[0];
if (u?.renew) await pool.query(`UPDATE sessions SET expires_at = now() + interval '${SESSION_DAYS} days' WHERE id = $1`, [u.sid]);
return u ?? null;
}
const ip = (req: FastifyRequest) => (req.headers['x-client-ip'] as string) || req.ip;
export function authRoutes(app: FastifyInstance) {
app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signup', async (req, reply) => {
if (!signupByIp(ip(req))) return reply.code(429).send({ error: 'بہت زیادہ کوششیں۔ کچھ دیر بعد دوبارہ کوشش کریں۔' });
const email = normaliseEmail(req.body?.email), problem = passwordProblem(req.body?.password);
if (!validEmail(email)) return reply.code(400).send({ error: 'درست ای میل پتہ لکھیں' });
if (problem) return reply.code(400).send({ error: problem });
const { rows } = await pool.query(
'INSERT INTO users (email, password_hash) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING *', [email, await hashPassword(req.body!.password!)]);
if (!rows[0]) return reply.code(409).send({ error: 'اس ای میل سے اکاؤنٹ پہلے سے موجود ہے' });
return { token: await newSession(rows[0].id), user: publicUser(rows[0]) };
});
app.post<{ Body: { email?: string; password?: string } }>('/api/auth/signin', async (req, reply) => {
const email = normaliseEmail(req.body?.email), password = String(req.body?.password ?? '');
if (!signinByIp(ip(req)) || !signinByEmail(email)) return reply.code(429).send({ error: 'بہت زیادہ کوششیں۔ کچھ دیر بعد دوبارہ کوشش کریں۔' });
const u = (await pool.query('SELECT * FROM users WHERE email = $1', [email])).rows[0];
const ok = await verifyPassword(password, u?.password_hash ?? DUMMY);
if (!u || !ok) return reply.code(401).send({ error: 'ای میل یا پاس ورڈ درست نہیں' });
return { token: await newSession(u.id), user: publicUser(u) };
});
app.get('/api/auth/me', async (req, reply) => {
const u = await sessionUser(req);
return u ? { user: publicUser(u) } : reply.code(401).send({ error: 'signed out' });
});
app.post('/api/auth/signout', async (req) => {
const u = await sessionUser(req);
if (u) await pool.query('DELETE FROM sessions WHERE id = $1', [u.sid]);
return { ok: true };
});
app.post<{ Body: { current?: string; next?: string } }>('/api/auth/password', async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'دوبارہ لاگ ان کریں' });
if (!(await verifyPassword(String(req.body?.current ?? ''), u.password_hash))) return reply.code(403).send({ error: 'موجودہ پاس ورڈ درست نہیں' });
const problem = passwordProblem(req.body?.next);
if (problem) return reply.code(400).send({ error: problem });
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [await hashPassword(req.body!.next!), u.id]);
await pool.query('DELETE FROM sessions WHERE user_id = $1 AND id <> $2', [u.id, u.sid]); // sign out other devices
return { ok: true };
});
app.post<{ Body: { password?: string } }>('/api/auth/delete', async (req, reply) => {
const u = await sessionUser(req);
if (!u) return reply.code(401).send({ error: 'دوبارہ لاگ ان کریں' });
if (!(await verifyPassword(String(req.body?.password ?? ''), u.password_hash))) return reply.code(403).send({ error: 'پاس ورڈ درست نہیں' });
await pool.query('DELETE FROM users WHERE id = $1', [u.id]); // sessions (and later the reader's library) cascade
return { ok: true };
});
}