Major Features: - ✅ Multi-tenant architecture (tenant isolation) - ✅ Employee CRUD with lifecycle management (onboarding/offboarding) - ✅ Department tree structure with email domain management - ✅ Company info management (single-record editing) - ✅ System functions CRUD (permission management) - ✅ Email account management (multi-account per employee) - ✅ Keycloak SSO integration (auth.lab.taipei) - ✅ Redis session storage (10.1.0.254:6379) - Solves Cookie 4KB limitation - Cross-system session sharing - Sliding expiration (8 hours) - Automatic token refresh Technical Stack: Backend: - FastAPI + SQLAlchemy - PostgreSQL 16 (10.1.0.20:5433) - Keycloak Admin API integration - Docker Mailserver integration (SSH) - Alembic migrations Frontend: - Next.js 14 (App Router) - NextAuth 4 with Keycloak Provider - Redis session storage (ioredis) - Tailwind CSS Infrastructure: - Redis 7 (10.1.0.254:6379) - Session + Cache - Keycloak 26.1.0 (auth.lab.taipei) - Docker Mailserver (10.1.0.254) Architecture Highlights: - Session管理由 Keycloak + Redis 統一控制 - 支援多系統 (HR/WebMail/Calendar/Drive/Office) 共享 session - Token 自動刷新,異質服務整合 - 未來可無縫遷移到雲端 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
214 lines
6.9 KiB
TypeScript
214 lines
6.9 KiB
TypeScript
/**
|
|
* NextAuth 4 API Route Handler with Redis Session Storage
|
|
*
|
|
* 架構說明:
|
|
* 1. Keycloak SSO - 統一認證入口
|
|
* 2. Redis - 集中式 session storage (解決 Cookie 4KB 限制)
|
|
* 3. Cookie - 只存 session_key (64 bytes)
|
|
*
|
|
* 多系統共享:
|
|
* - hr.ease.taipei, mail.ease.taipei, calendar.ease.taipei 等
|
|
* - 共用同一個 Redis session (透過 Keycloak user_id)
|
|
* - Token refresh 自動同步到所有系統
|
|
*/
|
|
import NextAuth, { NextAuthOptions } from 'next-auth'
|
|
import KeycloakProvider from 'next-auth/providers/keycloak'
|
|
import Redis from 'ioredis'
|
|
|
|
// Redis 客戶端 (DB 0 專用於 Session Storage)
|
|
const redis = new Redis({
|
|
host: process.env.REDIS_HOST || '10.1.0.20',
|
|
port: parseInt(process.env.REDIS_PORT || '6379'),
|
|
password: process.env.REDIS_PASSWORD,
|
|
db: 0, // Session Storage 專用
|
|
retryStrategy: (times) => {
|
|
const delay = Math.min(times * 50, 2000)
|
|
return delay
|
|
},
|
|
})
|
|
|
|
// Redis 連線錯誤處理
|
|
redis.on('error', (err) => {
|
|
console.error('[Redis] Connection error:', err)
|
|
})
|
|
|
|
redis.on('connect', () => {
|
|
console.log('[Redis] Connected to session storage')
|
|
})
|
|
|
|
const authOptions: NextAuthOptions = {
|
|
providers: [
|
|
KeycloakProvider({
|
|
clientId: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!,
|
|
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
|
issuer: `${process.env.NEXT_PUBLIC_KEYCLOAK_URL}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM}`,
|
|
}),
|
|
],
|
|
session: {
|
|
strategy: 'jwt',
|
|
maxAge: 8 * 60 * 60, // 8 小時
|
|
updateAge: 60 * 60, // 每 1 小時檢查一次
|
|
},
|
|
events: {
|
|
// 登出時清除 Redis session (所有系統同步登出)
|
|
async signOut({ token }) {
|
|
if (token?.sub) {
|
|
const sessionKey = `session:${token.sub}`
|
|
await redis.del(sessionKey)
|
|
console.log('[EVENT] Signed out, Redis session deleted:', sessionKey)
|
|
}
|
|
},
|
|
},
|
|
callbacks: {
|
|
async jwt({ token, account, profile, trigger }) {
|
|
const userId = token.sub || profile?.sub
|
|
if (!userId) return token
|
|
|
|
console.log('[JWT CALLBACK]', { trigger, userId: userId.substring(0, 8) })
|
|
|
|
// 初次登入 - 儲存 Keycloak tokens 到 Redis
|
|
if (account && profile) {
|
|
const sessionData = {
|
|
sub: profile.sub,
|
|
email: profile.email,
|
|
name: profile.name,
|
|
accessToken: account.access_token,
|
|
refreshToken: account.refresh_token,
|
|
expiresAt: account.expires_at,
|
|
createdAt: Math.floor(Date.now() / 1000),
|
|
}
|
|
|
|
// 存入 Redis (Key: session:{user_id})
|
|
const sessionKey = `session:${userId}`
|
|
await redis.setex(
|
|
sessionKey,
|
|
8 * 60 * 60, // TTL: 8 hours
|
|
JSON.stringify(sessionData)
|
|
)
|
|
|
|
console.log('[JWT CALLBACK] Stored session to Redis:', sessionKey)
|
|
|
|
// Cookie 只存輕量資料 (user_id)
|
|
return {
|
|
sub: userId,
|
|
email: profile.email,
|
|
name: profile.name,
|
|
sessionKey, // 引用 Redis 的 key
|
|
}
|
|
}
|
|
|
|
// 後續請求 - 從 Redis 讀取 tokens
|
|
const sessionKey = token.sessionKey as string || `session:${userId}`
|
|
const sessionDataStr = await redis.get(sessionKey)
|
|
|
|
if (!sessionDataStr) {
|
|
console.error('[JWT CALLBACK] Session not found in Redis:', sessionKey)
|
|
return { ...token, error: 'SessionNotFound' }
|
|
}
|
|
|
|
const sessionData = JSON.parse(sessionDataStr)
|
|
|
|
// 檢查 access_token 是否即將過期 (提前 1 分鐘刷新)
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const expiresAt = sessionData.expiresAt as number
|
|
|
|
if (now < expiresAt - 60) {
|
|
// Token 仍有效 - 更新 Redis TTL (Sliding Expiration)
|
|
await redis.expire(sessionKey, 8 * 60 * 60)
|
|
console.log('[JWT CALLBACK] Token valid, TTL refreshed')
|
|
return token
|
|
}
|
|
|
|
// Token 即將過期 - 使用 refresh_token 更新
|
|
console.log('[JWT CALLBACK] Refreshing token...')
|
|
try {
|
|
const response = await fetch(
|
|
`${process.env.NEXT_PUBLIC_KEYCLOAK_URL}/realms/${process.env.NEXT_PUBLIC_KEYCLOAK_REALM}/protocol/openid-connect/token`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: new URLSearchParams({
|
|
client_id: process.env.NEXT_PUBLIC_KEYCLOAK_CLIENT_ID!,
|
|
client_secret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
|
grant_type: 'refresh_token',
|
|
refresh_token: sessionData.refreshToken,
|
|
}),
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
console.error('[JWT CALLBACK] Refresh failed:', response.status)
|
|
await redis.del(sessionKey) // 刪除無效 session
|
|
return { ...token, error: 'RefreshTokenError' }
|
|
}
|
|
|
|
const refreshedTokens = await response.json()
|
|
|
|
// 更新 Redis session
|
|
const updatedSessionData = {
|
|
...sessionData,
|
|
accessToken: refreshedTokens.access_token,
|
|
refreshToken: refreshedTokens.refresh_token ?? sessionData.refreshToken,
|
|
expiresAt: now + refreshedTokens.expires_in,
|
|
}
|
|
|
|
await redis.setex(sessionKey, 8 * 60 * 60, JSON.stringify(updatedSessionData))
|
|
console.log('[JWT CALLBACK] Token refreshed and updated in Redis')
|
|
|
|
return token
|
|
} catch (error) {
|
|
console.error('[JWT CALLBACK] Refresh error:', error)
|
|
await redis.del(sessionKey)
|
|
return { ...token, error: 'RefreshTokenError' }
|
|
}
|
|
},
|
|
async session({ session, token }) {
|
|
console.log('[SESSION CALLBACK] Called')
|
|
|
|
// 處理錯誤狀態
|
|
if (token?.error) {
|
|
console.error('[SESSION CALLBACK] Session error:', token.error)
|
|
return { ...session, error: token.error }
|
|
}
|
|
|
|
if (!token.sub) {
|
|
return { ...session, error: 'InvalidSession' }
|
|
}
|
|
|
|
// 從 Redis 讀取完整 session 資料
|
|
const sessionKey = token.sessionKey as string || `session:${token.sub}`
|
|
const sessionDataStr = await redis.get(sessionKey)
|
|
|
|
if (!sessionDataStr) {
|
|
console.error('[SESSION CALLBACK] Session expired or not found')
|
|
return { ...session, error: 'SessionExpired' }
|
|
}
|
|
|
|
const sessionData = JSON.parse(sessionDataStr)
|
|
|
|
// 填充 session 物件 (前端可用)
|
|
if (session.user) {
|
|
session.user.id = sessionData.sub
|
|
session.user.email = sessionData.email
|
|
session.user.name = sessionData.name
|
|
session.accessToken = sessionData.accessToken // ← 從 Redis 取得!
|
|
|
|
// Tenant 資訊 (如果需要可以存在 Redis)
|
|
if (sessionData.tenant) {
|
|
;(session.user as any).tenant = sessionData.tenant
|
|
}
|
|
|
|
console.log('[SESSION CALLBACK] Session loaded from Redis')
|
|
}
|
|
|
|
return session
|
|
},
|
|
},
|
|
secret: process.env.NEXTAUTH_SECRET,
|
|
debug: true,
|
|
}
|
|
|
|
const handler = NextAuth(authOptions)
|
|
|
|
export { handler as GET, handler as POST }
|