Add Node auth routes for ACS login

This commit is contained in:
unknown
2026-07-14 15:57:58 +09:00
parent ce82cf5a26
commit 7c8bd37b12
8 changed files with 564 additions and 0 deletions

View File

@@ -18,6 +18,9 @@ export const env = {
sessionSecret: process.env.SESSION_SECRET ?? 'dev-only-change-me',
publicBaseUrl: process.env.ACS_PUBLIC_BASE_URL ?? 'http://localhost:3000',
smsProvider: process.env.ACS_SMS_PROVIDER ?? 'dev',
cookieSecure:
(process.env.ACS_COOKIE_SECURE ?? '').toLowerCase() === 'true' ||
(process.env.ACS_PUBLIC_BASE_URL ?? '').startsWith('https://'),
};
export function requireDatabaseUrl(): string {

View File

@@ -1,17 +1,39 @@
import path from 'node:path';
import express from 'express';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
import { env } from './config/env.js';
import { apiRouter } from './routes/index.js';
import { platformStatusRouter } from './routes/health.js';
import { errorHandler } from './http/errors.js';
import { getPool } from './db/pool.js';
const app = express();
const frontendDist = path.resolve(process.cwd(), 'frontend', 'dist');
const PgSession = connectPgSimple(session);
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use(session({
name: 'acs.sid',
secret: env.sessionSecret,
resave: false,
saveUninitialized: false,
store: new PgSession({
pool: getPool(),
tableName: 'user_sessions',
createTableIfMissing: true,
}),
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: env.cookieSecure,
maxAge: 1000 * 60 * 60 * 8,
},
}));
app.use(platformStatusRouter);
app.use('/api', apiRouter);

182
server/routes/auth.ts Normal file
View File

@@ -0,0 +1,182 @@
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, '비밀번호가 변경되었습니다.');
}));
export const authRouter = router;

View File

@@ -1,6 +1,8 @@
import { Router } from 'express';
import { authRouter } from './auth.js';
import { healthRouter } from './health.js';
export const apiRouter = Router();
apiRouter.use(healthRouter);
apiRouter.use('/auth', authRouter);

7
server/types/session.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
import 'express-session';
declare module 'express-session' {
interface SessionData {
userId?: number;
}
}