import bcrypt from 'bcrypt' import { randomBytes } from 'node:crypto' import { createError, getQuery, sendRedirect } from 'h3' import { createGoogleUser, getUserByEmail, getUserByGoogleSub, isPrivilegedMemberSession, linkGoogleIdentityToUser, touchUserActivity } from '../../../repositories/member-repository' import { clearAdminSession, setAdminSession } from '../../../utils/admin-auth' import { clearMemberSession, requireMemberSession, setMemberSession } from '../../../utils/member-auth' import { getClientIp } from '../../../utils/request-ip' import { exchangeGoogleOAuthCode, normalizeGoogleOAuthRedirect, verifyGoogleOAuthState } from '../../../utils/google-oauth' /** * Google 콜백 redirect 경로에 상태 query를 붙인다. * @param {string} redirectTo - redirect 경로 * @param {string} status - 상태 코드 * @returns {string} redirect URL */ const appendGoogleStatus = (redirectTo, status) => { const url = new URL(normalizeGoogleOAuthRedirect(redirectTo, '/'), 'http://local') url.searchParams.set('google', status) return `${url.pathname}${url.search}${url.hash}` } /** * Google 이름에서 사이트 닉네임을 만든다. * @param {{ name: string, email: string }} profile - Google 프로필 * @returns {string} 닉네임 */ const createGoogleUsername = (profile) => { const localPart = String(profile.email || '').split('@')[0] || 'google' return String(profile.name || localPart) .replace(/\s+/g, ' ') .trim() .slice(0, 60) || localPart } /** * 회원 세션을 발급하고 활동을 기록한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {Object} user - 회원 정보 * @returns {Promise} */ const signInMember = async (event, user) => { setMemberSession(event, { userId: user.id, email: user.email, sessionVersion: user.sessionVersion }) await touchUserActivity({ userId: user.id, ip: getClientIp(event) }) } /** * 관리자 세션을 발급하고 활동을 기록한다. * @param {import('h3').H3Event} event - 요청 이벤트 * @param {Object} user - 관리자 회원 정보 * @returns {Promise} */ const signInAdmin = async (event, user) => { setAdminSession(event, { userId: user.id, email: user.email, sessionVersion: user.sessionVersion }) await signInMember(event, user) } /** * Google OAuth 콜백 API * @param {import('h3').H3Event} event - 요청 이벤트 * @returns {Promise} 완료 후 화면 리다이렉트 */ export default defineEventHandler(async (event) => { const query = getQuery(event) const code = String(query.code || '') const stateValue = String(query.state || '') if (query.error) { let cancelledRedirect = '/signin' if (stateValue) { try { const cancelledState = verifyGoogleOAuthState(event, stateValue) cancelledRedirect = cancelledState.mode === 'admin' ? '/admin/login' : cancelledState.redirectTo } catch { cancelledRedirect = '/signin' } } return sendRedirect(event, appendGoogleStatus(cancelledRedirect, 'cancelled'), 302) } if (!code || !stateValue) { throw createError({ statusCode: 400, message: 'Google 로그인 응답이 올바르지 않습니다.' }) } const state = verifyGoogleOAuthState(event, stateValue) const googleProfile = await exchangeGoogleOAuthCode(event, code) if (!googleProfile.sub || !googleProfile.email || !googleProfile.emailVerified) { return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'email-unverified'), 302) } const linkedUser = await getUserByGoogleSub(googleProfile.sub) if (state.mode === 'link') { const session = await requireMemberSession(event) if (state.userId && String(state.userId) !== String(session.userId)) { clearMemberSession(event) clearAdminSession(event) return sendRedirect(event, appendGoogleStatus('/signin', 'session-mismatch'), 302) } if (linkedUser && String(linkedUser.id) !== String(session.userId)) { return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'already-linked'), 302) } await linkGoogleIdentityToUser({ userId: session.userId, googleSub: googleProfile.sub, googleEmail: googleProfile.email, googleName: googleProfile.name, googleAvatarUrl: googleProfile.picture }) return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'linked'), 302) } if (state.mode === 'admin') { if (!linkedUser || !(await isPrivilegedMemberSession(linkedUser.id, linkedUser.sessionVersion))) { return sendRedirect(event, appendGoogleStatus('/admin/login', 'admin-not-linked'), 302) } await signInAdmin(event, linkedUser) return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302) } if (linkedUser) { await signInMember(event, linkedUser) return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302) } const existingEmailUser = await getUserByEmail(googleProfile.email) if (existingEmailUser) { return sendRedirect(event, appendGoogleStatus('/signin', 'email-exists'), 302) } const passwordHash = await bcrypt.hash(randomBytes(32).toString('base64url'), 12) const created = await createGoogleUser({ username: createGoogleUsername(googleProfile), email: googleProfile.email, passwordHash, avatarUrl: googleProfile.picture, googleSub: googleProfile.sub, googleEmail: googleProfile.email, googleName: googleProfile.name, googleAvatarUrl: googleProfile.picture }) if (created.isAdmin) { await signInAdmin(event, created) } else { await signInMember(event, created) } return sendRedirect(event, appendGoogleStatus(state.redirectTo, 'signed-in'), 302) })