ppt-web / src /landppt /auth /middleware.py
26fwyzpz6f-max
Clean deploy without binary files
6aecb2e
Raw
History Blame Contribute Delete
12.9 kB
"""
Authentication middleware for LandPPT
"""
from typing import Optional, Callable
from fastapi import Request, Response, HTTPException, Depends
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
import logging
from .auth_service import get_auth_service
from ..database.database import get_db
from ..database.models import User
from ..core.config import app_config
from .request_context import current_base_url, current_user_id, resolve_request_base_url
logger = logging.getLogger(__name__)
def _is_api_path(path: str) -> bool:
"""Detect API-style paths that should return JSON auth errors."""
return (
path == "/api"
or path.startswith("/api/")
or path == "/v1"
or path.startswith("/v1/")
)
def _extract_api_key(request: Request) -> Optional[str]:
"""
Extract machine API key from request headers.
Supported headers:
- Authorization: Bearer <key>
- X-API-Key: <key>
"""
auth_header = (request.headers.get("authorization") or "").strip()
if auth_header.lower().startswith("bearer "):
token = auth_header[7:].strip()
if token:
return token
api_key = (request.headers.get("x-api-key") or "").strip()
return api_key or None
def _extract_session_id(request: Request) -> Optional[str]:
"""Extract session ID from cookie first, then optional X-Session-Id header."""
cookie_session = (request.cookies.get("session_id") or "").strip()
if cookie_session:
return cookie_session
if not app_config.allow_header_session_auth:
return None
header_session = (request.headers.get("x-session-id") or "").strip()
return header_session or None
class AuthMiddleware:
"""Authentication middleware"""
def __init__(self):
self.auth_service = get_auth_service()
# 不需要认证的路径
self.public_paths = {
"/",
"/health",
"/auth/login",
"/auth/logout",
"/auth/github/login",
"/auth/github/callback",
"/auth/linuxdo/login",
"/auth/linuxdo/callback",
"/auth/register",
"/auth/forgot-password",
"/auth/reset-password",
"/auth/api/send-code",
"/sponsors",
"/api/community/public-settings",
"/api/auth/login",
"/api/auth/logout",
"/api/auth/check",
"/static",
"/favicon.ico"
}
# 不需要认证的路径前缀
self.public_prefixes = [
"/static/",
"/temp/", # 添加temp目录用于图片缓存访问
"/api/image/view/", # 图床图片访问无需认证
"/api/image/thumbnail/", # 图片缩略图访问无需认证
"/share/", # 公开分享链接无需认证
"/api/share/", # 分享API无需认证
]
def is_public_path(self, path: str) -> bool:
"""Check if path is public (doesn't require authentication)"""
# Check exact matches
if path in self.public_paths:
return True
# Check prefixes
for prefix in self.public_prefixes:
if path.startswith(prefix):
return True
return False
def should_resolve_optional_user(self, path: str) -> bool:
"""公开页尽量识别当前用户,避免模板导航与真实登录态不一致。"""
if _is_api_path(path):
return False
if path == "/favicon.ico":
return False
return not path.startswith(("/static/", "/temp/"))
def _resolve_request_user(self, request: Request, db: Session) -> Optional[User]:
"""统一解析当前请求用户,供公开页与受保护页复用。"""
api_key = _extract_api_key(request)
if api_key:
user = self.auth_service.get_user_by_api_key(db, api_key)
if user:
return user
session_id = _extract_session_id(request)
if session_id:
return self.auth_service.get_user_by_session(db, session_id)
return None
async def _get_user_from_session_cache(self, session_id: Optional[str]) -> Optional[User]:
"""Resolve a user from cached session state without touching the database."""
normalized_session_id = str(session_id or "").strip()
if not normalized_session_id:
return None
try:
from ..services.cache_service import get_cache_service
cache = await get_cache_service()
if not cache or not cache.is_connected:
return None
payload = await cache.get_session(normalized_session_id)
user = self.auth_service.user_from_session_cache(payload)
if not self.auth_service.is_cached_session_valid(payload, user):
if payload:
await cache.delete_session(normalized_session_id)
return None
ttl = self.auth_service.get_session_cache_ttl((payload or {}).get("expires_at"))
await cache.refresh_session(normalized_session_id, ttl=ttl)
return user
except Exception as exc:
logger.debug("Session cache lookup failed for %s: %s", normalized_session_id, exc)
return None
async def __call__(self, request: Request, call_next: Callable):
"""Middleware function"""
# Ensure request-scoped user context is always initialized/reset
ctx_token = current_user_id.set(None)
base_url_token = current_base_url.set(resolve_request_base_url(request))
path = request.url.path
is_api_request = _is_api_path(path)
session_cookie = request.cookies.get("session_id")
try:
# 模板层会读取 request.state.user,这里统一初始化,避免未设置时报错。
request.state.user = None
# Skip authentication for public paths
if self.is_public_path(path):
if self.should_resolve_optional_user(path):
try:
db = None
db_gen = None
def ensure_db():
nonlocal db, db_gen
if db is None:
db_gen = get_db()
db = next(db_gen)
return db
try:
user: Optional[User] = None
session_id = _extract_session_id(request)
if session_id:
user = await self._get_user_from_session_cache(session_id)
if not user:
user = self._resolve_request_user(request, ensure_db())
if user:
request.state.user = user
current_user_id.set(user.id)
finally:
if db is not None:
db.close()
except Exception as exc:
# 公开页不应因可选登录态识别失败而中断访问。
logger.debug("Optional auth resolution failed for public path %s: %s", path, exc)
response = await call_next(request)
return response
try:
db = None
db_gen = None
def ensure_db():
nonlocal db, db_gen
if db is None:
db_gen = get_db()
db = next(db_gen)
return db
try:
user: Optional[User] = None
session_id = _extract_session_id(request)
if session_id:
user = await self._get_user_from_session_cache(session_id)
if not user:
user = self._resolve_request_user(request, ensure_db())
if not user:
# Unauthenticated request
if is_api_request:
return Response(
content='{"detail": "Authentication required"}',
status_code=401,
media_type="application/json"
)
else:
response = RedirectResponse(url="/auth/login", status_code=302)
# If cookie-based session is present but invalid, clear it.
if session_cookie:
response.delete_cookie("session_id")
return response
# Add user to request state and request context
request.state.user = user
current_user_id.set(user.id)
# Continue with request
response = await call_next(request)
return response
finally:
if db is not None:
db.close()
except Exception as e:
logger.error(f"Authentication middleware error: {e}")
if is_api_request:
return Response(
content='{"detail": "Authentication error"}',
status_code=500,
media_type="application/json"
)
else:
return RedirectResponse(url="/auth/login", status_code=302)
finally:
current_base_url.reset(base_url_token)
current_user_id.reset(ctx_token)
def get_current_user(request: Request) -> Optional[User]:
"""Get current authenticated user from request"""
return getattr(request.state, 'user', None)
def require_auth(request: Request) -> User:
"""Dependency to require authentication"""
user = get_current_user(request)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
def require_admin(request: Request) -> User:
"""Dependency to require admin privileges"""
user = require_auth(request)
if not user.is_admin:
raise HTTPException(status_code=403, detail="Admin privileges required")
return user
def get_current_user_optional(
request: Request,
db: Session = Depends(get_db)
) -> Optional[User]:
"""
Get current user if authenticated, None otherwise.
For use with FastAPI dependency injection.
"""
# Prefer middleware-resolved user.
state_user = get_current_user(request)
if state_user:
return state_user
auth_service = get_auth_service()
# Try machine API key auth.
api_key = _extract_api_key(request)
if api_key:
user = auth_service.get_user_by_api_key(db, api_key)
if user:
request.state.user = user
return user
# Fallback to session auth.
session_id = _extract_session_id(request)
if not session_id:
return None
user = auth_service.get_user_by_session(db, session_id)
if user:
request.state.user = user
return user
def get_current_user_required(
request: Request,
db: Session = Depends(get_db)
) -> User:
"""
Get current user, raise exception if not authenticated.
For use with FastAPI dependency injection.
"""
user = get_current_user_optional(request, db)
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
def get_current_admin_user(
request: Request,
db: Session = Depends(get_db)
) -> User:
"""
Get current admin user, raise exception if not admin.
For use with FastAPI dependency injection.
"""
user = get_current_user_required(request, db)
if not user.is_admin:
raise HTTPException(status_code=403, detail="Admin privileges required")
return user
def create_auth_middleware() -> AuthMiddleware:
"""Create authentication middleware instance"""
return AuthMiddleware()
# Utility functions for templates
def is_authenticated(request: Request) -> bool:
"""Check if user is authenticated"""
return get_current_user(request) is not None
def is_admin(request: Request) -> bool:
"""Check if user is admin"""
user = get_current_user(request)
return user is not None and user.is_admin
def get_user_info(request: Request) -> Optional[dict]:
"""Get user info for templates"""
user = get_current_user(request)
if user:
return user.to_dict()
return None