提交 07d17632 authored 作者: 陈泽健's avatar 陈泽健

fix(monitor): 修复报告通知链接免登录访问

后端报告详情接口校验访问 token,允许访客只读查看;前端路由、详情 API 与导出链接透传 token,并避免无效 token 误跳登录;新增 token 有效、过期及越权回归测试。
Co-Authored-By: 's avatarClaude Fable 5 <noreply@anthropic.com>
上级 b05775a4
...@@ -24,9 +24,13 @@ export async function getReports( ...@@ -24,9 +24,13 @@ export async function getReports(
} }
/** 获取报告详情 */ /** 获取报告详情 */
export async function getReport(reportId: string): Promise<{ success: boolean; report: ReportDetail }> { export async function getReport(
reportId: string,
token?: string
): Promise<{ success: boolean; report: ReportDetail }> {
const res = await http.get<{ success: boolean; report: ReportDetail }>( const res = await http.get<{ success: boolean; report: ReportDetail }>(
`/api/service-monitor/reports/${reportId}` `/api/service-monitor/reports/${reportId}`,
{ params: token ? { token } : undefined }
) )
return res.data return res.data
} }
......
...@@ -161,6 +161,13 @@ router.beforeEach(async (to, _from, next) => { ...@@ -161,6 +161,13 @@ router.beforeEach(async (to, _from, next) => {
// 设置页面标题 // 设置页面标题
document.title = to.meta.title ? `${to.meta.title} - 运行维护平台` : '运行维护平台' document.title = to.meta.title ? `${to.meta.title} - 运行维护平台` : '运行维护平台'
// 报告通知链接携带访问 token 时允许访客查看单份报告。
// 仅放行详情路由,避免 token 参数绕过其他页面的认证。
if (to.name === 'MonitorReportDetail' && typeof to.query.token === 'string' && to.query.token) {
next()
return
}
// 不需要认证的页面直接放行 // 不需要认证的页面直接放行
if (to.meta.requiresAuth === false) { if (to.meta.requiresAuth === false) {
// 已登录用户访问登录页,跳转首页 // 已登录用户访问登录页,跳转首页
......
...@@ -35,6 +35,12 @@ http.interceptors.response.use( ...@@ -35,6 +35,12 @@ http.interceptors.response.use(
switch (status) { switch (status) {
case 401: case 401:
// 报告 token 访问失败时,不应清除用户会话或跳转登录。
// 让报告详情页显示链接失效提示;普通 API 401 仍按会话失效处理。
if (error.config?.params?.token || error.config?.url?.includes('token=')) {
ElMessage.error('报告链接无效或已过期')
break
}
// 未登录 / Session 过期 // 未登录 / Session 过期
const userStore = useUserStore() const userStore = useUserStore()
userStore.clearUser() userStore.clearUser()
......
...@@ -29,8 +29,8 @@ function toggleExport() { ...@@ -29,8 +29,8 @@ function toggleExport() {
} }
function getExportUrl(format: 'md' | 'json' | 'excel' | 'pdf') { function getExportUrl(format: 'md' | 'json' | 'excel' | 'pdf') {
let url = `/api/service-monitor/reports/${reportId}/export?format=${format}` let url = `/api/service-monitor/reports/${encodeURIComponent(reportId)}/export?format=${format}`
if (token) url += `&token=${token}` if (token) url += `&token=${encodeURIComponent(token)}`
return url return url
} }
...@@ -182,7 +182,7 @@ async function loadReport() { ...@@ -182,7 +182,7 @@ async function loadReport() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const res = await reportApi.getReport(reportId) const res = await reportApi.getReport(reportId, token)
if (res.success && res.report) { if (res.success && res.report) {
report.value = res.report report.value = res.report
// Auto expand modules with abnormal items // Auto expand modules with abnormal items
......
...@@ -16,7 +16,6 @@ SSE:/api/service-monitor/run/stream 流式推送巡检进度 ...@@ -16,7 +16,6 @@ SSE:/api/service-monitor/run/stream 流式推送巡检进度
from __future__ import annotations from __future__ import annotations
import json import json
import logging
from datetime import datetime from datetime import datetime
from flask import ( from flask import (
...@@ -26,8 +25,9 @@ from flask import ( ...@@ -26,8 +25,9 @@ from flask import (
from .services import target_service, report_service, runner_service, schedule_service, notification_service, statistics_service, compare_service from .services import target_service, report_service, runner_service, schedule_service, notification_service, statistics_service, compare_service
from utils.audit import log_audit from utils.audit import log_audit
from utils.logger import get_logger
logger = logging.getLogger("service_monitor.routes") logger = get_logger("service_monitor.routes")
bp = Blueprint('service-monitor', __name__) bp = Blueprint('service-monitor', __name__)
...@@ -270,6 +270,9 @@ def api_list_reports(): ...@@ -270,6 +270,9 @@ def api_list_reports():
@bp.route('/api/service-monitor/reports/<report_id>', methods=['GET']) @bp.route('/api/service-monitor/reports/<report_id>', methods=['GET'])
def api_get_report(report_id): def api_get_report(report_id):
# token 免登查看详情(仅允许只读访问)
token = request.args.get('token', '').strip()
if not (token and report_service.validate_access_token(report_id, token)):
guard = _require_login_json() guard = _require_login_json()
if guard: if guard:
return guard return guard
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""test_report_service.py — 报告管理测试""" """test_report_service.py — 报告管理测试"""
import json
from datetime import datetime, timedelta
from service_monitor.services import report_service as rs from service_monitor.services import report_service as rs
...@@ -57,6 +60,29 @@ def test_export_json(tmp_data): ...@@ -57,6 +60,29 @@ def test_export_json(tmp_data):
assert data["id"] == rid assert data["id"] == rid
def test_validate_access_token(tmp_data):
rid = rs.save(_sample_target(), "quick", _sample_modules(), "2026-07-16T10:00:00")
token = rs.get_report(rid)["access_token"]
assert rs.validate_access_token(rid, token) is True
assert rs.validate_access_token(rid, "bad-token") is False
assert rs.validate_access_token(rid, token + "x") is False
assert rs.validate_access_token("missing", token) is False
def test_validate_access_token_expired_or_missing_metadata(tmp_data):
rid = rs.save(_sample_target(), "quick", _sample_modules(), "2026-07-16T10:00:00")
report_path = tmp_data["reports"] / f"{rid}.json"
report = rs.get_report(rid)
token = report["access_token"]
report["access_token_expires_at"] = "2020-01-01T00:00:00"
report_path.write_text(json.dumps(report), encoding="utf-8")
assert rs.validate_access_token(rid, token) is False
report.pop("access_token_expires_at")
report_path.write_text(json.dumps(report), encoding="utf-8")
assert rs.validate_access_token(rid, token) is False
def test_cleanup_expired(tmp_data): def test_cleanup_expired(tmp_data):
# 保存一个旧报告(finished_at 很久以前) # 保存一个旧报告(finished_at 很久以前)
rid = rs.save(_sample_target(), "quick", _sample_modules(), rid = rs.save(_sample_target(), "quick", _sample_modules(),
...@@ -64,3 +90,53 @@ def test_cleanup_expired(tmp_data): ...@@ -64,3 +90,53 @@ def test_cleanup_expired(tmp_data):
removed = rs.cleanup_expired(retention_days=14) removed = rs.cleanup_expired(retention_days=14)
assert removed == 1 assert removed == 1
assert rs.get_report(rid) is None assert rs.get_report(rid) is None
def _make_scheduled(tmp_data, target_id):
"""向 schedules.json 写入一条启用状态定时任务。"""
from service_monitor.utils import paths as sm_paths
schedules = [{
"id": "sched_test", "name": "测试任务", "target_id": target_id,
"suite": "full", "cron": "30 8 * * 1-5", "enabled": True,
}]
sm_paths.SCHEDULES_FILE.write_text(
json.dumps(schedules, ensure_ascii=False), encoding="utf-8"
)
def test_check_missing_only_scheduled_targets(tmp_data):
"""无定时任务的目标(内置 local)不参与报告缺失判定。"""
from service_monitor.services import target_service as ts
# 仅给 local 配置定时任务
_make_scheduled(tmp_data, "local")
missing = rs.check_missing_reports(2)
# local 从未生成报告 → 仍应标记缺失
assert any(m["target_id"] == "local" for m in missing)
def test_check_missing_skips_unscheduled_targets(tmp_data):
"""没有任何定时任务时,内置目标不参与判定,返回空。"""
missing = rs.check_missing_reports(2)
assert missing == []
def test_check_missing_recent_report_not_missing(tmp_data):
"""定时目标最近有报告 → 不缺失。"""
_make_scheduled(tmp_data, "local")
now_str = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
rs.save(_sample_target(), "quick", _sample_modules(), now_str, now_str)
missing = rs.check_missing_reports(2)
assert missing == []
def test_check_missing_stale_report(tmp_data):
"""定时目标报告过期 → 缺失,且 missing_days 为真实间隔天数。"""
_make_scheduled(tmp_data, "local")
old = (datetime.now() - timedelta(days=5)).strftime("%Y-%m-%dT%H:%M:%S")
rs.save(_sample_target(), "quick", _sample_modules(), old, old)
missing = rs.check_missing_reports(2)
assert len(missing) == 1
assert missing[0]["target_id"] == "local"
assert missing[0]["missing_days"] == 5
...@@ -169,6 +169,39 @@ class TestReportAPI: ...@@ -169,6 +169,39 @@ class TestReportAPI:
assert user.get(f"/service-monitor/report/{rid}").status_code == 200 assert user.get(f"/service-monitor/report/{rid}").status_code == 200
class TestReportTokenAccess(TestReportAPI):
"""报告通知链接可免登录查看,但 token 不获得写权限。"""
def test_valid_token_can_get_report_without_session(self, client):
rid = self._make_report()
token = report_service.get_report(rid)["access_token"]
response = client.get(f"/api/service-monitor/reports/{rid}?token={token}")
assert response.status_code == 200
assert response.get_json()["report"]["id"] == rid
def test_invalid_or_missing_token_requires_login(self, client):
rid = self._make_report()
assert client.get(f"/api/service-monitor/reports/{rid}").status_code == 401
assert client.get(f"/api/service-monitor/reports/{rid}?token=bad").status_code == 401
def test_expired_token_requires_login(self, client, tmp_data):
rid = self._make_report()
token = report_service.get_report(rid)["access_token"]
report = report_service.get_report(rid)
report["access_token_expires_at"] = "2020-01-01T00:00:00"
(tmp_data["reports"] / f"{rid}.json").write_text(
json.dumps(report), encoding="utf-8"
)
assert client.get(f"/api/service-monitor/reports/{rid}?token={token}").status_code == 401
def test_token_cannot_delete_report(self, client, tmp_data):
rid = self._make_report()
token = report_service.get_report(rid)["access_token"]
response = client.delete(f"/api/service-monitor/reports/{rid}?token={token}")
assert response.status_code == 401
assert report_service.get_report(rid) is not None
# ============================================================ # ============================================================
# 修复预留 # 修复预留
# ============================================================ # ============================================================
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论