Files
hr-portal/backend/app/api/deps.py
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

223 lines
5.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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