- google-auth-library로 Google ID Token 검증 (backend) - @react-oauth/google GoogleLogin 컴포넌트 (frontend) - gcsc.co.kr 도메인 자동 승인(ACTIVE), 기타 도메인 PENDING - 기존 ID/PW 사용자와 OAuth 사용자 동일 계정 체계 통합 - AdminView: 사용자 인증방식(Google/ID PW) 뱃지 표시 - AdminView: OAuth 자동 승인 도메인 설정 UI - deploy.yml: VITE_GOOGLE_CLIENT_ID 빌드 환경변수 추가 - nginx: Cross-Origin-Opener-Policy 헤더 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
188 lines
5.1 KiB
TypeScript
188 lines
5.1 KiB
TypeScript
import bcrypt from 'bcrypt'
|
|
import { authPool } from '../db/authDb.js'
|
|
import { signToken, setTokenCookie } from './jwtProvider.js'
|
|
import type { Response } from 'express'
|
|
|
|
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: 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]
|
|
|
|
// 역할 조회
|
|
const rolesResult = await authPool.query(
|
|
`SELECT 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 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]
|
|
)
|
|
|
|
const permissions = permsResult.rows.map((p: { rsrc_cd: string }) => p.rsrc_cd)
|
|
|
|
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'
|
|
}
|
|
}
|