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>
This commit is contained in:
222
backend/app/api/deps.py
Normal file
222
backend/app/api/deps.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
API 依賴項
|
||||
用於依賴注入
|
||||
"""
|
||||
from typing import Generator, Optional, Dict, Any
|
||||
from fastapi import Depends, HTTPException, status, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import get_db
|
||||
from app.schemas.base import PaginationParams
|
||||
from app.services.keycloak_service import keycloak_service
|
||||
from app.models import Tenant
|
||||
|
||||
# OAuth2 Bearer Token Scheme
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def get_pagination_params(
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> PaginationParams:
|
||||
"""
|
||||
獲取分頁參數
|
||||
|
||||
Args:
|
||||
page: 頁碼 (從 1 開始)
|
||||
page_size: 每頁數量
|
||||
|
||||
Returns:
|
||||
PaginationParams: 分頁參數
|
||||
|
||||
Raises:
|
||||
HTTPException: 參數驗證失敗
|
||||
"""
|
||||
if page < 1:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Page must be greater than 0"
|
||||
)
|
||||
|
||||
if page_size < 1 or page_size > 100:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Page size must be between 1 and 100"
|
||||
)
|
||||
|
||||
return PaginationParams(page=page, page_size=page_size)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
獲取當前登入用戶 (從 JWT Token)
|
||||
|
||||
Args:
|
||||
credentials: HTTP Bearer Token
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊 (包含 username, email, sub 等)
|
||||
None: 未提供 Token 或 Token 無效時
|
||||
|
||||
Raises:
|
||||
HTTPException: Token 無效時拋出 401
|
||||
"""
|
||||
if not credentials:
|
||||
return None
|
||||
|
||||
token = credentials.credentials
|
||||
|
||||
# 驗證 Token
|
||||
user_info = keycloak_service.get_user_info_from_token(token)
|
||||
|
||||
if not user_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return user_info
|
||||
|
||||
|
||||
def require_auth(
|
||||
current_user: Optional[Dict[str, Any]] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
要求必須認證
|
||||
|
||||
Args:
|
||||
current_user: 當前用戶資訊
|
||||
|
||||
Returns:
|
||||
dict: 用戶資訊
|
||||
|
||||
Raises:
|
||||
HTTPException: 未認證時拋出 401
|
||||
"""
|
||||
if not current_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
def check_permission(required_roles: list = None):
|
||||
"""
|
||||
檢查用戶權限 (基於角色)
|
||||
|
||||
Args:
|
||||
required_roles: 需要的角色列表 (例如: ["admin", "hr_manager"])
|
||||
|
||||
Returns:
|
||||
function: 權限檢查函數
|
||||
|
||||
使用範例:
|
||||
@router.get("/admin-only", dependencies=[Depends(check_permission(["admin"]))])
|
||||
"""
|
||||
if required_roles is None:
|
||||
required_roles = []
|
||||
|
||||
def permission_checker(
|
||||
current_user: Dict[str, Any] = Depends(require_auth)
|
||||
) -> Dict[str, Any]:
|
||||
"""檢查用戶是否有所需權限"""
|
||||
# TODO: 從 Keycloak Token 解析用戶角色
|
||||
# 目前暫時允許所有已認證用戶
|
||||
user_roles = current_user.get("realm_access", {}).get("roles", [])
|
||||
|
||||
if required_roles:
|
||||
has_permission = any(role in user_roles for role in required_roles)
|
||||
if not has_permission:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Required roles: {', '.join(required_roles)}"
|
||||
)
|
||||
|
||||
return current_user
|
||||
|
||||
return permission_checker
|
||||
|
||||
|
||||
def get_current_tenant(
|
||||
current_user: Dict[str, Any] = Depends(require_auth),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Tenant:
|
||||
"""
|
||||
獲取當前租戶 (從 JWT Token 的 realm)
|
||||
|
||||
多租戶架構:每個租戶對應一個 Keycloak Realm
|
||||
- JWT Token 來自哪個 Realm,就屬於哪個租戶
|
||||
- 透過 iss (Issuer) 欄位解析 Realm 名稱
|
||||
- 查詢 tenants 表找到對應租戶
|
||||
|
||||
Args:
|
||||
current_user: 當前用戶資訊 (含 iss)
|
||||
db: 資料庫 Session
|
||||
|
||||
Returns:
|
||||
Tenant: 租戶物件
|
||||
|
||||
Raises:
|
||||
HTTPException: 租戶不存在或未啟用時拋出 403
|
||||
|
||||
範例:
|
||||
iss: "https://auth.lab.taipei/realms/porscheworld"
|
||||
→ realm_name: "porscheworld"
|
||||
→ tenant.keycloak_realm: "porscheworld"
|
||||
"""
|
||||
# 從 JWT iss 欄位解析 Realm 名稱
|
||||
# iss 格式: "https://{domain}/realms/{realm_name}"
|
||||
iss = current_user.get("iss", "")
|
||||
|
||||
if not iss:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token: missing issuer"
|
||||
)
|
||||
|
||||
# 解析 realm_name
|
||||
try:
|
||||
realm_name = iss.split("/realms/")[-1]
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token: cannot parse realm"
|
||||
)
|
||||
|
||||
# 查詢租戶
|
||||
tenant = db.query(Tenant).filter(
|
||||
Tenant.keycloak_realm == realm_name,
|
||||
Tenant.is_active == True
|
||||
).first()
|
||||
|
||||
if not tenant:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=f"Tenant not found or inactive: {realm_name}"
|
||||
)
|
||||
|
||||
return tenant
|
||||
|
||||
|
||||
def get_tenant_id(
|
||||
tenant: Tenant = Depends(get_current_tenant)
|
||||
) -> int:
|
||||
"""
|
||||
獲取當前租戶 ID (簡化版)
|
||||
|
||||
用於只需要 tenant_id 的場景
|
||||
|
||||
Args:
|
||||
tenant: 租戶物件
|
||||
|
||||
Returns:
|
||||
int: 租戶 ID
|
||||
"""
|
||||
return tenant.id
|
||||
Reference in New Issue
Block a user