247 lines
6.5 KiB
TypeScript
247 lines
6.5 KiB
TypeScript
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<void>,
|
|
) {
|
|
return (req: Request, res: Response, next: NextFunction) => {
|
|
handler(req, res, next).catch(next);
|
|
};
|
|
}
|
|
|
|
async function findUserByUsername(username: string): Promise<UserRow | undefined> {
|
|
const result = await query<UserRow>(
|
|
`
|
|
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<UserRow | undefined> {
|
|
const result = await query<UserRow>(
|
|
`
|
|
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<UserRow> {
|
|
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;
|