Files
sori.studio/server/api/auth/google/callback.get.js

180 lines
5.7 KiB
JavaScript

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)
})