feat(auth): 添加用户权限获取接口并完善JWT令牌角色信息

- 在JWT令牌中添加用户角色信息
- 新增get_my_permissions接口用于获取当前用户权限码列表
- 重构认证回调逻辑,增加错误日志记录
- 更新用户信息获取接口使用Authorization头验证
```
This commit is contained in:
2026-04-06 00:40:08 +08:00
parent dfa8fa62a8
commit f1f8518985
71 changed files with 9402 additions and 191 deletions
+109
View File
@@ -0,0 +1,109 @@
"""审计日志中间件:拦截所有 API 请求,异步写入审计日志"""
import json
import time
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from app.core.security import verify_token
# 不记录审计日志的路径前缀
_SKIP_PATHS = {
"/health",
"/docs",
"/redoc",
"/openapi.json",
"/api/auth/login", # 仅获取登录 URL,无用户身份
"/api/auth/permissions", # 高频只读
"/api/stats/",
"/api/olt/regions",
"/api/olt/new-devices",
"/api/olt/duplicate-macs",
}
# 只记录写操作 + 登录回调 + 特定查询(GET 默认跳过,以下 GET 例外)
_ALWAYS_LOG_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
_LOG_GET_PATHS = {
"/api/auth/profile",
}
def _should_log(method: str, path: str) -> bool:
for skip in _SKIP_PATHS:
if path.startswith(skip):
return False
if method in _ALWAYS_LOG_METHODS:
return True
if method == "GET":
return path in _LOG_GET_PATHS
return False
def _extract_token_payload(request: Request) -> dict:
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
payload = verify_token(auth[7:])
if payload:
return payload
return {}
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
method = request.method
path = request.url.path
if not _should_log(method, path):
return await call_next(request)
# 读取请求体(只读一次,需要重新构造)
request_params = None
try:
body_bytes = await request.body()
if body_bytes:
try:
request_params = json.loads(body_bytes)
# 脱敏:移除密码字段
if isinstance(request_params, dict):
for k in ("password", "passwd", "secret"):
if k in request_params:
request_params[k] = "***"
except Exception:
pass
except Exception:
pass
start_time = time.time()
response = await call_next(request)
duration_ms = int((time.time() - start_time) * 1000)
# 异步写日志(不等待)
try:
payload = _extract_token_payload(request)
user_id = payload.get("sub", "anonymous")
username = payload.get("username", "anonymous")
user_role = payload.get("role", "")
ip_address = ""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
ip_address = forwarded.split(",")[0].strip()
elif request.client:
ip_address = request.client.host
from app.tasks.audit_tasks import create_audit_log_task
create_audit_log_task.delay(
user_id=str(user_id),
username=username,
user_role=user_role,
method=method,
path=path,
ip_address=ip_address,
user_agent=request.headers.get("user-agent", "")[:500],
status_code=response.status_code,
request_params=request_params,
response_data=None, # 不捕获响应体(性能考虑)
error_message=None if response.status_code < 400 else f"HTTP {response.status_code}",
)
except Exception:
pass # 中间件异常绝不影响主响应
return response
+85 -16
View File
@@ -1,27 +1,96 @@
"""权限检查中间件"""
from fastapi import HTTPException, Depends
"""权限检查中间件(数据库驱动 + Redis 缓存)"""
import json
from fastapi import HTTPException, Depends, Header
from sqlalchemy.orm import Session
from sqlalchemy import text
import redis
from app.core.database import get_db
from app.core.security import verify_token
from app.core.config import settings
ROLE_PERMISSIONS = {
'admin': ['*'],
'area_admin': ['device.view', 'device.check'],
'school_admin': ['device.view'],
'user': ['device.view']
}
_redis_client = None
def check_permission(required_permission: str):
def permission_checker(token: str):
def get_redis() -> redis.Redis:
global _redis_client
if _redis_client is None:
_redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True)
return _redis_client
def get_role_permissions(role: str, db: Session) -> list:
"""从数据库加载角色权限,结果缓存到 Redis(TTL 5分钟)"""
if role == 'admin':
return ['*']
r = get_redis()
cache_key = f"permissions:role:{role}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
rows = db.execute(
text("""
SELECT p.code FROM permissions p
JOIN role_permissions rp ON rp.permission_id = p.id
WHERE rp.role = :role
"""),
{"role": role}
).fetchall()
perms = [row[0] for row in rows]
r.setex(cache_key, 300, json.dumps(perms))
return perms
def invalidate_role_cache(role: str) -> None:
"""修改角色权限后清除缓存"""
get_redis().delete(f"permissions:role:{role}")
def require_permission(permission: str):
"""FastAPI Depends 工厂,检查 Bearer token 中的角色是否拥有指定权限"""
def dependency(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="未授权")
raise HTTPException(status_code=401, detail="令牌无效或已过期")
role = payload.get('role', 'user')
permissions = ROLE_PERMISSIONS.get(role, [])
perms = get_role_permissions(role, db)
if '*' in permissions or required_permission in permissions:
return payload
if '*' not in perms and permission not in perms:
raise HTTPException(status_code=403, detail="权限不足")
raise HTTPException(status_code=403, detail="权限不足")
# 附加用户的区域/学校分配信息,供数据范围过滤使用
user_id = payload.get('sub')
if user_id and role in ('area_admin', 'school_admin'):
from app.models.user import User
user = db.query(User).filter(User.id == int(user_id)).first()
if user:
payload['assigned_area'] = user.assigned_area
payload['assigned_school'] = user.assigned_school
return permission_checker
return payload
return dependency
def get_current_user(
authorization: str = Header(..., alias="Authorization"),
db: Session = Depends(get_db),
) -> dict:
"""仅验证登录状态,不检查具体权限"""
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization[7:]
payload = verify_token(token)
if not payload:
raise HTTPException(status_code=401, detail="令牌无效或已过期")
return payload