diff --git a/prisma/migrations/20260706090000_add_user_oauth_accounts/migration.sql b/prisma/migrations/20260706090000_add_user_oauth_accounts/migration.sql new file mode 100644 index 0000000..49fcb79 --- /dev/null +++ b/prisma/migrations/20260706090000_add_user_oauth_accounts/migration.sql @@ -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 '매핑 수정 시각'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 82e2820..bf5d456 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -44,10 +44,26 @@ model User { affiliations UserAffiliation[] userLicenses UserLicense[] appRoles UserAppRole[] + oauthAccounts UserOauthAccount[] @@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 { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/src/auth/repository.ts b/src/auth/repository.ts index af45d65..3355d08 100644 --- a/src/auth/repository.ts +++ b/src/auth/repository.ts @@ -197,6 +197,89 @@ export async function createUser(input: { email: string; password: string; name? return toAuthUser(user) } +export async function findOrCreateOAuthUser(input: { + provider: string + providerUserId: string + email: string + name?: string | null +}): Promise { + 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 { if (!durationDays) return null @@ -503,6 +586,42 @@ export async function rotateRefreshToken(refreshToken: string): Promise { + 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 { + const token = createOpaqueToken() + await redis.set(oauthLoginTokenKey(token), JSON.stringify({ userId }), 'EX', config.oauthLoginTokenTtlSeconds) + return token +} + +export async function exchangeOAuthLoginToken(loginToken: string): Promise { + 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 { await Promise.all([ 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 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)}` diff --git a/src/auth/routes.ts b/src/auth/routes.ts index 2bfbd61..47f1dc7 100644 --- a/src/auth/routes.ts +++ b/src/auth/routes.ts @@ -11,13 +11,18 @@ import { clearLoginAttempts, assignUserAffiliation, changeUserPassword, + consumeOAuthState, + createOAuthLoginToken, + createOAuthState, createManagedUser, createOrganization, createHandoffToken, createRefreshToken, createUser, deleteUserProfileImage, + exchangeOAuthLoginToken, exchangeHandoffToken, + findOrCreateOAuthUser, findUserProfileImage, findUserByEmail, listOrganizations, @@ -111,6 +116,10 @@ const handoffExchangeSchema = z.object({ 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_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 }>() +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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + + return ` + + + + + Autonomy Studio 로그인 + + + +
+
${state === 'success' ? '✓' : '!'}
+

${state === 'success' ? '로그인이 완료되었습니다' : '로그인에 실패했습니다'}

+

${safeMessage}

+ +

앱이 열렸다면 이 창은 닫아도 됩니다.

+
+ + +` +} + +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) => { const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null)) if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400) diff --git a/src/config.ts b/src/config.ts index 0bf59ce..49bfe24 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,5 +34,10 @@ export const config = { smtpSecure: process.env.SMTP_SECURE === 'true', smtpUser: process.env.SMTP_USER, 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) }