feat: add Node ACS admin workflows

This commit is contained in:
unknown
2026-07-16 11:00:09 +09:00
parent a25872394a
commit c6f342a861
24 changed files with 3442 additions and 266 deletions

View File

@@ -4,19 +4,7 @@ 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[];
}
import { findUserById, findUserByUsername, requireCurrentUser, type QueryFn, type UserRow } from './security.js';
interface CurrentUser {
id: number;
@@ -28,7 +16,9 @@ interface CurrentUser {
mustChangePassword: boolean;
}
const router = Router();
interface AuthRouterDeps {
query: QueryFn;
}
function asyncRoute(
handler: (req: Request, res: Response, next: NextFunction) => Promise<void>,
@@ -38,54 +28,6 @@ function asyncRoute(
};
}
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),
@@ -98,168 +40,160 @@ function toCurrentUser(user: UserRow): CurrentUser {
};
}
async function requireCurrentUser(req: Request): Promise<UserRow> {
const userId = req.session.userId;
if (!userId) {
throw new ApiError(401, '로그인이 필요합니다.');
}
export function createAuthRouter(deps: AuthRouterDeps = { query }): Router {
const router = Router();
const dbQuery = deps.query;
const user = await findUserById(userId);
if (!user || !user.enabled || user.locked) {
req.session.userId = undefined;
throw new ApiError(401, '로그인이 필요합니다.');
}
router.post('/login', asyncRoute(async (req, res) => {
const username = String(req.body?.username ?? '').trim();
const password = String(req.body?.password ?? '');
return user;
if (!username || !password) {
throw new ApiError(400, '아이디와 비밀번호를 입력하세요.');
}
const user = await findUserByUsername(dbQuery, 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(dbQuery, req);
ok(res, toCurrentUser(user));
}));
router.post('/change-password', asyncRoute(async (req, res) => {
const user = await requireCurrentUser(dbQuery, 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 dbQuery(
`
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 dbQuery<{
current_schema: string;
users_table: string | null;
user_roles_table: string | null;
sessions_table: string | null;
migrations_table: string | null;
}>(
`
SELECT
current_schema() AS current_schema,
to_regclass('users')::text AS users_table,
to_regclass('user_roles')::text AS user_roles_table,
to_regclass('user_sessions')::text AS sessions_table,
to_regclass('schema_migrations')::text AS migrations_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;
let migrations: string[] = [];
let migrationQueryError: string | undefined;
if (tables?.users_table && tables.user_roles_table) {
try {
const userResult = await dbQuery<{
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);
}
}
if (tables?.migrations_table) {
try {
const migrationResult = await dbQuery<{ filename: string }>(
'SELECT filename FROM schema_migrations ORDER BY filename',
);
migrations = migrationResult.rows.map((row) => row.filename);
} catch (error) {
migrationQueryError = error instanceof Error ? error.message : String(error);
}
}
ok(res, {
authApi: true,
checkedAt: new Date().toISOString(),
tables,
users,
userQueryError,
migrations,
migrationQueryError,
});
}));
return router;
}
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<{
current_schema: string;
users_table: string | null;
user_roles_table: string | null;
sessions_table: string | null;
migrations_table: string | null;
}>(
`
SELECT
current_schema() AS current_schema,
to_regclass('users')::text AS users_table,
to_regclass('user_roles')::text AS user_roles_table,
to_regclass('user_sessions')::text AS sessions_table,
to_regclass('schema_migrations')::text AS migrations_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;
let migrations: string[] = [];
let migrationQueryError: 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);
}
}
if (tables?.migrations_table) {
try {
const migrationResult = await query<{ filename: string }>(
'SELECT filename FROM schema_migrations ORDER BY filename',
);
migrations = migrationResult.rows.map((row) => row.filename);
} catch (error) {
migrationQueryError = error instanceof Error ? error.message : String(error);
}
}
ok(res, {
authApi: true,
checkedAt: new Date().toISOString(),
tables,
users,
userQueryError,
migrations,
migrationQueryError,
});
}));
export const authRouter = router;
export const authRouter = createAuthRouter();