wing-ops/backend/src/auth/authService.ts
htlee 8657190578 feat(auth): RBAC 오퍼레이션 기반 2차원 권한 시스템 구현
리소스 가시성(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>
2026-02-28 17:55:06 +09:00

236 lines
7.0 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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'
}
}