This commit is contained in:
@@ -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 '매핑 수정 시각';
|
||||
@@ -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
|
||||
|
||||
@@ -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<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 {
|
||||
if (!durationDays) return null
|
||||
|
||||
@@ -503,6 +586,42 @@ export async function rotateRefreshToken(refreshToken: string): Promise<AuthUser
|
||||
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> {
|
||||
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)}`
|
||||
|
||||
|
||||
@@ -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, '>')
|
||||
.replace(/"/g, '"')
|
||||
|
||||
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) => {
|
||||
const body = emailVerificationSendSchema.safeParse(await c.req.json().catch(() => null))
|
||||
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
||||
|
||||
+6
-1
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user