wing-ops/backend/src/auth/oauthService.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

179 lines
5.5 KiB
TypeScript

import { OAuth2Client } from 'google-auth-library'
import { authPool } from '../db/authDb.js'
import { signToken, setTokenCookie } from './jwtProvider.js'
import { getSetting } from '../settings/settingsService.js'
import { AuthError, getUserInfo } from './authService.js'
import type { Response } from 'express'
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID || ''
const googleClient = new OAuth2Client(GOOGLE_CLIENT_ID)
interface GoogleProfile {
sub: string
email: string
name: string
picture?: string
hd?: string // hosted domain (Google Workspace)
}
export async function googleLogin(
credential: string,
ipAddr: string,
userAgent: string,
res: Response
) {
const profile = await verifyGoogleToken(credential)
// 1. OAUTH_SUB로 기존 사용자 조회
let userResult = await authPool.query(
'SELECT USER_ID as user_id, USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE OAUTH_PROVIDER = $1 AND OAUTH_SUB = $2',
['GOOGLE', profile.sub]
)
let userId: string
if (userResult.rows.length > 0) {
// 기존 OAuth 사용자
const user = userResult.rows[0]
userId = user.user_id
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)
}
} else {
// EMAIL로 기존 PW 사용자 조회 (계정 연결)
userResult = await authPool.query(
'SELECT USER_ID as user_id, USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE EMAIL = $1 OR USER_ACNT = $1',
[profile.email]
)
if (userResult.rows.length > 0) {
// 기존 계정에 OAuth 연결
const user = userResult.rows[0]
userId = user.user_id
await authPool.query(
'UPDATE AUTH_USER SET OAUTH_PROVIDER = $1, OAUTH_SUB = $2, EMAIL = $3, MDFCN_DTM = NOW() WHERE USER_ID = $4',
['GOOGLE', profile.sub, profile.email, userId]
)
if (user.user_stts_cd !== 'ACTIVE') {
throw new AuthError('계정이 활성 상태가 아닙니다. 관리자에게 문의하세요.', 403)
}
} else {
// 신규 사용자 생성
userId = await createOAuthUser(profile)
}
}
// 로그인 처리
await authPool.query(
'UPDATE AUTH_USER SET LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1',
[userId]
)
await recordLoginHistory(userId, ipAddr, userAgent)
const userInfo = await getUserInfo(userId)
// PENDING 사용자는 JWT 발급하지 않음
if (userInfo.roles.length === 0) {
const userStatus = await authPool.query(
'SELECT USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE USER_ID = $1',
[userId]
)
if (userStatus.rows[0]?.user_stts_cd === 'PENDING') {
throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403)
}
}
const token = signToken({
sub: userInfo.id,
acnt: userInfo.account,
name: userInfo.name,
roles: userInfo.roles,
})
setTokenCookie(res, token)
return userInfo
}
async function verifyGoogleToken(credential: string): Promise<GoogleProfile> {
try {
const ticket = await googleClient.verifyIdToken({
idToken: credential,
audience: GOOGLE_CLIENT_ID,
})
const payload = ticket.getPayload()
if (!payload || !payload.email || !payload.sub) {
throw new AuthError('Google 인증 정보가 유효하지 않습니다.', 401)
}
return {
sub: payload.sub,
email: payload.email,
name: payload.name || payload.email.split('@')[0],
picture: payload.picture,
hd: payload.hd,
}
} catch (err) {
if (err instanceof AuthError) throw err
throw new AuthError('Google 인증 토큰 검증에 실패했습니다.', 401)
}
}
async function createOAuthUser(profile: GoogleProfile): Promise<string> {
const domain = profile.email.split('@')[1]
// 자동 승인 도메인 확인
const autoApproveDomains = await getSetting('oauth.auto-approve-domains')
const allowedDomains = autoApproveDomains
? autoApproveDomains.split(',').map(d => d.trim().toLowerCase())
: []
const isAutoApproved = allowedDomains.includes(domain.toLowerCase())
const status = isAutoApproved ? 'ACTIVE' : 'PENDING'
// 사용자 생성
const result = await authPool.query(
`INSERT INTO AUTH_USER (USER_ACNT, USER_NM, EMAIL, OAUTH_PROVIDER, OAUTH_SUB, USER_STTS_CD)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING USER_ID as user_id`,
[profile.email, profile.name, profile.email, 'GOOGLE', profile.sub, status]
)
const userId = result.rows[0].user_id
// 자동 승인된 사용자에게 기본 역할 할당
if (isAutoApproved) {
await authPool.query(
`INSERT INTO AUTH_USER_ROLE (USER_ID, ROLE_SN)
SELECT $1, ROLE_SN FROM AUTH_ROLE WHERE DFLT_YN = 'Y'`,
[userId]
)
}
return userId
}
async function recordLoginHistory(
userId: string,
ipAddr: string,
userAgent: string
): Promise<void> {
await authPool.query(
`INSERT INTO AUTH_LOGIN_HIST (USER_ID, IP_ADDR, USER_AGENT, SUCCESS_YN)
VALUES ($1, $2, $3, 'Y')`,
[userId, ipAddr, userAgent?.substring(0, 500)]
)
}