add google oauth login
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-07-06 11:45:35 +09:00
parent 7c67e53b1e
commit ea5eac64c7
5 changed files with 421 additions and 1 deletions
@@ -0,0 +1,28 @@
CREATE TABLE "user_oauth_accounts" (
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
"user_id" UUID NOT NULL,
"provider" TEXT NOT NULL,
"provider_user_id" TEXT NOT NULL,
"email" TEXT,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "user_oauth_accounts_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "user_oauth_accounts_provider_provider_user_id_key"
ON "user_oauth_accounts"("provider", "provider_user_id");
CREATE INDEX "user_oauth_accounts_user_id_idx" ON "user_oauth_accounts"("user_id");
ALTER TABLE "user_oauth_accounts"
ADD CONSTRAINT "user_oauth_accounts_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
COMMENT ON TABLE "user_oauth_accounts" IS '중앙 사용자와 외부 OAuth 제공자 계정을 매핑한다';
COMMENT ON COLUMN "user_oauth_accounts"."id" IS 'OAuth 계정 매핑 고유 ID';
COMMENT ON COLUMN "user_oauth_accounts"."user_id" IS '연결된 중앙 사용자 ID';
COMMENT ON COLUMN "user_oauth_accounts"."provider" IS 'OAuth 제공자 코드. 예: google';
COMMENT ON COLUMN "user_oauth_accounts"."provider_user_id" IS 'OAuth 제공자가 발급한 사용자 고유 ID';
COMMENT ON COLUMN "user_oauth_accounts"."email" IS 'OAuth 제공자에서 받은 이메일';
COMMENT ON COLUMN "user_oauth_accounts"."created_at" IS '매핑 생성 시각';
COMMENT ON COLUMN "user_oauth_accounts"."updated_at" IS '매핑 수정 시각';
+16
View File
@@ -44,10 +44,26 @@ model User {
affiliations UserAffiliation[] affiliations UserAffiliation[]
userLicenses UserLicense[] userLicenses UserLicense[]
appRoles UserAppRole[] appRoles UserAppRole[]
oauthAccounts UserOauthAccount[]
@@map("users") @@map("users")
} }
model UserOauthAccount {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
provider String
providerUserId String @map("provider_user_id")
email String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerUserId])
@@index([userId])
@@map("user_oauth_accounts")
}
model UserAppRole { model UserAppRole {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid userId String @map("user_id") @db.Uuid
+121
View File
@@ -197,6 +197,89 @@ export async function createUser(input: { email: string; password: string; name?
return toAuthUser(user) return toAuthUser(user)
} }
export async function findOrCreateOAuthUser(input: {
provider: string
providerUserId: string
email: string
name?: string | null
}): Promise<AuthUser> {
const provider = input.provider.toLowerCase()
const email = input.email.toLowerCase()
const oauthAccount = await prisma.userOauthAccount.findUnique({
where: {
provider_providerUserId: {
provider,
providerUserId: input.providerUserId
}
},
include: {
user: {
select: userSelect()
}
}
})
if (oauthAccount?.user) return toAuthUser(oauthAccount.user)
const existingUser = await prisma.user.findUnique({
where: { email },
select: { id: true }
})
if (existingUser) {
const user = await prisma.user.update({
where: { id: existingUser.id },
data: {
oauthAccounts: {
create: {
provider,
providerUserId: input.providerUserId,
email
}
}
},
select: userSelect()
})
return toAuthUser(user)
}
const freeLicense = await prisma.license.findUnique({
where: { code: 'FREE' },
select: { defaultDurationDays: true }
})
const licenseStartsAt = new Date()
const licenseExpiresAt = calculateLicenseExpiresAt(licenseStartsAt, freeLicense?.defaultDurationDays)
const user = await prisma.user.create({
data: {
email,
passwordHash: await hashPassword(createOpaqueToken()),
name: input.name?.trim() || null,
oauthAccounts: {
create: {
provider,
providerUserId: input.providerUserId,
email
}
},
userLicenses: {
create: {
startsAt: licenseStartsAt,
expiresAt: licenseExpiresAt,
license: {
connect: { code: 'FREE' }
}
}
}
},
select: userSelect()
})
return toAuthUser(user)
}
function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null { function calculateLicenseExpiresAt(startsAt: Date, durationDays: number | null | undefined): Date | null {
if (!durationDays) return null if (!durationDays) return null
@@ -503,6 +586,42 @@ export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser
return findUserById(token.userId) return findUserById(token.userId)
} }
export async function createOAuthState(redirectUri: string): Promise<string> {
const state = createOpaqueToken()
await redis.set(oauthStateKey(state), JSON.stringify({ redirectUri }), 'EX', 10 * 60)
return state
}
export async function consumeOAuthState(state: string): Promise<{ redirectUri: string } | null> {
const tokenJson = await redis.send('GETDEL', [oauthStateKey(state)])
if (!tokenJson) return null
try {
const payload = JSON.parse(String(tokenJson)) as { redirectUri?: unknown }
return typeof payload.redirectUri === 'string' ? { redirectUri: payload.redirectUri } : null
} catch {
return null
}
}
export async function createOAuthLoginToken(userId: string): Promise<string> {
const token = createOpaqueToken()
await redis.set(oauthLoginTokenKey(token), JSON.stringify({ userId }), 'EX', config.oauthLoginTokenTtlSeconds)
return token
}
export async function exchangeOAuthLoginToken(loginToken: string): Promise<AuthUser | null> {
const tokenJson = await redis.send('GETDEL', [oauthLoginTokenKey(loginToken)])
if (!tokenJson) return null
try {
const payload = JSON.parse(String(tokenJson)) as { userId?: unknown }
return typeof payload.userId === 'string' ? findUserById(payload.userId) : null
} catch {
return null
}
}
export async function revokeRefreshToken(refreshToken: string): Promise<void> { export async function revokeRefreshToken(refreshToken: string): Promise<void> {
await Promise.all([ await Promise.all([
redis.del(refreshTokenKey(refreshToken)), redis.del(refreshTokenKey(refreshToken)),
@@ -549,6 +668,8 @@ export async function exchangeHandoffToken(handoffToken: string, audienceApp?: s
} }
const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}` const refreshTokenKey = (token: string): string => `auth:refresh:${hashToken(token)}`
const oauthStateKey = (state: string): string => `auth:oauth:state:${hashToken(state)}`
const oauthLoginTokenKey = (token: string): string => `auth:oauth:login:${hashToken(token)}`
const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}` const handoffTokenKey = (token: string): string => `auth:handoff:${hashToken(token)}`
+250
View File
@@ -11,13 +11,18 @@ import {
clearLoginAttempts, clearLoginAttempts,
assignUserAffiliation, assignUserAffiliation,
changeUserPassword, changeUserPassword,
consumeOAuthState,
createOAuthLoginToken,
createOAuthState,
createManagedUser, createManagedUser,
createOrganization, createOrganization,
createHandoffToken, createHandoffToken,
createRefreshToken, createRefreshToken,
createUser, createUser,
deleteUserProfileImage, deleteUserProfileImage,
exchangeOAuthLoginToken,
exchangeHandoffToken, exchangeHandoffToken,
findOrCreateOAuthUser,
findUserProfileImage, findUserProfileImage,
findUserByEmail, findUserByEmail,
listOrganizations, listOrganizations,
@@ -111,6 +116,10 @@ const handoffExchangeSchema = z.object({
audienceApp: z.string().min(1).max(120).optional() audienceApp: z.string().min(1).max(120).optional()
}) })
const oauthExchangeSchema = z.object({
loginToken: z.string().min(1)
})
const AVATAR_MAX_BYTES = 80 * 1024 const AVATAR_MAX_BYTES = 80 * 1024
const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg']) const AVATAR_ALLOWED_MIME_TYPES = new Set(['image/webp', 'image/png', 'image/jpeg'])
@@ -192,6 +201,247 @@ const readRefreshToken = async (c: { req: { header: (name: string) => string | u
export const authRoutes = new Hono<{ Variables: AuthVariables }>() export const authRoutes = new Hono<{ Variables: AuthVariables }>()
type GoogleTokenResponse = {
access_token?: string
error?: string
error_description?: string
}
type GoogleUserInfo = {
sub?: string
email?: string
email_verified?: boolean
name?: string
}
function redirectOAuthError(redirectUri: string, error: string) {
const url = new URL(redirectUri)
url.searchParams.set('error', error)
return url.toString()
}
function oauthCompletePage(targetUrl: string, state: 'success' | 'error', message: string) {
const safeTargetUrl = JSON.stringify(targetUrl)
const safeMessage = message
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
return `<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Autonomy Studio 로그인</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #070b12;
color: #eef3ff;
}
* { box-sizing: border-box; }
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
background:
radial-gradient(circle at 50% 0%, rgba(59, 127, 250, 0.24), transparent 34%),
linear-gradient(145deg, #070b12 0%, #101827 100%);
}
main {
width: min(420px, calc(100vw - 40px));
padding: 30px 28px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: rgba(12, 18, 30, 0.72);
box-shadow: 0 24px 90px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.09);
backdrop-filter: blur(18px);
text-align: center;
}
.mark {
width: 46px;
height: 46px;
margin: 0 auto 18px;
display: grid;
place-items: center;
border-radius: 50%;
background: ${state === 'success' ? 'rgba(59, 127, 250, 0.16)' : 'rgba(248, 113, 113, 0.16)'};
color: ${state === 'success' ? '#70a5ff' : '#fca5a5'};
font-size: 24px;
font-weight: 800;
}
h1 {
margin: 0 0 10px;
font-size: 20px;
line-height: 1.3;
font-weight: 800;
letter-spacing: 0;
}
p {
margin: 0;
font-size: 13px;
line-height: 1.7;
color: rgba(228, 235, 248, 0.68);
}
button {
margin-top: 22px;
height: 38px;
padding: 0 16px;
border: 0;
border-radius: 8px;
background: #3b7ffa;
color: #fff;
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
button:hover { background: #5f96ff; }
.hint {
margin-top: 14px;
font-size: 12px;
color: rgba(228, 235, 248, 0.48);
}
</style>
</head>
<body>
<main>
<div class="mark">${state === 'success' ? '✓' : '!'}</div>
<h1>${state === 'success' ? '로그인이 완료되었습니다' : '로그인에 실패했습니다'}</h1>
<p>${safeMessage}</p>
<button type="button" onclick="openLauncher()">Autonomy Studio 열기</button>
<p class="hint">앱이 열렸다면 이 창은 닫아도 됩니다.</p>
</main>
<script>
const targetUrl = ${safeTargetUrl};
function openLauncher() {
window.location.href = targetUrl;
setTimeout(() => window.close(), 900);
}
openLauncher();
</script>
</body>
</html>`
}
authRoutes.get('/google/login', async (c) => {
if (!config.googleClientId || !config.googleClientSecret) {
return c.json({ error: 'google_oauth_not_configured' }, 503)
}
const state = await createOAuthState(config.launcherOAuthRedirectUri)
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
url.searchParams.set('client_id', config.googleClientId)
url.searchParams.set('redirect_uri', config.googleRedirectUri)
url.searchParams.set('response_type', 'code')
url.searchParams.set('scope', 'openid email profile')
url.searchParams.set('state', state)
url.searchParams.set('prompt', 'select_account')
return c.redirect(url.toString(), 302)
})
authRoutes.get('/google/callback', async (c) => {
const state = c.req.query('state')
const code = c.req.query('code')
const error = c.req.query('error')
const storedState = state ? await consumeOAuthState(state) : null
const redirectUri = storedState?.redirectUri ?? config.launcherOAuthRedirectUri
if (error) {
return c.html(oauthCompletePage(
redirectOAuthError(redirectUri, error),
'error',
'Google 인증이 취소되었거나 완료되지 않았습니다.'
))
}
if (!storedState || !code) {
return c.html(oauthCompletePage(
redirectOAuthError(redirectUri, 'invalid_oauth_state'),
'error',
'로그인 요청 정보를 확인할 수 없습니다. 런처에서 다시 시도해주세요.'
))
}
if (!config.googleClientId || !config.googleClientSecret) {
return c.html(oauthCompletePage(
redirectOAuthError(redirectUri, 'google_oauth_not_configured'),
'error',
'Google 로그인 설정이 완료되지 않았습니다.'
))
}
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
code,
client_id: config.googleClientId,
client_secret: config.googleClientSecret,
redirect_uri: config.googleRedirectUri,
grant_type: 'authorization_code'
})
})
const tokenPayload = await tokenResponse.json().catch(() => null) as GoogleTokenResponse | null
if (!tokenResponse.ok || !tokenPayload?.access_token) {
console.error('Google token exchange failed', tokenPayload)
return c.html(oauthCompletePage(
redirectOAuthError(redirectUri, 'google_token_exchange_failed'),
'error',
'Google 인증 정보를 가져오지 못했습니다.'
))
}
const userInfoResponse = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
headers: { authorization: `Bearer ${tokenPayload.access_token}` }
})
const userInfo = await userInfoResponse.json().catch(() => null) as GoogleUserInfo | null
if (!userInfoResponse.ok || !userInfo?.sub || !userInfo.email || userInfo.email_verified === false) {
console.error('Google userinfo failed', userInfo)
return c.html(oauthCompletePage(
redirectOAuthError(redirectUri, 'google_userinfo_failed'),
'error',
'Google 계정 정보를 확인하지 못했습니다.'
))
}
const user = await findOrCreateOAuthUser({
provider: 'google',
providerUserId: userInfo.sub,
email: userInfo.email,
name: userInfo.name ?? null
})
const loggedInAt = new Date()
await markLastLogin(user.id, loggedInAt)
const loginToken = await createOAuthLoginToken(user.id)
const url = new URL(redirectUri)
url.searchParams.set('loginToken', loginToken)
return c.html(oauthCompletePage(
url.toString(),
'success',
'Autonomy Studio로 돌아가는 중입니다.'
))
})
authRoutes.post('/oauth/exchange', async (c) => {
const body = oauthExchangeSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
const user = await exchangeOAuthLoginToken(body.data.loginToken)
if (!user) return c.json({ error: 'invalid_oauth_login_token' }, 401)
const refreshToken = await createRefreshToken(user.id)
setRefreshCookie(c, refreshToken)
return c.json(await accessTokenResponse(user))
})
authRoutes.post('/email-verification/send', async (c) => { authRoutes.post('/email-verification/send', async (c) => {
const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null)) const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null))
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
+6 -1
View File
@@ -34,5 +34,10 @@ export const config = {
smtpSecure: process.env.SMTP_SECURE === 'true', smtpSecure: process.env.SMTP_SECURE === 'true',
smtpUser: process.env.SMTP_USER, smtpUser: process.env.SMTP_USER,
smtpPass: process.env.SMTP_PASS, smtpPass: process.env.SMTP_PASS,
smtpFrom: process.env.SMTP_FROM smtpFrom: process.env.SMTP_FROM,
googleClientId: process.env.GOOGLE_CLIENT_ID,
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI ?? 'http://localhost:4100/auth/google/callback',
launcherOAuthRedirectUri: process.env.LAUNCHER_OAUTH_REDIRECT_URI ?? 'autonomy-studio://auth/google/callback',
oauthLoginTokenTtlSeconds: toInt(process.env.OAUTH_LOGIN_TOKEN_TTL_SECONDS, 120)
} }