import bcrypt from 'bcryptjs'; import type { NextFunction, Request, Response } from 'express'; import { Router } from 'express'; import { ok } from '../http/apiResponse.js'; import { ApiError } from '../http/errors.js'; import { query } from '../db/pool.js'; interface UserRow { id: string; username: string; password_hash: string; full_name: string; email: string | null; department: string | null; must_change_password: boolean; enabled: boolean; locked: boolean; roles: string[]; } interface CurrentUser { id: number; username: string; fullName: string; department?: string; email?: string; roles: string[]; mustChangePassword: boolean; } const router = Router(); function asyncRoute( handler: (req: Request, res: Response, next: NextFunction) => Promise, ) { return (req: Request, res: Response, next: NextFunction) => { handler(req, res, next).catch(next); }; } async function findUserByUsername(username: string): Promise { const result = await query( ` SELECT u.id, u.username, u.password_hash, u.full_name, u.email, u.department, u.must_change_password, u.enabled, u.locked, COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles FROM users u LEFT JOIN user_roles ur ON ur.user_id = u.id WHERE u.username = $1 GROUP BY u.id `, [username], ); return result.rows[0]; } async function findUserById(id: number): Promise { const result = await query( ` SELECT u.id, u.username, u.password_hash, u.full_name, u.email, u.department, u.must_change_password, u.enabled, u.locked, COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles FROM users u LEFT JOIN user_roles ur ON ur.user_id = u.id WHERE u.id = $1 GROUP BY u.id `, [id], ); return result.rows[0]; } function toCurrentUser(user: UserRow): CurrentUser { return { id: Number(user.id), username: user.username, fullName: user.full_name, department: user.department ?? undefined, email: user.email ?? undefined, roles: user.roles, mustChangePassword: user.must_change_password, }; } async function requireCurrentUser(req: Request): Promise { const userId = req.session.userId; if (!userId) { throw new ApiError(401, '로그인이 필요합니다.'); } const user = await findUserById(userId); if (!user || !user.enabled || user.locked) { req.session.userId = undefined; throw new ApiError(401, '로그인이 필요합니다.'); } return user; } router.post('/login', asyncRoute(async (req, res) => { const username = String(req.body?.username ?? '').trim(); const password = String(req.body?.password ?? ''); if (!username || !password) { throw new ApiError(400, '아이디와 비밀번호를 입력하세요.'); } const user = await findUserByUsername(username); const matches = user ? await bcrypt.compare(password, user.password_hash) : false; if (!user || !matches || !user.enabled || user.locked) { throw new ApiError(401, '아이디 또는 비밀번호가 올바르지 않습니다.'); } req.session.userId = Number(user.id); ok(res, toCurrentUser(user)); })); router.post('/logout', (req, res, next) => { req.session.destroy((error) => { if (error) { next(error); return; } res.clearCookie('connect.sid'); ok(res, '로그아웃되었습니다.'); }); }); router.get('/me', asyncRoute(async (req, res) => { const user = await requireCurrentUser(req); ok(res, toCurrentUser(user)); })); router.post('/change-password', asyncRoute(async (req, res) => { const user = await requireCurrentUser(req); const oldPassword = String(req.body?.oldPassword ?? ''); const newPassword = String(req.body?.newPassword ?? ''); if (newPassword.length < 8) { throw new ApiError(400, '새 비밀번호는 8자 이상이어야 합니다.'); } if (!(await bcrypt.compare(oldPassword, user.password_hash))) { throw new ApiError(400, '현재 비밀번호가 올바르지 않습니다.'); } if (await bcrypt.compare(newPassword, user.password_hash)) { throw new ApiError(400, '기존 비밀번호와 다른 비밀번호를 입력하세요.'); } const passwordHash = await bcrypt.hash(newPassword, 12); await query( ` UPDATE users SET password_hash = $1, must_change_password = FALSE, updated_at = now() WHERE id = $2 `, [passwordHash, Number(user.id)], ); ok(res, '비밀번호가 변경되었습니다.'); })); router.post('/diagnostics', asyncRoute(async (_req, res) => { const tableResult = await query<{ users_table: string | null; user_roles_table: string | null; sessions_table: string | null; }>( ` SELECT to_regclass('public.users')::text AS users_table, to_regclass('public.user_roles')::text AS user_roles_table, to_regclass('public.user_sessions')::text AS sessions_table `, ); const tables = tableResult.rows[0]; let users: Array<{ username: string; enabled: boolean; locked: boolean; must_change_password: boolean; password_hash_length: number; roles: string[]; }> = []; let userQueryError: string | undefined; if (tables?.users_table && tables.user_roles_table) { try { const userResult = await query<{ username: string; enabled: boolean; locked: boolean; must_change_password: boolean; password_hash_length: number; roles: string[]; }>( ` SELECT u.username, u.enabled, u.locked, u.must_change_password, length(u.password_hash) AS password_hash_length, COALESCE(array_agg(ur.role) FILTER (WHERE ur.role IS NOT NULL), '{}') AS roles FROM users u LEFT JOIN user_roles ur ON ur.user_id = u.id WHERE u.username IN ('admin', 'security', 'host', 'a', 's', 'h') GROUP BY u.id ORDER BY u.username `, ); users = userResult.rows; } catch (error) { userQueryError = error instanceof Error ? error.message : String(error); } } ok(res, { authApi: true, checkedAt: new Date().toISOString(), tables, users, userQueryError, }); })); export const authRouter = router;