wing-ops/backend/src/auth/authService.ts
leedano 3743027ce7 feat(weather): 기상 정보 기상 레이어 업데이트 (#78)
## Summary
- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환

## 변경 파일
- OceanForecastOverlay.tsx
- WeatherMapOverlay.tsx
- WeatherView.tsx
- useOceanForecast.ts
- khoaApi.ts
- vite.config.ts

## Test plan
- [ ] 기상정보 -> 기상 레이어 -> 해황 예보도 클릭 -> 이미지 렌더링 확인
- [ ] 기상정보 -> 기상 레이어 -> 백터 바람 클릭 -> 백터 이미지 렌더링 확인

Co-authored-by: Nan Kyung Lee <nankyunglee@Nanui-Macmini.local>
Reviewed-on: #78
Co-authored-by: leedano <dnlee@gcsc.co.kr>
Co-committed-by: leedano <dnlee@gcsc.co.kr>
2026-03-11 11:14:25 +09:00

238 lines
7.2 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
}
/** AUTH_PERM_TREE 없이 플랫 권한을 RSRC_CD + OPER_CD 기준으로 조회 */
async function flatPermissionsFallback(userId: string): Promise<Record<string, string[]>> {
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<string, string[]> = {}
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<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
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<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'
}
}