From 1281e5c533cd0d9a36c6a8657e26ada4eff16290 Mon Sep 17 00:00:00 2001 From: htlee Date: Sat, 28 Feb 2026 11:09:44 +0900 Subject: [PATCH] =?UTF-8?q?fix(auth):=20=EB=B9=84=EC=9E=90=EB=8F=99?= =?UTF-8?q?=EC=8A=B9=EC=9D=B8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20Google=20OAu?= =?UTF-8?q?th=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20PENDING=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존에 비자동승인 도메인(gmail.com 등) Google 로그인 시 403 에러를 던져 등록 실패처럼 보이던 문제를 수정. PENDING 사용자에 대해 에러 대신 200 응답으로 pending 상태를 반환하고, 프론트엔드에서 청록색 안내 메시지로 표시하도록 변경. Co-Authored-By: Claude Opus 4.6 --- backend/src/auth/authRouter.ts | 9 ++++-- backend/src/auth/oauthService.ts | 37 +++++++++++++--------- frontend/src/components/auth/LoginPage.tsx | 17 +++++++++- frontend/src/services/authApi.ts | 12 +++++++ frontend/src/store/authStore.ts | 12 +++++-- 5 files changed, 66 insertions(+), 21 deletions(-) diff --git a/backend/src/auth/authRouter.ts b/backend/src/auth/authRouter.ts index c374f8e..dd0840e 100644 --- a/backend/src/auth/authRouter.ts +++ b/backend/src/auth/authRouter.ts @@ -45,9 +45,14 @@ router.post('/oauth/google', async (req, res) => { const ipAddr = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || req.ip || '' const userAgent = req.headers['user-agent'] || '' - const userInfo = await googleLogin(credential, ipAddr, userAgent, res) + const result = await googleLogin(credential, ipAddr, userAgent, res) - res.json({ success: true, user: userInfo }) + if (result.type === 'pending') { + res.json({ success: true, pending: true, message: result.message }) + return + } + + res.json({ success: true, user: result.user }) } catch (err) { if (err instanceof AuthError) { res.status(err.status).json({ error: err.message }) diff --git a/backend/src/auth/oauthService.ts b/backend/src/auth/oauthService.ts index f0b9732..f801f8f 100644 --- a/backend/src/auth/oauthService.ts +++ b/backend/src/auth/oauthService.ts @@ -16,12 +16,18 @@ interface GoogleProfile { hd?: string // hosted domain (Google Workspace) } +export interface GoogleLoginResult { + type: 'success' | 'pending' + user?: Awaited> + message?: string +} + export async function googleLogin( credential: string, ipAddr: string, userAgent: string, res: Response -) { +): Promise { const profile = await verifyGoogleToken(credential) // 1. OAUTH_SUB로 기존 사용자 조회 @@ -38,7 +44,7 @@ export async function googleLogin( userId = user.user_id if (user.user_stts_cd === 'PENDING') { - throw new AuthError('계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.', 403) + return { type: 'pending', message: '계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.' } } if (user.user_stts_cd === 'LOCKED') { throw new AuthError('계정이 잠겨있습니다. 관리자에게 문의하세요.', 403) @@ -66,16 +72,28 @@ export async function googleLogin( ['GOOGLE', profile.sub, profile.email, userId] ) + if (user.user_stts_cd === 'PENDING') { + return { type: 'pending', message: '계정이 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.' } + } if (user.user_stts_cd !== 'ACTIVE') { throw new AuthError('계정이 활성 상태가 아닙니다. 관리자에게 문의하세요.', 403) } } else { // 신규 사용자 생성 userId = await createOAuthUser(profile) + + // PENDING 상태로 생성된 경우 등록 완료 안내 + const statusResult = await authPool.query( + 'SELECT USER_STTS_CD as user_stts_cd FROM AUTH_USER WHERE USER_ID = $1', + [userId] + ) + if (statusResult.rows[0]?.user_stts_cd === 'PENDING') { + return { type: 'pending', message: '계정이 등록되었습니다. 관리자 승인 후 로그인할 수 있습니다.' } + } } } - // 로그인 처리 + // ACTIVE 사용자 로그인 처리 await authPool.query( 'UPDATE AUTH_USER SET LAST_LOGIN_DTM = NOW(), MDFCN_DTM = NOW() WHERE USER_ID = $1', [userId] @@ -85,17 +103,6 @@ export async function googleLogin( 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, @@ -104,7 +111,7 @@ export async function googleLogin( }) setTokenCookie(res, token) - return userInfo + return { type: 'success', user: userInfo } } async function verifyGoogleToken(credential: string): Promise { diff --git a/frontend/src/components/auth/LoginPage.tsx b/frontend/src/components/auth/LoginPage.tsx index b91a940..bfc7cce 100644 --- a/frontend/src/components/auth/LoginPage.tsx +++ b/frontend/src/components/auth/LoginPage.tsx @@ -11,7 +11,7 @@ export function LoginPage() { const [userId, setUserId] = useState('') const [password, setPassword] = useState('') const [remember, setRemember] = useState(false) - const { login, googleLogin, isLoading, error, clearError } = useAuthStore() + const { login, googleLogin, isLoading, error, pendingMessage, clearError } = useAuthStore() const GOOGLE_ENABLED = !!import.meta.env.VITE_GOOGLE_CLIENT_ID useEffect(() => { @@ -213,6 +213,21 @@ export function LoginPage() { + {/* Pending approval */} + {pendingMessage && ( +
+ + + + {pendingMessage} +
+ )} + {/* Error */} {error && (
{ @@ -20,8 +22,18 @@ export async function loginApi(account: string, password: string): Promise { const response = await api.post('/auth/oauth/google', { credential }) + if (response.data.pending) { + throw new PendingApprovalError(response.data.message || '관리자 승인 후 로그인할 수 있습니다.') + } return response.data.user } diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 2478313..299cdc6 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,5 +1,5 @@ import { create } from 'zustand' -import { loginApi, googleLoginApi, logoutApi, fetchMe } from '../services/authApi' +import { loginApi, googleLoginApi, logoutApi, fetchMe, PendingApprovalError } from '../services/authApi' import type { AuthUser } from '../services/authApi' interface AuthState { @@ -7,6 +7,7 @@ interface AuthState { isAuthenticated: boolean isLoading: boolean error: string | null + pendingMessage: string | null login: (account: string, password: string) => Promise googleLogin: (credential: string) => Promise logout: () => Promise @@ -20,6 +21,7 @@ export const useAuthStore = create((set, get) => ({ isAuthenticated: false, isLoading: true, error: null, + pendingMessage: null, login: async (account: string, password: string) => { set({ isLoading: true, error: null }) @@ -34,11 +36,15 @@ export const useAuthStore = create((set, get) => ({ }, googleLogin: async (credential: string) => { - set({ isLoading: true, error: null }) + set({ isLoading: true, error: null, pendingMessage: null }) try { const user = await googleLoginApi(credential) set({ user, isAuthenticated: true, isLoading: false }) } catch (err) { + if (err instanceof PendingApprovalError) { + set({ isLoading: false, pendingMessage: err.message }) + return + } const message = (err as { message?: string })?.message || 'Google 로그인에 실패했습니다.' set({ isLoading: false, error: message }) throw err @@ -70,5 +76,5 @@ export const useAuthStore = create((set, get) => ({ return user.permissions.includes(resource) }, - clearError: () => set({ error: null }), + clearError: () => set({ error: null, pendingMessage: null }), }))