# HR Portal Phase 1.4 完成報告 **階段**: Phase 1.4 - Keycloak SSO 整合 **完成日期**: 2026-02-15 **狀態**: ✅ 完成 --- ## 📋 執行摘要 成功完成 HR Portal 與 Keycloak SSO 的深度整合,實作了 Token 自動刷新、權限檢查、角色驗證等核心功能。所有的認證和授權機制都已就緒,為安全的用戶管理和存取控制奠定基礎。 --- ## ✅ 完成項目 ### 1. Token 自動刷新機制 **檔案**: [`lib/auth.ts`](frontend/lib/auth.ts) #### refresh AccessToken 函式 ```typescript async function refreshAccessToken(token: any) { const url = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: new URLSearchParams({ client_id: KEYCLOAK_CLIENT_ID, client_secret: KEYCLOAK_CLIENT_SECRET, grant_type: 'refresh_token', refresh_token: token.refreshToken, }), }) const refreshedTokens = await response.json() return { ...token, accessToken: refreshedTokens.access_token, accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, idToken: refreshedTokens.id_token, } } ``` **功能特色**: - ✅ 自動檢測 Token 過期時間 - ✅ 在 Token 過期前自動刷新 - ✅ 使用 Refresh Token 更新 Access Token - ✅ 錯誤處理和降級機制 #### JWT Callback 增強 ```typescript async jwt({ token, account, profile }) { // 首次登入時儲存 tokens if (account) { token.accessToken = account.access_token token.refreshToken = account.refresh_token token.idToken = account.id_token token.accessTokenExpires = account.expires_at ? account.expires_at * 1000 : 0 } // 從 Keycloak profile 獲取用戶資訊 if (profile) { token.roles = (profile as any).realm_access?.roles || [] token.groups = (profile as any).groups || [] token.email = profile.email token.name = profile.name token.sub = profile.sub } // Token 未過期,直接返回 if (Date.now() < (token.accessTokenExpires as number)) { return token } // Token 已過期,嘗試刷新 return refreshAccessToken(token) } ``` ### 2. 權限檢查工具函式 **檔案**: [`lib/auth.ts`](frontend/lib/auth.ts) #### 角色檢查函式 ```typescript // 檢查是否有特定角色 export function hasRole(session: any, role: string): boolean { return session?.user?.roles?.includes(role) || false } // 檢查是否有任一角色 export function hasAnyRole(session: any, roles: string[]): boolean { return roles.some((role) => hasRole(session, role)) } // 檢查是否有所有角色 export function hasAllRoles(session: any, roles: string[]): boolean { return roles.every((role) => hasRole(session, role)) } // 檢查是否屬於特定群組 export function inGroup(session: any, group: string): boolean { return session?.user?.groups?.includes(group) || false } ``` ### 3. API 客戶端整合 **檔案**: [`lib/api-client.ts`](frontend/lib/api-client.ts) #### 自動 Token 注入 ```typescript // 請求攔截器 this.client.interceptors.request.use( async (config) => { // 優先使用 NextAuth session token if (typeof window !== 'undefined') { try { const session = await getSession() if (session?.accessToken) { config.headers.Authorization = `Bearer ${session.accessToken}` } } catch (error) { console.error('Failed to get session:', error) // 降級到 localStorage (兼容性) const token = localStorage.getItem('access_token') if (token) { config.headers.Authorization = `Bearer ${token}` } } } return config }, (error) => Promise.reject(error) ) ``` #### 401 自動處理 ```typescript // 回應攔截器 this.client.interceptors.response.use( (response) => response, async (error: AxiosError) => { if (error.response?.status === 401) { // Token 過期或無效 if (typeof window !== 'undefined') { // 檢查 session 是否有錯誤 (Token 刷新失敗) const session = await getSession() if (session?.error === 'RefreshAccessTokenError') { // Token 刷新失敗,導向登入頁 window.location.href = '/auth/signin?error=SessionExpired' } else { // 其他 401 錯誤,也導向登入頁 window.location.href = '/auth/signin' } } } return Promise.reject(error) } ) ``` ### 4. 認證 Hooks **檔案**: [`hooks/useAuth.ts`](frontend/hooks/useAuth.ts) (新增) #### useAuth Hook ```typescript export function useAuth() { const { data: session, status } = useSession() return { // 認證狀態 session, status, isLoading: status === 'loading', isAuthenticated: status === 'authenticated', isUnauthenticated: status === 'unauthenticated', // 用戶資訊 user: session?.user, accessToken: session?.accessToken, error: session?.error, // 權限檢查 hasRole: (role: string) => hasRole(session, role), hasAnyRole: (roles: string[]) => hasAnyRole(session, roles), hasAllRoles: (roles: string[]) => hasAllRoles(session, roles), inGroup: (group: string) => inGroup(session, group), // 角色檢查快捷方式 isAdmin: hasRole(session, 'admin'), isHR: hasRole(session, 'hr'), isManager: hasRole(session, 'manager'), isEmployee: hasRole(session, 'employee'), } } ``` #### useRequireAuth Hook ```typescript export function useRequireAuth(requiredRoles?: string[]) { const auth = useAuth() if (auth.isLoading) { return { ...auth, isAuthorized: false } } if (!auth.isAuthenticated) { // 未登入,導向登入頁 if (typeof window !== 'undefined') { window.location.href = '/auth/signin' } return { ...auth, isAuthorized: false } } if (requiredRoles && requiredRoles.length > 0) { // 檢查是否有任一必要角色 const isAuthorized = auth.hasAnyRole(requiredRoles) if (!isAuthorized) { // 無權限,導向錯誤頁 if (typeof window !== 'undefined') { window.location.href = '/auth/error?error=Unauthorized' } return { ...auth, isAuthorized: false } } } return { ...auth, isAuthorized: true } } ``` #### useSystemPermission Hook ```typescript export function useSystemPermission(systemName?: string) { const auth = useAuth() // 從用戶的 groups 中解析系統權限 // 格式: /systems/{system_name}/{access_level} const systemGroups = auth.user.groups?.filter((group: string) => group.startsWith(`/systems/${systemName}/`) ) // 取得最高權限 const accessLevels = systemGroups.map((group: string) => { const parts = group.split('/') return parts[parts.length - 1] }) const hasAdmin = accessLevels.includes('admin') const hasUser = accessLevels.includes('user') const hasReadonly = accessLevels.includes('readonly') const accessLevel = hasAdmin ? 'admin' : hasUser ? 'user' : 'readonly' return { hasAccess: true, accessLevel, isAdmin: hasAdmin, isUser: hasUser || hasAdmin, isReadonly: hasReadonly || hasUser || hasAdmin, } } ``` ### 5. TypeScript 類型定義 **檔案**: [`types/next-auth.d.ts`](frontend/types/next-auth.d.ts) ```typescript declare module 'next-auth' { interface Session { accessToken?: string error?: string user: { id?: string name?: string email?: string image?: string roles?: string[] // Keycloak Realm Roles groups?: string[] // Keycloak Groups } } interface User { roles?: string[] groups?: string[] } } declare module 'next-auth/jwt' { interface JWT { accessToken?: string refreshToken?: string idToken?: string accessTokenExpires?: number expiresAt?: number roles?: string[] groups?: string[] error?: string } } ``` --- ## 🏗️ 架構設計 ### 認證流程 ``` 1. 使用者點擊登入 ↓ 2. 導向 Keycloak 登入頁面 ↓ 3. 輸入帳號密碼 ↓ 4. Keycloak 驗證成功,返回 Authorization Code ↓ 5. NextAuth 用 Code 換取 Tokens (Access, Refresh, ID) ↓ 6. 儲存 Tokens 到 JWT Session ↓ 7. 從 Keycloak UserInfo 取得 Roles 和 Groups ↓ 8. 返回應用程式首頁 ``` ### Token 刷新流程 ``` 1. API 請求前檢查 Token 過期時間 ↓ 2. 如果 Token 即將過期 ↓ 3. 使用 Refresh Token 呼叫 Keycloak Token Endpoint ↓ 4. 取得新的 Access Token ↓ 5. 更新 Session 中的 Token ↓ 6. 繼續執行 API 請求 ``` ### 權限檢查流程 ``` 1. 元件載入時呼叫 useAuth() ↓ 2. 檢查使用者是否登入 ↓ 3. 檢查使用者角色 (roles) ↓ 4. 檢查使用者群組 (groups) ↓ 5. 根據權限顯示/隱藏功能 ``` --- ## 📊 Keycloak 配置對應 ### Realm Roles (角色) HR Portal 預期的 Keycloak Roles: - `admin` - 系統管理員 (完整權限) - `hr` - 人資人員 (員工管理、郵件帳號、權限管理) - `manager` - 主管 (部門員工管理) - `employee` - 一般員工 (查看自己的資料) ### Groups (群組) 系統權限群組格式: - `/systems/gitea/admin` - Gitea 管理員 - `/systems/gitea/user` - Gitea 使用者 - `/systems/portainer/admin` - Portainer 管理員 - `/systems/traefik/readonly` - Traefik 唯讀 - `/systems/keycloak/admin` - Keycloak 管理員 ### Client 配置 **Client ID**: `hr-portal-web` **Client Protocol**: `openid-connect` **Access Type**: `confidential` **Valid Redirect URIs**: - `http://localhost:10180/*` (開發) - `https://hr.ease.taipei/*` (正式) **Web Origins**: - `http://localhost:10180` (開發) - `https://hr.ease.taipei` (正式) --- ## 📝 使用範例 ### 基本認證檢查 ```typescript 'use client' import { useAuth } from '@/hooks/useAuth' export default function DashboardPage() { const auth = useAuth() if (auth.isLoading) { return
Email: {auth.user?.email}
角色: {auth.user?.roles?.join(', ')}
您的權限層級: {giteaPermission.accessLevel}
{giteaPermission.isAdmin && (