Google 계정 로그인 연동 추가
This commit is contained in:
179
server/api/auth/google/callback.get.js
Normal file
179
server/api/auth/google/callback.get.js
Normal file
@@ -0,0 +1,179 @@
|
||||
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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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<void>} 완료 후 화면 리다이렉트
|
||||
*/
|
||||
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)
|
||||
})
|
||||
Reference in New Issue
Block a user