Google 계정 로그인 연동 추가

This commit is contained in:
2026-07-29 14:02:07 +09:00
parent 99af25b8fa
commit 52ed053876
20 changed files with 897 additions and 9 deletions

View File

@@ -131,6 +131,11 @@ const mapAdminMemberRow = (row) => {
* @property {boolean} isAdmin - 관리자 여부
* @property {'owner' | 'admin' | 'vip' | 'member'} role - 권한 코드
* @property {number} sessionVersion - 세션 무효화 버전
* @property {string} googleSub - Google 사용자 고유 ID
* @property {string} googleEmail - Google 계정 이메일
* @property {string} googleName - Google 계정 이름
* @property {string} googleAvatarUrl - Google 계정 프로필 이미지
* @property {string | null} googleLinkedAt - Google 계정 연결 시각
* @property {string} createdAt - 생성 시각(ISO)
* @property {string} updatedAt - 수정 시각(ISO)
* @property {string | null} lastSeenAt - 최근 접속 시각(ISO)
@@ -171,6 +176,11 @@ export const getUserByEmail = async (email) => {
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -185,6 +195,42 @@ export const getUserByEmail = async (email) => {
return rows?.[0] || null
}
/**
* Google 사용자 고유 ID로 회원을 조회한다.
* @param {string} googleSub - Google 사용자 고유 ID
* @returns {Promise<MemberUser | null>} 회원
*/
export const getUserByGoogleSub = async (googleSub) => {
const sql = requireSql()
const rows = await sql`
SELECT
id,
username,
email,
password_hash AS "passwordHash",
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
FROM users
WHERE google_sub = ${googleSub}
LIMIT 1
`
return rows?.[0] || null
}
/**
* ID로 회원 조회
* @param {string} id - 사용자 ID
@@ -200,6 +246,11 @@ export const getUserById = async (id) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -229,6 +280,11 @@ export const getUserProfileById = async (id) => {
users.avatar_url AS "avatarUrl",
users.is_admin AS "isAdmin",
users.user_role AS "role",
users.google_sub AS "googleSub",
users.google_email AS "googleEmail",
users.google_name AS "googleName",
users.google_avatar_url AS "googleAvatarUrl",
users.google_linked_at AS "googleLinkedAt",
users.created_at AS "createdAt",
users.updated_at AS "updatedAt",
users.last_seen_at AS "lastSeenAt",
@@ -262,6 +318,12 @@ export const getUserByIdWithPassword = async (id) => {
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
@@ -336,6 +398,135 @@ export const createUser = async (input) => {
return created
}
/**
* Google 계정 기반 회원을 생성한다.
* @param {{ username: string, email: string, passwordHash: string, avatarUrl: string, googleSub: string, googleEmail: string, googleName: string, googleAvatarUrl: string }} input - 입력
* @returns {Promise<Omit<MemberUser, 'passwordHash'>>} 생성된 회원
*/
export const createGoogleUser = async (input) => {
const sql = requireSql()
const rows = await sql.begin(async (tx) => {
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
const privilegedRows = await tx`
SELECT EXISTS (
SELECT 1
FROM users
WHERE user_role = ANY(${PRIVILEGED_ROLES})
) AS "hasPrivilegedUsers"
`
const shouldCreateOwner = !Boolean(privilegedRows?.[0]?.hasPrivilegedUsers)
return tx`
INSERT INTO users (
username,
email,
password_hash,
avatar_url,
is_admin,
user_role,
google_sub,
google_email,
google_name,
google_avatar_url,
google_linked_at
)
VALUES (
${input.username},
${input.email},
${input.passwordHash},
${input.avatarUrl},
${shouldCreateOwner},
CASE
WHEN ${shouldCreateOwner} THEN ${MEMBER_ROLE.OWNER}
ELSE ${MEMBER_ROLE.MEMBER}
END,
${input.googleSub},
${input.googleEmail},
${input.googleName},
${input.googleAvatarUrl},
now()
)
RETURNING
id,
username,
email,
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
})
const created = rows?.[0]
if (!created) {
throw createError({
statusCode: 500,
message: 'Google 회원 생성에 실패했습니다.'
})
}
return created
}
/**
* 기존 회원에 Google 계정을 연결한다.
* @param {{ userId: string, googleSub: string, googleEmail: string, googleName: string, googleAvatarUrl: string }} input - 연결 정보
* @returns {Promise<MemberUser | null>} 수정된 회원
*/
export const linkGoogleIdentityToUser = async (input) => {
const sql = requireSql()
const rows = await sql`
UPDATE users
SET
google_sub = ${input.googleSub},
google_email = ${input.googleEmail},
google_name = ${input.googleName},
google_avatar_url = ${input.googleAvatarUrl},
google_linked_at = now(),
avatar_url = CASE
WHEN avatar_url = '' THEN ${input.googleAvatarUrl}
ELSE avatar_url
END,
updated_at = now()
WHERE id = ${input.userId}
RETURNING
id,
username,
email,
password_hash AS "passwordHash",
avatar_url AS "avatarUrl",
is_admin AS "isAdmin",
user_role AS "role",
session_version AS "sessionVersion",
google_sub AS "googleSub",
google_email AS "googleEmail",
google_name AS "googleName",
google_avatar_url AS "googleAvatarUrl",
google_linked_at AS "googleLinkedAt",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_seen_at AS "lastSeenAt",
last_seen_ip AS "lastSeenIp",
previous_last_seen_at AS "previousLastSeenAt",
previous_last_seen_ip AS "previousLastSeenIp"
`
return rows?.[0] || null
}
/**
* 관리자 권한이 없을 때 환경 변수 기반 owner 계정을 생성하거나 기존 회원을 승격한다.
* @param {{ username: string, email: string, passwordHash: string }} input - 입력