签入版本
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# 5.7 演示模式
|
||||
|
||||
:::tip 概述
|
||||
演示模式是系统内置的安全机制,用于公共演示环境的数据保护。开启后,DemoMiddleware 自动拦截所有写操作(POST/PUT/DELETE/PATCH),仅允许读操作(GET)通过,防止演示环境被随意修改。通过 `#[DemoAllow]` 注解可豁免特定接口。
|
||||
演示模式是系统内置的安全机制,用于公共演示环境的数据保护。开启后,`DemoMiddleware` 自动拦截所有写操作(POST/PUT/DELETE/PATCH),仅允许读操作(GET)通过,防止演示环境被随意修改。通过 `#[DemoAllow]` 注解可豁免特定接口。
|
||||
:::
|
||||
|
||||
## 应用场景
|
||||
@@ -24,17 +24,60 @@
|
||||
|
||||
```ini
|
||||
# .env
|
||||
[APP]
|
||||
# 开启演示模式(true=开启,false=关闭)
|
||||
DEMO = true
|
||||
DEMO_MODE=true
|
||||
```
|
||||
|
||||
| 变量 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `DEMO` | `false` | 演示模式开关,`true` 开启拦截,`false` 正常运行 |
|
||||
| 变量 | 默认值 | 读取位置 | 说明 |
|
||||
|------|--------|---------|------|
|
||||
| `DEMO_MODE` | `false` | `config('app.demo_mode')` → DemoMiddleware | 演示模式开关,`true` 开启拦截,`false` 正常运行 |
|
||||
|
||||
:::warning 生产环境务必关闭
|
||||
演示模式会拦截所有写操作,生产环境必须设为 `false`,否则用户无法执行任何增删改操作。
|
||||
演示模式会拦截所有写操作,生产环境必须保持 `DEMO_MODE=false`,否则用户无法执行任何增删改操作。
|
||||
:::
|
||||
|
||||
## 启用与验证
|
||||
|
||||
演示模式生效需**同时满足两个条件**,缺一不可:
|
||||
|
||||
1. **配置接入**:`config/app.php` 定义 `demo_mode` 键(读取 `DEMO_MODE` 环境变量);
|
||||
2. **中间件挂载**:`demo` 中间件已挂载到目标路由组(见[中间件挂载与执行顺序](#中间件挂载与执行顺序))。
|
||||
|
||||
启用步骤:
|
||||
|
||||
```bash
|
||||
# ① .env 开启
|
||||
DEMO_MODE=true
|
||||
|
||||
# ② 清除配置缓存(如执行过 config:cache,改完需重新缓存)
|
||||
php artisan config:clear
|
||||
```
|
||||
|
||||
逐项验证:
|
||||
|
||||
```bash
|
||||
# ① 配置键已接入(期望输出 true)
|
||||
php artisan tinker --execute="dump(config('app.demo_mode'))"
|
||||
|
||||
# ② 中间件已挂载到认证路由组(demo 位于 tenant 与 log.operation 之间)
|
||||
php artisan route:list --path=user/add --json
|
||||
# → "middleware": ["api", "auth.jwt", "tenant", "demo", "log.operation"]
|
||||
|
||||
# ③ 拦截生效(携带有效 token 发起写操作,期望 HTTP 403)
|
||||
curl -X POST http://localhost:8000/api/user/add \
|
||||
-H "Authorization: Bearer {access_token}" \
|
||||
-H "Accept: application/json" \
|
||||
-d '{"username":"demo"}'
|
||||
# → {"code":403,"ok":false,"msg":"演示模式下禁止操作","data":null}
|
||||
```
|
||||
|
||||
:::warning 两层缺一不可
|
||||
仅设置 `DEMO_MODE=true` 不代表生效:
|
||||
|
||||
- 若 `config/app.php` 缺少 `demo_mode` 键,`config('app.demo_mode')` 永远返回默认值 `false`,中间件直接放行;
|
||||
- 若 `demo` 中间件未挂载到路由组,`DemoMiddleware::handle()` 根本不会执行。
|
||||
|
||||
启用后请按上述清单逐项验证,不要只改 `.env`。
|
||||
:::
|
||||
|
||||
## 拦截规则
|
||||
@@ -43,25 +86,28 @@ DEMO = true
|
||||
请求进入
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ ① DEMO = false ? │──── 是 ──→ 直接放行(非演示环境)
|
||||
└──────────┬───────────────┘
|
||||
┌──────────────────────────────┐
|
||||
│ ① demo_mode = false ? │──── 是 ──→ 直接放行(非演示环境)
|
||||
└──────────┬───────────────────┘
|
||||
│ 否
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ ② 请求方法 = GET ? │──── 是 ──→ 直接放行(读操作)
|
||||
└──────────┬───────────────┘
|
||||
┌──────────────────────────────┐
|
||||
│ ② 请求方法 = GET ? │──── 是 ──→ 直接放行(读操作)
|
||||
└──────────┬───────────────────┘
|
||||
│ 否(POST/PUT/DELETE/PATCH)
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ ③ 标注 #[DemoAllow] ? │──── 是 ──→ 直接放行(白名单豁免)
|
||||
└──────────┬───────────────┘
|
||||
┌──────────────────────────────┐
|
||||
│ ③ 标注 #[DemoAllow] ? │──── 是 ──→ 直接放行(白名单豁免)
|
||||
│ (ReflectionMethod 反射读取, │
|
||||
│ 反射异常静默视为未标注) │
|
||||
└──────────┬───────────────────┘
|
||||
│ 否
|
||||
▼
|
||||
┌──────────────────────────┐
|
||||
│ 返回 403 │
|
||||
│ "演示环境,禁止操作" │
|
||||
└──────────────────────────┘
|
||||
┌──────────────────────────────┐
|
||||
│ HTTP 403 │
|
||||
│ {"code":403, │
|
||||
│ "msg":"演示模式下禁止操作"} │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
| 请求方法 | 行为 | 说明 |
|
||||
@@ -73,24 +119,45 @@ DEMO = true
|
||||
| PATCH | ❌ 拦截 | 部分修改操作 |
|
||||
| 任意方法 + `#[DemoAllow]` | ✅ 放行 | 白名单豁免 |
|
||||
|
||||
## 中间件执行位置
|
||||
## 中间件挂载与执行顺序
|
||||
|
||||
DemoMiddleware 位于需认证路由组的中间件链中,执行顺序:
|
||||
`DemoMiddleware` 以别名 `demo` 注册于 `bootstrap/app.php`,并挂载在认证路由组上(`routes/api.php`):
|
||||
|
||||
```php
|
||||
// bootstrap/app.php —— 别名注册
|
||||
$middleware->alias([
|
||||
// ...
|
||||
'demo' => DemoMiddleware::class,
|
||||
]);
|
||||
|
||||
// routes/api.php —— 认证路由组
|
||||
// demo 位于 tenant 之后、log.operation 之前
|
||||
Route::middleware(['auth.jwt', 'tenant', 'demo', 'log.operation'])
|
||||
->group(function () { /* ... */ });
|
||||
```
|
||||
|
||||
```text
|
||||
CorsMiddleware(全局)
|
||||
→ AuthMiddleware(认证 + 权限)
|
||||
→ TenantMiddleware(租户上下文)
|
||||
→ DemoMiddleware(演示模式拦截) ← 本中间件
|
||||
→ LogMiddleware(操作日志)
|
||||
→ 控制器
|
||||
挂载后的执行顺序(认证路由组内):
|
||||
|
||||
ProxyMiddleware(全局)
|
||||
→ Cors(全局)
|
||||
→ ForceJsonResponse(全局)
|
||||
→ Authenticate(认证 + 权限)
|
||||
→ ResolveTenant(租户上下文)
|
||||
→ DemoMiddleware(演示模式拦截) ← 本中间件
|
||||
→ LogOperation(操作日志)
|
||||
→ 控制器
|
||||
```
|
||||
|
||||
:::tip 执行位置说明
|
||||
DemoMiddleware 在 AuthMiddleware 和 TenantMiddleware 之后执行,意味着:
|
||||
- 未登录用户已被 AuthMiddleware 拦截(401),不会到达演示模式判断
|
||||
- 租户状态已由 TenantMiddleware 校验,确保用户合法
|
||||
- 演示模式仅拦截"合法用户的写操作",不影响认证流程
|
||||
DemoMiddleware 设计在 `Authenticate` 和 `ResolveTenant` 之后执行,意味着:
|
||||
|
||||
- 未登录用户已被 `Authenticate` 拦截(401),不会到达演示模式判断;
|
||||
- 租户状态已由 `ResolveTenant` 校验,确保用户合法;
|
||||
- **位于 `LogOperation` 之前**:被演示模式拦截(403)的请求不再写入操作日志;
|
||||
- 演示模式仅拦截"合法用户的写操作",不影响认证流程。
|
||||
|
||||
登录、验证码、令牌刷新等公开接口定义在路由组外,不经过 `demo` 中间件。
|
||||
:::
|
||||
|
||||
## 核心代码
|
||||
@@ -98,42 +165,20 @@ DemoMiddleware 在 AuthMiddleware 和 TenantMiddleware 之后执行,意味着
|
||||
### DemoMiddleware
|
||||
|
||||
```php
|
||||
// app/middleware/DemoMiddleware.php
|
||||
// app/Http/Middleware/DemoMiddleware.php(节选)
|
||||
|
||||
class DemoMiddleware
|
||||
{
|
||||
public function handle(Request $request, \Closure $next): Response
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
// ① 非演示环境,直接放行
|
||||
if (!env('app_demo', false)) {
|
||||
// ① 非演示模式直接放行
|
||||
if (! config('app.demo_mode', false)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// ② GET 请求放行(读操作)
|
||||
$method = strtoupper($request->method());
|
||||
if ($method === 'GET') {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// ③ 检查是否有 #[DemoAllow] 注解
|
||||
$controllerClass = $request->controller();
|
||||
$action = $request->action();
|
||||
|
||||
// 补全命名空间(ThinkPHP 控制器解析不带命名空间前缀)
|
||||
if (strpos($controllerClass, '\\') === false) {
|
||||
$controllerClass = 'app\\controller\\' . $controllerClass;
|
||||
}
|
||||
|
||||
if (AttributeService::hasDemoAllow($controllerClass, $action)) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// ④ 演示环境,拦截写操作
|
||||
return json([
|
||||
'code' => 403,
|
||||
'msg' => '演示环境,禁止操作',
|
||||
'data' => null,
|
||||
], 403);
|
||||
// (后续:GET 放行 → 反射 #[DemoAllow] 放行 → 否则 403)
|
||||
// 注解读取通过反射完成,反射异常时静默处理,视为未标注;
|
||||
// 未放行的写操作统一返回 403,响应结构与 Result::fail() 一致。
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -141,7 +186,7 @@ class DemoMiddleware
|
||||
### DemoAllow 注解
|
||||
|
||||
```php
|
||||
// app/attribute/DemoAllow.php
|
||||
// app/Attributes/DemoAllow.php
|
||||
|
||||
#[Attribute(Attribute::TARGET_METHOD)] // 仅支持方法级,不支持类级
|
||||
class DemoAllow
|
||||
@@ -150,63 +195,66 @@ class DemoAllow
|
||||
}
|
||||
```
|
||||
|
||||
### 注解读取(AttributeService)
|
||||
### 注解读取(中间件内反射)
|
||||
|
||||
```php
|
||||
// app/service/AttributeService.php
|
||||
// DemoMiddleware 通过 ReflectionMethod 读取方法上的 #[DemoAllow]
|
||||
$controllerClass = $route->getControllerClass();
|
||||
$method = $route->getActionMethod();
|
||||
|
||||
// 通过反射判断方法是否标注了 #[DemoAllow]
|
||||
public static function hasDemoAllow(string $controllerClass, string $method): bool
|
||||
{
|
||||
$attributes = self::getMethodAttributes($controllerClass, $method);
|
||||
return $attributes['demoAllow'] !== null;
|
||||
$reflection = new \ReflectionMethod($controllerClass, $method);
|
||||
$attrs = $reflection->getAttributes(DemoAllow::class);
|
||||
|
||||
if (! empty($attrs)) {
|
||||
return $next($request); // 白名单放行
|
||||
}
|
||||
```
|
||||
|
||||
:::details 注解解析流程
|
||||
1. 以 `类名@方法名` 为键查缓存,命中直接返回
|
||||
2. 通过 `ReflectionMethod` 获取方法上的所有 PHP 8 Attribute
|
||||
3. 遍历属性实例,匹配 `DemoAllow` 类型并存入结果
|
||||
4. 写入缓存,同一请求内不重复反射
|
||||
:::tip 反射性能
|
||||
注解读取开销为微秒级(与 `Authenticate`、`LogOperation` 一致),不做缓存——避免开发阶段改注解后需等缓存过期。
|
||||
:::
|
||||
|
||||
## 白名单示例
|
||||
|
||||
系统中已标注 `#[DemoAllow]` 的方法:
|
||||
当前代码中唯一标注 `#[DemoAllow]` 的方法:
|
||||
|
||||
| 控制器 | 方法 | 说明 | 为什么放行 |
|
||||
|--------|------|------|-----------|
|
||||
| `DictController` | `refreshCache` | 刷新字典缓存 | 仅清除缓存,不修改业务数据 |
|
||||
| `GeneratorController` | `preview` | 代码生成预览 | 仅预览不写盘,展示生成器能力 |
|
||||
| 控制器 | 方法 | 路由 | 说明 | 放行理由 |
|
||||
|--------|------|------|------|---------|
|
||||
| `GeneratorController` | `preview` | `GET generator/preview` | 代码生成预览 | 仅预览不写盘,不修改业务数据 |
|
||||
|
||||
:::tip GET 路由天然放行
|
||||
`dict/refreshCache` 等维护接口均为 GET 方法,本就不受拦截、无需注解。`#[DemoAllow]` 的价值在于**防御性兜底**——即使后续将路由方法改为 POST,演示模式下仍可正常访问。
|
||||
:::
|
||||
|
||||
### 使用示例
|
||||
|
||||
```php
|
||||
use app\attribute\DemoAllow;
|
||||
use app\attribute\Permission;
|
||||
use app\attribute\Log;
|
||||
use App\Attributes\DemoAllow;
|
||||
use App\Attributes\OperationLog;
|
||||
use App\Attributes\Permission;
|
||||
|
||||
class DictController extends BaseController
|
||||
class GeneratorController extends Controller
|
||||
{
|
||||
/**
|
||||
* 刷新字典缓存
|
||||
* 预览生成代码
|
||||
*
|
||||
* 演示环境下也可执行:清除缓存不影响业务数据,
|
||||
* 且是展示字典功能的必要操作。
|
||||
* 标注 #[DemoAllow],演示环境下允许访问。
|
||||
* 根据请求参数预览待生成的代码内容,不写入文件。
|
||||
*/
|
||||
#[Log('字典管理-刷新缓存', Log::TYPE_CLEAR)]
|
||||
#[Permission('sys:dict:update', '修改字典')]
|
||||
#[OperationLog('代码生成-预览代码', OperationLog::TYPE_QUERY)]
|
||||
#[Permission('sys:generator:generate', '代码生成')]
|
||||
#[DemoAllow] // ← 演示模式白名单
|
||||
public function refreshCache(): Json
|
||||
public function preview(Request $request): JsonResponse
|
||||
{
|
||||
DictService::clearAllCache();
|
||||
return $this->success(null, '缓存刷新成功');
|
||||
$result = $this->logic->preview($request->all());
|
||||
|
||||
return $this->success($result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
:::warning 注意事项
|
||||
- `#[DemoAllow]` 仅支持方法级标注,不支持类级(与 `#[Permission]` 不同)
|
||||
- `#[DemoAllow]` 仅支持方法级标注,不支持类级(与 `#[Permission]` 相同为方法级)
|
||||
- 仅对"安全的写操作"使用,如刷新缓存、预览、导出等
|
||||
- 不要对增删改数据的接口使用,否则失去演示模式的保护意义
|
||||
:::
|
||||
@@ -221,7 +269,7 @@ axios.interceptors.response.use(response => {
|
||||
const { code, msg } = response.data;
|
||||
|
||||
// 演示模式拦截
|
||||
if (code === 403 && msg === '演示环境,禁止操作') {
|
||||
if (code === 403 && msg === '演示模式下禁止操作') {
|
||||
ElMessage.warning('当前为演示环境,不允许执行此操作');
|
||||
return Promise.reject(response.data);
|
||||
}
|
||||
@@ -245,31 +293,39 @@ axios.interceptors.response.use(response => {
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useAppStore } from '@/store/modules/projectSetting';
|
||||
|
||||
const isDemoMode = computed(() => import.meta.env.VITE_DEMO_MODE === 'true');
|
||||
</script>
|
||||
```
|
||||
|
||||
## 响应示例
|
||||
|
||||
当演示模式拦截写操作时,返回:
|
||||
当演示模式拦截写操作时,返回(HTTP 状态码 403):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 403,
|
||||
"msg": "演示环境,禁止操作",
|
||||
"ok": false,
|
||||
"msg": "演示模式下禁止操作",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
HTTP 状态码为 `403`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 演示模式下登录还能用吗?
|
||||
|
||||
能。登录接口 `/api/login` 使用 POST 方法,但它位于**公开接口组**(不经过 DemoMiddleware),因此不受演示模式影响。
|
||||
能。登录接口 `/api/login` 位于**公开接口组**(中间件组外),不经过 `demo` 中间件,因此不受演示模式影响。
|
||||
|
||||
### Q: 设置 DEMO_MODE=true 后仍能正常增删改?
|
||||
|
||||
演示模式生效需同时满足**配置接入**与**中间件挂载**两个条件,逐项排查(详见[启用与验证](#启用与验证)):
|
||||
|
||||
```bash
|
||||
php artisan tinker --execute="dump(config('app.demo_mode'))" # 期望 true
|
||||
php artisan route:list --path=user/add --json # middleware 应含 demo
|
||||
```
|
||||
|
||||
若配置键缺失或中间件未挂载,改 `.env` 不会有任何效果。另外注意:执行过 `config:cache` 时,改完 `.env` 需重新缓存。
|
||||
|
||||
### Q: 如何让某个 POST 接口在演示模式下也能用?
|
||||
|
||||
@@ -277,7 +333,7 @@ HTTP 状态码为 `403`。
|
||||
|
||||
```php
|
||||
#[DemoAllow]
|
||||
public function mySafeAction(): Json
|
||||
public function mySafeAction(): JsonResponse
|
||||
{
|
||||
// 演示模式下也可执行
|
||||
}
|
||||
@@ -286,18 +342,22 @@ public function mySafeAction(): Json
|
||||
### Q: 演示模式和权限控制会冲突吗?
|
||||
|
||||
不会。两者是独立机制,按顺序执行:
|
||||
1. **AuthMiddleware** 先校验权限(无权限返回 403 "无访问权限")
|
||||
2. **DemoMiddleware** 再判断演示模式(拦截写操作返回 403 "演示环境,禁止操作")
|
||||
|
||||
1. **Authenticate** 先校验权限(无权限返回 403 "无访问权限:{权限码}")
|
||||
2. **DemoMiddleware** 再判断演示模式(拦截写操作返回 403 "演示模式下禁止操作")
|
||||
|
||||
两者的 403 含义不同,前端可通过 `msg` 字段区分。
|
||||
|
||||
### Q: 临时关闭演示模式需要重启服务吗?
|
||||
|
||||
取决于 `.env` 的缓存配置:
|
||||
- **文件缓存**:修改 `.env` 后需清缓存或重启(`php think clear`)
|
||||
- **Redis 缓存**:同上
|
||||
- **开发环境**(`APP_DEBUG=true`):每次请求重新读取 `.env`,无需重启
|
||||
修改 `.env` 后需清除配置缓存并重启:
|
||||
|
||||
```bash
|
||||
php artisan config:clear
|
||||
# 生产环境如执行过 config:cache,改完需重新缓存
|
||||
php artisan config:cache
|
||||
```
|
||||
|
||||
### Q: `#[DemoAllow]` 支持类级标注吗?
|
||||
|
||||
不支持。`DemoAllow` 注解的 `Attribute` 目标仅为 `TARGET_METHOD`(方法级)。如果需要整个控制器放行,需在每个方法上单独标注。
|
||||
不支持。`DemoAllow` 注解的 `Attribute` 目标仅为 `TARGET_METHOD`(方法级)。如果需要整个控制器放行,需在每个方法上单独标注。
|
||||
|
||||
Reference in New Issue
Block a user