UNPKG

@athenox/talos

Version:

Talos: a backend you describe instead of write. Declare a schema, write the rules in plain language, map URLs to sentences; a small built-in model turns them into query plans.

140 lines (139 loc) • 8.16 kB
import { randomBytes } from 'node:crypto'; import { createError, defineEventHandler, getHeader, readBody, sendRedirect } from 'h3'; export function accounts(o) { const q = (s) => { if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(s)) throw new Error(`bad identifier: ${s}`); return o.store.dialect === 'mysql' ? `\`${s}\`` : `"${s}"`; }; const EMAIL = o.email ?? 'email', PASSWORD = o.password ?? 'password', KEY = o.as ?? 'account'; const say = { invalid: 'invalid email or password', taken: 'an account already uses this email', required: 'email and password are required', short: 'password too short', signedOut: 'not signed in', badToken: 'this link is invalid or has expired', ...o.messages }; const fail = (statusCode, message) => { throw createError({ statusCode, message }); }; const clean = (email) => String(email ?? '').trim().toLowerCase(); const stored = o.date ?? ((d) => o.store.dialect === 'mysql' ? d : d.toISOString()); const pick = (row) => Object.fromEntries(o.expose.map(c => [c, o.read?.[c] && row[c] != null ? o.read[c](row[c]) : row[c]])); const byEmail = async (email) => (await o.store.all(`SELECT * FROM ${q(o.table)} WHERE ${q(EMAIL)} = ? LIMIT 1`, email))[0]; const strong = (pw) => { if (typeof pw !== 'string' || !pw) fail(400, say.required); if (o.minPassword && pw.length < o.minPassword) fail(400, say.short); return pw; }; /** the signed-in account, or null */ const current = async (event) => (await o.session.get(event)) ?? null; /** a guard: the signed-in account, or a 401 */ const required = async (event) => (await current(event)) ?? fail(401, say.signedOut); const isActive = (row) => !o.active || (row[o.active] !== 0 && row[o.active] !== false && row[o.active] !== null && row[o.active] !== '0'); /** * A guard that reads the account again from the table (the session copy may * be stale), refuses a disabled one, and applies `check` (a role, a * permission): `guards: { admin: members.guard(a => a.role === 'admin') }`. */ const guard = (check, status = 403, message = 'forbidden') => async (event) => { const s = await required(event); const row = (await o.store.all(`SELECT * FROM ${q(o.table)} WHERE ${q('id')} = ?`, s.id))[0]; if (!row || !isActive(row)) fail(401, say.signedOut); const account = pick(row); if (check && !check(account)) fail(status, message); return account; }; const login = defineEventHandler(async (event) => { const body = (await readBody(event)) ?? {}; const form = String(getHeader(event, 'content-type') ?? '').includes('application/x-www-form-urlencoded'); try { const email = clean(body.email); if (!email || !body.password) fail(400, say.required); const row = await byEmail(email); // The same answer for an unknown email, a wrong password and a disabled account. if (!row || !(await o.verify(String(body.password), String(row[PASSWORD] ?? ''))) || !isActive(row)) fail(401, say.invalid); const stamps = Object.entries(o.onLogin ?? {}); if (stamps.length) await o.store.run(`UPDATE ${q(o.table)} SET ${stamps.map(([c]) => `${q(c)} = ?`).join(', ')} WHERE ${q('id')} = ?`, ...stamps.map(([, f]) => { const v = f(); return v instanceof Date ? stored(v) : v; }), row.id); const account = pick(row); await o.session.set(event, account, body); if (form && o.redirect) return sendRedirect(event, o.redirect.success, 303); return { success: true, [KEY]: account }; } catch (e) { if (form && o.redirect) return sendRedirect(event, `${o.redirect.failure}${o.redirect.failure.includes('?') ? '&' : '?'}error=${encodeURIComponent(e?.message ?? '')}`, 303); throw e; } }); const register = defineEventHandler(async (event) => { const body = (await readBody(event)) ?? {}; const email = clean(body.email); if (!email) fail(400, say.required); const password = strong(body.password); if (await byEmail(email)) fail(400, say.taken); const extra = (o.signUp ?? []).filter(c => body[c] !== undefined && body[c] !== ''); const cols = [EMAIL, PASSWORD, ...extra]; const r = await o.store.run(`INSERT INTO ${q(o.table)} (${cols.map(q).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`, email, await o.hash(password), ...extra.map(c => body[c])); const row = (await o.store.all(`SELECT * FROM ${q(o.table)} WHERE ${q('id')} = ?`, Number(r.lastInsertRowid)))[0] ?? { id: Number(r.lastInsertRowid), [EMAIL]: email }; const account = pick(row); await o.session.set(event, account); return { success: true, [KEY]: account }; }); const logout = defineEventHandler(async (event) => { await o.session.clear(event); return { success: true }; }); /** the current account, read again from the table (the session copy may be stale) */ const me = defineEventHandler(async (event) => { const s = await required(event); const row = (await o.store.all(`SELECT * FROM ${q(o.table)} WHERE ${q('id')} = ?`, s.id))[0] ?? fail(401, say.signedOut); return { [KEY]: pick(row) }; }); const r = o.reset; const TOKEN = r?.token ?? 'token', EXPIRES = r?.expires ?? 'expires_at', USED = r?.used ?? 'used_at'; const forgot = defineEventHandler(async (event) => { if (!r) fail(404, 'not found'); const body = (await readBody(event)) ?? {}; const row = await byEmail(clean(body.email)); if (row) { await o.store.run(`DELETE FROM ${q(r.table)} WHERE ${q(r.owner)} = ? AND ${q(USED)} IS NULL`, row.id); const token = randomBytes(32).toString('hex'); const expires = new Date(Date.now() + (r.minutes ?? 30) * 60_000); await o.store.run(`INSERT INTO ${q(r.table)} (${q(r.owner)}, ${q(TOKEN)}, ${q(EXPIRES)}) VALUES (?, ?, ?)`, row.id, token, stored(expires)); await r.send(pick(row), token, event); } // Whether or not the account exists. return { success: true }; }); const reset = defineEventHandler(async (event) => { if (!r) fail(404, 'not found'); const body = (await readBody(event)) ?? {}; const password = strong(body.password); const now = new Date(); const found = (await o.store.all(`SELECT * FROM ${q(r.table)} WHERE ${q(TOKEN)} = ? AND ${q(USED)} IS NULL LIMIT 1`, String(body.token ?? '')))[0]; const expiry = found && (found[EXPIRES] instanceof Date ? found[EXPIRES] : new Date(String(found[EXPIRES]).replace(' ', 'T'))); if (!found || !(expiry.getTime() > now.getTime())) fail(400, say.badToken); await o.store.run(`UPDATE ${q(o.table)} SET ${q(PASSWORD)} = ? WHERE ${q('id')} = ?`, await o.hash(password), found[r.owner]); await o.store.run(`UPDATE ${q(r.table)} SET ${q(USED)} = ? WHERE ${q('id')} = ?`, stored(now), found.id); return { success: true }; }); /** the signed-in account changes its own password, giving the current one */ const change = defineEventHandler(async (event) => { const s = await required(event); const body = (await readBody(event)) ?? {}; const row = (await o.store.all(`SELECT * FROM ${q(o.table)} WHERE ${q('id')} = ?`, s.id))[0]; if (!row || !(await o.verify(String(body.currentPassword ?? ''), String(row[PASSWORD] ?? '')))) fail(401, say.invalid); await o.store.run(`UPDATE ${q(o.table)} SET ${q(PASSWORD)} = ? WHERE ${q('id')} = ?`, await o.hash(strong(body.newPassword ?? body.password)), s.id); return { success: true }; }); return { login, register, logout, me, forgot, reset, change, current, required, guard }; }