Files
track/backend/app/services/auth_service.py
openhands 04eb87b091 fix: 访问日志的 user 字段恒为 null —— 改用 request.state 跨 task 传递
上一提交(4454047)的 RequestContextMiddleware 通过 contextvar 读取 user,
但 Starlette 的 BaseHTTPMiddleware 用 anyio start_soon 把下游放进新 task
执行,而 asyncio 每个 Task 创建时会复制 context —— 路由内 set 的
contextvar 不会回流到中间件,导致访问日志的 user 永远是 null。

修复:
- get_current_user 同时写 contextvar(供请求任务内业务日志用)与
  request.state(由 ASGI scope 承载,跨 task 可见),并带上
  display_name / role 备用。
- 中间件 _log_access 改为优先读 request.state.audit_user。

验证(TestClient + 解析最终 JSON 输出,而非读 record 属性):
- track.access 日志 user=zhangsan01(经 request.state)
- 请求任务内 track.service 日志 user=zhangsan01(经 contextvar)
- 两者 request_id 一致;X-Request-ID 透传正常
2026-09-21 02:11:41 +00:00

150 lines
5.1 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.

"""认证服务 — 对接 MOM 系统 sys_user 表 + Track 自有 JWT双 Token 架构)"""
from fastapi import HTTPException, status, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from werkzeug.security import check_password_hash
from app.core.config import settings
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
ALGORITHM,
TOKEN_TYPE_ACCESS,
TOKEN_TYPE_REFRESH,
)
from app.core.mom_database import MomSessionLocal
from app.core.logging import user_var
from app.schemas.user import LoginResponse, UserResponse
security = HTTPBearer()
def login(username: str, password: str) -> LoginResponse:
"""登录 — 签发双 TokenAccess + Refresh"""
db = MomSessionLocal()
try:
# 1. 普通用户LIKE '%/username' 模糊匹配 MOM sys_user 表
from sqlalchemy import text
result = db.execute(
text(
"SELECT id, username, department, role, password_hash "
"FROM sys_user "
"WHERE username LIKE :pattern"
),
{"pattern": f"%/{username}"},
)
row = result.fetchone()
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
user_id, full_username, department, role, password_hash = row
# 2. Werkzeug scrypt 密码验证
if not check_password_hash(password_hash, password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
)
# 3. 解析 display_name"张三/zhangsan01" → "张三"
display_name = full_username.split("/")[0] if "/" in full_username else full_username
token_data = {
"sub": str(user_id),
"role": role or "operator",
"username": username,
"display_name": display_name,
}
return LoginResponse(
access_token=create_access_token(data=token_data),
refresh_token=create_refresh_token(data=token_data),
user=UserResponse(
id=str(user_id),
username=username,
display_name=display_name,
role=role or "operator",
),
)
finally:
db.close()
def refresh_access_token(refresh_token: str) -> dict:
"""
使用 Refresh Token 换取新的 Access Token。
校验:
1. Token 签名是否有效
2. Token type 是否为 "refresh"
3. Token 是否未过期
"""
try:
payload = decode_token(refresh_token)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh Token 无效或已过期,请重新登录",
)
# 校验 token 类型
if payload.get("type") != TOKEN_TYPE_REFRESH:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 Token 类型,仅接受 Refresh Token",
)
# 提取用户信息,签发新的 Access Token
access_token = create_access_token(
data={
"sub": payload.get("sub"),
"role": payload.get("role", "operator"),
"username": payload.get("username", ""),
"display_name": payload.get("display_name", ""),
}
)
return {"access_token": access_token, "token_type": "bearer"}
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
"""从 Bearer Token 解析当前用户(仅接受 Access Token"""
token = credentials.credentials
try:
payload = decode_token(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="无效的 Token")
# 校验:仅接受 access token
if payload.get("type") == TOKEN_TYPE_REFRESH:
raise HTTPException(
status_code=401,
detail="请使用 Access Token 访问 APIRefresh Token 仅用于刷新",
)
# 操作人身份要写两处,用途不同,缺一不可:
# 1) contextvar —— 供本请求任务内的业务/service 日志使用;
# 2) request.state —— 中间件在独立 task 中执行Starlette 的
# BaseHTTPMiddleware 用 anyio start_soon 起新 task而 asyncio
# 每个 Task 会复制 context因此中间件读不到路由内改的
# contextvar只能通过 ASGI scope 承载的 state 拿到。
# username 即 assignee_id 口径,比数字 id 直观得多。
user_label = payload.get("username") or user_id
user_var.set(user_label)
request.state.audit_user = user_label
request.state.audit_display_name = payload.get("display_name") or ""
request.state.audit_role = payload.get("role") or ""
return payload
except JWTError:
raise HTTPException(status_code=401, detail="无效的 Token")