import bcrypt from 'bcrypt' import { authPool } from '../db/authDb.js' import { signToken, setTokenCookie } from './jwtProvider.js' import type { Response } from 'express' const MAX_FAIL_COUNT = 5 const SALT_ROUNDS = 10 interface AuthUserRow { user_id: string user_acnt: string pswd_hash: string | null user_nm: string rnkp_nm: string | null org_sn: number | null user_stts_cd: string fail_cnt: number } interface AuthUserInfo { id: string account: string name: string rank: string | null org: { sn: number; name: string; abbr: string } | null roles: string[] permissions: string[] } export async function login( account: string, password: string, ipAddr: string, userAgent: string, res: Response ): Promise { const userResult = await authPool.query( `SELECT USER_ID as user_id, USER_ACNT as user_acnt, PSWD_HASH as pswd_hash, USER_NM as user_nm, RNKP_NM as rnkp_nm, ORG_SN as org_sn, USER_STTS_CD as user_stts_cd, FAIL_CNT as fail_cnt FROM AUTH_USER WHERE USER_ACNT = $1`, [account] ) if (userResult.rows.length === 0) { throw new AuthError('아이디 또는 비밀번호가 올바르지 않습니다.', 401) } const user = userResult.rows[0] if (user.user_stts_cd === 'PENDING') { throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403) } if (user.user_stts_cd === 'LOCKED') { throw new AuthError('계정이 잠겨있습니다. 관리자에게 문의하세요.', 403) } if (user.user_stts_cd === 'INACTIVE') { throw new AuthError('비활성화된 계정입니다.', 403) } if (user.user_stts_cd === 'REJECTED') { throw new AuthError('가입이 거절된 계정입니다. 관리자에게 문의하세요.', 403) } if (!user.pswd_hash) { throw new AuthError('이 계정은 Google 로그인만 지원합니다.', 401) } const passwordValid = await bcrypt.compare(password, user.pswd_hash) if (!passwordValid) { const newFailCount = user.fail_cnt + 1 const newStatus = newFailCount >= MAX_FAIL_COUNT ? 'LOCKED' : user.user_stts_cd await authPool.query( 'UPDATE AUTH_USER SET FAIL_CNT = $1, USER_STTS_CD = $2, MDFCN_DTM = NOW() WHERE USER_ID = $3', [newFailCount, newStatus, user.user_id] ) await recordLoginHistory(user.user_id, ipAddr, userAgent, false) if (newStatus === 'LOCKED') { throw new AuthError('로그인 실패 횟수 초과로 계정이 잠겼습니다.', 403) } throw new AuthError('아이디 또는 비밀번호가 올바르지 않습니다.', 401) } // 성공: FAIL_CNT 리셋, LAST_LOGIN_DTM 갱신 await authPool.query( 'UPDATE AUTH_USER SET FAIL_CNT = 0, LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1', [user.user_id] ) await recordLoginHistory(user.user_id, ipAddr, userAgent, true) const userInfo = await getUserInfo(user.user_id) const token = signToken({ sub: userInfo.id, acnt: userInfo.account, name: userInfo.name, roles: userInfo.roles, }) setTokenCookie(res, token) return userInfo } export async function getUserInfo(userId: string): Promise { const userResult = await authPool.query( `SELECT u.USER_ID as user_id, u.USER_ACNT as user_acnt, u.USER_NM as user_nm, u.RNKP_NM as rnkp_nm, u.ORG_SN as org_sn, o.ORG_NM as org_nm, o.ORG_ABBR_NM as org_abbr_nm FROM AUTH_USER u LEFT JOIN AUTH_ORG o ON u.ORG_SN = o.ORG_SN WHERE u.USER_ID = $1`, [userId] ) if (userResult.rows.length === 0) { throw new AuthError('사용자를 찾을 수 없습니다.', 404) } const row = userResult.rows[0] // 역할 조회 const rolesResult = await authPool.query( `SELECT r.ROLE_CD as role_cd FROM AUTH_USER_ROLE ur JOIN AUTH_ROLE r ON ur.ROLE_SN = r.ROLE_SN WHERE ur.USER_ID = $1`, [userId] ) const roles = rolesResult.rows.map((r: { role_cd: string }) => r.role_cd) // 권한 조회 (역할 기반) const permsResult = await authPool.query( `SELECT DISTINCT p.RSRC_CD as rsrc_cd FROM AUTH_PERM p JOIN AUTH_USER_ROLE ur ON p.ROLE_SN = ur.ROLE_SN WHERE ur.USER_ID = $1 AND p.GRANT_YN = 'Y'`, [userId] ) const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd) return { id: row.user_id, account: row.user_acnt, name: row.user_nm, rank: row.rnkp_nm, org: row.org_sn ? { sn: row.org_sn, name: row.org_nm, abbr: row.org_abbr_nm } : null, roles, permissions, } } export async function hashPassword(password: string): Promise { return bcrypt.hash(password, SALT_ROUNDS) } async function recordLoginHistory( userId: string, ipAddr: string, userAgent: string, success: boolean ): Promise { await authPool.query( `INSERT INTO AUTH_LOGIN_HIST (USER_ID, IP_ADDR, USER_AGENT, SUCCESS_YN) VALUES ($1, $2, $3, $4)`, [userId, ipAddr, userAgent?.substring(0, 500), success ? 'Y' : 'N'] ) } export class AuthError extends Error { status: number constructor(message: string, status: number) { super(message) this.status = status this.name = 'AuthError' } }