This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user