A组认证加固: P0兜底+Token刷新+环境检测+OTP+RBRAC落地+P1日志审计+Token撤销 - 全局一致性审查通过

This commit is contained in:
Simon
2026-07-02 19:06:12 +08:00
parent 78f60c6857
commit fc22de7f4d
12 changed files with 1813 additions and 106 deletions
+365 -66
View File
@@ -20,6 +20,7 @@
# =============================================================================
import logging
import re
import secrets
import urllib.parse
from datetime import datetime, timedelta
@@ -35,6 +36,7 @@ from app.database import get_db
from app.models.role import Role
from app.models.user_role import UserRole
from app.services.wecom_service import WecomService
from app.services.audit_log_service import record_audit_log
from app.utils.response import AppException
from app.dependencies import get_redis
@@ -46,6 +48,15 @@ router = APIRouter(prefix="/auth_wecom", tags=["企微 SSO"])
OAUTH_STATE_TTL = 300
# SSO token 长度
SSO_TOKEN_BYTES = 32
# Token TTL 常量(8小时)
TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时
# 企微 API 超时设置(秒)
WECOM_API_TIMEOUT = 10
# --------------------------------------------------------------------------
# 企微环境检测(使用统一工具模块)
# --------------------------------------------------------------------------
from app.utils.wecom_auth import require_wecom_ua as _require_wework_ua
def _sso_enabled() -> bool:
@@ -101,6 +112,9 @@ async def sso_init(
Args:
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
"""
# 后端第二道防线:非企微环境拒绝授权
_require_wework_ua(request)
if not _sso_enabled():
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
@@ -116,103 +130,229 @@ async def sso_init(
str(state_payload).encode("utf-8"),
)
# 2. 拼企微 OAuth URL
# 2. 拼企微 OAuth URL(回调URL中包含next参数,用于state失效时仍能知道目标路径)
callback_url = _get_oauth_callback_url(request)
oauth_url = _build_oauth_url(state, callback_url)
# 在回调URL中添加next参数
separator = "&" if "?" in callback_url else "?"
callback_url_with_next = f"{callback_url}{separator}next={urllib.parse.quote(next)}"
oauth_url = _build_oauth_url(state, callback_url_with_next)
logger.info(f"SSO init: state={state[:8]}..., next={next}")
return RedirectResponse(url=oauth_url, status_code=302)
def _get_error_redirect_url(error_code: str, error_msg: str, next_path: str = "/itdesk/") -> str:
"""生成 OAuth 错误重定向 URL。
异常时重定向到前端错误页面(ErrorPage),而不是返回 JSON 错误。
ErrorPage 读取 code 和 message 参数显示友好错误提示。
Args:
error_code: 错误码
error_msg: 错误信息
next_path: 原始请求的目标路径(保留但不再用于决定重定向)
"""
import os
base = getattr(settings, "wecom_sso_callback_base", None)
if not base:
base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn")
# 重定向到 Portal 的 ErrorPage,带错误参数
# ErrorPage 期望格式:?code=xxx&message=yyy
return f"{base.rstrip('/')}/itportal/error?code={error_code}&message={urllib.parse.quote(error_msg)}"
@router.get("/sso/callback")
async def sso_callback(
code: str = Query(..., description="企微 OAuth2 授权 code"),
state: str = Query(..., description="防 CSRF state"),
request: Request,
code: Optional[str] = Query(None, description="企微 OAuth2 授权 code"),
state: Optional[str] = Query(None, description="防 CSRF state"),
errcode: Optional[int] = Query(None, description="企微 OAuth 错误码"),
errmsg: Optional[str] = Query(None, description="企微 OAuth 错误信息"),
next: Optional[str] = Query(None, description="原始请求的目标路径(可选,用于错误时重定向)"),
redis_client = Depends(get_redis),
db: AsyncSession = Depends(get_db),
):
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。"""
# 1. 校验 state(防 CSRF)
state_key = f"wecom_sso:state:{state}"
state_raw = await redis_client.get(state_key)
if not state_raw:
raise AppException(1002, "SSO state 已过期或无效, 请重新进入")
"""企微 OAuth 回调: 用 code 换 userid → 查 role → 生成 token → 跳 next。
# 删除 state(一次性)
await redis_client.delete(state_key)
异常时重定向到前端错误页面,避免白屏。
所有未处理的异常都会记录详细日志(包含 traceback)。
import ast
state_data = ast.literal_eval(state_raw.decode("utf-8"))
next_path = state_data.get("next", "/itdesk/")
Args:
next: 原始请求的目标路径,用于错误重定向。如果 state 验证失败,使用此参数决定重定向位置。
"""
import traceback
# 默认 next 路径
next_path = next or "/itdesk/"
# 2. 用 code 换 userid
wecom = WecomService(redis_client)
try:
oauth_info = await wecom.get_oauth_user_info(code)
user_id = oauth_info.get("userid", "")
if not user_id:
raise AppException(1003, "企微 OAuth 返回 userid 为空")
# 后端第二道防线:非企微环境拒绝回调
_require_wework_ua(request)
user_info = await wecom.get_user_info(user_id)
name = user_info.get("name", user_id)
except Exception as e:
logger.error(f"SSO callback 企微 API 失败: code={code[:8]}..., error={e}")
raise AppException(1004, f"企微身份识别失败: {str(e)}")
finally:
# 0. 处理企微返回的错误(用户拒绝授权等)
if errcode is not None:
msg = errmsg or "用户取消授权或授权失败"
logger.warning(f"SSO callback 企微返回错误: errcode={errcode}, errmsg={errmsg}")
return RedirectResponse(url=_get_error_redirect_url(f"wecom_{errcode}", msg, next_path), status_code=302)
# 1. 校验必要参数
if not code or not state:
logger.warning(f"SSO callback 缺少必要参数: code={bool(code)}, state={bool(state)}")
return RedirectResponse(url=_get_error_redirect_url("missing_params", "授权参数不完整,请重试", next_path), status_code=302)
# 2. 校验 state(防 CSRF)
state_key = f"wecom_sso:state:{state}"
try:
await wecom.close()
except Exception:
pass
state_raw = await redis_client.get(state_key)
except Exception as e:
logger.error(f"SSO callback Redis 获取 state 失败: {e}")
return RedirectResponse(url=_get_error_redirect_url("redis_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
# 3. 查 role (user/agent/admin)
role_stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == user_id)
)
role_result = await db.execute(role_stmt)
roles = role_result.scalars().all()
if not state_raw:
logger.warning(f"SSO callback state 过期: state={state[:8]}...")
return RedirectResponse(url=_get_error_redirect_url("state_expired", "授权已过期,请重新进入", next_path), status_code=302)
if not roles:
# 没有绑定角色: 跳"无权限"页
logger.warning(f"SSO: user_id={user_id} 没绑定任何角色")
return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302)
# 删除 state(一次性)
try:
await redis_client.delete(state_key)
except Exception as e:
logger.warning(f"SSO callback 删除 state 失败: {e}") # 不阻塞流程
# 4. 选最高权限角色 (admin > agent > user)
role_priority = {"admin": 3, "agent": 2, "user": 1}
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
role_name = best_role.name
# 解析 state 数据(添加异常处理)
import ast
import json
try:
state_data = json.loads(state_raw.decode("utf-8"))
except (json.JSONDecodeError, AttributeError) as e:
# 兼容旧格式(使用 ast.literal_eval
try:
state_data = ast.literal_eval(state_raw.decode("utf-8"))
except (ValueError, SyntaxError) as e2:
logger.error(f"SSO callback state 解析失败: {e2}")
return RedirectResponse(url=_get_error_redirect_url("state_invalid", "授权信息无效,请重新进入", next_path), status_code=302)
# 5. 生成 SSO token(随机 + Redis 存 8 小时)
sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES)
sso_payload = {
"user_id": user_id,
"name": name,
"role": role_name,
"created_at": datetime.now().isoformat(),
}
import json
await redis_client.setex(
f"wecom_sso:token:{sso_token}",
8 * 3600, # 8 小时
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
)
# 优先使用 state 中存储的 nextfallback 到 URL 参数
next_path = state_data.get("next", next_path)
# 6. 跳转到 next + token
separator = "&" if "?" in next_path else "?"
redirect_url = f"{next_path}{separator}sso_token={sso_token}"
# 3. 用 code 换 userid
wecom = WecomService(redis_client)
try:
oauth_info = await wecom.get_oauth_user_info(code)
user_id = oauth_info.get("userid", "")
if not user_id:
logger.warning("SSO callback 企微返回 userid 为空")
return RedirectResponse(url=_get_error_redirect_url("empty_userid", "无法获取您的企业微信身份,请重试", next_path), status_code=302)
logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}")
return RedirectResponse(url=redirect_url, status_code=302)
user_info = await wecom.get_user_info(user_id)
name = user_info.get("name", user_id)
except Exception as e:
logger.error(f"SSO callback 调企微 API 失败: code={code[:8]}..., error={e}")
return RedirectResponse(url=_get_error_redirect_url("api_failed", f"企业微信服务异常: {str(e)}"), status_code=302)
finally:
try:
await wecom.close()
except Exception:
pass
# 3. 查 role (user/agent/admin)
try:
role_stmt = (
select(Role)
.join(UserRole, Role.id == UserRole.role_id)
.where(UserRole.employee_id == user_id)
)
role_result = await db.execute(role_stmt)
roles = role_result.scalars().all()
except Exception as e:
logger.error(f"SSO callback 查询角色失败: {e}")
return RedirectResponse(url=_get_error_redirect_url("db_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
if not roles:
# 没有绑定角色: 跳"无权限"页
logger.warning(f"SSO: user_id={user_id} 没绑定任何角色")
return RedirectResponse(url=f"/itdesk/no-role?user_id={user_id}", status_code=302)
# 4. 选最高权限角色 (admin > agent > user)
role_priority = {"admin": 3, "agent": 2, "user": 1}
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
role_name = best_role.name
# 5. 生成 SSO token(随机 + Redis 存 8 小时)
sso_token = secrets.token_urlsafe(SSO_TOKEN_BYTES)
sso_payload = {
"user_id": user_id,
"name": name,
"role": role_name,
"created_at": datetime.now().isoformat(),
}
import json
try:
await redis_client.setex(
f"wecom_sso:token:{sso_token}",
TOKEN_TTL_SECONDS,
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
)
except Exception as e:
logger.error(f"SSO callback 存储 token 失败: {e}")
return RedirectResponse(url=_get_error_redirect_url("redis_error", "服务暂不可用,请稍后重试", next_path), status_code=302)
# 6. 记录登录日志
try:
await record_audit_log(
db=db,
employee_id=user_id,
action="sso_login",
resource="auth",
resource_id=user_id,
details={"name": name, "role": role_name, "login_method": "wecom_sso"},
result="success",
request=None, # callback 请求没有直接可用的 request 对象
)
await db.commit()
except Exception as e:
logger.warning(f"SSO callback 记录登录日志失败: {e}") # 不阻塞登录流程
# 7. 跳转到 next + token
separator = "&" if "?" in next_path else "?"
redirect_url = f"{next_path}{separator}sso_token={sso_token}"
logger.info(f"SSO 成功: user_id={user_id}, role={role_name}, next={next_path}")
return RedirectResponse(url=redirect_url, status_code=302)
except Exception as e:
# 捕获所有未处理的异常,记录详细日志(包含 traceback)并重定向到错误页
error_details = {
"error": str(e),
"error_type": type(e).__name__,
"code": code[:8] + "..." if code else None,
"state": state[:8] + "..." if state else None,
"next": next_path,
}
logger.error(
f"SSO callback 未处理的异常: {error_details}\n"
f"traceback: {traceback.format_exc()}"
)
return RedirectResponse(
url=_get_error_redirect_url("oauth_failed", "登录过程出现异常,请重试"),
status_code=302
)
@router.get("/sso/verify")
async def sso_verify(
request: Request,
sso_token: str = Query(..., description="SSO token"),
redis_client = Depends(get_redis),
db: AsyncSession = Depends(get_db),
):
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。"""
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。
后端第二道防线:非企微环境拒绝验证。
"""
# 后端第二道防线:非企微环境拒绝验证
_require_wework_ua(request)
import json
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
if not token_raw:
@@ -226,3 +366,162 @@ async def sso_verify(
"code": 0,
"data": payload,
}
@router.post("/refresh")
async def refresh_token(
token: str = Query(..., description="当前 Bearer token"),
redis_client = Depends(get_redis),
):
"""刷新 Token TTL。
前端在 Token 过期前 5 分钟自动调用此接口,实现静默刷新。
如果 Token 无效或已过期,返回 401 错误。
Returns:
刷新成功:{ code: 0, data: { token: "新token", expires_in: 28800 } }
"""
import json
# 1. 尝试统一格式 Token
token_key = f"user:token:{token}"
token_data_raw = await redis_client.get(token_key)
if token_data_raw:
try:
user_info = json.loads(token_data_raw)
# 更新最后活跃时间
user_info["last_active"] = datetime.now().isoformat()
# 延长 TTL(重新设置 8 小时)
await redis_client.setex(
token_key,
TOKEN_TTL_SECONDS,
json.dumps(user_info, ensure_ascii=False),
)
logger.info(f"Token 刷新成功: employee_id={user_info.get('employee_id')}")
return {
"code": 0,
"data": {
"token": token, # 复用同一个 token,只延长 TTL
"expires_in": TOKEN_TTL_SECONDS,
},
}
except json.JSONDecodeError:
pass
# 2. 尝试旧格式 Token (employee:token)
employee_key = f"employee:token:{token}"
employee_id = await redis_client.get(employee_key)
if employee_id:
# 延长 TTL
await redis_client.expire(employee_key, TOKEN_TTL_SECONDS)
logger.info(f"Token 刷新成功(employee): employee_id={employee_id}")
return {
"code": 0,
"data": {
"token": token,
"expires_in": TOKEN_TTL_SECONDS,
},
}
# 3. 尝试旧格式 Token (agent:token)
agent_key = f"agent:token:{token}"
agent_id = await redis_client.get(agent_key)
if agent_id:
await redis_client.expire(agent_key, TOKEN_TTL_SECONDS)
logger.info(f"Token 刷新成功(agent): agent_id={agent_id}")
return {
"code": 0,
"data": {
"token": token,
"expires_in": TOKEN_TTL_SECONDS,
},
}
# Token 无效或已过期
logger.warning(f"Token 刷新失败: token 不存在或已过期")
raise AppException(401, "Token 已过期,请重新登录")
# --------------------------------------------------------------------------
# 别名路由:支持前端 /api/auth/refresh 调用(与 /api/auth_wecom/refresh 等效)
# --------------------------------------------------------------------------
# 前端 H5/坐席/管理后台调用 /api/auth/refresh,后端响应 /api/auth_wecom/refresh
# 为兼容前端习惯,添加此别名路由
# --------------------------------------------------------------------------
# 创建别名路由器(无 prefix
alias_router = APIRouter(tags=["认证"])
@alias_router.post("/auth/refresh")
async def refresh_token_alias(
token: str = Query(..., description="当前 Bearer token"),
redis_client = Depends(get_redis),
):
"""Token 刷新接口别名。
前端调用 /api/auth/refresh,后端实际处理逻辑与 /api/auth_wecom/refresh 相同。
这是为了兼容前端的调用习惯。
Returns:
刷新成功:{ code: 0, data: { token: "新token", expires_in: 28800 } }
"""
import json
# 1. 尝试统一格式 Token
token_key = f"user:token:{token}"
token_data_raw = await redis_client.get(token_key)
if token_data_raw:
try:
user_info = json.loads(token_data_raw)
user_info["last_active"] = datetime.now().isoformat()
await redis_client.setex(
token_key,
TOKEN_TTL_SECONDS,
json.dumps(user_info, ensure_ascii=False),
)
logger.info(f"Token 刷新成功(alias): employee_id={user_info.get('employee_id')}")
return {
"code": 0,
"data": {
"token": token,
"expires_in": TOKEN_TTL_SECONDS,
},
}
except json.JSONDecodeError:
pass
# 2. 尝试旧格式 Token
employee_key = f"employee:token:{token}"
employee_id = await redis_client.get(employee_key)
if employee_id:
await redis_client.expire(employee_key, TOKEN_TTL_SECONDS)
logger.info(f"Token 刷新成功(alias employee): employee_id={employee_id}")
return {
"code": 0,
"data": {
"token": token,
"expires_in": TOKEN_TTL_SECONDS,
},
}
# 3. 尝试 agent token
agent_key = f"agent:token:{token}"
agent_id = await redis_client.get(agent_key)
if agent_id:
await redis_client.expire(agent_key, TOKEN_TTL_SECONDS)
logger.info(f"Token 刷新成功(alias agent): agent_id={agent_id}")
return {
"code": 0,
"data": {
"token": token,
"expires_in": TOKEN_TTL_SECONDS,
},
}
logger.warning(f"Token 刷新失败(alias): token 不存在或已过期")
raise AppException(401, "Token 已过期,请重新登录")