@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
JavaScript
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 };
}