- 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>
179 lines
5.5 KiB
TypeScript
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)]
|
|
)
|
|
}
|