feat: Initial commit - Task Reporter incident response system
Complete implementation of the production line incident response system (生產線異常即時反應系統) including: Backend (FastAPI): - User authentication with AD integration and session management - Chat room management (create, list, update, members, roles) - Real-time messaging via WebSocket (typing indicators, reactions) - File storage with MinIO (upload, download, image preview) Frontend (React + Vite): - Authentication flow with token management - Room list with filtering, search, and pagination - Real-time chat interface with WebSocket - File upload with drag-and-drop and image preview - Member management and room settings - Breadcrumb navigation - 53 unit tests (Vitest) Specifications: - authentication: AD auth, sessions, JWT tokens - chat-room: rooms, members, templates - realtime-messaging: WebSocket, messages, reactions - file-storage: MinIO integration, file management - frontend-core: React SPA structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
21
app/modules/auth/__init__.py
Normal file
21
app/modules/auth/__init__.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Authentication module - Reusable authentication system with AD API integration
|
||||
|
||||
This module provides:
|
||||
- Dual-token session management (internal + AD tokens)
|
||||
- Automatic AD token refresh with retry limit (max 3 attempts)
|
||||
- 3-day inactivity timeout
|
||||
- Encrypted password storage for auto-refresh
|
||||
- FastAPI dependency injection for protected routes
|
||||
|
||||
Usage in other modules:
|
||||
from app.modules.auth import get_current_user
|
||||
|
||||
@router.get("/protected-endpoint")
|
||||
async def my_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
# current_user contains: {"username": "...", "display_name": "..."}
|
||||
return {"user": current_user["display_name"]}
|
||||
"""
|
||||
from app.modules.auth.router import router
|
||||
from app.modules.auth.dependencies import get_current_user
|
||||
|
||||
__all__ = ["router", "get_current_user"]
|
||||
31
app/modules/auth/dependencies.py
Normal file
31
app/modules/auth/dependencies.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""FastAPI dependencies for authentication
|
||||
|
||||
供其他模組引用的 dependency injection 函數
|
||||
"""
|
||||
from fastapi import Request, HTTPException, status
|
||||
|
||||
|
||||
async def get_current_user(request: Request) -> dict:
|
||||
"""Get current authenticated user from request state
|
||||
|
||||
Usage in other modules:
|
||||
from app.modules.auth import get_current_user
|
||||
|
||||
@router.get("/my-endpoint")
|
||||
async def my_endpoint(current_user: dict = Depends(get_current_user)):
|
||||
username = current_user["username"]
|
||||
display_name = current_user["display_name"]
|
||||
...
|
||||
|
||||
Returns:
|
||||
dict: {"id": int, "username": str, "display_name": str}
|
||||
|
||||
Raises:
|
||||
HTTPException: If user not authenticated (middleware should prevent this)
|
||||
"""
|
||||
if not hasattr(request.state, "user"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
|
||||
)
|
||||
|
||||
return request.state.user
|
||||
131
app/modules/auth/middleware.py
Normal file
131
app/modules/auth/middleware.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Authentication middleware for protected routes
|
||||
|
||||
自動處理:
|
||||
1. Token 驗證
|
||||
2. 3 天不活動逾時檢查
|
||||
3. AD token 自動刷新(5 分鐘內過期時)
|
||||
4. 重試計數器管理(最多 3 次)
|
||||
"""
|
||||
from fastapi import Request, HTTPException, status
|
||||
from datetime import datetime, timedelta
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.config import get_settings
|
||||
from app.modules.auth.services.session_service import session_service
|
||||
from app.modules.auth.services.encryption import encryption_service
|
||||
from app.modules.auth.services.ad_client import ad_auth_service
|
||||
import logging
|
||||
|
||||
settings = get_settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AuthMiddleware:
|
||||
"""Authentication middleware"""
|
||||
|
||||
async def __call__(self, request: Request, call_next):
|
||||
"""Process request through authentication checks"""
|
||||
|
||||
# Skip auth for login/logout endpoints
|
||||
if request.url.path in ["/api/auth/login", "/api/auth/logout", "/docs", "/openapi.json"]:
|
||||
return await call_next(request)
|
||||
|
||||
# Extract token from Authorization header
|
||||
authorization = request.headers.get("Authorization")
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required"
|
||||
)
|
||||
|
||||
internal_token = authorization.replace("Bearer ", "")
|
||||
|
||||
# Get database session
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Query session
|
||||
user_session = session_service.get_session_by_token(db, internal_token)
|
||||
if not user_session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
|
||||
)
|
||||
|
||||
# Check 3-day inactivity timeout
|
||||
inactivity_limit = datetime.utcnow() - timedelta(days=settings.SESSION_INACTIVITY_DAYS)
|
||||
if user_session.last_activity < inactivity_limit:
|
||||
session_service.delete_session(db, user_session.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired due to inactivity. Please login again.",
|
||||
)
|
||||
|
||||
# Check if refresh attempts exceeded
|
||||
if user_session.refresh_attempt_count >= settings.MAX_REFRESH_ATTEMPTS:
|
||||
session_service.delete_session(db, user_session.id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session expired due to authentication failures. Please login again.",
|
||||
)
|
||||
|
||||
# Check if AD token needs refresh (< 5 minutes until expiry)
|
||||
time_until_expiry = user_session.ad_token_expires_at - datetime.utcnow()
|
||||
if time_until_expiry < timedelta(minutes=settings.TOKEN_REFRESH_THRESHOLD_MINUTES):
|
||||
# Auto-refresh AD token
|
||||
await self._refresh_ad_token(db, user_session)
|
||||
|
||||
# Update last_activity
|
||||
session_service.update_activity(db, user_session.id)
|
||||
|
||||
# Attach user info to request state
|
||||
request.state.user = {
|
||||
"id": user_session.id,
|
||||
"username": user_session.username,
|
||||
"display_name": user_session.display_name,
|
||||
}
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
async def _refresh_ad_token(self, db, user_session):
|
||||
"""Auto-refresh AD token using stored encrypted password"""
|
||||
try:
|
||||
# Decrypt password
|
||||
password = encryption_service.decrypt_password(user_session.encrypted_password)
|
||||
|
||||
# Re-authenticate with AD API
|
||||
ad_result = await ad_auth_service.authenticate(user_session.username, password)
|
||||
|
||||
# Update session with new token
|
||||
session_service.update_ad_token(
|
||||
db, user_session.id, ad_result["token"], ad_result["expires_at"]
|
||||
)
|
||||
|
||||
logger.info(f"AD token refreshed successfully for user: {user_session.username}")
|
||||
|
||||
except (ValueError, ConnectionError) as e:
|
||||
# Refresh failed, increment counter
|
||||
new_count = session_service.increment_refresh_attempts(db, user_session.id)
|
||||
|
||||
logger.warning(
|
||||
f"AD token refresh failed for user {user_session.username}. "
|
||||
f"Attempt {new_count}/{settings.MAX_REFRESH_ATTEMPTS}"
|
||||
)
|
||||
|
||||
# If reached max attempts, delete session
|
||||
if new_count >= settings.MAX_REFRESH_ATTEMPTS:
|
||||
session_service.delete_session(db, user_session.id)
|
||||
logger.error(
|
||||
f"Session terminated for {user_session.username} after {new_count} failed refresh attempts"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Session terminated. Your password may have been changed. Please login again.",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Token refresh failed. Please try again or re-login if issue persists.",
|
||||
)
|
||||
|
||||
|
||||
auth_middleware = AuthMiddleware()
|
||||
31
app/modules/auth/models.py
Normal file
31
app/modules/auth/models.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""SQLAlchemy models for authentication
|
||||
|
||||
資料表結構:
|
||||
- user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Index
|
||||
from datetime import datetime
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
"""User session model with encrypted password for auto-refresh"""
|
||||
|
||||
__tablename__ = "user_sessions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(255), nullable=False, comment="User email from AD")
|
||||
display_name = Column(String(255), nullable=False, comment="Display name for chat")
|
||||
internal_token = Column(
|
||||
String(255), unique=True, nullable=False, index=True, comment="Internal session token (UUID)"
|
||||
)
|
||||
ad_token = Column(String(500), nullable=False, comment="AD API token")
|
||||
encrypted_password = Column(String(500), nullable=False, comment="AES-256 encrypted password")
|
||||
ad_token_expires_at = Column(DateTime, nullable=False, comment="AD token expiry time")
|
||||
refresh_attempt_count = Column(
|
||||
Integer, default=0, nullable=False, comment="Failed refresh attempts counter"
|
||||
)
|
||||
last_activity = Column(
|
||||
DateTime, default=datetime.utcnow, nullable=False, comment="Last API request time"
|
||||
)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
95
app/modules/auth/router.py
Normal file
95
app/modules/auth/router.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Authentication API endpoints
|
||||
|
||||
提供:
|
||||
- POST /api/auth/login - 使用者登入
|
||||
- POST /api/auth/logout - 使用者登出
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from app.core.database import get_db
|
||||
from app.modules.auth.schemas import LoginRequest, LoginResponse, LogoutResponse, ErrorResponse
|
||||
from app.modules.auth.services.ad_client import ad_auth_service
|
||||
from app.modules.auth.services.encryption import encryption_service
|
||||
from app.modules.auth.services.session_service import session_service
|
||||
from fastapi import Header
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=LoginResponse,
|
||||
responses={
|
||||
401: {"model": ErrorResponse, "description": "Invalid credentials"},
|
||||
503: {"model": ErrorResponse, "description": "Authentication service unavailable"},
|
||||
},
|
||||
)
|
||||
async def login(request: LoginRequest, db: Session = Depends(get_db)):
|
||||
"""使用者登入
|
||||
|
||||
流程:
|
||||
1. 呼叫 AD API 驗證憑證
|
||||
2. 加密密碼(用於自動刷新)
|
||||
3. 生成 internal token (UUID)
|
||||
4. 儲存 session 到資料庫
|
||||
5. 回傳 internal token 和 display_name
|
||||
"""
|
||||
try:
|
||||
# Step 1: Authenticate with AD API
|
||||
ad_result = await ad_auth_service.authenticate(request.username, request.password)
|
||||
|
||||
except ValueError as e:
|
||||
# Invalid credentials
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
||||
)
|
||||
|
||||
except ConnectionError as e:
|
||||
# AD API unavailable
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Authentication service unavailable",
|
||||
)
|
||||
|
||||
# Step 2: Encrypt password for future auto-refresh
|
||||
encrypted_password = encryption_service.encrypt_password(request.password)
|
||||
|
||||
# Step 3 & 4: Generate internal token and create session
|
||||
user_session = session_service.create_session(
|
||||
db=db,
|
||||
username=request.username,
|
||||
display_name=ad_result["username"],
|
||||
ad_token=ad_result["token"],
|
||||
encrypted_password=encrypted_password,
|
||||
ad_token_expires_at=ad_result["expires_at"],
|
||||
)
|
||||
|
||||
# Step 5: Return internal token to client
|
||||
return LoginResponse(token=user_session.internal_token, display_name=user_session.display_name)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
response_model=LogoutResponse,
|
||||
responses={401: {"model": ErrorResponse, "description": "No authentication token provided"}},
|
||||
)
|
||||
async def logout(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)):
|
||||
"""使用者登出
|
||||
|
||||
刪除 session 記錄,使 token 失效
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="No authentication token provided"
|
||||
)
|
||||
|
||||
# Extract token
|
||||
internal_token = authorization.replace("Bearer ", "")
|
||||
|
||||
# Find and delete session
|
||||
user_session = session_service.get_session_by_token(db, internal_token)
|
||||
if user_session:
|
||||
session_service.delete_session(db, user_session.id)
|
||||
|
||||
return LogoutResponse(message="Logout successful")
|
||||
28
app/modules/auth/schemas.py
Normal file
28
app/modules/auth/schemas.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Pydantic schemas for authentication API requests/responses"""
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request body"""
|
||||
|
||||
username: str # Email address
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
"""Login response"""
|
||||
|
||||
token: str # Internal session token
|
||||
display_name: str
|
||||
|
||||
|
||||
class LogoutResponse(BaseModel):
|
||||
"""Logout response"""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response"""
|
||||
|
||||
error: str
|
||||
1
app/modules/auth/services/__init__.py
Normal file
1
app/modules/auth/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Authentication services"""
|
||||
98
app/modules/auth/services/ad_client.py
Normal file
98
app/modules/auth/services/ad_client.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""AD API client service for authentication
|
||||
|
||||
與 Panjit AD API 整合,負責:
|
||||
- 驗證使用者憑證
|
||||
- 取得 AD token 和使用者名稱
|
||||
- 處理 API 連線錯誤
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
import httpx
|
||||
from typing import Dict
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class ADAuthService:
|
||||
"""Active Directory authentication service"""
|
||||
|
||||
def __init__(self):
|
||||
self.ad_api_url = settings.AD_API_URL
|
||||
self._client = httpx.AsyncClient(timeout=10.0)
|
||||
|
||||
async def authenticate(self, username: str, password: str) -> Dict[str, any]:
|
||||
"""Authenticate user with AD API
|
||||
|
||||
Args:
|
||||
username: User email (e.g., ymirliu@panjit.com.tw)
|
||||
password: User password
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- token: AD authentication token
|
||||
- username: Display name from AD
|
||||
- expires_at: Estimated token expiry datetime
|
||||
|
||||
Raises:
|
||||
httpx.HTTPStatusError: If authentication fails (401, 403)
|
||||
httpx.RequestError: If AD API is unreachable
|
||||
"""
|
||||
payload = {"username": username, "password": password}
|
||||
|
||||
try:
|
||||
response = await self._client.post(
|
||||
self.ad_api_url, json=payload, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
# Raise exception for 4xx/5xx status codes
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Extract token and username from response
|
||||
# Response structure: {"success": true, "data": {"access_token": "...", "userInfo": {"name": "...", "email": "..."}}}
|
||||
if not data.get("success"):
|
||||
raise ValueError("Authentication failed")
|
||||
|
||||
token_data = data.get("data", {})
|
||||
ad_token = token_data.get("access_token")
|
||||
user_info = token_data.get("userInfo", {})
|
||||
display_name = user_info.get("name") or username
|
||||
|
||||
if not ad_token:
|
||||
raise ValueError("No token received from AD API")
|
||||
|
||||
# Parse expiry time from response (expiresAt field)
|
||||
expires_at_str = token_data.get("expiresAt")
|
||||
if expires_at_str:
|
||||
# Parse ISO format: "2025-11-16T14:38:37.912Z"
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
except:
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
else:
|
||||
# Fallback: assume 1 hour if not provided
|
||||
expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||
|
||||
return {"token": ad_token, "username": display_name, "expires_at": expires_at}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
# Authentication failed (401) or other HTTP errors
|
||||
if e.response.status_code == 401:
|
||||
raise ValueError("Invalid credentials") from e
|
||||
elif e.response.status_code >= 500:
|
||||
raise ConnectionError("Authentication service error") from e
|
||||
else:
|
||||
raise
|
||||
|
||||
except httpx.RequestError as e:
|
||||
# Network error, timeout, etc.
|
||||
raise ConnectionError("Authentication service unavailable") from e
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self._client.aclose()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
ad_auth_service = ADAuthService()
|
||||
47
app/modules/auth/services/encryption.py
Normal file
47
app/modules/auth/services/encryption.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Password encryption service using Fernet (AES-256)
|
||||
|
||||
安全性說明:
|
||||
- 使用 Fernet 對稱加密(基於 AES-256)
|
||||
- 加密金鑰從環境變數 FERNET_KEY 讀取
|
||||
- 密碼加密後儲存於資料庫,用於自動刷新 AD token
|
||||
"""
|
||||
from cryptography.fernet import Fernet
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class EncryptionService:
|
||||
"""Password encryption/decryption service"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with Fernet key from settings"""
|
||||
self._fernet = Fernet(settings.FERNET_KEY.encode())
|
||||
|
||||
def encrypt_password(self, plaintext: str) -> str:
|
||||
"""Encrypt password for storage
|
||||
|
||||
Args:
|
||||
plaintext: Plain text password
|
||||
|
||||
Returns:
|
||||
Encrypted password as base64 string
|
||||
"""
|
||||
encrypted_bytes = self._fernet.encrypt(plaintext.encode())
|
||||
return encrypted_bytes.decode()
|
||||
|
||||
def decrypt_password(self, ciphertext: str) -> str:
|
||||
"""Decrypt stored password
|
||||
|
||||
Args:
|
||||
ciphertext: Encrypted password (base64 string)
|
||||
|
||||
Returns:
|
||||
Decrypted plain text password
|
||||
"""
|
||||
decrypted_bytes = self._fernet.decrypt(ciphertext.encode())
|
||||
return decrypted_bytes.decode()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
encryption_service = EncryptionService()
|
||||
144
app/modules/auth/services/session_service.py
Normal file
144
app/modules/auth/services/session_service.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Session management service
|
||||
|
||||
處理 user_sessions 資料庫操作:
|
||||
- 建立/查詢/刪除 session
|
||||
- 更新活動時間戳
|
||||
- 管理 refresh 重試計數器
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy.orm import Session
|
||||
from app.modules.auth.models import UserSession
|
||||
|
||||
|
||||
class SessionService:
|
||||
"""Session management service"""
|
||||
|
||||
def create_session(
|
||||
self,
|
||||
db: Session,
|
||||
username: str,
|
||||
display_name: str,
|
||||
ad_token: str,
|
||||
encrypted_password: str,
|
||||
ad_token_expires_at: datetime,
|
||||
) -> UserSession:
|
||||
"""Create new user session
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
username: User email from AD
|
||||
display_name: Display name from AD
|
||||
ad_token: AD API token
|
||||
encrypted_password: Encrypted password for auto-refresh
|
||||
ad_token_expires_at: AD token expiry datetime
|
||||
|
||||
Returns:
|
||||
Created UserSession object
|
||||
"""
|
||||
# Generate unique internal token
|
||||
internal_token = str(uuid.uuid4())
|
||||
|
||||
session = UserSession(
|
||||
username=username,
|
||||
display_name=display_name,
|
||||
internal_token=internal_token,
|
||||
ad_token=ad_token,
|
||||
encrypted_password=encrypted_password,
|
||||
ad_token_expires_at=ad_token_expires_at,
|
||||
refresh_attempt_count=0,
|
||||
last_activity=datetime.utcnow(),
|
||||
)
|
||||
|
||||
db.add(session)
|
||||
db.commit()
|
||||
db.refresh(session)
|
||||
|
||||
return session
|
||||
|
||||
def get_session_by_token(self, db: Session, internal_token: str) -> UserSession | None:
|
||||
"""Get session by internal token
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
internal_token: Internal session token (UUID)
|
||||
|
||||
Returns:
|
||||
UserSession if found, None otherwise
|
||||
"""
|
||||
return db.query(UserSession).filter(UserSession.internal_token == internal_token).first()
|
||||
|
||||
def update_activity(self, db: Session, session_id: int) -> None:
|
||||
"""Update last_activity timestamp
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session ID
|
||||
"""
|
||||
db.query(UserSession).filter(UserSession.id == session_id).update(
|
||||
{"last_activity": datetime.utcnow()}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
def update_ad_token(
|
||||
self, db: Session, session_id: int, new_ad_token: str, new_expires_at: datetime
|
||||
) -> None:
|
||||
"""Update AD token after successful refresh
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session ID
|
||||
new_ad_token: New AD token
|
||||
new_expires_at: New expiry datetime
|
||||
"""
|
||||
db.query(UserSession).filter(UserSession.id == session_id).update(
|
||||
{
|
||||
"ad_token": new_ad_token,
|
||||
"ad_token_expires_at": new_expires_at,
|
||||
"refresh_attempt_count": 0, # Reset counter on success
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
def increment_refresh_attempts(self, db: Session, session_id: int) -> int:
|
||||
"""Increment refresh attempt counter
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session ID
|
||||
|
||||
Returns:
|
||||
New refresh_attempt_count value
|
||||
"""
|
||||
session = db.query(UserSession).filter(UserSession.id == session_id).first()
|
||||
if session:
|
||||
session.refresh_attempt_count += 1
|
||||
db.commit()
|
||||
return session.refresh_attempt_count
|
||||
return 0
|
||||
|
||||
def reset_refresh_attempts(self, db: Session, session_id: int) -> None:
|
||||
"""Reset refresh attempt counter
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session ID
|
||||
"""
|
||||
db.query(UserSession).filter(UserSession.id == session_id).update(
|
||||
{"refresh_attempt_count": 0}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
def delete_session(self, db: Session, session_id: int) -> None:
|
||||
"""Delete session
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
session_id: Session ID
|
||||
"""
|
||||
db.query(UserSession).filter(UserSession.id == session_id).delete()
|
||||
db.commit()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
session_service = SessionService()
|
||||
Reference in New Issue
Block a user