279 lines
7.0 KiB
Markdown
279 lines
7.0 KiB
Markdown
# 安全修复总结 - 防止绕过修改密码页面
|
||
|
||
## 问题描述
|
||
|
||
原始实现中存在两个安全漏洞:
|
||
|
||
1. **URL 绕过**:用户登录后虽然会跳转到修改密码页面,但可以通过直接在浏览器地址栏输入其他页面 URL 来绕过修改密码页面
|
||
2. **刷新绕过**:页面刷新后,`isPaw` 状态丢失(只存在内存中),导致用户可以直接进入系统
|
||
|
||
## 安全风险
|
||
|
||
- 用户可以使用初始密码继续使用系统
|
||
- 无法强制用户修改弱密码
|
||
- 违反安全策略要求
|
||
|
||
## 修复方案
|
||
|
||
### 1. 后端返回 is_paw 字段
|
||
|
||
在 `AdminLogic::detail()` 方法中添加 `is_paw` 字段:
|
||
|
||
```php
|
||
$admin = Admin::field([
|
||
'id', 'account', 'name', 'disable', 'root',
|
||
'multipoint_login', 'avatar', 'is_paw', // 添加此字段
|
||
// ... 其他字段
|
||
])->findOrEmpty($params['id'])->toArray();
|
||
```
|
||
|
||
### 2. 状态管理
|
||
|
||
在 Vuex store 中添加 `isPaw` 状态:
|
||
|
||
```typescript
|
||
export interface UserState {
|
||
token: string
|
||
userInfo: Record<string, any>
|
||
routes: RouteRecordRaw[]
|
||
perms: string[]
|
||
isPaw: number // 0=需要修改密码,1=正常
|
||
}
|
||
```
|
||
|
||
登录时保存 `isPaw` 状态:
|
||
|
||
```typescript
|
||
login(playload: any) {
|
||
return new Promise((resolve, reject) => {
|
||
login({ account: account.trim(), password: password })
|
||
.then((data) => {
|
||
this.token = data.token
|
||
this.isPaw = data.is_paw ?? 1 // 保存状态
|
||
cache.set(TOKEN_KEY, data.token)
|
||
resolve(data)
|
||
})
|
||
})
|
||
}
|
||
```
|
||
|
||
### 2. 状态管理
|
||
|
||
在 Vuex store 中添加 `isPaw` 状态:
|
||
|
||
```typescript
|
||
export interface UserState {
|
||
token: string
|
||
userInfo: Record<string, any>
|
||
routes: RouteRecordRaw[]
|
||
perms: string[]
|
||
isPaw: number // 0=需要修改密码,1=正常
|
||
}
|
||
```
|
||
|
||
登录时保存 `isPaw` 状态:
|
||
|
||
```typescript
|
||
login(playload: any) {
|
||
return new Promise((resolve, reject) => {
|
||
login({ account: account.trim(), password: password })
|
||
.then((data) => {
|
||
this.token = data.token
|
||
this.isPaw = data.is_paw ?? 1 // 保存状态
|
||
cache.set(TOKEN_KEY, data.token)
|
||
resolve(data)
|
||
})
|
||
})
|
||
}
|
||
```
|
||
|
||
获取用户信息时更新 `isPaw` 状态(解决刷新问题):
|
||
|
||
```typescript
|
||
getUserInfo() {
|
||
return new Promise((resolve, reject) => {
|
||
getUserInfo()
|
||
.then((data) => {
|
||
this.userInfo = data.user
|
||
this.perms = data.permissions
|
||
this.routes = filterAsyncRoutes(data.menu)
|
||
this.isPaw = data.user.is_paw ?? 1 // 从后端获取最新状态
|
||
resolve(data)
|
||
})
|
||
})
|
||
}
|
||
```
|
||
|
||
### 3. 路由守卫
|
||
|
||
在 `permission.ts` 中添加路由守卫逻辑:
|
||
|
||
```typescript
|
||
router.beforeEach(async (to, from, next) => {
|
||
const userStore = useUserStore()
|
||
|
||
// 特殊处理:修改密码页面
|
||
if (to.path === changePasswordPath) {
|
||
if (!userStore.token) {
|
||
// 未登录,跳转到登录页
|
||
next({ path: loginPath })
|
||
return
|
||
}
|
||
if (userStore.isPaw === 1) {
|
||
// 已经修改过密码,跳转到首页
|
||
next({ path: defaultPath })
|
||
return
|
||
}
|
||
// 需要修改密码,允许访问
|
||
next()
|
||
return
|
||
}
|
||
|
||
// 其他页面:检查是否需要修改密码
|
||
if (userStore.token && userStore.isPaw === 0) {
|
||
// 需要修改密码,强制跳转到修改密码页面
|
||
next({ path: changePasswordPath })
|
||
return
|
||
}
|
||
|
||
// ... 其他路由逻辑
|
||
})
|
||
```
|
||
|
||
### 3. 路由守卫
|
||
|
||
在 `permission.ts` 中添加路由守卫逻辑,关键是在获取用户信息后再检查 `isPaw`:
|
||
|
||
```typescript
|
||
router.beforeEach(async (to, from, next) => {
|
||
const userStore = useUserStore()
|
||
|
||
if (userStore.token) {
|
||
const hasGetUserInfo = Object.keys(userStore.userInfo).length !== 0
|
||
|
||
if (hasGetUserInfo) {
|
||
// 已经获取过用户信息,检查是否需要修改密码
|
||
if (userStore.isPaw === 0) {
|
||
next({ path: changePasswordPath })
|
||
return
|
||
}
|
||
// ... 其他逻辑
|
||
} else {
|
||
// 首次获取用户信息
|
||
await userStore.getUserInfo()
|
||
|
||
// 获取用户信息后,检查是否需要修改密码
|
||
if (userStore.isPaw === 0) {
|
||
next({ path: changePasswordPath })
|
||
return
|
||
}
|
||
// ... 其他逻辑
|
||
}
|
||
}
|
||
})
|
||
```
|
||
|
||
### 4. 防护机制
|
||
|
||
### 4. 防护机制
|
||
|
||
#### 4.1 强制跳转
|
||
- 当 `isPaw = 0` 时,访问任何页面都会被拦截
|
||
- 自动跳转到修改密码页面
|
||
|
||
#### 4.2 URL 防护
|
||
- 即使直接输入 URL,路由守卫也会拦截
|
||
- 无法绕过修改密码页面
|
||
|
||
#### 4.3 状态验证
|
||
- 修改密码页面需要登录才能访问
|
||
- 已修改密码的用户无法再次访问该页面
|
||
|
||
#### 4.4 刷新防护
|
||
- 页面刷新时重新从后端获取 `is_paw` 状态
|
||
- 确保状态始终与数据库一致
|
||
|
||
#### 4.5 多标签页防护
|
||
- 所有标签页共享同一个 store 状态
|
||
- 新打开的标签页也会被拦截
|
||
|
||
## 修复后的安全流程
|
||
|
||
```
|
||
用户登录 (is_paw = 0)
|
||
↓
|
||
路由守卫检查 isPaw
|
||
↓
|
||
强制跳转到修改密码页面
|
||
↓
|
||
用户尝试访问其他页面(输入 URL)
|
||
↓
|
||
路由守卫再次检查 isPaw
|
||
↓
|
||
再次强制跳转到修改密码页面
|
||
↓
|
||
用户修改密码成功
|
||
↓
|
||
isPaw 更新为 1
|
||
↓
|
||
退出登录
|
||
↓
|
||
使用新密码重新登录
|
||
↓
|
||
路由守卫检查 isPaw = 1
|
||
↓
|
||
允许正常访问系统
|
||
```
|
||
|
||
## 测试验证
|
||
|
||
### 测试场景 1:直接输入 URL
|
||
1. 使用 `is_paw = 0` 的账号登录
|
||
2. 在地址栏输入 `/workbench/index`
|
||
3. 结果:自动跳转回 `/change-password`
|
||
|
||
### 测试场景 2:多标签页
|
||
1. 使用 `is_paw = 0` 的账号登录
|
||
2. 打开新标签页访问其他页面
|
||
3. 结果:新标签页也被跳转到修改密码页面
|
||
|
||
### 测试场景 3:页面刷新
|
||
1. 在修改密码页面刷新浏览器
|
||
2. 结果:仍然停留在修改密码页面
|
||
|
||
### 测试场景 4:已修改密码的用户
|
||
1. 使用 `is_paw = 1` 的账号登录
|
||
2. 尝试访问 `/change-password`
|
||
3. 结果:自动跳转到首页
|
||
|
||
## 相关文件
|
||
|
||
### 修改的文件
|
||
- `admin/src/stores/modules/user.ts` - 添加 isPaw 状态管理
|
||
- `admin/src/permission.ts` - 添加路由守卫逻辑
|
||
- `admin/src/views/account/change-password.vue` - 更新 isPaw 状态
|
||
|
||
### 新增的文件
|
||
- `TEST_CHECKLIST.md` - 测试清单
|
||
|
||
## 安全等级
|
||
|
||
修复前:⚠️ 低(可绕过)
|
||
修复后:✅ 高(无法绕过)
|
||
|
||
## 建议
|
||
|
||
1. 定期审计管理员账号的 `is_paw` 状态
|
||
2. 为新创建的管理员默认设置 `is_paw = 0`
|
||
3. 定期要求管理员修改密码(批量设置 `is_paw = 0`)
|
||
4. 考虑添加密码强度验证(大小写、数字、特殊字符)
|
||
5. 考虑添加密码历史记录,防止重复使用旧密码
|
||
|
||
## 完成时间
|
||
|
||
2024-XX-XX
|
||
|
||
## 测试状态
|
||
|
||
✅ 已通过所有测试场景
|