Files
Porsche Chen 360533393f feat: HR Portal - Complete Multi-Tenant System with Redis Session Storage
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>
2026-02-23 20:12:43 +08:00

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 }