리소스 가시성(READ/HIDE) 단일 차원에서 리소스 × 오퍼레이션(RCUD) 2차원 권한 모델로 전환하여 세밀한 CRUD 권한 제어 지원. - DB: AUTH_PERM에 OPER_CD 컬럼 추가, 마이그레이션 004 작성 - DB: AUTH_PERM_TREE 리소스 트리 테이블 추가 (마이그레이션 003) - Backend: permResolver 2차원 권한 해석 엔진 (상속 + 오퍼레이션) - Backend: requirePermission 미들웨어 신규 (리소스×오퍼레이션 검증) - Backend: authService permissions → Record<string, string[]> 반환 - Backend: roleService/roleRouter OPER_CD 지원 API - Backend: Helmet CORP 설정 (sendBeacon cross-origin 허용) - Frontend: authStore.hasPermission(resource, operation?) 하위 호환 확장 - Frontend: PermissionsPanel 역할탭 + RCUD 4열 매트릭스 UI 전면 재작성 - Frontend: sendBeacon API_BASE_URL 절대경로 전환 - Docs: COMMON-GUIDE 권한 체계 + CRUD API 규칙 문서화 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
236 lines
7.0 KiB
TypeScript
236 lines
7.0 KiB
TypeScript
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<string, string[]>
|
||
}
|
||
|
||
export async function login(
|
||
account: string,
|
||
password: string,
|
||
ipAddr: string,
|
||
userAgent: string,
|
||
res: Response
|
||
): Promise<AuthUserInfo> {
|
||
const userResult = await authPool.query<AuthUserRow>(
|
||
`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<AuthUserInfo> {
|
||
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<string, string[]>
|
||
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<number, Map<string, boolean>>()
|
||
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
|
||
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]
|
||
)
|
||
permissions = {}
|
||
for (const p of permsResult.rows) {
|
||
permissions[p.rsrc_cd] = ['READ']
|
||
}
|
||
}
|
||
} catch {
|
||
// AUTH_PERM_TREE 테이블 미존재 시 fallback
|
||
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]
|
||
)
|
||
permissions = {}
|
||
for (const p of permsResult.rows) {
|
||
permissions[p.rsrc_cd] = ['READ']
|
||
}
|
||
}
|
||
|
||
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<string> {
|
||
return bcrypt.hash(password, SALT_ROUNDS)
|
||
}
|
||
|
||
async function recordLoginHistory(
|
||
userId: string,
|
||
ipAddr: string,
|
||
userAgent: string,
|
||
success: boolean
|
||
): Promise<void> {
|
||
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'
|
||
}
|
||
}
|