wing-ops/backend/src/auth/authService.ts
htlee 7743e40767 feat(auth): Google OAuth 로그인 연동
- 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>
2026-02-27 16:42:59 +09:00

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