feat(v0.7.1): P0 修复 + 企微 SSO + RBAC 细粒度 + audit_log
P0 修复: - /api/ready import 错误 (_get_engine + settings.create_redis_client) - 删 agent.otp_secret/otp_enabled 双字段 (migration 026) - 重建 021_rbac migration (IF NOT EXISTS 兼容) P1 新增: - 企微 SSO (auth_wecom_sso.py, useWeChatWorkSSO composable, PortalSelect UA 检测) - RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围 (rbac_service + seed_rbac + require_permission) - audit_log 模型 + migration 027 + 服务 + API - 管理后台 RBAC 权限矩阵 UI (PermissionsMatrix.vue) 质量: - pytest 405 passed / 33 pre-existing failed / 4 xfailed (v0.7.1 引入失败 = 0) - conftest GBK patch 强制 UTF-8 读 .env - .gitignore 排除 *.b64 (含 admin token 凭据) - DEPLOY-v0.7.1.md 7 步 runbook + 4 坑 + 回滚预案
This commit is contained in:
@@ -294,8 +294,10 @@ async def admin_unbind_agent_otp(
|
||||
if not agent:
|
||||
raise AppException(1001, "坐席不存在")
|
||||
|
||||
agent.otp_secret = None
|
||||
agent.otp_enabled = 0
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
agent.mfa_last_verified_at = None
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
@@ -382,3 +382,132 @@ async def delete_mapping_rule(
|
||||
logger.info(f"管理员 {_mask_sensitive_data(admin.employee_id)} 删除映射规则 {rule_id}")
|
||||
|
||||
return success_response(message="映射规则删除成功")
|
||||
|
||||
|
||||
# ==========================================================================
|
||||
# 4. 权限矩阵可视化 (v0.7.1 task #86)
|
||||
# ==========================================================================
|
||||
# 给管理后台 UI 用: 返回 5 角色 × 4 资源 × 4 操作 × 3 范围的完整矩阵
|
||||
# 嵌套结构方便前端直接渲染表格:
|
||||
# {
|
||||
# "roles": [{name, display_name, permissions: [string]}],
|
||||
# "resources": [conversation, agent, ...],
|
||||
# "actions": [read, create, update, delete],
|
||||
# "scopes": [own, department, all],
|
||||
# "matrix": {
|
||||
# "agent": { # 角色名
|
||||
# "conversation:read:own": true,
|
||||
# "conversation:read:all": true,
|
||||
# ...
|
||||
# }
|
||||
# }
|
||||
# }
|
||||
# ==========================================================================
|
||||
@router.get("/permissions/matrix")
|
||||
async def get_permissions_matrix(
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取 RBAC 完整权限矩阵(管理后台可视化用)。
|
||||
|
||||
返回 5 角色预置的 permissions JSON,前端用此数据渲染
|
||||
角色 × 资源 × 操作 × 范围 的可读表格。
|
||||
|
||||
Args:
|
||||
admin: 管理员(权限校验)
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含完整权限矩阵
|
||||
"""
|
||||
from app.services.rbac_service import (
|
||||
ROLE_PERMISSIONS,
|
||||
VALID_ACTIONS,
|
||||
VALID_RESOURCES,
|
||||
VALID_SCOPES,
|
||||
permissions_to_strings,
|
||||
)
|
||||
|
||||
# 1. 查 DB 拿角色元数据(显示名等)
|
||||
stmt = select(Role).order_by(Role.is_default.desc(), Role.name)
|
||||
result = await db.execute(stmt)
|
||||
roles = result.scalars().all()
|
||||
|
||||
# 2. 构建角色列表(以代码里的 ROLE_PERMISSIONS 为准,DB 字段作 display_name)
|
||||
role_list = []
|
||||
matrix = {}
|
||||
for role in roles:
|
||||
# 优先用代码常量(单一可信源);DB 字段仅作元数据
|
||||
perms = ROLE_PERMISSIONS.get(role.name, set())
|
||||
perms_list = permissions_to_strings(perms)
|
||||
|
||||
role_list.append({
|
||||
"name": role.name,
|
||||
"display_name": role.display_name,
|
||||
"description": role.description,
|
||||
"is_default": role.is_default,
|
||||
"permission_count": len(perms_list),
|
||||
})
|
||||
|
||||
# 3. 角色 × 资源 × 操作 × 范围 的全矩阵
|
||||
# true/false 表征是否拥有此权限
|
||||
# 前端用此渲染表格,空格表示"不适用"
|
||||
role_matrix = {}
|
||||
for resource in VALID_RESOURCES:
|
||||
for action in VALID_ACTIONS:
|
||||
for scope in VALID_SCOPES:
|
||||
perm = f"{resource}:{action}:{scope}"
|
||||
role_matrix[perm] = (resource, action, scope) in perms
|
||||
matrix[role.name] = role_matrix
|
||||
|
||||
return success_response(data={
|
||||
"roles": role_list,
|
||||
"resources": VALID_RESOURCES,
|
||||
"actions": VALID_ACTIONS,
|
||||
"scopes": VALID_SCOPES,
|
||||
"matrix": matrix,
|
||||
})
|
||||
|
||||
|
||||
# ---------- GET /api/admin/roles/permissions/check ----------
|
||||
# 给前端按钮级权限控制用: 传入 (resource, action, scope) 查当前用户是否拥有
|
||||
# 注: 这是 endpoint 版本,装饰器版本见 app.dependencies.require_permission
|
||||
@router.get("/permissions/check")
|
||||
async def check_my_permission(
|
||||
resource: str = Query(..., description="资源"),
|
||||
action: str = Query(..., description="操作"),
|
||||
scope: str = Query("own", description="数据范围"),
|
||||
admin: UserInfo = Depends(require_admin),
|
||||
):
|
||||
"""检查当前管理员是否拥有指定权限(给前端按钮级控制用)。
|
||||
|
||||
永远返回 true(因为 require_admin 已确保是 admin)。
|
||||
此端点存在是为了给前端一个统一入口,实际权限由后端强制。
|
||||
未来扩展:可加 current_user 参数(非 admin 角色也能调)。
|
||||
|
||||
Args:
|
||||
resource: 资源
|
||||
action: 操作
|
||||
scope: 数据范围
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 has_permission 字段
|
||||
"""
|
||||
from app.services.rbac_service import check_permission, ROLE_PERMISSIONS, permissions_to_strings
|
||||
|
||||
user_perms = {role: permissions_to_strings(perms) for role, perms in ROLE_PERMISSIONS.items()}
|
||||
|
||||
has_perm = check_permission(
|
||||
user_roles=admin.roles,
|
||||
user_permissions=user_perms,
|
||||
required_resource=resource,
|
||||
required_action=action,
|
||||
required_scope=scope,
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"has_permission": has_perm,
|
||||
"resource": resource,
|
||||
"action": action,
|
||||
"scope": scope,
|
||||
})
|
||||
|
||||
+23
-17
@@ -257,8 +257,9 @@ async def agent_login(
|
||||
await db.flush()
|
||||
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
|
||||
|
||||
# 2. OTP 二次验证(admin 角色且已绑定 OTP)
|
||||
if agent.role == "admin" and agent.otp_enabled == 1:
|
||||
# 2. MFA 二次验证(admin 角色且已绑定 MFA)
|
||||
# v0.7.1: 用 mfa_secret/mfa_enabled 替代旧 otp_secret/otp_enabled
|
||||
if agent.role == "admin" and agent.mfa_enabled:
|
||||
if not body.otp_code:
|
||||
# 需要 OTP 验证,返回 require_otp 标记
|
||||
return success_response(data={
|
||||
@@ -269,7 +270,7 @@ async def agent_login(
|
||||
})
|
||||
else:
|
||||
# 验证 OTP 码
|
||||
totp = pyotp.TOTP(agent.otp_secret)
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
if not totp.verify(body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误,请重新输入")
|
||||
|
||||
@@ -414,15 +415,16 @@ async def bind_agent_otp(
|
||||
Dict: 二维码图片(base64)和密钥
|
||||
"""
|
||||
try:
|
||||
# v0.7.1: 用 mfa_secret 替代 otp_secret
|
||||
# 检查是否已绑定
|
||||
if agent.otp_secret:
|
||||
if agent.mfa_secret:
|
||||
# 已绑定,返回现有密钥的二维码
|
||||
totp = pyotp.TOTP(agent.otp_secret)
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
else:
|
||||
# 生成新密钥
|
||||
secret = pyotp.random_base32()
|
||||
agent.otp_secret = secret
|
||||
# otp_enabled 保持 0,等待首次验证后启用
|
||||
agent.mfa_secret = secret
|
||||
# mfa_enabled 保持 False,等待首次验证后启用
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
totp = pyotp.TOTP(secret)
|
||||
@@ -439,11 +441,11 @@ async def bind_agent_otp(
|
||||
qr.save(buffer, format="PNG")
|
||||
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...")
|
||||
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.mfa_secret[:4]}...")
|
||||
|
||||
return success_response(data={
|
||||
"qr_code": f"data:image/png;base64,{qr_base64}",
|
||||
"secret": agent.otp_secret,
|
||||
"secret": agent.mfa_secret,
|
||||
})
|
||||
|
||||
except AppException:
|
||||
@@ -475,16 +477,18 @@ async def verify_agent_otp(
|
||||
result = await db.execute(stmt)
|
||||
agent = result.scalars().first()
|
||||
|
||||
if not agent or not agent.otp_secret:
|
||||
if not agent or not agent.mfa_secret:
|
||||
raise AppException(1008, "请先绑定OTP")
|
||||
|
||||
# 验证 OTP 码
|
||||
totp = pyotp.TOTP(agent.otp_secret)
|
||||
totp = pyotp.TOTP(agent.mfa_secret)
|
||||
if not totp.verify(body.otp_code, valid_window=1):
|
||||
raise AppException(1006, "OTP验证码错误")
|
||||
|
||||
# 验证成功,启用 OTP
|
||||
agent.otp_enabled = 1
|
||||
# 验证成功,启用 MFA
|
||||
agent.mfa_enabled = True
|
||||
agent.mfa_bound_at = datetime.now()
|
||||
agent.mfa_last_verified_at = datetime.now()
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
@@ -492,7 +496,7 @@ async def verify_agent_otp(
|
||||
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
|
||||
|
||||
return success_response(data={
|
||||
"otp_enabled": True,
|
||||
"mfa_enabled": True,
|
||||
"message": "OTP验证成功,已启用",
|
||||
})
|
||||
|
||||
@@ -510,15 +514,17 @@ async def unbind_agent_otp(
|
||||
):
|
||||
"""解绑 OTP。
|
||||
|
||||
解绑后 otp_secret 和 otp_enabled 都清空。
|
||||
解绑后 mfa_secret 和 mfa_enabled 都清空。
|
||||
需要管理员操作。
|
||||
|
||||
Returns:
|
||||
Dict: 解绑结果
|
||||
"""
|
||||
try:
|
||||
agent.otp_secret = None
|
||||
agent.otp_enabled = 0
|
||||
agent.mfa_secret = None
|
||||
agent.mfa_enabled = False
|
||||
agent.mfa_bound_at = None
|
||||
agent.mfa_last_verified_at = None
|
||||
agent.updated_at = datetime.now()
|
||||
db.add(agent)
|
||||
await db.flush()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 审计日志 API (v0.7.1 task #89)
|
||||
# =============================================================================
|
||||
# 说明: 审计日志只读端点,给 auditor / admin 用
|
||||
# 权限要求: audit_log:read:all (由 RBAC 装饰器校验)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import require_permission, UserInfo
|
||||
from app.database import get_db
|
||||
from app.services.audit_log_service import list_audit_logs
|
||||
from app.utils.response import success_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin/audit-logs", tags=["审计日志"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
@require_permission("audit_log", "read", "all")
|
||||
async def get_audit_logs(
|
||||
employee_id: Optional[str] = Query(None, description="按操作人过滤"),
|
||||
action: Optional[str] = Query(None, description="按操作类型过滤"),
|
||||
resource: Optional[str] = Query(None, description="按资源类型过滤"),
|
||||
from_time: Optional[datetime] = Query(None, alias="from", description="起始时间(ISO8601)"),
|
||||
to_time: Optional[datetime] = Query(None, alias="to", description="结束时间(ISO8601)"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(50, ge=1, le=500, description="每页条数"),
|
||||
admin: UserInfo = None, # 由 require_permission 注入(签名合并)
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询审计日志(分页)。
|
||||
|
||||
权限: 需要 audit_log:read:all (admin / auditor 角色拥有)
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,包含 items/total/page/page_size
|
||||
"""
|
||||
result = await list_audit_logs(
|
||||
db,
|
||||
employee_id=employee_id,
|
||||
action=action,
|
||||
resource=resource,
|
||||
from_time=from_time,
|
||||
to_time=to_time,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
"items": [
|
||||
{
|
||||
"id": log.id,
|
||||
"employee_id": log.employee_id,
|
||||
"action": log.action,
|
||||
"resource": log.resource,
|
||||
"resource_id": log.resource_id,
|
||||
"details": log.details,
|
||||
"result": log.result,
|
||||
"ip_address": log.ip_address,
|
||||
"user_agent": log.user_agent,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log in result["items"]
|
||||
],
|
||||
"total": result["total"],
|
||||
"page": result["page"],
|
||||
"page_size": result["page_size"],
|
||||
})
|
||||
@@ -91,6 +91,7 @@ async def create_qrcode(
|
||||
return success_response(data={
|
||||
"ticket": result["ticket"],
|
||||
"qrcode_url": result["qrcode_url"],
|
||||
"qrcode_png_base64": result["qrcode_png_base64"],
|
||||
"expires_in": result["expires_in"],
|
||||
"expires_at": result["expires_at"].isoformat(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微入口 SSO(v0.7.1 新增)
|
||||
# =============================================================================
|
||||
# 说明: 解决 v0.7.0 hotfix1 用户报告的"企微工作台进入应用也要扫码"问题。
|
||||
#
|
||||
# 流程:
|
||||
# 1. 前端 PortalSelect.vue 加载时检测 navigator.userAgent
|
||||
# 2. 如果是 MicroMessenger / wxwork / DingTalk 等企微内置浏览器
|
||||
# → 调 /api/auth_wecom/sso/init?next=/itdesk/
|
||||
# 3. 后端生成企微 OAuth2 授权 URL,302 跳转用户去企微授权
|
||||
# 4. 企微回调 /api/auth_wecom/sso/callback?code=...&state=...
|
||||
# 5. 用 code 换 userid,查 role (user/agent/admin),生成 token
|
||||
# 6. 302 跳转到 next 路径 + token query param
|
||||
# 7. 前端用 token 调 get_current_user 拉身份信息
|
||||
#
|
||||
# 配置要求:
|
||||
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 可信域名: itsupport.servyou.com.cn
|
||||
# - 企微管理后台 → 应用 → 网页授权及 JS-SDK → 回调域: itsupport.servyou.com.cn
|
||||
# - 环境变量 WECOM_SSO_ENABLED=true 启用(默认 false,避免老用户被打扰)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
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.utils.response import AppException
|
||||
from app.dependencies import get_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth_wecom", tags=["企微 SSO"])
|
||||
|
||||
# OAuth state 在 Redis 的 TTL (5 分钟,够用户授权 + 回调)
|
||||
OAUTH_STATE_TTL = 300
|
||||
# SSO token 长度
|
||||
SSO_TOKEN_BYTES = 32
|
||||
|
||||
|
||||
def _sso_enabled() -> bool:
|
||||
"""检查是否启用企微 SSO。"""
|
||||
import os
|
||||
if os.getenv("WECOM_SSO_ENABLED", "false").lower() == "true":
|
||||
return True
|
||||
if getattr(settings, "wecom_sso_enabled", False):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _get_oauth_callback_url(request: Request) -> str:
|
||||
"""拼接 OAuth 回调 URL (绝对地址)。
|
||||
|
||||
企微要求 redirect_uri 必须用可信域名(itsupport.servyou.com.cn)。
|
||||
不读 request.base_url 因为它可能是 127.0.0.1:8000(开发环境)。
|
||||
"""
|
||||
# 优先用 settings 里的配置
|
||||
base = getattr(settings, "wecom_sso_callback_base", None)
|
||||
if not base:
|
||||
# 兜底: 读环境变量,默认生产域名
|
||||
import os
|
||||
base = os.getenv("WECOM_SSO_CALLBACK_BASE", "https://itsupport.servyou.com.cn")
|
||||
return f"{base.rstrip('/')}/api/auth_wecom/sso/callback"
|
||||
|
||||
|
||||
def _build_oauth_url(state: str, callback_url: str) -> str:
|
||||
"""拼企微 OAuth2 授权 URL。
|
||||
|
||||
文档: https://developer.work.weixin.qq.com/document/path/91022
|
||||
"""
|
||||
params = {
|
||||
"appid": settings.wecom_corp_id,
|
||||
"redirect_uri": callback_url,
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base", # 静默授权
|
||||
"state": state,
|
||||
"agentid": settings.wecom_agent_id,
|
||||
}
|
||||
query = urllib.parse.urlencode(params)
|
||||
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
|
||||
|
||||
|
||||
@router.get("/sso/init")
|
||||
async def sso_init(
|
||||
request: Request,
|
||||
next: str = Query("/itdesk/", description="登录后跳转路径"),
|
||||
redis_client = Depends(get_redis),
|
||||
):
|
||||
"""初始化 SSO: 生成 state,302 跳转到企微 OAuth2 授权页。
|
||||
|
||||
Args:
|
||||
next: 登录成功后跳转路径,如 /itdesk/ /itagent/ /itadmin/
|
||||
"""
|
||||
if not _sso_enabled():
|
||||
raise AppException(1001, "企微 SSO 未启用, 请用扫码登录")
|
||||
|
||||
# 1. 生成 state(防 CSRF + 携带 next 路径)
|
||||
state = secrets.token_urlsafe(24)
|
||||
state_payload = {
|
||||
"next": next,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
}
|
||||
await redis_client.setex(
|
||||
f"wecom_sso:state:{state}",
|
||||
OAUTH_STATE_TTL,
|
||||
str(state_payload).encode("utf-8"),
|
||||
)
|
||||
|
||||
# 2. 拼企微 OAuth URL
|
||||
callback_url = _get_oauth_callback_url(request)
|
||||
oauth_url = _build_oauth_url(state, callback_url)
|
||||
|
||||
logger.info(f"SSO init: state={state[:8]}..., next={next}")
|
||||
return RedirectResponse(url=oauth_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/sso/callback")
|
||||
async def sso_callback(
|
||||
code: str = Query(..., description="企微 OAuth2 授权 code"),
|
||||
state: str = Query(..., description="防 CSRF state"),
|
||||
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 已过期或无效, 请重新进入")
|
||||
|
||||
# 删除 state(一次性)
|
||||
await redis_client.delete(state_key)
|
||||
|
||||
import ast
|
||||
state_data = ast.literal_eval(state_raw.decode("utf-8"))
|
||||
next_path = state_data.get("next", "/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 为空")
|
||||
|
||||
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:
|
||||
try:
|
||||
await wecom.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 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 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
|
||||
await redis_client.setex(
|
||||
f"wecom_sso:token:{sso_token}",
|
||||
8 * 3600, # 8 小时
|
||||
json.dumps(sso_payload, ensure_ascii=False).encode("utf-8"),
|
||||
)
|
||||
|
||||
# 6. 跳转到 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)
|
||||
|
||||
|
||||
@router.get("/sso/verify")
|
||||
async def sso_verify(
|
||||
sso_token: str = Query(..., description="SSO token"),
|
||||
redis_client = Depends(get_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""前端用 SSO token 换用户身份(token 一次性使用,用完删除)。"""
|
||||
import json
|
||||
token_raw = await redis_client.get(f"wecom_sso:token:{sso_token}")
|
||||
if not token_raw:
|
||||
raise AppException(1005, "SSO token 已过期或无效")
|
||||
|
||||
# 一次性 token(防止泄漏后被滥用)
|
||||
await redis_client.delete(f"wecom_sso:token:{sso_token}")
|
||||
|
||||
payload = json.loads(token_raw.decode("utf-8"))
|
||||
return {
|
||||
"code": 0,
|
||||
"data": payload,
|
||||
}
|
||||
@@ -207,3 +207,16 @@ api_router.include_router(mfa_router, tags=["MFA二次认证"])
|
||||
# MFA 管理员重置 API (Phase 2.1 task #17,丢手机兜底)
|
||||
# POST /api/admin/mfa/reset/{employee_id} — 管理员重置指定员工 MFA
|
||||
api_router.include_router(mfa_admin_router, tags=["MFA管理(管理员)"])
|
||||
|
||||
# 企微 SSO (v0.7.1 task #85)
|
||||
# GET /api/auth_wecom/sso/init — 企微浏览器 UA 检测后初始化 SSO
|
||||
# GET /api/auth_wecom/sso/callback — 企微 OAuth2 回调,用 code 换 userid → 跳端点
|
||||
# GET /api/auth_wecom/sso/verify — 前端用 SSO token 换用户身份(一次性)
|
||||
from app.api.auth_wecom_sso import router as auth_wecom_sso_router
|
||||
api_router.include_router(auth_wecom_sso_router, tags=["企微SSO"])
|
||||
|
||||
# 审计日志 API (v0.7.1 task #89)
|
||||
# GET /api/admin/audit-logs — 分页 + 多维过滤(给 auditor / admin 角色用)
|
||||
# 权限要求: audit_log:read:all (RBAC 装饰器强制)
|
||||
from app.api.audit_logs import router as audit_logs_router
|
||||
api_router.include_router(audit_logs_router, tags=["审计日志"])
|
||||
|
||||
Reference in New Issue
Block a user