219 lines
		
	
	
	
		
			5.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			219 lines
		
	
	
	
		
			5.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import { PASSWORD_ENTRIES } from '../../../models/password_entry.ts';
 | |
| import { USER, USERS } from '../../../models/user.ts';
 | |
| import { encodeBase32 } from '@std/encoding';
 | |
| import lurid from '@andyburke/lurid';
 | |
| import { SESSION, SESSIONS } from '../../../models/session.ts';
 | |
| import { TOTP_ENTRIES } from '../../../models/totp_entry.ts';
 | |
| import { encodeBase64 } from '@std/encoding/base64';
 | |
| import parse_body from '../../../utils/bodyparser.ts';
 | |
| import { get_session, get_user, PRECHECK_TABLE, require_user, SESSION_ID_TOKEN, SESSION_SECRET_TOKEN } from '../../../utils/prechecks.ts';
 | |
| import * as bcrypt from '@da/bcrypt';
 | |
| import { verifyTotp } from '../../../utils/totp.ts';
 | |
| 
 | |
| const DEFAULT_SESSION_TIME: number = 28 * (24 * (60 * (60 * 1_000))); // 28 days
 | |
| 
 | |
| export const PRECHECKS: PRECHECK_TABLE = {};
 | |
| 
 | |
| // POST /api/auth - Authenticate
 | |
| export async function POST(req: Request, meta: Record<string, any>): Promise<Response> {
 | |
| 	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 = bcrypt.compareSync(password_hash, 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;
 | |
| };
 | |
| 
 | |
| // DELETE /api/auth - log out (delete session)
 | |
| PRECHECKS.DELETE = [get_session, get_user, require_user];
 | |
| const back_then = new Date(0).toISOString();
 | |
| export async function DELETE(_request: Request, meta: Record<string, any>): Promise<Response> {
 | |
| 	await SESSIONS.delete(meta.session);
 | |
| 
 | |
| 	const headers = new Headers();
 | |
| 
 | |
| 	headers.append('Set-Cookie', `${SESSION_ID_TOKEN}=; Path=/; Expires=${back_then}`);
 | |
| 
 | |
| 	// 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}=; Path=/; Expires=${back_then}`);
 | |
| 
 | |
| 	return Response.json({
 | |
| 		deleted: true
 | |
| 	}, {
 | |
| 		status: 200,
 | |
| 		headers
 | |
| 	});
 | |
| }
 | |
| 
 | |
| const session_secret_buffer = new Uint8Array(20);
 | |
| export async function create_new_session(session_settings: SESSION_INFO): Promise<SESSION_RESULT> {
 | |
| 	const now = new Date().toISOString();
 | |
| 	const expires: string = session_settings.expires ??
 | |
| 		new Date(new Date(now).valueOf() + DEFAULT_SESSION_TIME).toISOString();
 | |
| 
 | |
| 	crypto.getRandomValues(session_secret_buffer);
 | |
| 
 | |
| 	const session: SESSION = {
 | |
| 		id: lurid(),
 | |
| 		user_id: session_settings.user.id,
 | |
| 		secret: encodeBase32(session_secret_buffer),
 | |
| 		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
 | |
| 	};
 | |
| }
 |