import bcrypt from 'bcrypt' import { authPool } from '../db/authDb.js' import { signToken, setTokenCookie } from './jwtProvider.js' import type { Response } from 'express' import { resolvePermissions, makePermKey, grantedSetToRecord } from '../roles/permResolver.js' import { getPermTreeNodes } from '../roles/roleService.js' 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: Record } 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 } /** AUTH_PERM_TREE 없이 플랫 권한을 RSRC_CD + OPER_CD 기준으로 조회 */ async function flatPermissionsFallback(userId: string): Promise> { const permsResult = await authPool.query( `SELECT DISTINCT p.RSRC_CD as rsrc_cd, p.OPER_CD as oper_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 perms: Record = {} for (const p of permsResult.rows) { if (!perms[p.rsrc_cd]) perms[p.rsrc_cd] = [] if (!perms[p.rsrc_cd].includes(p.oper_cd)) perms[p.rsrc_cd].push(p.oper_cd) } return perms } 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] // 역할 조회 (ROLE_SN + ROLE_CD) const rolesResult = await authPool.query( `SELECT r.ROLE_SN as role_sn, 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 roleSns = rolesResult.rows.map((r: { role_sn: number }) => r.role_sn) // 트리 기반 resolved permissions (리소스 × 오퍼레이션) let permissions: Record try { const treeNodes = await getPermTreeNodes() if (treeNodes.length > 0) { // AUTH_PERM_TREE가 존재 → 트리 기반 resolve const explicitPermsResult = await authPool.query( `SELECT ROLE_SN as role_sn, RSRC_CD as rsrc_cd, OPER_CD as oper_cd, GRANT_YN as grant_yn FROM AUTH_PERM WHERE ROLE_SN = ANY($1)`, [roleSns] ) const explicitPermsPerRole = new Map>() for (const sn of roleSns) { explicitPermsPerRole.set(sn, new Map()) } for (const p of explicitPermsResult.rows) { const roleMap = explicitPermsPerRole.get(p.role_sn) if (roleMap) { const key = makePermKey(p.rsrc_cd, p.oper_cd) roleMap.set(key, p.grant_yn === 'Y') } } const granted = resolvePermissions(treeNodes, explicitPermsPerRole) permissions = grantedSetToRecord(granted) } else { // AUTH_PERM_TREE 미존재 (마이그레이션 전) → 기존 플랫 방식 fallback permissions = await flatPermissionsFallback(userId) } } catch { // AUTH_PERM_TREE 테이블 미존재 시 fallback try { permissions = await flatPermissionsFallback(userId) } catch { console.error('[auth] 권한 조회 fallback 실패, 빈 권한 반환') permissions = {} } } 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' } }