Add Node auth routes for ACS login
This commit is contained in:
182
server/routes/auth.ts
Normal file
182
server/routes/auth.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user