Files
rxthinkcmf-wiki/RXThinkCMF_Laravel13_AntdVue/zh/architecture/security.md
T
2026-10-08 17:50:29 +08:00

479 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 4.10 安全架构设计
:::tip 概述
本章详细阐述系统的安全架构设计,围绕认证、授权、防护、审计四大安全领域展开,旨在帮助开发者全面理解系统的安全保障体系。
:::
**认证(Authentication)**:系统采用基于 JWT 的双令牌认证机制,用户通过用户名密码登录后获取 Token,后续请求通过 `Authorization: Bearer <token>` 头传递,由 `Authenticate` 中间件(别名 `auth.jwt`)统一拦截并验证 Token 的有效性(签名校验、有效期检查),签名密钥启动时 fail-close 校验。
**授权(Authorization)**:基于 RBAC 模型设计,权限控制粒度细化至接口级。用户通过角色继承权限节点(如 `sys:user:add`),由 `#[Permission]` 注解在控制器层声明,`Authenticate` 中间件反射校验(三级缓存:Redis → Laravel Cache → 数据库)。超级管理员(`uid=1`)自动放行。
**防护(Protection)**:系统从多维度构建安全防护体系。参数校验通过 FormRequest 在 HTTP 入口自动拦截非法输入;敏感密码采用 bcrypt 双重加盐哈希存储;演示模式下 `DemoMiddleware` 统一拦截写操作;跨域配置精确控制可信域名;Nginx 仅允许入口 PHP 执行。
**审计(Audit)**:系统提供完备的操作审计能力。`LogOperation` 中间件自动记录所有写操作的请求路径、参数(排除敏感字段)、操作人、IP 归属地及执行结果;登录日志独立记录每次登录尝试,便于安全事件追溯。
:::tip 安全设计原则
- **纵深防御**:认证、授权、参数校验多层防护,单一防线失效时仍有其他机制兜底
- **最小权限**:用户仅能访问已授权接口,角色权限按需分配
- **数据不落地**:敏感信息(Token、密码)不在日志中明文记录(操作日志 `param` 排除 `password`/`token`),密码哈希不可逆
- **安全默认**:`JWT_SECRET` 弱密钥拒绝启动(fail-close),生产 `APP_DEBUG=false` 隐藏错误详情
:::
## 安全架构总览
```text
┌─────────────────────────────────────────────────────────────────┐
│ 安全防御层次 │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 网络层防护 │ │
│ │ ┌──────────┐ ┌──────────────┐ ┌────────────────────────┐ │ │
│ │ │ CORS │ │ 上传体积限制 │ │ Nginx 仅入口 index.php │ │ │
│ │ │ 跨域控制 │ │ DoS 防御 │ │ 可见性攻击防护 │ │ │
│ │ └──────────┘ └──────────────┘ └────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 认证层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ JWT 认证 │ │ 验证码 │ │ 双令牌 │ │ 密钥 fail- │ │ │
│ │ │ Bearer │ │ 图形验证 │ │ access+ │ │ close 启动 │ │ │
│ │ │ │ │ + IP限流 │ │ refresh │ │ 校验 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 授权层 │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │
│ │ │ RBAC 权限 │ │ 演示模式保护 │ │ 超级管理员跳过 │ │ │
│ │ │ #[Permission]│ │ DemoMiddleware│ │ uid=1 bypass │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────┐ │
│ │ 数据层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │ 密码加密 │ │ 参数校验 │ │ SQL 注入 │ │ 操作日志 │ │ │
│ │ │ 双重加盐 │ │FormRequest│ │ ORM 防护 │ │ 审计追踪 │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └───────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
## 一、认证机制
### 登录流程
```text
1. 前端提交 { username, password, key, code }
(验证码参数 key/code 与 /api/captcha 响应字段一致)
│
▼
2. CaptchaService::verify(key, code) ← 验证码校验(配置 CAPTCHA_LENGTH>0 时启用)
│ 失败 → 返回"验证码错误"
▼
3. JwtService::login(username, password) ← 查询用户 + 密码校验
│ 用户不存在/密码错误 → 统一提示(不暴露账号是否存在)
▼
4. password_verify(password + salt, hash) ← bcrypt 双重加盐校验
│ 不匹配 → 统一提示
▼
5. 检查 user.status == 1 ← 账号状态
│ 非启用 → 返回"账号已被禁用"
▼
6. 签发双令牌 + 写登录日志(成功/失败均记录)
│
▼
7. 返回 { access_token, refresh_token, token_type, expires_in, user }
```
### JWT 双令牌认证
```text
[前端] [后端]
│ │
│── POST /api/login ──────────→│ (公开接口,中间件组外)
│ {username, password, │── 验证码校验(key/code)
│ key, code} │── 密码校验(双重加盐 bcrypt)
│ │── 签发 access_token + refresh_token
│←─ {access_token, ─────────────│
│ refresh_token} │
│ │
│── GET /api/xxx ─────────────→│ (认证接口,auth.jwt 组内)
│ Authorization: │── JwtService::parseToken
│ Bearer <access_token> │── 校验签名 + 有效期 + type=access
│ │── 注入 attributes['userInfo']
│←─ {data} ────────────────────│
│ │
│── access_token 过期 ─────────→│
│── POST /api/refresh ─────────→│ (公开接口)
│ {refresh_token} │── 校验 refresh 类型 + Redis jti(一次性消费)
│←─ {新令牌对} ─────────────────│
```
### Token 设计
| Token 类型 | 默认有效期 | 用途 | 存储 |
|-----------|--------|------|------|
| access_token | 2 小时(`JWT_ACCESS_TTL=7200`) | 接口认证 | 无状态(JWT) |
| refresh_token | 7 天(`JWT_REFRESH_TTL=604800`) | 刷新令牌 | **Redis 有状态**(支持踢下线、改密全端失效、一次性消费) |
### 安全要点
1. **密码错误统一提示**:无论用户不存在还是密码错误,统一提示,避免暴露账号是否存在
2. **账号状态校验**:`status != 1` 时禁止登录
3. **Token 类型校验**:access 与 refresh 通过 `type` 字段区分,不可混用(refresh 令牌不能访问业务接口)
4. **密钥 fail-close**:`JWT_SECRET` 为空或不足 32 字符时 `AppServiceProvider` 抛异常**拒绝启动**——弱密钥无法上线
5. **验证码限流**:按 IP 限流(`CAPTCHA_RATE_WINDOW` / `CAPTCHA_RATE_LIMIT`),万能码仅 `APP_DEBUG=true` 生效
## 二、授权机制
### RBAC 权限模型(菜单即权限)
```text
┌─────────┐ ┌────────────┐ ┌─────────┐ ┌────────────┐ ┌─────────┐
│ User │───►│ UserRole │───►│ Role │───►│ RoleMenu │───►│ Menu │
│ │ │ │ │ │ │ │ │ │
│ id │ │ user_id │ │ id │ │ role_id │ │ id │
│ username│ │ role_id │ │ name │ │ menu_id │ │permission│
└─────────┘ └────────────┘ │ code │ └────────────┘ │ type │
└─────────┘ └─────────┘
```
- 菜单表的 `permission` 字段即权限编码(`sys:{模块}:{操作}`)
- `type=1`(菜单)且 `permission` 非空的节点参与接口鉴权
- 角色-菜单变更时 `RoleMenuObserver` / `MenuObserver` 自动清除权限缓存
### 权限校验流程(Authenticate::checkPermission)
```php
// app/Http/Middleware/Authenticate.php(节选)
protected function checkPermission(int $uid, string $permissionCode): bool
{
// 超级管理员跳过
if ($uid === 1) {
return true;
}
// 从 Redis 获取权限(一级缓存)
try {
$cached = Redis::get("perms:{$uid}");
if ($cached) {
$perms = json_decode($cached, true) ?? [];
// 拥有通配权限或包含目标编码即通过
return in_array('*:*:*', $perms) || in_array($permissionCode, $perms);
}
} catch (\Exception $e) {
// Redis 不可用时继续降级
}
// 二级缓存:Laravel Cache(5 分钟)
$cacheKey = "perms_fallback:{$uid}";
$permissions = Cache::remember($cacheKey, 300, function () use ($uid) {
return $this->loadPermissionsFromDb($uid);
});
return in_array('*:*:*', $permissions) || in_array($permissionCode, $permissions);
}
// 数据库加载:user_role → role_menu join menu(未删除、type=1、permission 非空)
protected function loadPermissionsFromDb(int $uid): array
{
$roleIds = UserRole::where('user_id', $uid)->pluck('role_id')->toArray();
if (empty($roleIds)) {
return [];
}
return RoleMenu::join('menu', 'role_menu.menu_id', '=', 'menu.id')
->whereIn('role_menu.role_id', $roleIds)
->where('menu.is_delete', 0)
->where('menu.type', 1)
->where('menu.permission', '!=', '')
->pluck('menu.permission')
->toArray();
}
```
### 数据权限
`HasDataScope` 支持三种数据范围(当前 `laravel_role` 表不含 `data_scope` 字段,实际一律按"全部数据"执行):
| data_scope | 范围 | 实现(HasDataScope) |
|-----------|------|------|
| 1 | 全部数据 | 不过滤 |
| 2 | 本部门数据 | `WHERE dept_id = 用户部门ID` |
| 3 | 仅本人数据(默认) | `WHERE create_user = 用户名` |
## 三、防护机制
### 密码加密(bcrypt 双重加盐)
```text
密码存储流程:
原始密码 + 外部 salt(随机字符串,存 user.salt 字段)
│
▼ password_hash($password . $salt, PASSWORD_BCRYPT, ['cost' => 12])
│
▼ bcrypt 内部自动生成 salt 并嵌入哈希
│
▼ 存储:password 字段(60 字符哈希)+ salt 字段(独立存储)
密码校验流程:
用户输入密码 + 数据库 salt
│
▼ password_verify($input . $salt, $hashed)
│
▼ true / false
```
- 成本因子固定为 12(`PasswordService::BCRYPT_ROUNDS`,对应 `.env` 的 `BCRYPT_ROUNDS=12`)
- **双重加盐**:外部 salt 存独立字段,数据库泄露后仍需同时获取 salt 才能暴力破解;bcrypt 内部 salt 进一步增加彩虹表攻击难度
### 参数校验(FormRequest 入口拦截)
通过控制器方法类型注入 FormRequest,进入业务逻辑前完成格式校验:
```php
class UserRequest extends BaseRequest
{
public function rules(): array
{
return [
'username' => 'required|string|min:3|max:50',
'realname' => 'required|string|max:50',
'mobile' => 'nullable|max:20',
'email' => 'nullable|email',
];
}
}
```
校验失败由 `failedValidation()` 直接返回 `code=1` + 字段错误明细,**不进入控制器方法**;唯一性等业务规则由 Service 层兜底(防止绕过格式校验的组合攻击)。
### SQL 注入防护
Eloquent 查询构造器使用参数绑定,自动防止 SQL 注入:
```php
// ✅ ORM 查询(安全,自动参数绑定)
User::where('username', $username)->first();
// 生成:SELECT * FROM laravel_user WHERE username = ? [$username]
// ✅ 原生查询(手动绑定参数)
DB::select("SELECT * FROM laravel_user WHERE username = ?", [$username]);
// ❌ 危险!字符串拼接(禁止)
DB::select("SELECT * FROM laravel_user WHERE username = '$username'");
```
查询排序也受白名单保护:前端 `orderField` 必须命中 `pageOrderBy` 与 `orderFieldAllow` 的并集,否则忽略(防排序注入)。
:::warning 安全红线
永远不要将用户输入直接拼接到 SQL 字符串中。即使使用原生 SQL,也必须使用参数绑定(`?` 占位符)。
:::
### 文件上传安全
```text
上传文件安全检查链:
│
▼ 1. 文件大小限制(config/file.php → FILE_MAX_SIZE,默认 10MB)
│
▼ 2. 文件扩展名白名单(FILE_IMAGE_EXT / FILE_EXT)
│ (svg 已移除:内嵌 JS 导致 XSS 风险)
│
▼ 3. MIME 类型真实检测(fileinfo 扩展,_import 等场景强制校验)
│
▼ 4. 存储路径由服务端生成(防止路径遍历)
│
▼ 5. Nginx 仅允许入口 index.php 执行 —— 上传目录中的 .php 一律 404
```
```nginx
# docker/nginx.conf:只有入口可执行,其余 .php 一律拒绝
location = /index.php { /* FastCGI 转发 */ }
location ~ \.php$ {
return 404;
}
```
### XSS 防护
```text
防护措施:
1. 前端:Vue 模板插值默认转义({{ }}),避免注入
2. 后端:响应强制 Content-Type: application/json(ForceJsonResponse 中间件),
浏览器不会按 HTML 渲染
3. 富文本:字段独立处理链(contentFields),内容过滤与占位符机制
4. 上传白名单移除 svg(内嵌脚本风险)
```
### 演示模式保护
```php
// DemoMiddleware 拦截写操作
// .env: DEMO_MODE=true(读取 config('app.demo_mode'))
// 受保护:POST/PUT/DELETE/PATCH 请求 → 返回 403
// 放行:GET 请求 + 标注 #[DemoAllow] 注解的方法
```
```php
// 标注 #[DemoAllow] 的方法在演示模式下仍可执行
#[DemoAllow]
public function login(LoginRequest $request): JsonResponse { ... }
```
### CORS 跨域控制
```ini
# .env
# 开发环境:允许所有(仅限开发)
CORS_ALLOW_ORIGIN=*
# 生产环境:限制具体域名(多个来源用逗号分隔)
CORS_ALLOW_ORIGIN=https://admin.example.com
```
```php
// Cors 中间件处理逻辑(见 4.2 请求处理链路):
// 1. OPTIONS 预检请求 → 直接返回 204 + CORS 头(不进业务逻辑)
// 2. 凭证模式下不能用 * → 回显具体 Origin + Vary: Origin(防 CDN 缓存串扰)
// 3. 仅 Origin 被允许时才设置其余 CORS 头
```
## 四、审计机制
### 操作日志(laravel_operation_log)
`LogOperation` 中间件基于 `#[OperationLog]` 注解自动记录:
```php
#[OperationLog('用户管理-新增', OperationLog::TYPE_ADD, '新增用户:{username}')]
public function add(UserRequest $request): JsonResponse { ... }
```
**记录字段:**
| 字段 | 说明 | 示例 |
|------|------|------|
| `title` | 操作标题(来自注解,无注解回退请求路径) | 用户管理-新增 |
| `type` | 操作类型(30 种常量,无注解按 HTTP 方法推断) | 1=新增 2=修改 3=删除 4=查询 5=导入 6=导出 |
| `method` | HTTP 方法 | POST |
| `request_method` | 控制器方法名 | add |
| `url` | 请求路径 | /api/user/add |
| `param` | 请求参数(JSON,**排除 password/token**,截断 2000 字) | {"username":"admin"} |
| `result` | 响应内容(截断 2000 字) | {"code":0,...} |
| `ip` / `location` | 客户端 IP / 归属地(ip2region) | 192.168.1.100 / 江苏省南京市 |
| `os` / `browser` | 操作系统 / 浏览器 | Windows 11 / Chrome 128 |
| `create_user` | 操作人(userInfo.username) | admin |
| `status` | 执行状态(code=0/200 → 0,其余 → 1) | 0=成功 1=失败 |
| `error` | 错误信息(失败时取 msg) | 名称已存在 |
| `consume_time` | 执行耗时(ms) | 125 |
**记录规则:**
- 写操作(POST/PUT/DELETE/PATCH)始终记录
- GET 仅在注解类型为 `EXPORT / DOWNLOAD / IMPORT` 时记录
- `handle` 阶段采集、`terminate` 阶段落库(响应后执行,不阻塞业务;写入失败静默)
### 登录日志(laravel_login_log)
```php
// AuthController::writeLoginLog(节选)—— 成功与失败均记录
$data = [
'username' => $username,
'log_type' => 1, // 1=登录 2=登出
'source' => 0, // 来源端:0=后台
'method' => $request->method(),
'request_method' => $entry, // login / refresh 等入口
'url' => $request->fullUrl(),
'param' => json_encode(['username' => $username], ...), // 只记录用户名
'result' => json_encode(['code' => $success ? 0 : 1, 'msg' => $message], ...),
'ip' => $requestInfo->getIp(),
'location' => $requestInfo->getIpLocation(),
// + os / browser / 耗时
];
```
**记录字段:**
| 字段 | 说明 |
|------|------|
| `username` | 登录用户名(无论成功失败都记录) |
| `log_type` | 1=登录 2=登出 |
| `status` / `result` | 0=成功 1=失败 + 结果消息 |
| `ip` / `location` | 客户端 IP / 归属地 |
| `os` / `browser` | 操作系统 / 浏览器 |
### 日志安全要点
| 要点 | 说明 |
|------|------|
| 密码与令牌不记录 | 操作日志 `param` 排除 `password`、`token`;登录日志只记录用户名 |
| 参数截断 | `param` / `result` 截断 2000 字,防止超长内容撑爆字段 |
| 独立存储 | 操作日志和登录日志分别存储在不同表 |
| 失败不阻断 | 日志写入异常静默处理,不影响主业务 |
## 五、安全配置清单
### 环境变量(.env)
| 配置项 | 开发环境 | 生产环境 | 说明 |
|--------|---------|---------|------|
| `APP_DEBUG` | `true` | **`false`** | 关闭调试模式,异常不返回真实消息 |
| `JWT_SECRET` | 强随机 | **强随机(≥32 字符)** | 不足 32 字符**拒绝启动**(fail-close) |
| `CORS_ALLOW_ORIGIN` | `*` | **指定域名** | 如 `https://admin.example.com` |
| `DEMO_MODE` | `false` | `false` | 演示环境按需开启 |
| `FILE_MAX_SIZE` | 10 | 合理限制 | 防止大文件 DoS |
| `CAPTCHA_BYPASS_CODE` | 可设 | **必须留空** | 万能验证码仅 debug 生效,生产留空 |
| `TRUSTED_PROXIES` | 空 | CDN/负载均衡地址 | 防止伪造代理头获取错误 IP |
| `BCRYPT_ROUNDS` | 12 | 12+ | 密码哈希成本因子 |
| `API_CAMEL_SNAKE_CONVERT` | `true` | `true` | 驼峰转换开关 |
### Nginx 配置
```nginx
# 1. 启用 HTTPS(生产必须)
server {
listen 443 ssl;
ssl_certificate /etc/ssl/cert.pem;
ssl_certificate_key /etc/ssl/key.pem;
}
# 2. 仅入口 PHP 可执行(docker/nginx.conf 已内置)
location = /index.php { /* FastCGI 转发 */ }
location ~ \.php$ { return 404; }
# 3. 禁止访问隐藏文件(.env / .git 等,docker/nginx.conf 已内置)
location ~ /\. { deny all; }
# 4. 安全响应头(按需追加)
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY;
add_header Referrer-Policy strict-origin-when-cross-origin;
```
### 安全检查清单
```text
上线前逐项检查:
□ .env 中 APP_DEBUG = false
□ .env 中 JWT_SECRET 已更换为 ≥32 字符强随机密钥(否则应用拒绝启动)
□ .env 中 CORS_ALLOW_ORIGIN 指定具体域名
□ .env 中 CAPTCHA_BYPASS_CODE 留空
□ Nginx 已配置 HTTPS
□ Nginx 确认仅入口 index.php 可执行、隐藏文件已拒绝
□ 数据库密码为强密码,Redis 已设置 requirepass 并同步 REDIS_PASSWORD
□ 默认管理员密码已修改(admin / 123456)
□ 演示模式已关闭(除非需要)
□ 后端 /api/* 路由确认在认证中间件组内(公开接口仅 login/refresh/captcha/oauth2)
□ 日志中不包含密码等敏感信息
```
## 总结
系统通过认证、授权、防护、审计四层安全架构,实现了从网络层到数据层的纵深防御。JWT 双令牌 + Redis 有状态保证认证安全且支持会话管控,RBAC 权限控制细化到按钮级(三级缓存保证性能),bcrypt 双重加盐保护密码安全,操作日志与登录日志实现全链路审计。各层安全机制相互独立又协同配合,确保系统在企业级应用场景下的安全可靠性。