## 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>
238 lines
7.2 KiB
TypeScript
238 lines
7.2 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
|
||
}
|
||
|
||
/** 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'
|
||
}
|
||
}
|