180 lines
5.7 KiB
JavaScript
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)
|
|
})
|