import { PASSWORD_ENTRIES } from '../../../models/password_entry.ts'; import { USER, USERS } from '../../../models/user.ts'; import { generateSecret } from 'jsr:@stdext/crypto/utils'; import { encodeBase32 } from 'jsr:@std/encoding'; import { verify } from 'jsr:@stdext/crypto/hash'; import lurid from 'jsr:@andyburke/lurid'; import { SESSION, SESSIONS } from '../../../models/session.ts'; import { TOTP_ENTRIES } from '../../../models/totp_entry.ts'; import { verifyTotp } from 'jsr:@stdext/crypto/totp'; import { encodeBase64 } from 'jsr:@std/encoding/base64'; import parse_body from '../../../utils/bodyparser.ts'; import { SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts'; const DEFAULT_SESSION_TIME: number = 60 * 60 * 1_000; // 1 Hour // POST /api/auth - Authenticate export async function POST(req: Request, meta: Record): Promise { try { const body = await parse_body(req); const username: string = body.username?.toLowerCase() ?? ''; const password: string | undefined = body.password; const password_hash: string | undefined = body.password_hash ?? (typeof password === 'string' ? encodeBase64( await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password ?? '')) ) : undefined); const totp: string | undefined = body.totp; if (!username.length) { return Response.json({ error: { message: 'You must specify a username to log in.', cause: 'username_required' } }, { status: 400 }); } // password has should be a base64-encoded SHA-256 hash if (typeof password_hash !== 'string') { return Response.json({ error: { message: 'invalid password hash', cause: 'invalid_password_hash' } }, { status: 400 }); } let user: USER | undefined = undefined; user = (await USERS.find({ username })).shift()?.load(); if (!user) { return Response.json({ error: { message: `Could not locate an account with username: ${username}`, cause: 'missing_account' } }, { status: 400 }); } const password_entry = await PASSWORD_ENTRIES.get(user.id); if (!password_entry) { return Response.json({ error: { message: 'Missing password entry for this account, please contact support.', cause: 'missing_password_entry' } }, { status: 500 }); } const verified = verify('bcrypt', `${password_hash}${password_entry.salt}`, password_entry.hash); if (!verified) { return Response.json({ error: { message: 'Incorrect password.', cause: 'incorrect_password' } }, { status: 400 }); } const totp_entry = await TOTP_ENTRIES.get(user.id); if (totp_entry) { if (typeof totp !== 'string' || !totp.length) { return Response.json({ two_factor: true, totp: true }, { status: 202, headers: { 'Set-Cookie': `totp_user_id=${user.id}; Path=/; Max-Age=300` } }); } const valid_totp: boolean = await verifyTotp(totp, totp_entry.secret); if (!valid_totp) { return Response.json({ error: { message: 'Incorrect TOTP.', cause: 'incorrect_totp' } }, { status: 400 }); } } const session_result: SESSION_RESULT = await create_new_session({ user, expires: body.session?.expires }); // TODO: verify this redirect is relative? const headers = session_result.headers; let status = 201; if (typeof meta?.query?.redirect === 'string') { const url = new URL(req.url); session_result.headers.append('location', `${url.origin}${meta.query.redirect}`); status = 302; } return Response.json({ user, session: session_result.session }, { status, headers }); } catch (error) { return Response.json({ error: { message: (error as Error).message ?? 'Unknown Error!', cause: (error as Error).cause ?? 'unknown' } }, { status: 400 }); } } export type SESSION_RESULT = { session: SESSION; headers: Headers; }; export type SESSION_INFO = { user: USER; expires: string | undefined; }; export async function create_new_session(session_settings: SESSION_INFO): Promise { const now = new Date().toISOString(); const expires: string = session_settings.expires ?? new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toISOString(); const session: SESSION = { id: lurid(), user_id: session_settings.user.id, secret: encodeBase32(generateSecret()), timestamps: { created: now, expires, ended: '' } }; await SESSIONS.create(session); const headers = new Headers(); headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=${session.id}; Path=/; Expires=${expires}`); headers.append(`x-${SESSION_ID_TOKEN}`, session.id); // TODO: this wasn't really intended to be persisted in a cookie, but we are using it to // generate the TOTP for the call to /api/users/me headers.append('Set-Cookie', `${SESSION_SECRET_TOKEN}=${session.secret}; Path=/; Expires=${expires}`); return { session, headers }; }