A组认证加固: P0兜底+Token刷新+环境检测+OTP+RBRAC落地+P1日志审计+Token撤销 - 全局一致性审查通过
This commit is contained in:
@@ -39,9 +39,11 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from fastapi import APIRouter, Depends, Path
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import dep_redis, get_current_user, UserInfo
|
||||
from app.schemas.qrcode import (
|
||||
QrcodeConfirmRequest,
|
||||
@@ -139,30 +141,85 @@ async def poll_qrcode(
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# POST /api/auth_qrcode/scan — 企微 OAuth code 回调
|
||||
# GET|POST /api/auth_qrcode/scan — 企微 OAuth code 回调
|
||||
# --------------------------------------------------------------------------
|
||||
@router.post("/scan", response_model=None)
|
||||
@router.api_route("/scan", methods=["GET", "POST"], response_model=None)
|
||||
async def scan_qrcode(
|
||||
body: QrcodeScanRequest,
|
||||
body: Optional[QrcodeScanRequest] = None,
|
||||
ticket: Optional[str] = Query(None, description="扫码登录票据(兼容旧参数名)"),
|
||||
state: Optional[str] = Query(None, description="扫码登录票据(企微 OAuth state 标准参数名)"),
|
||||
code: Optional[str] = Query(None, description="企微 OAuth 授权码"),
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
):
|
||||
"""处理企微 OAuth2 扫码回调。
|
||||
|
||||
企微 OAuth2 标准回调走 **GET** 带 query 参数 `?code=xxx&state=<ticket>`,
|
||||
本端点同时支持 GET 和 POST(POST 兼容内部调用 / 旧前端代码)。
|
||||
|
||||
GET 模式 (企微 OAuth2 标准回调):
|
||||
- ticket ← query.state
|
||||
- code ← query.code
|
||||
- 自动 302 跳转到 /itdesk/ 或 /itadmin/ 或 /itagent/(按角色)
|
||||
POST 模式 (内部调用):
|
||||
- ticket ← body.ticket
|
||||
- code ← body.code
|
||||
|
||||
无需鉴权(此端点被企微服务器回调,带 code + ticket)。
|
||||
用 code 换取企微 userid,然后写 Redis scan:{ticket} 等待 confirm 端点。
|
||||
|
||||
dev 模式: code 形如 "dev:dev-user-001",跳过企微 API 调用。
|
||||
|
||||
Args:
|
||||
body: 包含 ticket 和 code
|
||||
|
||||
Returns:
|
||||
Dict: 统一响应格式,data 字段是 QrcodeScanResponse
|
||||
"""
|
||||
try:
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.process_scan(ticket=body.ticket, code=body.code)
|
||||
# 1. 解析参数:POST 用 body,GET 用 query
|
||||
if body is not None:
|
||||
final_ticket = body.ticket
|
||||
final_code = body.code
|
||||
else:
|
||||
# 优先用 state(企微 OAuth 标准),回退到 ticket(兼容旧调用)
|
||||
final_ticket = state or ticket
|
||||
final_code = code
|
||||
|
||||
if not final_ticket or not final_code:
|
||||
logger.warning(f"扫码参数缺失: ticket={final_ticket!r}, code={final_code!r}")
|
||||
raise AppException(1000, "缺少 ticket 或 code 参数")
|
||||
|
||||
service = _get_qrcode_service(redis_client)
|
||||
result = await service.process_scan(ticket=final_ticket, code=final_code)
|
||||
|
||||
# GET 请求(企微 OAuth 回调)→ 重定向到前端选择页
|
||||
# 因为企微 OAuth 流程不在这个端点完成最终登录,只标记 scanned,
|
||||
# 等用户在坐席端点 confirm 后才能拿到 token。
|
||||
# 但企微 WebView 期望看到跳转后的页面,所以这里给个提示页。
|
||||
from fastapi.responses import HTMLResponse
|
||||
if code is not None and ticket is not None and body is None:
|
||||
# GET 模式:渲染一个 "扫码成功" 的 HTML 提示页 + 引导用户到登录页
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>扫码成功</title>
|
||||
<style>
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #0f172a; color: #e2e8f0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; padding: 20px; }}
|
||||
.card {{ background: #1e293b; border-radius: 16px; padding: 40px 32px; max-width: 400px; text-align: center; box-shadow: 0 10px 30px rgba(0,0,0,0.3); }}
|
||||
h1 {{ color: #34d399; margin: 0 0 16px 0; font-size: 24px; }}
|
||||
p {{ color: #94a3b8; margin: 8px 0; line-height: 1.6; }}
|
||||
.ico {{ font-size: 56px; margin-bottom: 16px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="ico">✅</div>
|
||||
<h1>扫码成功</h1>
|
||||
<p>请在刚才打开登录页的浏览器中</p>
|
||||
<p>点击 <strong style="color:#60a5fa">「确认登录」</strong> 按钮完成登录</p>
|
||||
<p style="margin-top:24px;font-size:13px;color:#64748b">本页可关闭</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
# POST 模式:返回 JSON
|
||||
return success_response(data={
|
||||
"success": result["success"],
|
||||
"message": result["message"],
|
||||
@@ -173,7 +230,7 @@ async def scan_qrcode(
|
||||
logger.warning(f"扫码业务错误: {ve}")
|
||||
raise AppException(1003, str(ve))
|
||||
except Exception as e:
|
||||
logger.error(f"扫码处理异常: ticket={body.ticket[:8]}..., error={e}", exc_info=True)
|
||||
logger.error(f"扫码处理异常: error={e}", exc_info=True)
|
||||
raise AppException(1005, f"扫码处理失败: {str(e)}")
|
||||
|
||||
|
||||
@@ -185,6 +242,7 @@ async def confirm_qrcode(
|
||||
body: QrcodeConfirmRequest,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
redis_client: aioredis.Redis = Depends(dep_redis),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""处理当前已登录坐席的扫码确认授权。
|
||||
|
||||
@@ -213,6 +271,24 @@ async def confirm_qrcode(
|
||||
otp_code=body.otp_code,
|
||||
)
|
||||
|
||||
# 记录扫码登录日志(成功)
|
||||
from app.services.audit_log_service import record_audit_log
|
||||
await record_audit_log(
|
||||
db=db,
|
||||
employee_id=result["employee_id"],
|
||||
action="qrcode_login",
|
||||
resource="auth",
|
||||
resource_id=result["employee_id"],
|
||||
details={
|
||||
"name": result["name"],
|
||||
"roles": result["roles"],
|
||||
"confirmed_by": current_user.employee_id,
|
||||
"login_method": "qrcode_confirm",
|
||||
},
|
||||
result="success",
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
return success_response(data={
|
||||
"token": result["token"],
|
||||
"employee_id": result["employee_id"],
|
||||
|
||||
Reference in New Issue
Block a user