820 lines
26 KiB
TypeScript
820 lines
26 KiB
TypeScript
import { Hono } from 'hono'
|
|
import { randomInt } from 'crypto'
|
|
import { z } from 'zod'
|
|
import { config } from '../config'
|
|
import { sendEmailVerificationCode } from '../mail'
|
|
import { verifyPassword } from '../security/password'
|
|
import { signAccessToken, verifyAccessToken } from '../security/tokens'
|
|
import type { AuthUser } from '../types'
|
|
import { authRequired, type AuthVariables } from './middleware'
|
|
import {
|
|
clearLoginAttempts,
|
|
assignUserAffiliation,
|
|
changeUserPassword,
|
|
consumeOAuthState,
|
|
createOAuthLoginToken,
|
|
createOAuthState,
|
|
createManagedUser,
|
|
createOrganization,
|
|
createHandoffToken,
|
|
createRefreshToken,
|
|
createUser,
|
|
deleteUserProfileImage,
|
|
exchangeOAuthLoginToken,
|
|
exchangeHandoffToken,
|
|
findOrCreateOAuthUser,
|
|
findUserProfileImage,
|
|
findUserByEmail,
|
|
listOrganizations,
|
|
isLoginBlocked,
|
|
markLastLogin,
|
|
recordFailedLogin,
|
|
revokeRefreshToken,
|
|
revokeRefreshTokensForUser,
|
|
rotateRefreshToken,
|
|
storeEmailVerificationCode,
|
|
storePasswordResetCode,
|
|
updateUserPassword,
|
|
updateUserProfileImage,
|
|
verifyEmailVerificationCode,
|
|
verifyPasswordResetCode
|
|
} from './repository'
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(1)
|
|
})
|
|
|
|
const signupSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
name: z.string().trim().min(1).max(80),
|
|
verificationCode: z.string().regex(/^\d{6}$/)
|
|
})
|
|
|
|
const emailVerificationSendSchema = z.object({
|
|
email: z.string().email()
|
|
})
|
|
|
|
const passwordResetSendSchema = z.object({
|
|
email: z.string().email()
|
|
})
|
|
|
|
const passwordResetConfirmSchema = z.object({
|
|
email: z.string().email(),
|
|
verificationCode: z.string().regex(/^\d{6}$/),
|
|
password: z.string().min(8)
|
|
})
|
|
|
|
const passwordChangeSchema = z.object({
|
|
currentPassword: z.string().min(1),
|
|
newPassword: z.string().min(8)
|
|
}).refine((value) => value.currentPassword !== value.newPassword, {
|
|
path: ['newPassword'],
|
|
message: 'new_password_must_be_different'
|
|
})
|
|
|
|
const organizationTypeSchema = z.enum(['COMPANY', 'SCHOOL', 'GOVERNMENT', 'LAB', 'OTHER'])
|
|
|
|
const createOrganizationSchema = z.object({
|
|
name: z.string().trim().min(1).max(120),
|
|
type: organizationTypeSchema.default('OTHER'),
|
|
domain: z.string().trim().max(120).optional().nullable()
|
|
})
|
|
|
|
const assignAffiliationSchema = z.object({
|
|
organizationId: z.string().uuid(),
|
|
department: z.string().trim().max(120).optional().nullable(),
|
|
position: z.string().trim().max(120).optional().nullable(),
|
|
isPrimary: z.boolean().optional()
|
|
})
|
|
|
|
const adminCreateUserSchema = z.object({
|
|
email: z.string().email(),
|
|
password: z.string().min(8),
|
|
name: z.string().trim().min(1).max(80),
|
|
organizationId: z.string().uuid(),
|
|
department: z.string().trim().max(120).optional().nullable(),
|
|
position: z.string().trim().max(120).optional().nullable(),
|
|
licenseCode: z.enum(['FREE', 'EDU', 'PRO']).optional()
|
|
})
|
|
|
|
const refreshSchema = z.object({
|
|
refreshToken: z.string().min(1).optional()
|
|
})
|
|
|
|
const logoutSchema = z.object({
|
|
refreshToken: z.string().min(1).optional()
|
|
})
|
|
|
|
const handoffSchema = z.object({
|
|
audienceApp: z.string().min(1).max(120)
|
|
})
|
|
|
|
const handoffExchangeSchema = z.object({
|
|
handoffToken: z.string().min(1),
|
|
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'])
|
|
|
|
const userResponse = (user: AuthUser) => ({
|
|
id: user.id,
|
|
email: user.email,
|
|
name: user.name,
|
|
role: user.role,
|
|
roles: [...new Set([user.role, ...(user.appRoles ?? []).map((appRole) => appRole.roleCode)])],
|
|
appRoles: user.appRoles ?? [],
|
|
createdAt: user.createdAt instanceof Date ? user.createdAt.toISOString() : user.createdAt,
|
|
lastLoginAt: user.lastLoginAt instanceof Date ? user.lastLoginAt.toISOString() : user.lastLoginAt,
|
|
primaryAffiliation: user.primaryAffiliation ?? null,
|
|
currentLicense: user.currentLicense
|
|
? {
|
|
...user.currentLicense,
|
|
startsAt: user.currentLicense.startsAt instanceof Date
|
|
? user.currentLicense.startsAt.toISOString()
|
|
: user.currentLicense.startsAt,
|
|
expiresAt: user.currentLicense.expiresAt instanceof Date
|
|
? user.currentLicense.expiresAt.toISOString()
|
|
: user.currentLicense.expiresAt
|
|
}
|
|
: null
|
|
})
|
|
|
|
const requireAdmin = (user: AuthUser): boolean => user.role === 'ADMIN'
|
|
|
|
const accessTokenResponse = async (user: AuthUser) => ({
|
|
accessToken: await signAccessToken(user),
|
|
accessTokenExpiresIn: config.accessTokenTtlSeconds,
|
|
user: userResponse(user)
|
|
})
|
|
|
|
const tokenPairResponse = async (user: AuthUser) => ({
|
|
...(await accessTokenResponse(user)),
|
|
refreshToken: await createRefreshToken(user.id),
|
|
refreshTokenExpiresIn: config.refreshTokenTtlSeconds
|
|
})
|
|
|
|
const cookieAttributes = (maxAgeSeconds: number): string[] => [
|
|
'HttpOnly',
|
|
'Path=/',
|
|
'SameSite=Lax',
|
|
`Max-Age=${maxAgeSeconds}`,
|
|
...(config.refreshCookieSecure ? ['Secure'] : [])
|
|
]
|
|
|
|
const setRefreshCookie = (c: { header: (name: string, value: string) => void }, refreshToken: string): void => {
|
|
c.header(
|
|
'Set-Cookie',
|
|
`${config.refreshCookieName}=${encodeURIComponent(refreshToken)}; ${cookieAttributes(config.refreshTokenTtlSeconds).join('; ')}`
|
|
)
|
|
}
|
|
|
|
const clearRefreshCookie = (c: { header: (name: string, value: string) => void }): void => {
|
|
c.header('Set-Cookie', `${config.refreshCookieName}=; ${cookieAttributes(0).join('; ')}`)
|
|
}
|
|
|
|
const readCookie = (cookieHeader: string | undefined, name: string): string | undefined => {
|
|
if (!cookieHeader) return undefined
|
|
|
|
const prefix = `${name}=`
|
|
const cookie = cookieHeader
|
|
.split(';')
|
|
.map((part) => part.trim())
|
|
.find((part) => part.startsWith(prefix))
|
|
|
|
return cookie ? decodeURIComponent(cookie.slice(prefix.length)) : undefined
|
|
}
|
|
|
|
const readRefreshToken = async (c: { req: { header: (name: string) => string | undefined; json: () => Promise<unknown> } }) => {
|
|
const cookieToken = readCookie(c.req.header('cookie'), config.refreshCookieName)
|
|
if (cookieToken) return cookieToken
|
|
|
|
const body = refreshSchema.safeParse(await c.req.json().catch(() => ({})))
|
|
return body.success ? body.data.refreshToken : undefined
|
|
}
|
|
|
|
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)
|
|
|
|
const email = body.data.email.toLowerCase()
|
|
const existingUser = await findUserByEmail(email)
|
|
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
|
|
|
const code = String(randomInt(100000, 1000000))
|
|
await storeEmailVerificationCode(email, code)
|
|
|
|
try {
|
|
await sendEmailVerificationCode(email, code)
|
|
} catch (error) {
|
|
console.error('Failed to send email verification code', error)
|
|
return c.json({ error: 'email_send_failed' }, 502)
|
|
}
|
|
|
|
return c.json({
|
|
ok: true,
|
|
expiresIn: config.emailVerificationTtlSeconds
|
|
})
|
|
})
|
|
|
|
authRoutes.post('/password-reset/send', async (c) => {
|
|
const body = passwordResetSendSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const email = body.data.email.toLowerCase()
|
|
const existingUser = await findUserByEmail(email)
|
|
|
|
if (!existingUser?.isActive) {
|
|
return c.json({ error: 'email_not_registered' }, 404)
|
|
}
|
|
|
|
const code = String(randomInt(100000, 1000000))
|
|
await storePasswordResetCode(email, code)
|
|
|
|
try {
|
|
await sendEmailVerificationCode(email, code)
|
|
} catch (error) {
|
|
console.error('Failed to send password reset code', error)
|
|
return c.json({ error: 'email_send_failed' }, 502)
|
|
}
|
|
|
|
return c.json({
|
|
ok: true,
|
|
expiresIn: config.emailVerificationTtlSeconds
|
|
})
|
|
})
|
|
|
|
authRoutes.post('/password-reset/confirm', async (c) => {
|
|
const body = passwordResetConfirmSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const email = body.data.email.toLowerCase()
|
|
const existingUser = await findUserByEmail(email)
|
|
if (!existingUser?.isActive) return c.json({ error: 'invalid_password_reset_code' }, 400)
|
|
|
|
const codeVerified = await verifyPasswordResetCode(email, body.data.verificationCode)
|
|
if (!codeVerified) return c.json({ error: 'invalid_password_reset_code' }, 400)
|
|
|
|
await updateUserPassword(existingUser.id, body.data.password)
|
|
await clearLoginAttempts(email)
|
|
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
authRoutes.post('/password/change', authRequired, async (c) => {
|
|
const body = passwordChangeSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const changed = await changeUserPassword(
|
|
c.get('user').id,
|
|
body.data.currentPassword,
|
|
body.data.newPassword
|
|
)
|
|
|
|
if (!changed) return c.json({ error: 'invalid_current_password' }, 400)
|
|
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
authRoutes.post('/login', async (c) => {
|
|
const body = loginSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const email = body.data.email.toLowerCase()
|
|
if (await isLoginBlocked(email)) return c.json({ error: 'too_many_login_attempts' }, 429)
|
|
|
|
const dbUser = await findUserByEmail(email)
|
|
if (!dbUser || !dbUser.isActive) {
|
|
await recordFailedLogin(email)
|
|
return c.json({ error: 'invalid_credentials' }, 401)
|
|
}
|
|
|
|
const passwordOk = await verifyPassword(body.data.password, dbUser.passwordHash)
|
|
if (!passwordOk) {
|
|
await recordFailedLogin(email)
|
|
return c.json({ error: 'invalid_credentials' }, 401)
|
|
}
|
|
|
|
const loggedInAt = new Date()
|
|
const user: AuthUser = {
|
|
id: dbUser.id,
|
|
email: dbUser.email,
|
|
name: dbUser.name,
|
|
role: dbUser.role,
|
|
appRoles: dbUser.appRoles ?? [],
|
|
createdAt: dbUser.createdAt,
|
|
lastLoginAt: loggedInAt,
|
|
primaryAffiliation: dbUser.affiliations?.[0]
|
|
? {
|
|
id: dbUser.affiliations[0].id,
|
|
organizationId: dbUser.affiliations[0].organizationId,
|
|
organizationName: dbUser.affiliations[0].organization.name,
|
|
organizationType: dbUser.affiliations[0].organization.type,
|
|
department: dbUser.affiliations[0].department,
|
|
position: dbUser.affiliations[0].position
|
|
}
|
|
: null,
|
|
currentLicense: dbUser.userLicenses?.[0]
|
|
? {
|
|
id: dbUser.userLicenses[0].id,
|
|
code: dbUser.userLicenses[0].license.code,
|
|
name: dbUser.userLicenses[0].license.name,
|
|
startsAt: dbUser.userLicenses[0].startsAt,
|
|
expiresAt: dbUser.userLicenses[0].expiresAt
|
|
}
|
|
: null
|
|
}
|
|
|
|
await markLastLogin(user.id, loggedInAt)
|
|
await clearLoginAttempts(email)
|
|
|
|
const refreshToken = await createRefreshToken(user.id)
|
|
setRefreshCookie(c, refreshToken)
|
|
|
|
return c.json(await accessTokenResponse(user))
|
|
})
|
|
|
|
authRoutes.post('/signup', async (c) => {
|
|
const body = signupSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const email = body.data.email.toLowerCase()
|
|
const existingUser = await findUserByEmail(email)
|
|
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
|
|
|
const emailVerified = await verifyEmailVerificationCode(email, body.data.verificationCode)
|
|
if (!emailVerified) return c.json({ error: 'invalid_email_verification_code' }, 400)
|
|
|
|
const user = await createUser({
|
|
email,
|
|
password: body.data.password,
|
|
name: body.data.name
|
|
})
|
|
|
|
const refreshToken = await createRefreshToken(user.id)
|
|
setRefreshCookie(c, refreshToken)
|
|
|
|
return c.json(await accessTokenResponse(user), 201)
|
|
})
|
|
|
|
authRoutes.post('/refresh', async (c) => {
|
|
const refreshToken = await readRefreshToken(c)
|
|
if (!refreshToken) return c.json({ error: 'missing_refresh_token' }, 401)
|
|
|
|
const user = await rotateRefreshToken(refreshToken)
|
|
if (!user) {
|
|
clearRefreshCookie(c)
|
|
return c.json({ error: 'invalid_refresh_token' }, 401)
|
|
}
|
|
|
|
const nextRefreshToken = await createRefreshToken(user.id)
|
|
setRefreshCookie(c, nextRefreshToken)
|
|
|
|
return c.json(await accessTokenResponse(user))
|
|
})
|
|
|
|
authRoutes.post('/logout', async (c) => {
|
|
const body = logoutSchema.safeParse(await c.req.json().catch(() => ({})))
|
|
const refreshToken = body.success && body.data.refreshToken
|
|
? body.data.refreshToken
|
|
: readCookie(c.req.header('cookie'), config.refreshCookieName)
|
|
|
|
if (refreshToken) {
|
|
await revokeRefreshToken(refreshToken)
|
|
}
|
|
|
|
const bearerToken = c.req.header('authorization')?.match(/^Bearer\s+(.+)$/i)?.[1]
|
|
if (bearerToken) {
|
|
try {
|
|
const claims = await verifyAccessToken(bearerToken)
|
|
await revokeRefreshTokensForUser(claims.sub)
|
|
} catch {
|
|
// Logout should remain idempotent even when the access token is already invalid.
|
|
}
|
|
}
|
|
|
|
clearRefreshCookie(c)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
authRoutes.get('/me', authRequired, (c) => {
|
|
return c.json({ user: userResponse(c.get('user')) })
|
|
})
|
|
|
|
authRoutes.get('/profile/avatar', authRequired, async (c) => {
|
|
const image = await findUserProfileImage(c.get('user').id)
|
|
if (!image) return c.body(null, 404)
|
|
if (image.url) return c.redirect(image.url, 302)
|
|
|
|
c.header('content-type', image.mimeType)
|
|
c.header('cache-control', 'no-store')
|
|
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
|
|
|
|
return c.body(new Uint8Array(image.data!))
|
|
})
|
|
|
|
authRoutes.get('/users/:userId/avatar', authRequired, async (c) => {
|
|
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
|
|
|
const userId = c.req.param('userId')
|
|
const image = await findUserProfileImage(userId)
|
|
if (!image) return c.body(null, 404)
|
|
if (image.url) return c.redirect(image.url, 302)
|
|
|
|
c.header('content-type', image.mimeType)
|
|
c.header('cache-control', 'no-store')
|
|
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
|
|
|
|
return c.body(new Uint8Array(image.data!))
|
|
})
|
|
|
|
authRoutes.get('/users/avatar', authRequired, async (c) => {
|
|
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
|
|
|
const email = c.req.query('email')?.toLowerCase()
|
|
if (!email) return c.json({ error: 'email_required' }, 400)
|
|
|
|
const user = await findUserByEmail(email)
|
|
if (!user) return c.body(null, 404)
|
|
|
|
const image = await findUserProfileImage(user.id)
|
|
if (!image) return c.body(null, 404)
|
|
if (image.url) return c.redirect(image.url, 302)
|
|
|
|
c.header('content-type', image.mimeType)
|
|
c.header('cache-control', 'no-store')
|
|
if (image.updatedAt) c.header('last-modified', image.updatedAt.toUTCString())
|
|
|
|
return c.body(new Uint8Array(image.data!))
|
|
})
|
|
|
|
authRoutes.put('/profile/avatar', authRequired, async (c) => {
|
|
const contentType = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase() ?? ''
|
|
if (!AVATAR_ALLOWED_MIME_TYPES.has(contentType)) {
|
|
return c.json({ error: 'unsupported_image_type' }, 415)
|
|
}
|
|
|
|
const body = new Uint8Array(await c.req.arrayBuffer())
|
|
if (body.length === 0) return c.json({ error: 'empty_image' }, 400)
|
|
if (body.length > AVATAR_MAX_BYTES) return c.json({ error: 'image_too_large', maxBytes: AVATAR_MAX_BYTES }, 413)
|
|
|
|
await updateUserProfileImage(c.get('user').id, body, contentType)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
authRoutes.delete('/profile/avatar', authRequired, async (c) => {
|
|
await deleteUserProfileImage(c.get('user').id)
|
|
return c.json({ ok: true })
|
|
})
|
|
|
|
authRoutes.get('/admin/organizations', authRequired, async (c) => {
|
|
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
|
|
|
const organizations = await listOrganizations()
|
|
return c.json({
|
|
organizations: organizations.map((organization) => ({
|
|
...organization,
|
|
createdAt: organization.createdAt.toISOString()
|
|
}))
|
|
})
|
|
})
|
|
|
|
authRoutes.post('/admin/organizations', authRequired, async (c) => {
|
|
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
|
|
|
const body = createOrganizationSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
try {
|
|
const organization = await createOrganization(body.data)
|
|
return c.json({
|
|
organization: {
|
|
...organization,
|
|
createdAt: organization.createdAt.toISOString()
|
|
}
|
|
}, 201)
|
|
} catch {
|
|
return c.json({ error: 'organization_already_exists' }, 409)
|
|
}
|
|
})
|
|
|
|
authRoutes.post('/admin/users', authRequired, async (c) => {
|
|
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
|
|
|
const body = adminCreateUserSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const email = body.data.email.toLowerCase()
|
|
const existingUser = await findUserByEmail(email)
|
|
if (existingUser) return c.json({ error: 'email_already_registered' }, 409)
|
|
|
|
try {
|
|
const user = await createManagedUser({
|
|
email,
|
|
password: body.data.password,
|
|
name: body.data.name,
|
|
organizationId: body.data.organizationId,
|
|
department: body.data.department,
|
|
position: body.data.position,
|
|
licenseCode: body.data.licenseCode
|
|
})
|
|
|
|
return c.json({ user: userResponse(user) }, 201)
|
|
} catch {
|
|
return c.json({ error: 'user_create_failed' }, 400)
|
|
}
|
|
})
|
|
|
|
authRoutes.post('/admin/users/:userId/affiliations', authRequired, async (c) => {
|
|
if (!requireAdmin(c.get('user'))) return c.json({ error: 'forbidden' }, 403)
|
|
|
|
const userId = c.req.param('userId')
|
|
const body = assignAffiliationSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
try {
|
|
const affiliation = await assignUserAffiliation({
|
|
userId,
|
|
organizationId: body.data.organizationId,
|
|
department: body.data.department,
|
|
position: body.data.position,
|
|
isPrimary: body.data.isPrimary
|
|
})
|
|
|
|
return c.json({ affiliation }, 201)
|
|
} catch {
|
|
return c.json({ error: 'affiliation_assign_failed' }, 400)
|
|
}
|
|
})
|
|
|
|
authRoutes.post('/handoff', authRequired, async (c) => {
|
|
const body = handoffSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const token = await createHandoffToken(c.get('user').id, body.data.audienceApp)
|
|
return c.json({
|
|
handoffToken: token,
|
|
expiresIn: config.handoffTokenTtlSeconds,
|
|
audienceApp: body.data.audienceApp
|
|
})
|
|
})
|
|
|
|
authRoutes.post('/handoff/exchange', async (c) => {
|
|
const body = handoffExchangeSchema.safeParse(await c.req.json().catch(() => null))
|
|
if (!body.success) return c.json({ error: 'invalid_request', details: body.error.flatten() }, 400)
|
|
|
|
const user = await exchangeHandoffToken(body.data.handoffToken, body.data.audienceApp)
|
|
if (!user) return c.json({ error: 'invalid_handoff_token' }, 401)
|
|
|
|
return c.json(await tokenPairResponse(user))
|
|
})
|