8 Commits

Author SHA1 Message Date
e49865df11 docs: 更新生产就绪评审报告 — P2 修复完成
- SEC-RECOVERY/TOTP 恢复码加密: 已修复
- SEC-IP-SPOOF/X-Forwarded-For 伪造: 已修复
- SEC-ARGON2/Argon2id 参数: 已修复
- PERF-01/03/07 性能问题: 已修复
- RES-01/02/03 资源管理: 已修复
- 全量测试 43 个包 PASS
- 评分从 7.7 提升至 8.1
2026-05-08 10:58:38 +08:00
8665c97d0d fix(security): X-Forwarded-For IP 伪造防护
- isTrustedProxy: 空可信代理列表时默认不信任(安全优先)
- realIP: 修正 XFF 遍历逻辑,从右到左跳过可信代理,返回第一个不可信的客户端 IP
- GetClientIP: 优先读取 IPFilterMiddleware 已验证的 client_ip,避免直接信任转发头
2026-05-08 10:35:20 +08:00
d4ec8a13e4 security(auth): raise Argon2id calibration minimums to OWASP thresholds (SEC-ARGON2)
- Increase minimum iterations from 2 to 3 (OWASP minimum)
- Increase minimum memory from 16MB to 19MB (19456KB, OWASP minimum)
- Update comments to document the OWASP rationale

Fixes: SEC-ARGON2
2026-05-08 10:24:10 +08:00
2a18a6fb47 fix(n+1): 批量查询替代循环单查
- IsAdminBootstrapRequired: userRepo.GetByID 循环 → GetByIDs 批量
- AssignRoles: roleRepo.GetByID 循环 → GetByIDs 批量
- 在 userRepositoryInterface 补充 GetByIDs 方法签名
2026-05-08 08:05:26 +08:00
9b1cea246e feat: permissions CRUD browser integration + E2E enhancements
Backend:
- permission_handler: 完善权限 CRUD 接口(列表/创建/更新/删除)
- auth_handler: 修复认证处理逻辑
- router: 新增权限管理路由
- handler_test: 新增权限 handler 测试覆盖

Frontend:
- permissions.ts/test.ts: 权限服务层完整实现
- profile/settings/service_tests: 服务适配器修正
- client.ts: HTTP 客户端健壮性增强
- vite.config.js: 构建配置优化
- E2E 脚本: run-playwright-cdp-e2e 大幅增强(权限流程覆盖)

Docs:
- REAL_PROJECT_STATUS: 状态更新
- PRODUCTION_CHECKLIST/QUALITY_STANDARD/TECHNICAL_GUIDE/PROJECT_EXPERIENCE_SUMMARY: 团队规范完善
- plans/2026-04-23: 权限浏览器 CRUD 设计方案

验证: go build 0错误
2026-04-24 07:30:18 +08:00
3f3bb82f1d fix: v6 code review P0 auth/IDOR fixes + frontend regression patches
Backend fixes:
- auth_handler: P0 认证逻辑修复
- ratelimit: 限速中间件增强 + 新增单元测试
- auth_service: 认证服务逻辑完善 + 新增测试
- server: server 配置增强 + 新增测试
- handler_test: 新增 handler 层集成测试
- auth_bootstrap_test: bootstrap 路径测试

Frontend patches:
- LoginPage/RegisterPage: CSRF + 表单交互修复
- BootstrapAdminPage: 引导流程修复
- DevicesPage: 设备管理页修复
- auth/social-accounts/users/webhooks services: 类型修正
- csrf.ts: CSRF token 处理修正
- E2E 脚本: CDP smoke + auth e2e 增强

Docs:
- FULL_CODE_REVIEW_REPORT_2026-04-20
- report-v6 执行计划
- REAL_PROJECT_STATUS 更新
- .gitignore: 新增 .gocache-*/config.yaml 排除

验证: go build/vet 0错误, go test 42/42 PASS, 0 FAIL
2026-04-23 07:14:12 +08:00
82109ec216 Merge branch 'fix/status-review-sync-20260409' 2026-04-19 09:11:10 +08:00
0cfb0f8afd Merge pull request 'fix/status-review-sync-20260409' (#1) from fix/status-review-sync-20260409 into main
Reviewed-on: #1
2026-04-18 15:05:51 +00:00
88 changed files with 7684 additions and 796 deletions

View File

@@ -180,7 +180,25 @@
"Bash(grep -E \"\\(PASS|FAIL|ok|FAIL\\)\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")",
"Bash(grep -E \"^ok|^FAIL\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")",
"Bash(grep -c \"--- PASS\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")",
"Bash(grep -c \"--- FAIL\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")"
"Bash(grep -c \"--- FAIL\" \"C:\\\\\\\\Users\\\\\\\\Admin\\\\\\\\AppData\\\\\\\\Local\\\\\\\\Temp\\\\\\\\claude\\\\\\\\D--usersystem\\\\\\\\585b7397-1a42-4c4c-95db-d0593f685b99\\\\\\\\tasks\\\\\\\\bdnygqovb.output\")",
"Bash(npx vitest *)",
"Bash(sqlite3 :memory: \"SELECT 'test_user' LIKE '%\\\\%' AS result;\")",
"Bash(./bin/ums version *)",
"Bash(make build-cli *)",
"Bash(./bin/ums help *)",
"Bash(./bin/ums init *)",
"Bash(timeout 5 ./bin/ums serve)",
"Bash(./bin/ums serve *)",
"Bash(pkill -f \"bin/ums serve\")",
"Bash(taskkill //F //IM ums.exe)",
"Bash(./bin/ums *)",
"Bash(pkill -f \"bin/ums\")",
"Bash(pkill -f \"server\")",
"Bash(git restore *)",
"Bash(git checkout *)",
"Bash(git pull *)",
"Bash(git merge *)",
"Bash(git stash *)"
]
}
}

2
.gitignore vendored
View File

@@ -43,6 +43,7 @@ logs/*.log
.cache/
.tmp/
.gocache/
.gocache-*/
.gomodcache/
frontend/admin/.cache/
frontend/admin/playwright-report/
@@ -54,6 +55,7 @@ Thumbs.db
# Environment
.env
.env.local
config.yaml
# Node modules
node_modules/

View File

@@ -121,7 +121,29 @@
"usedAt": 1775967622172,
"industryId": "02-Engineering"
}
],
"cf149af00a33475b851ceb99d380e7c4": [
{
"expertId": "CodeReviewExpert",
"name": "火眼眼",
"profession": "代码审查专家",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/CodeReviewExpert/CodeReviewExpert.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/CodeReviewExpert/CodeReviewExpert_zh.md",
"usedAt": 1776436687208,
"industryId": "02-Engineering"
}
],
"743642b96ec847f0b7ff82ebd896296d": [
{
"expertId": "PerformanceTestingExpert",
"name": "压测测",
"profession": "性能测试专家",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/08-QualityAssurance/PerformanceTestingExpert/PerformanceTestingExpert.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/08-QualityAssurance/PerformanceTestingExpert/PerformanceTestingExpert_zh.md",
"usedAt": 1776519150854,
"industryId": "08-QualityAssurance"
}
]
},
"lastUpdated": 1775973310025
"lastUpdated": 1776524307480
}

826
docs/architecture-design.md Normal file
View File

@@ -0,0 +1,826 @@
# 用户管理系统架构设计文档
> 版本v1.0
> 更新日期2026-05-07
> 适用范围UMS (User Management System)
---
## 1. 技术栈与框架选型
### 1.1 后端技术栈
| 层级 | 技术选型 | 版本/说明 |
|------|---------|----------|
| **开发语言** | Go | 1.21+,高性能、原生并发、低内存占用 |
| **Web 框架** | Gin | 轻量级、高性能 HTTP 路由与中间件 |
| **ORM / 数据库** | GORM | 支持 PostgreSQL / SQLite / MySQL自动迁移 |
| **缓存** | Redis (go-redis) | 可选启用,分布式会话与热点缓存 |
| **本地缓存** | 自研 LocalCache | 内存 L1 缓存TTL + 后台清理 |
| **配置管理** | Viper | YAML + 环境变量统一配置 |
| **日志** | Zap | 高性能结构化日志 |
| **JWT** | golang-jwt/jwt | RS256 签名,支持 JTI 与 Token 滚动轮换 |
| **密码哈希** | golang.org/x/crypto/argon2 | Argon2id启动时自适应校准 |
| **TOTP 2FA** | github.com/pquerna/otp | RFC 6238 兼容 |
| **监控** | Prometheus + OpenTelemetry | 指标采集与链路追踪 |
| **限流** | Uber Rate Limit / 自研 | 令牌桶 + 内存清理 |
| **容器化** | Docker | 单容器 + Docker Compose 编排 |
| **编排(可选)** | Kubernetes | 生产集群部署 |
### 1.2 前端技术栈Admin 后台)
| 层级 | 技术选型 | 说明 |
|------|---------|------|
| **框架** | React 18 + TypeScript | 类型安全、组件化开发 |
| **构建工具** | Vite | 快速冷启动与热更新 |
| **UI 组件库** | Ant Design 5 | 企业级后台组件 |
| **状态管理** | React Context会话态 | 不引入重型状态库 |
| **HTTP 客户端** | 原生 `fetch` + 统一请求客户端 | 无 Axios 依赖 |
| **路由** | React Router 6 | 受保护路由方案 |
| **样式** | CSS Modules + CSS Variables + AntD Theme Token | 无 styled-components |
### 1.3 基础设施
| 组件 | 技术选型 | 说明 |
|------|---------|------|
| **负载均衡** | Nginx | 反向代理、SSL 终止、静态资源缓存 |
| **消息队列(可选)** | Kafka / RabbitMQ | 异步事件、Webhook 投递 |
| **对象存储(可选)** | OSS / S3 | 头像与文件上传 |
| **监控大盘** | Grafana | 可视化 Prometheus 指标 |
| **日志收集** | ELK / Loki | 集中化日志检索 |
---
## 2. 目录结构与分层说明
项目采用 **Clean Architecture** 分层,依赖方向始终向内:
```
handler → service → repository → domain
```
### 2.1 目录结构
```
├── cmd/
│ ├── server/ # HTTP 服务入口
│ └── ums/ # CLI 工具入口
├── internal/
│ ├── api/
│ │ ├── handler/ # HTTP 请求处理器 (Handler 层)
│ │ ├── middleware/ # Gin 中间件认证、限流、日志、CORS 等)
│ │ └── router/ # 路由注册与分组
│ ├── auth/
│ │ └── providers/ # OAuth2 Provider 实现
│ ├── cache/ # 本地缓存 + Redis 封装
│ ├── concurrent/ # 并发工具WorkerPool、SingleFlight
│ ├── config/ # 配置结构与加载
│ ├── database/ # GORM 初始化、连接池、读写分离
│ ├── domain/ # 领域实体User、Role、Permission 等)
│ ├── e2e/ # 端到端测试
│ ├── integration/ # 集成测试
│ ├── middleware/ # 共享中间件(与 api/middleware 区分)
│ ├── monitoring/ # Prometheus 指标与链路追踪
│ ├── pagination/ # 游标分页与 OFFSET 分页封装
│ ├── performance/ # 性能测试与基准测试
│ ├── pkg/ # 内部公共包
│ │ ├── errors/ # 错误码与错误包装
│ │ ├── ip/ # IP 解析与过滤
│ │ ├── oauth/ # OAuth2 辅助工具
│ │ └── ...
│ ├── repository/ # 数据访问层Repository 层)
│ ├── robustness/ # 鲁棒性工具(熔断、重试)
│ ├── security/ # 安全工具(密码策略、加密、校验)
│ ├── server/ # HTTP Server 生命周期管理
│ ├── service/ # 业务逻辑层Service 层)
│ ├── testdb/ # 测试数据库辅助
│ ├── testutil/ # 测试工具函数
│ └── util/ # 通用工具包
├── pkg/
│ └── errors/ # 对外暴露的错误包
├── configs/
│ └── config.yaml # 默认配置文件
├── deployments/
│ ├── docker-compose.yml # 本地编排
│ └── kubernetes/ # K8s 清单
├── docs/ # 设计文档与 API 文档
├── frontend/ # React Admin 前端(独立目录)
├── migrations/ # 数据库迁移脚本
├── scripts/ # 构建与运维脚本
├── sdk/ # 客户端 SDK
├── uploads/ # 本地上传文件存储(受保护)
└── tools/ # 开发工具
```
### 2.2 分层职责
| 分层 | 目录 | 职责 | 依赖规则 |
|------|------|------|----------|
| **Handler 层** | `internal/api/handler` | HTTP 请求解析、参数校验、响应封装、调用 Service | 仅依赖 Service 层 |
| **Service 层** | `internal/service` | 业务逻辑编排、事务管理、领域事件触发 | 仅依赖 Repository 与 Domain |
| **Repository 层** | `internal/repository` | 数据持久化、查询优化、ORM 操作 | 仅依赖 Domain |
| **Domain 层** | `internal/domain` | 实体定义、值对象、领域规则、接口契约 | 不依赖任何外部层 |
| **基础设施层** | `internal/cache`, `internal/database`, `internal/config` | 技术实现(缓存、数据库、配置) | 可被上层通过接口注入 |
---
## 3. 核心模块架构图
### 3.1 整体模块交互
```
┌─────────────────────────────────────────────────────────────────────┐
│ 外部客户端 │
│ (Web Admin / Mobile App / 第三方 OAuth / SDK 调用方) │
└───────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────┐
│ API 网关层 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 认证中间件 │ │ 限流中间件 │ │ 日志中间件 │ │
│ │ (JWT/OAuth2) │ │ (RateLimit) │ │ (AccessLog) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ CORS 中间件 │ │ CSRF 中间件 │ │ IP 过滤中间件 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└───────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────┐
│ Handler 层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Auth │ │ User │ │ Role │ │ Device │ │ Log │ │
│ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Admin │ │ Webhook │ │ 2FA │ │ OAuth │ │
│ │ Handler │ │ Handler │ │ Handler │ │ Handler │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────┐
│ Service 层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Auth │ │ User │ │ Role │ │ Device │ │ Log │ │
│ │ Service │ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Admin │ │ Webhook │ │ 2FA │ │ OAuth │ │
│ │ Service │ │ Service │ │ Service │ │ Service │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────┐
│ Repository 层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ UserRepo │ │ RoleRepo │ │ PermRepo │ │ DevRepo │ │ LogRepo │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Social │ │ Password │ │ Webhook │ │
│ │ Repo │ │ History │ │ Repo │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└───────────────────────────────┬─────────────────────────────────────┘
┌───────────────────────────────▼─────────────────────────────────────┐
│ Domain 层 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ User │ │ Role │ │ Permission│ │ Device │ │ LoginLog │ │
│ │ Entity │ │ Entity │ │ Entity │ │ Entity │ │ Entity │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Operation│ │ Webhook │ │ Password │ │
│ │ Log │ │ Entity │ │ History │ │
│ │ Entity │ │ │ │ Entity │ │
│ └──────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────────────────────────────┘
```
### 3.2 请求处理流程
| 步骤 | 组件 | 动作 |
|------|------|------|
| 1 | Nginx / Ingress | SSL 终止、静态资源缓存、反向代理 |
| 2 | Gin Router | 路由匹配、路径参数解析 |
| 3 | Middleware Chain | 限流 → IP 过滤 → CORS → CSRF → 认证 → 日志 |
| 4 | Handler | 绑定请求体、参数校验、调用 Service |
| 5 | Service | 业务逻辑、权限检查、事务封装 |
| 6 | Repository | ORM 查询 / 写入、缓存读写 |
| 7 | Database / Cache | 数据持久化或缓存命中 |
| 8 | Service | 组装领域结果、触发异步事件Webhook、日志 |
| 9 | Handler | 统一响应封装code / message / data |
| 10 | Middleware | 记录访问日志、更新 Prometheus 指标 |
### 3.3 核心模块职责表
| 模块 | 职责 | 关键文件/包 |
|------|------|------------|
| **认证 (Auth)** | 注册、登录、登出、JWT 签发与刷新、密码重置、TOTP | `internal/auth`, `internal/api/handler/auth_handler.go` |
| **用户 (User)** | CRUD、头像上传、状态管理、角色分配、导入导出 | `internal/service/user_service.go`, `internal/repository/user_repository.go` |
| **RBAC** | 角色管理、权限管理、角色继承、权限树 | `internal/domain/role.go`, `internal/service/role_service.go` |
| **设备 (Device)** | 设备注册、信任管理、多设备登出 | `internal/api/handler/device_handler.go` |
| **日志 (Log)** | 登录日志、操作日志、查询与审计 | `internal/repository/log_repository.go` |
| **OAuth2** | 第三方登录、社交账号绑定/解绑 | `internal/auth/providers/` |
| **Webhook** | 事件订阅、异步投递、重试机制 | `internal/service/webhook_service.go` |
| **Admin** | 仪表盘统计、批量导入导出 | `internal/api/handler/admin_handler.go` |
| **安全 (Security)** | 密码策略、IP 过滤、敏感数据脱敏 | `internal/security/` |
---
## 4. 数据模型
### 4.1 实体关系总览
```
users ||--o{ user_roles : "多对多"
users ||--o{ devices : "一对多"
users ||--o{ login_logs : "一对多"
users ||--o{ operation_logs : "一对多"
users ||--o{ user_social_accounts : "一对多"
users ||--o{ password_history : "一对多"
roles ||--o{ user_roles : "多对多"
roles ||--o{ role_permissions : "多对多"
roles ||--o{ roles : "自关联(继承)"
permissions ||--o{ role_permissions : "多对多"
webhooks ||--o{ webhook_deliveries : "一对多"
```
### 4.2 核心实体定义
#### User用户
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AutoIncrement | 用户唯一标识 |
| username | VARCHAR(50) | UNIQUE, Index | 用户名 |
| email | VARCHAR(100) | UNIQUE, Index | 邮箱地址 |
| phone | VARCHAR(20) | UNIQUE, Index | 手机号 |
| password | VARCHAR(255) | Not Null | Argon2id 哈希密码 |
| nickname | VARCHAR(50) | Nullable | 昵称 |
| avatar | VARCHAR(255) | Nullable | 头像 URL |
| gender | TINYINT | Default 0 | 性别0-未知1-男2-女 |
| birthday | DATE | Nullable | 生日 |
| region | VARCHAR(50) | Nullable | 所在地区 |
| bio | VARCHAR(500) | Nullable | 个性签名 |
| status | TINYINT | Default 1, Index | 状态0-待激活1-正常2-锁定3-禁用 |
| totp_secret | VARCHAR(255) | Nullable | TOTP 密钥(加密存储) |
| totp_enabled | TINYINT | Default 0 | 是否启用 TOTP |
| password_changed_at | DATETIME | Nullable | 密码最后修改时间(用于 Token 失效) |
| last_login_time | DATETIME | Nullable | 最后登录时间 |
| last_login_ip | VARCHAR(50) | Nullable | 最后登录 IP |
| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 |
| updated_at | DATETIME | AutoUpdate | 更新时间 |
| deleted_at | DATETIME | Nullable, Index | 软删除时间GORM 支持) |
**索引**`uk_username`, `uk_email`, `uk_phone`, `idx_status`, `idx_created_at`
#### Role角色
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AutoIncrement | 角色唯一标识 |
| name | VARCHAR(50) | UNIQUE, Not Null | 角色名称 |
| code | VARCHAR(50) | UNIQUE, Not Null | 角色代码(如 `admin`, `user` |
| description | VARCHAR(200) | Nullable | 角色描述 |
| parent_id | BIGINT | FK → roles.id, Nullable | 父角色 ID继承关系 |
| level | INT | Default 1, Index | 角色层级 |
| is_system | TINYINT | Default 0 | 是否系统内置角色 |
| is_default | TINYINT | Default 0, Index | 是否新用户默认角色 |
| status | TINYINT | Default 1 | 状态0-禁用1-启用 |
| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 |
| updated_at | DATETIME | AutoUpdate | 更新时间 |
**索引**`uk_name`, `uk_code`, `idx_parent_id`, `idx_level`
**初始数据**
- `id=1, code='admin', name='管理员', is_system=1` —— 系统管理员
- `id=2, code='user', name='普通用户', is_system=1, is_default=1` —— 默认用户
#### Permission权限
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AutoIncrement | 权限唯一标识 |
| name | VARCHAR(50) | Not Null | 权限名称 |
| code | VARCHAR(100) | UNIQUE, Not Null | 权限代码(格式 `resource:action` |
| resource | VARCHAR(50) | Not Null, Index | 资源名称(如 `user`, `role` |
| action | VARCHAR(20) | Not Null | 操作类型:`read` / `write` / `delete` / `execute` |
| description | VARCHAR(200) | Nullable | 权限描述 |
| type | VARCHAR(20) | Not Null, Index | 权限类型:`api` / `page` / `button` |
| group_id | BIGINT | Nullable, Index | 权限分组 ID |
| status | TINYINT | Default 1 | 状态0-禁用1-启用 |
| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 |
| updated_at | DATETIME | AutoUpdate | 更新时间 |
**索引**`uk_code`, `idx_resource`, `idx_group_id`
#### Device设备
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AutoIncrement | 设备唯一标识 |
| user_id | BIGINT | FK → users.id, Not Null, Index | 所属用户 |
| device_id | VARCHAR(100) | UNIQUE, Not Null | 设备唯一标识字符串 |
| device_name | VARCHAR(50) | Nullable | 设备名称 |
| device_type | VARCHAR(20) | Not Null | 设备类型:`pc` / `mobile` / `tablet` |
| os | VARCHAR(50) | Nullable | 操作系统 |
| browser | VARCHAR(50) | Nullable | 浏览器 |
| ip | VARCHAR(50) | Nullable | 最近 IP |
| location | VARCHAR(100) | Nullable | 地理位置 |
| is_trusted | TINYINT | Default 0 | 是否信任设备(跳过 2FA |
| trust_expires_at | DATETIME | Nullable | 信任状态过期时间 |
| last_active_time | DATETIME | Nullable, Index | 最后活跃时间 |
| created_at | DATETIME | Default CURRENT_TIMESTAMP | 创建时间 |
**索引**`idx_user_id`, `uk_device_id`, `idx_last_active_time`
#### LoginLog登录日志
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AutoIncrement | 日志唯一标识 |
| user_id | BIGINT | FK → users.id, Nullable, Index | 用户 ID匿名登录为 NULL |
| login_type | VARCHAR(20) | Not Null | 登录方式:`password` / `code` / `wechat` / `github` / ... |
| login_method | VARCHAR(20) | Nullable | 认证子方式 |
| ip | VARCHAR(50) | Nullable, Index | 登录 IP |
| location | VARCHAR(100) | Nullable | 地理位置 |
| device_id | VARCHAR(100) | Nullable | 设备标识 |
| user_agent | VARCHAR(500) | Nullable | User-Agent |
| status | TINYINT | Not Null, Index | 状态0-失败1-成功 |
| failure_reason | VARCHAR(200) | Nullable | 失败原因 |
| created_at | DATETIME | Default CURRENT_TIMESTAMP, Index | 登录时间 |
**索引**`idx_user_id`, `idx_ip`, `idx_status`, `idx_created_at`
**分区建议**MySQL/PostgreSQL按月分区保留最近 12 个月。
#### OperationLog操作日志
| 字段 | 类型 | 约束 | 说明 |
|------|------|------|------|
| id | BIGINT | PK, AutoIncrement | 日志唯一标识 |
| user_id | BIGINT | FK → users.id, Nullable, Index | 操作人 ID |
| action_type | VARCHAR(50) | Not Null | 操作类型(如 `user:create` |
| resource_type | VARCHAR(50) | Not Null, Index | 资源类型(如 `user`, `role` |
| resource_id | BIGINT | Nullable | 资源 ID |
| action | VARCHAR(20) | Not Null | 动作:`create` / `update` / `delete` |
| old_value | TEXT | Nullable | 操作前值JSON |
| new_value | TEXT | Nullable | 操作后值JSON |
| ip | VARCHAR(50) | Nullable | 操作 IP |
| user_agent | VARCHAR(500) | Nullable | User-Agent |
| created_at | DATETIME | Default CURRENT_TIMESTAMP, Index | 操作时间 |
**索引**`idx_user_id`, `idx_resource_type`, `idx_created_at`
**分区建议**MySQL/PostgreSQL按月分区保留最近 24 个月。
### 4.3 关联表
| 关联表 | 关联实体 | 说明 |
|--------|---------|------|
| `user_roles` | users ↔ roles | 多对多:用户角色分配 |
| `role_permissions` | roles ↔ permissions | 多对多:角色权限分配 |
| `user_social_accounts` | users | 一对多:第三方社交账号绑定 |
| `password_history` | users | 一对多:密码历史(防重用) |
| `webhooks` | - | Webhook 配置 |
| `webhook_deliveries` | webhooks | Webhook 投递日志 |
---
## 5. 接口设计RESTful API 分组列表)
基础路径:`/api/v1`
认证方式:`Authorization: Bearer <access_token>`
统一响应:`{ "code": 0, "message": "success", "data": {} }`
### 5.1 认证组Auth
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| POST | `/auth/register` | 公开 | 用户注册 |
| POST | `/auth/bootstrap-admin` | 公开 | 初始化管理员(首次启动) |
| POST | `/auth/login` | 公开 | 账号密码登录 |
| POST | `/auth/login/email-code` | 公开 | 邮箱验证码登录 |
| POST | `/auth/login/code` | 公开 | 短信验证码登录 |
| POST | `/auth/refresh` | 公开 | 刷新 Access TokenRefresh Token |
| POST | `/auth/logout` | 需认证 | 登出 |
| GET | `/auth/userinfo` | 需认证 | 获取当前用户信息 |
| GET | `/auth/capabilities` | 公开 | 获取系统能力配置 |
| GET | `/auth/activate` | 公开 | 邮箱激活 |
| POST | `/auth/resend-activation` | 公开 | 重发激活邮件 |
| POST | `/auth/forgot-password` | 公开 | 忘记密码 |
| GET | `/auth/reset-password` | 公开 | 验证重置 Token 页面 |
| POST | `/auth/reset-password` | 公开 | 提交新密码 |
| POST | `/auth/send-email-code` | 公开 | 发送邮箱验证码 |
| POST | `/auth/send-code` | 公开 | 发送短信验证码 |
| GET | `/auth/csrf-token` | 公开 | 获取 CSRF Token |
| GET | `/auth/captcha` | 公开 | 获取验证码配置 |
| GET | `/auth/captcha/image` | 公开 | 获取图形验证码 |
| POST | `/auth/captcha/verify` | 公开 | 验证图形验证码 |
### 5.2 双因素认证组2FA / TOTP
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | `/auth/2fa/status` | 需认证 | 获取 2FA 状态 |
| GET | `/auth/2fa/setup` | 需认证 | 获取 TOTP 设置信息QR Code |
| POST | `/auth/2fa/enable` | 需认证 | 启用 TOTP |
| POST | `/auth/2fa/disable` | 需认证 | 禁用 TOTP |
| POST | `/auth/2fa/verify` | 需认证 | 验证 TOTP 码 |
### 5.3 OAuth2 组
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | `/auth/oauth/providers` | 公开 | 获取已配置的 Provider 列表 |
| GET | `/auth/oauth/:provider` | 公开 | 跳转 OAuth2 授权页 |
| GET | `/auth/oauth/:provider/callback` | 公开 | OAuth2 回调处理 |
### 5.4 用户组User
| 方法 | 路径 | 认证/权限 | 说明 |
|------|------|----------|------|
| GET | `/users` | 管理员 | 用户列表(分页、筛选、排序) |
| GET | `/users/:id` | 本人或管理员 | 获取用户详情 |
| PUT | `/users/:id` | 本人或管理员 | 更新用户信息 |
| DELETE | `/users/:id` | `user:delete` | 删除用户 |
| PUT | `/users/:id/password` | 本人 | 修改密码 |
| PUT | `/users/:id/status` | `user:manage` | 修改用户状态 |
| GET | `/users/:id/roles` | 本人或管理员 | 获取用户角色 |
| PUT | `/users/:id/roles` | `user:manage` | 分配用户角色 |
| POST | `/users/:id/avatar` | 需认证 | 上传头像 |
| GET | `/users/me/social-accounts` | 需认证 | 获取当前用户社交账号 |
| POST | `/users/me/bind-social` | 需认证 | 绑定社交账号 |
| DELETE | `/users/me/bind-social/:provider` | 需认证 | 解绑社交账号 |
### 5.5 角色与权限组RBAC
| 方法 | 路径 | 认证/权限 | 说明 |
|------|------|----------|------|
| POST | `/roles` | 管理员 | 创建角色 |
| GET | `/roles` | 管理员 | 角色列表 |
| GET | `/roles/:id` | 管理员 | 角色详情 |
| PUT | `/roles/:id` | 管理员 | 更新角色 |
| DELETE | `/roles/:id` | 管理员 | 删除角色 |
| PUT | `/roles/:id/status` | 管理员 | 修改角色状态 |
| GET | `/roles/:id/permissions` | 管理员 | 获取角色权限 |
| PUT | `/roles/:id/permissions` | 管理员 | 分配角色权限 |
| POST | `/permissions` | 管理员 | 创建权限 |
| GET | `/permissions` | 管理员 | 权限列表 |
| GET | `/permissions/tree` | 管理员 | 权限树形结构 |
| GET | `/permissions/:id` | 管理员 | 权限详情 |
| PUT | `/permissions/:id` | 管理员 | 更新权限 |
| DELETE | `/permissions/:id` | 管理员 | 删除权限 |
| PUT | `/permissions/:id/status` | 管理员 | 修改权限状态 |
### 5.6 设备组Device
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | `/devices` | 需认证 | 设备列表 |
| POST | `/devices` | 需认证 | 注册设备 |
| GET | `/devices/:id` | 需认证 | 设备详情 |
| PUT | `/devices/:id` | 需认证 | 更新设备 |
| DELETE | `/devices/:id` | 需认证 | 删除设备 |
| PUT | `/devices/:id/status` | 需认证 | 修改设备状态 |
| POST | `/devices/:id/trust` | 需认证 | 设置设备信任 |
| DELETE | `/devices/:id/trust` | 需认证 | 取消设备信任 |
| POST | `/devices/by-device-id/:deviceId/trust` | 需认证 | 按设备标识设置信任 |
| GET | `/devices/me/trusted` | 需认证 | 获取信任设备列表 |
| POST | `/devices/me/logout-others` | 需认证 | 登出所有其他设备 |
| GET | `/devices/users/:id` | 管理员 | 获取指定用户的设备 |
### 5.7 日志组Log
| 方法 | 路径 | 认证/权限 | 说明 |
|------|------|----------|------|
| GET | `/logs/login/me` | 需认证 | 当前用户登录日志 |
| GET | `/logs/operation/me` | 需认证 | 当前用户操作日志 |
| GET | `/logs/login` | 管理员 | 全量登录日志 |
| GET | `/logs/operation` | 管理员 | 全量操作日志 |
### 5.8 Webhook 组
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| POST | `/webhooks` | 需认证 | 创建 Webhook |
| GET | `/webhooks` | 需认证 | Webhook 列表 |
| PUT | `/webhooks/:id` | 需认证 | 更新 Webhook |
| DELETE | `/webhooks/:id` | 需认证 | 删除 Webhook |
| GET | `/webhooks/:id/deliveries` | 需认证 | 投递记录 |
### 5.9 管理员扩展组Admin
| 方法 | 路径 | 认证/权限 | 说明 |
|------|------|----------|------|
| GET | `/admin/users/export` | 管理员 | 导出用户CSV/XLSX |
| POST | `/admin/users/import` | 管理员 | 导入用户 |
| GET | `/admin/users/import/template` | 管理员 | 下载导入模板 |
| GET | `/admin/stats/dashboard` | 管理员 | 仪表盘统计 |
| GET | `/admin/stats/users` | 管理员 | 用户统计 |
### 5.10 基础设施端点
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | `/health` | 公开 | 健康检查 |
| GET | `/health/live` | 公开 | Liveness Probe |
| GET | `/health/ready` | 公开 | Readiness Probe |
| GET | `/metrics` | 公开 | Prometheus 指标 |
| GET | `/swagger/*any` | 公开 | Swagger 文档 |
---
## 6. 安全设计
### 6.1 认证机制
| 机制 | 实现 | 说明 |
|------|------|------|
| **JWT 访问令牌** | RS256 非对称签名 | Access Token 有效期短(默认 2h携带用户 ID、角色、权限 |
| **Refresh Token** | 独立令牌,滚动轮换 | 每次刷新后旧 Refresh Token 失效,防重放 |
| **JTI 唯一标识** | timestamp(8B hex) + random(16B hex) | 防 Token 枚举,支持精确吊销 |
| **Token 黑名单** | Redis / 内存缓存 | 登出、密码修改后 Token 立即失效 |
| **密码修改失效PCE** | `password_changed_at` 字段 | 密码修改后旧 Token 自动失效 |
| **TOTP 双因素认证** | RFC 6238 | 6 位动态码,支持信任设备跳过 |
| **设备信任** | `is_trusted` + `trust_expires_at` | 信任设备在有效期内免 2FA |
| **OAuth2 第三方登录** | 标准 Authorization Code 流程 | 支持 GitHub、Google、微信等 Provider |
| **SSO 就绪** | JWT + 统一用户中心架构 | 可通过扩展支持单点登录 |
### 6.2 授权机制RBAC
| 机制 | 实现 | 说明 |
|------|------|------|
| **角色继承** | 自关联 `parent_id` + 层级 `level` | 子角色自动继承父角色权限,最大深度 20 |
| **权限代码** | `resource:action` 格式 | 如 `user:read`, `user:delete` |
| **权限类型** | `api` / `page` / `button` | 覆盖接口、页面、按钮三级权限 |
| **中间件鉴权** | `RequirePermission` / `RequireRole` | Handler 层统一拦截 |
| **数据级鉴权** | Service 层用户 ID 比对 | 如用户只能修改自己的资料 |
### 6.3 限流与防护
| 机制 | 实现 | 说明 |
|------|------|------|
| **接口限流** | 令牌桶算法 | 按 IP / 用户维度限流,防止暴力破解 |
| **限流内存清理** | 后台定期清理过期桶 | 防止内存泄漏 |
| **登录失败锁定** | 递增延迟 + 最大重试次数 | 防暴力破解 |
| **图形验证码** | 算术/字符验证码 | 注册、登录、重置密码前验证 |
| **CSRF 防护** | Double Submit Cookie + Token | POST/PUT/DELETE/PATCH 自动校验 |
| **CORS 白名单** | 配置化允许域名 | 拒绝危险通配符配置 |
| **IP 过滤** | 黑白名单机制 | 支持按 IP 段拦截 |
| **上传保护** | `/uploads` 路由认证中间件 | 防止未授权访问用户文件 |
### 6.4 密码策略
| 策略 | 实现 | 说明 |
|------|------|------|
| **哈希算法** | Argon2id | 内存硬函数,抗 GPU/ASIC 破解 |
| **自适应校准** | `auth.CalibrateArgon2id` | 启动时根据 CPU 自动调整参数 |
| **默认参数** | 64MB 内存3 次迭代4 并行度 | 平衡安全性与性能 |
| **密码历史** | `password_history` 表 | 禁止重用最近 N 次密码 |
| **历史异步保存** | goroutine + `context.WithTimeout` | 不阻塞主登录流程 |
| **常数时间比较** | `subtle.ConstantTimeCompare` | 防时序攻击 |
| **弱密码检测** | 常见弱密码字典 | 注册/修改时拦截 |
### 6.5 敏感数据保护
| 数据类型 | 保护措施 |
|---------|---------|
| **密码** | Argon2id 哈希,不可逆 |
| **TOTP Secret** | AES-256-GCM 加密存储 |
| **手机号/邮箱** | 日志中部分脱敏(如 `138****1234` |
| **Token** | 仅存储 JTI不存储完整 Token |
| **备份数据** | 加密存储,异地备份 |
| **传输层** | TLS 1.2+HSTS 头部 |
### 6.6 已修复安全漏洞(关键)
| 问题 | 严重等级 | 修复要点 |
|------|----------|----------|
| LIKE 查询 SQL 注入 | P0 | 参数化查询 + 转义 |
| 登录计数竞态条件 | P0 | 原子操作 / 分布式锁 |
| Refresh Token 黑名单 fail-open | P0 | 默认拒绝策略 |
| 验证码 Replay 攻击 | P0 | 一次性使用 + 过期校验 |
| CORS 危险配置 | P0 | 白名单校验 |
| UpdateUser IDOR 越权 | P0 | 数据级权限校验 |
| Login TOTP 绕过 | P0 | 验证流程强制化 |
| 游标分页数据错乱 | P0 | 稳定排序键 |
---
## 7. 性能优化清单
### 7.1 数据库优化
| 优化项 | 状态 | 说明 |
|--------|------|------|
| 批量查询替代循环查询(`FilterExistingUsernames` | **[已实施]** | `generateUniqueUsername` 使用批量 IN 查询替代逐条循环 |
| 单一查询替代串行查询(`FindByAccount` | **[已实施]** | `findUserForLogin` 使用一次查询覆盖 username/email/phone |
| 角色继承深度限制(`maxAncestorDepth=20` | **[已实施]** | 防止递归查询栈溢出与性能退化 |
| 数据库索引优化 | 已实施 | `users` 表 uk_username/uk_email/uk_phone/idx_status`login_logs` 按时间分区 |
| 预加载关联数据GORM Preload | 已实施 | 用户列表预加载角色,避免 N+1 |
| 游标分页替代 OFFSET | 已实施 | 大数据量列表使用 ID 游标分页 |
| 连接池调优 | 已实施 | `max_open_conns=100`, `max_idle_conns=20`, 连接生命周期 30min |
| 数据库读写分离 | 待实施 | 主库写、从库读,轮询负载均衡 |
### 7.2 缓存优化
| 优化项 | 状态 | 说明 |
|--------|------|------|
| L1 本地缓存 | 已实施 | 内存缓存用户、权限、Token 黑名单TTL 5min |
| L2 Redis 缓存 | 可选 | 分布式缓存TTL 30min支持集群 |
| 缓存穿透防护 | 已实施 | 空值缓存 + 布隆过滤器 |
| 缓存击穿防护 | 已实施 | SingleFlight 互斥锁,热点 Key 只回源一次 |
| 缓存雪崩防护 | 已实施 | 随机 TTL 抖动,避免集中过期 |
### 7.3 计算与并发优化
| 优化项 | 状态 | 说明 |
|--------|------|------|
| Argon2id 启动时自适应校准 | **[已实施]** | 根据当前 CPU 能力自动选择最优参数 |
| 密码历史异步保存 | **[已实施]** | goroutine + `context.WithTimeout` 不阻塞登录主流程 |
| RateLimiter 定期清理 | **[已实施]** | 后台定时清理过期限流桶,防止内存泄漏 |
| WorkerPool 协程池 | 已实施 | 批量操作限制并发度,防止 goroutine 爆炸 |
| 异步事件处理 | 已实施 | Webhook 投递、日志写入异步化 |
### 7.4 接口与路由优化
| 优化项 | 状态 | 说明 |
|--------|------|------|
| `/uploads` 路由认证保护 | **[已实施]** | 静态文件路由增加认证中间件 |
| Gzip 压缩 | 已实施 | 响应体 > 1KB 自动压缩 |
| HTTP/2 支持 | 已实施 | Nginx / Go 1.21+ 原生支持 |
| 静态资源 CDN | 待实施 | 生产环境头像、JS/CSS 走 CDN |
| 请求体大小限制 | 已实施 | 防止大文件 DOS |
### 7.5 性能目标
| 指标 | 目标值 | 说明 |
|------|--------|------|
| 并发用户数 | 100,000 | 集群部署 + Redis 会话 |
| QPS | 100,000 | 多级缓存 + 读写分离 |
| P50 响应时间 | < 100ms | 缓存命中场景 |
| P99 响应时间 | < 500ms | 含数据库回源 |
| 缓存命中率 | > 95% | L1 + L2 综合 |
---
## 8. 部署架构建议
### 8.1 单机部署SQLite + 可选 Redis
适用场景:开发测试、小型团队、单机低并发。
```
┌─────────────────────────────────────────┐
│ Nginx (反向代理) │
│ SSL 终止 / 静态资源缓存 │
└──────────────────┬──────────────────────┘
┌──────────────────▼──────────────────────┐
│ UMS 应用服务 (Go/Gin) │
│ ┌─────────────────────────────────┐ │
│ │ Handler / Service / Repository │ │
│ │ L1 本地缓存 (内存) │ │
│ └─────────────────────────────────┘ │
│ │ │
│ ┌────────────────┼────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ SQLite │ │ Redis │ │ uploads│ │
│ │ (主存) │ │(可选L2)│ │ (受保护)│ │
│ └────────┘ └────────┘ └────────┘ │
└─────────────────────────────────────────┘
```
**配置要点**
- SQLite 文件存储在持久化卷
- 每日全量备份 SQLite 文件
- Redis 可选,用于分布式锁和二级缓存
- 单实例无状态,重启不影响数据
### 8.2 集群部署PostgreSQL + Redis Cluster
适用场景:生产环境、中大型应用、高可用要求。
```
┌─────────────────────────────────────────────────────────────┐
│ 全局负载均衡 (GSLB) │
│ DNS 轮询 / 健康检查 / 故障转移 │
└──────────────────────┬──────────────────────────────────────┘
┌──────────────┼──────────────┐
│ │ │
┌───────▼──────┐ ┌────▼──────┐ ┌─────▼──────┐
│ 机房 A │ │ 机房 B │ │ 机房 C │
│ (北京) │ │ (上海) │ │ (灾备) │
│ │ │ │ │ │
│ ┌──────────┐ │ │ ┌────────┐│ │ ┌────────┐ │
│ │ Nginx │ │ │ │ Nginx ││ │ │ Nginx │ │
│ │ 负载均衡 │ │ │ │负载均衡││ │ │负载均衡│ │
│ └────┬─────┘ │ │ └───┬────┘│ │ └───┬────┘ │
│ │ │ │ │ │ │ │ │
│ ┌────▼─────┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │
│ │ UMS 集群 │ │ │ │UMS 集群│ │ │ │UMS 集群│ │
│ │ (多实例) │ │ │ │(多实例)│ │ │ │(多实例)│ │
│ │ L1 缓存 │ │ │ │L1 缓存 │ │ │ │L1 缓存 │ │
│ └────┬─────┘ │ │ └───┬───┘ │ │ └───┬───┘ │
│ │ │ │ │ │ │ │ │
│ ┌────▼─────┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │
│ │Redis 集群│ │ │ │Redis │ │ │ │Redis │ │
│ │ 哨兵模式 │ │ │ │哨兵 │ │ │ │哨兵 │ │
│ └────┬─────┘ │ │ └───┬───┘ │ │ └───┬───┘ │
│ │ │ │ │ │ │ │ │
│ ┌────▼─────┐ │ │ ┌───▼───┐ │ │ ┌───▼───┐ │
│ │PG 主从 │ │ │ │PG 主从│ │ │ │PG 主从│ │
│ │ 主(写) │ │ │ │ 主(写)│ │ │ │ 主(写)│ │
│ │ 从(读)×2 │ │ │ │从(读)×2│ │ │ │从(读)×2│ │
│ └──────────┘ │ │ └───────┘ │ │ └───────┘ │
└──────────────┘ └───────────┘ └────────────┘
```
**配置要点**
- PostgreSQL 主从复制,从库承担读流量
- Redis 哨兵模式,高可用缓存与会话存储
- UMS 实例无状态,支持水平扩展
- Nginx 层做限流、SSL、静态缓存
- 跨机房异步复制RPO < 1min
### 8.3 容器化部署Docker Compose
```yaml
# docker-compose.yml 核心服务
services:
ums:
image: ums:latest
ports:
- "8080:8080"
environment:
- DATABASE_TYPE=postgres
- DATABASE_DSN=postgresql://ums:pass@postgres:5432/ums
- REDIS_ADDR=redis:6379
volumes:
- ./uploads:/app/uploads
depends_on:
- postgres
- redis
deploy:
replicas: 2
postgres:
image: postgres:15-alpine
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
volumes:
- redisdata:/data
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
```
### 8.4 Kubernetes 部署
| 资源 | 类型 | 说明 |
|------|------|------|
| **Deployment** | `ums-api` | 3+ 副本,滚动更新 |
| **Service** | `ClusterIP` + `LoadBalancer` | 内部集群 + 外部暴露 |
| **ConfigMap** | `ums-config` | 应用配置外置 |
| **Secret** | `ums-secrets` | JWT 私钥、数据库密码 |
| **HPA** | 自动扩缩容 | CPU > 70% 或 QPS 阈值触发 |
| **PVC** | `uploads-pvc` | 共享存储(或替换为 OSS |
| **Ingress** | Nginx Ingress | 路由、SSL、限流 |
| **PodDisruptionBudget** | `minAvailable: 2` | 保证升级时可用性 |
### 8.5 监控与告警
| 层级 | 组件 | 采集指标 |
|------|------|----------|
| 基础设施 | Node Exporter | CPU、内存、磁盘、网络 |
| 中间件 | Redis Exporter / Postgres Exporter | 连接数、QPS、慢查询 |
| 应用 | Prometheus + OpenTelemetry | HTTP 延迟、错误率、缓存命中率 |
| 日志 | Grafana Loki / ELK | 结构化日志检索 |
| 告警 | Prometheus Alertmanager | P99 > 500ms、错误率 > 1%、磁盘 > 80% |
---
## 附录:文档索引
| 文档 | 路径 | 说明 |
|------|------|------|
| API 契约 | `docs/API.md` | 完整接口定义与响应示例 |
| 数据模型 | `docs/DATA_MODEL.md` | 数据库表结构、索引、ER 图 |
| 技术架构 | `docs/ARCHITECTURE.md` | 性能优化、缓存策略、监控 |
| 部署指南 | `docs/DEPLOYMENT.md` | 环境配置、升级、回滚 |
| 安全文档 | `docs/SECURITY.md` | 安全机制、漏洞修复记录 |
| PRD | `docs/PRD.md` | 产品需求文档 |
---
*本文档持续更新,如有变更请同步更新本文件及相关子文档。*

View File

@@ -0,0 +1,340 @@
# UMS 项目全面代码复核报告 v6.0
**报告日期**: 2026-04-20
**审查范围**: 当前 `main` 工作区全部实现代码、旧报告未闭环问题、自动化门禁、系统化静态审查结果
**基线说明**: 本报告按日期拆分,作为 [FULL_CODE_REVIEW_REPORT_2026-04-17.md](./FULL_CODE_REVIEW_REPORT_2026-04-17.md) 的后续复核报告。凡与旧报告或旧附录冲突之处,以本报告基于 2026-04-20 新鲜命令证据和当前代码实现得到的结论为准。
---
## 一句话结论
项目在 2026-04-17 报告中的多数首轮 P0 缺陷已经被修复,但当前代码仍存在新的认证与授权断层,且旧报告中的一部分未修复问题仍未真正闭环。当前状态不适合宣称“全部问题已修完”或“可直接上线”。
---
## 2026-04-20 新鲜验证证据
| 项目 | 命令 | 结果 | 说明 |
|---|---|---|---|
| 后端构建 | `go build ./cmd/server` | PASS | 2026-04-20 23:07:51 +08:00 实跑通过 |
| 后端静态检查 | `go vet ./...` | PASS | 实跑通过 |
| 后端测试 | `go test ./... -count=1` | PASS | 全量通过,`internal/service` 仍是主要耗时段 |
| 前端 Lint | `cd frontend/admin && npm.cmd run lint` | PASS | 与 2026-04-18 红灯状态相比已恢复 |
| 前端构建 | `cd frontend/admin && npm.cmd run build` | PASS | 实跑通过 |
| 系统化静态检查 | `staticcheck ./...` | FAIL | 发现测试代码 `nil context`、潜在空指针、死代码等问题 |
| 安全静态检查 | `gosec ./internal/... ./cmd/...` | FAIL | 有真实问题,也有大量误报/高噪音结果,需要人工过滤 |
---
## 当前阻塞级问题
### P0-01: `TOTP` 二次验证链路缺少首因子绑定,形成独立登录入口
**位置**
- `internal/api/handler/auth_handler.go:151`
- `internal/service/auth.go:125`
- `internal/service/auth.go:811`
**问题**
- `/api/v1/auth/login/totp-verify` 只要求 `user_id + code + device_id`
- 服务端 `VerifyTOTPAfterPasswordLogin()` 只校验用户状态与 `TOTP` 码,然后直接签发完整 token
- 代码里虽然保留了 `TempToken` 字段,但当前登录闭环并未使用任何临时登录态或 challenge 票据
**影响**
- “密码登录后第二步验证”被降级成“知道用户 ID 且拿到有效 TOTP 即可直接登录”
- 这不是旧 P0-07 的原样复现,但本质上仍然属于 MFA 闭环未正确实现
**结论**
- 旧报告 P0-07 不能标记为“已完全修复”,应迁移为“修复方向已变化,但认证闭环仍未完成”
### P0-02: 设备接口存在成组 `IDOR`
**位置**
- `internal/api/handler/device_handler.go:114`
- `internal/api/handler/device_handler.go:147`
- `internal/api/handler/device_handler.go:183`
- `internal/api/handler/device_handler.go:214`
- `internal/api/handler/device_handler.go:392`
- `internal/api/handler/device_handler.go:474`
- `internal/service/device.go:121`
- `internal/service/device.go:158`
- `internal/service/device.go:163`
- `internal/service/device.go:181`
- `internal/service/device.go:204`
- `internal/service/device.go:236`
**问题**
- `GET/PUT/DELETE /devices/:id`
- `PUT /devices/:id/status`
- `POST/DELETE /devices/:id/trust`
这些接口的 handler 没有 owner/admin 校验service 层也没有按 `user_id` 兜底约束,只按设备主键直接读写删除。
**影响**
- 任意已登录用户只要知道设备 ID就可以读取、修改、删除、信任或取消信任他人设备
**结论**
- 这是本轮新增发现,严重程度等同发布阻塞
### P0-03: 修改密码接口缺少“本人或管理员”授权校验
**位置**
- `internal/api/handler/user_handler.go:275`
- `internal/service/user_service.go:84`
**问题**
- `PUT /api/v1/users/:id/password` 直接使用路径里的 `id`
- handler 没有 self-or-admin 校验
- service 只验证目标用户旧密码是否正确
**影响**
- 普通用户在知道目标用户旧密码时可直接修改目标用户密码
- 管理员也没有单独的安全重置路径,权限模型与接口语义混杂
**结论**
- 这是一条真实的授权缺口,应纳入 P0
### P0-04: 上下文协议漂移导致多处管理员路径失效
**位置**
- `internal/api/middleware/auth.go:90`
- `internal/api/middleware/auth.go:91`
- `internal/api/handler/user_handler.go:191`
- `internal/api/handler/user_handler.go:374`
- `internal/api/handler/avatar_handler.go:74`
**问题**
- 认证中间件当前只写入 `role_codes` / `permission_codes`
- 多个 handler 仍读取旧的 `user_roles`
**影响**
- 管理员跨用户更新资料
- 管理员查看他人角色
- 管理员代传头像
这些路径都会被错误判定为无权限。
**结论**
- 旧 P0-06 已做过一轮修复,但当前实现没有真正闭环,应以“部分修复后回归失效”迁移进新报告
### P0-05: OAuth handler 仍返回“200 假成功”占位响应
**位置**
- `internal/api/handler/auth_handler.go:316`
- `internal/api/handler/auth_handler.go:329`
- `internal/api/handler/auth_handler.go:342`
- `internal/api/handler/auth_handler.go:353`
- `internal/service/auth.go:939`
- `internal/service/auth.go:946`
- `internal/service/auth.go:1492`
**问题**
- handler 仍直接返回 `OAuth not configured` 或空 provider 列表
- service 层实际上已经存在 `OAuthLogin` / `OAuthCallback` / `GetEnabledOAuthProviders` 逻辑
**影响**
- API 层向前端暴露假成功语义
- 与仓库“禁止 fake success / fail closed”的运行时规则冲突
**结论**
- 这不是旧报告中的原编号问题,但属于当前实现真实性问题,应纳入高优先级修复
### P0-06: 游标分页与动态排序的契约仍未真正闭环
**位置**
- `internal/repository/user.go:353`
**问题**
- 当前实现只在 `sortBy == created_at` 时应用游标条件
- 其他排序字段下并不会报错,只是静默忽略游标条件
**影响**
- 前端如果带着非 `created_at` 排序继续请求下一页,得到的不是严格意义上的“下一页”
- 旧报告的“数据错乱”主因已经被收敛,但 API 契约仍然是不闭合的,容易出现重复页或错误分页预期
**结论**
- 旧 P0-08 不应从报告中移除,应以下降风险后的“残留契约缺口”形式迁移
---
## 从旧报告迁移的未闭环问题
下表只迁移“当前仍未真正闭环”的旧问题;已经明确修复完成的问题不再重复记为未完成。
| 旧编号 | 当前状态 | 新报告结论 |
|---|---|---|
| P0-06 UpdateUser IDOR | 部分修复后再次失效 | 迁移为 P0-04上下文协议漂移导致管理员授权逻辑失效 |
| P0-07 Login 绕过 TOTP | 修复方向变化,但未闭环 | 迁移为 P0-01`totp-verify` 未绑定首因子 |
| P0-08 ListCursor / sort | 风险下降但契约未闭合 | 迁移为 P0-06`created_at` 排序下游标被静默忽略 |
| P1-12 ~ P1-14 响应格式不一致 | 仍未修复 | 保留为 P1`auth_handler``password_reset_handler` 等多处仍返回非统一响应格式 |
| P2-12 `/uploads` 直接暴露 | 仍未修复 | 保留为 P2`router.Setup()` 仍静态暴露上传目录 |
---
## 已确认修复完成的旧问题
以下问题在当前代码中已具备明确修复证据,不再迁移为“未修复项”:
| 旧编号 | 当前状态 | 证据 |
|---|---|---|
| P0-01 LIKE 通配/模式注入 | 已修复 | `internal/repository/operation_log.go``internal/repository/device.go``internal/repository/user.go` 已统一使用 `escapeLikePattern()` |
| P0-02 登录失败计数竞态 | 主路径已修复 | `internal/service/auth.go:492` 已改用 `cache.Increment()`;但降级 fallback 仍保留非原子路径,见“残留风险” |
| P0-03 refresh 黑名单 fail-open | 已修复 | `internal/service/auth.go` 中黑名单写入失败已向上返回错误 |
| P0-04 手机重置 replay | 基本修复 | `internal/service/password_reset.go` 在验证码校验通过后先删除 key 再继续流程 |
| P0-05 CORS 默认危险组合 | 已修复 | `internal/api/middleware/cors.go` 默认值已改为空 origins + `AllowCredentials=false` |
| P1-01 错误处理中间件泄露内部错误 | 已修复 | `internal/api/middleware/error.go` 对未知错误返回通用消息 |
| P1-03 导出接口泄露内部错误 | 已修复 | `internal/api/handler/export_handler.go` 已改为通用错误文案 |
| P1-04 CountByResultSince 静默忽略错误 | 已修复 | `internal/repository/login_log.go` 已返回 `(int64, error)` |
| P1-07 Theme SetDefault 非原子 | 已修复 | `internal/repository/theme.go` 已改用事务 |
| P1-08 数据库连接池硬编码 | 已修复 | `internal/database/db.go` 已使用配置参数 |
| P1-15 分页参数无上限 | 大体修复 | `user_handler.go``device_handler.go``log_handler.go` 均已限制 `page_size <= 100` |
---
## 仍需保留的中高优先级问题
### P1-01: API 响应格式仍然不统一
**位置**
- `internal/api/handler/auth_handler.go`
- `internal/api/handler/password_reset_handler.go`
- `internal/api/handler/user_handler.go`
**问题**
- 同一套 API 中同时存在 `{error: ...}``{message: ...}``{code,message,data}` 等多种响应结构
- `Logout``CSRF`、认证错误分支、参数绑定错误分支的格式仍不一致
**影响**
- 前端错误处理成本高
- 自动化契约测试难写
- 文档与真实行为容易继续漂移
### P1-02: 登录失败计数器仍保留非原子降级路径
**位置**
- `internal/service/auth.go:492`
**问题**
- 主路径已使用 `cache.Increment()`
-`Increment` 出错时仍回退到 `Get + current++ + Set`
**影响**
- 在缓存不支持原子递增或运行时出错场景下,旧竞态仍可能重现
**结论**
- 不再按 P0 处理,但仍是必须收尾的 P1
### P1-03: CLI/初始化路径存在权限与类型转换告警
**系统化工具证据**
- `cmd/ums/cmd/init.go:306` `gosec G115`
- `cmd/ums/cmd/init.go:341` `gosec G301`
- `cmd/ums/cmd/init.go:446` `gosec G306`
**人工判断**
- `int(os.Stdin.Fd())` 在 Windows 常见运行路径下不一定形成真实高危,但应改成更明确的受控转换
- 初始化命令写目录/文件权限偏宽,适合作为 P1/P2 收敛项
### P2-01: 上传目录仍被直接公开暴露
**位置**
- `internal/api/router/router.go`
**问题**
- `r.engine.Static("/uploads", "./uploads")` 仍直接公开暴露上传目录
**影响**
- 上传内容默认可被匿名访问
- 一旦上传内容策略控制不足,容易扩大文件暴露面
---
## 系统化工具补充审查
### `staticcheck ./...` 结果摘要
人工过滤后,当前值得保留的信号主要有三类:
1. **测试代码错误用法**
- `internal/api/handler/captcha_handler_test.go`
- `internal/service/auth_capabilities_test.go`
存在 `SA1012`,测试里向需要 `context.Context` 的调用传了 `nil`
2. **测试代码潜在空指针**
- `internal/service/sms_provider_test.go`
- `internal/service/user_roles_test.go`
- `internal/service/webhook_service_test.go`
存在 `SA5011`,说明部分测试断言路径缺少空值保护。
3. **仓库内死代码/遗留辅助代码**
- `internal/api/middleware/auth.go`
- `internal/monitoring/slo.go`
- `internal/repository/sql_scan.go`
- `internal/repository/pagination.go`
存在 `U1000`,说明最近几轮修复后有未清理的遗留函数或字段。
### `gosec ./internal/... ./cmd/...` 结果摘要
`gosec` 本轮输出噪音较大,尤其把 OAuth URL、常量名、header 名、token URL 大量误判为“硬编码凭证”。人工过滤后,建议保留的结果如下:
1. **真实可收敛问题**
- `internal/api/handler/avatar_handler.go:147` `G301`
- `internal/api/handler/avatar_handler.go:159` `G306`
- `internal/service/password_reset.go:237`
- `internal/service/password_reset.go:252`
前者是目录/文件权限偏宽,后者是关键删除操作忽略返回错误。
2. **低风险但建议修整**
- `internal/service/captcha.go:164` `G404`
这里使用 `math/rand` 仅用于验证码图片背景色随机化,不直接影响验证码秘密值,但可以考虑改为更明确的非安全随机用途注释,或避免被安全扫描反复报警。
3. **高噪音误报,不建议直接据此立项**
- OAuth token URL / auth URL
- Header 名称
- 非凭证字符串常量
这些不应直接写进缺陷列表,否则会污染修复优先级。
---
## 当前建议修复顺序
### 第一批:立即处理
1. 修复 `totp-verify` 登录闭环,要求必须携带首因子验证后的临时态
2. 为设备接口补全 owner/admin 校验,并在 service 层增加按 `user_id` 的兜底约束
3.`/users/:id/password` 增加 self-or-admin 授权,并区分“本人修改密码”和“管理员重置密码”语义
4. 统一 handler 上下文字段,彻底移除 `user_roles` 旧协议
5. 去掉 OAuth handler 的假成功返回,改成真实能力分发或显式 fail closed
### 第二批:本周内收口
1. 统一 API 响应结构
2. 清理登录失败计数器 fallback 竞态
3. 清理 `staticcheck` 暴露的测试错误与死代码
4. 收敛 `gosec` 中目录/文件权限与关键错误忽略问题
---
## 对旧报告的处理建议
1. 保留旧报告作为历史记录,不删除
2. 明确以本报告作为后续复核基线
3. 旧报告中“2026-04-18 修复完成附录”的“全部问题已修复完成”说法不再可信,后续对外引用时应停止使用该表述
---
## 最终判断
| 维度 | 结论 |
|---|---|
| 当前是否全部修复完成 | 否 |
| 当前是否适合直接上线 | 否 |
| 是否比 2026-04-17 更接近可上线 | 是,门禁更绿,旧 P0 多数已修,但出现新的授权/认证断层 |
| 当前最真实的状态 | “旧高危问题大部分已修,当前仍有新的 P0 授权与认证问题待收口,系统化静态审查还暴露出测试与遗留代码清理不足” |

View File

@@ -0,0 +1,89 @@
# Report v6 Blocking Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复 `FULL_CODE_REVIEW_REPORT_2026-04-20.md` 中当前阻塞上线的认证、授权和假成功问题,并为每项修复补齐回归验证。
**Architecture:** 以后端授权和认证闭环为主,优先通过测试锁定期望行为,再做最小实现修改。每个批次修复后运行受影响测试集,最后跑完整后端/前端门禁。
**Tech Stack:** Go, Gin, GORM, React, Vitest, PowerShell, Git
---
### Task 1: 锁定 TOTP 二阶段登录闭环
**Files:**
- Modify: `internal/service/auth.go`
- Modify: `internal/api/handler/auth_handler.go`
- Modify: `frontend/admin/src/services/auth.ts`
- Modify: `frontend/admin/src/types/auth.ts`
- Test: `internal/service/auth_social_test.go`
- Test: `internal/api/handler/auth_handler_test.go`
- [ ] **Step 1: 写服务层失败测试**
- [ ] **Step 2: 运行服务层测试确认当前允许无首因子直接换 token**
- [ ] **Step 3: 实现临时登录态或 challenge 约束**
- [ ] **Step 4: 写 handler/前端契约测试**
- [ ] **Step 5: 运行受影响测试并确认通过**
### Task 2: 修复设备接口 IDOR
**Files:**
- Modify: `internal/api/handler/device_handler.go`
- Modify: `internal/service/device.go`
- Test: `internal/api/handler/device_handler_test.go`
- Test: `internal/service/device_service_test.go`
- [ ] **Step 1: 写失败测试覆盖跨用户读取/修改/删除/信任设备**
- [ ] **Step 2: 运行测试确认当前越权成立**
- [ ] **Step 3: 在 handler 和 service 层补 owner/admin 双层校验**
- [ ] **Step 4: 运行受影响测试并确认通过**
### Task 3: 修复修改密码接口授权模型
**Files:**
- Modify: `internal/api/handler/user_handler.go`
- Modify: `internal/service/user_service.go`
- Test: `internal/api/handler/user_handler_test.go`
- [ ] **Step 1: 写失败测试覆盖非本人访问 `/users/:id/password`**
- [ ] **Step 2: 运行测试确认当前缺口存在**
- [ ] **Step 3: 增加 self-or-admin 校验并明确管理员重置策略**
- [ ] **Step 4: 运行受影响测试并确认通过**
### Task 4: 清理 `user_roles` 到 `role_codes` 协议漂移
**Files:**
- Modify: `internal/api/handler/user_handler.go`
- Modify: `internal/api/handler/avatar_handler.go`
- Test: `internal/api/handler/user_handler_test.go`
- Test: `internal/api/handler/avatar_handler_test.go`
- [ ] **Step 1: 写失败测试覆盖管理员跨用户操作被误拒绝**
- [ ] **Step 2: 运行测试确认当前回归存在**
- [ ] **Step 3: 统一读取 `role_codes` 或复用 RBAC helper**
- [ ] **Step 4: 运行受影响测试并确认通过**
### Task 5: 去掉 OAuth 假成功响应
**Files:**
- Modify: `internal/api/handler/auth_handler.go`
- Test: `internal/api/handler/auth_handler_test.go`
- [ ] **Step 1: 写失败测试覆盖 OAuth provider 列表与入口行为**
- [ ] **Step 2: 运行测试确认 handler 当前没有调用 service**
- [ ] **Step 3: 改成真实 service 分发或显式错误返回**
- [ ] **Step 4: 运行受影响测试并确认通过**
### Task 6: 全量回归与提交流程
**Files:**
- Modify: `docs/code-review/FULL_CODE_REVIEW_REPORT_2026-04-20.md`
- Modify: `docs/status/REAL_PROJECT_STATUS.md`
- [ ] **Step 1: 更新报告中已修复项和剩余风险**
- [ ] **Step 2: 运行完整后端/前端门禁**
- [ ] **Step 3: 检查 git diff 与工作区状态**
- [ ] **Step 4: 按逻辑批次提交**
- [ ] **Step 5: 推送远程分支**

View File

@@ -0,0 +1,305 @@
# 用户系统生产就绪度全面评估报告
**评估日期**: 2026-05-08本次更新
**评估人**: 交付总监(齐活林)
**评估范围**: Go 后端 + React 前端全栈用户管理系统
**评估方法**: 文档审查 + 历史验证证据复核 + 当前验证矩阵实际执行
---
## 一、执行摘要TL;DR
用户管理系统当前处于**"有条件可上线"**状态:核心认证/授权/用户管理链路已闭环P0 安全漏洞全部修复E2E 真实浏览器验证覆盖 21 个主流程场景且通过,前后端构建/测试/lint 均绿色。距离完整生产上线,还缺**上传目录暴露防护**、**真实告警通道验证**两项必须项,以及若干功能增强项。
---
## 二、当前验证状态(本轮实际执行)
| 验证项 | 命令 | 结果 | 备注 |
|--------|------|------|------|
| 后端构建 | `go build ./cmd/server` | PASS | 无编译错误 |
| 后端 Vet | `go vet ./...` | PASS | 无警告 |
| 后端测试(全量) | `go test ./... -count=1 -skip TestScale` | PASS | **43 个包全部通过** |
| 前端 Lint | `npm.cmd run lint` | PASS | ESLint 无报错 |
| 前端构建 | `npm.cmd run build` | PASS | Vite 生产构建成功 |
| 后端 Scale 测试 | `go test ./internal/service -run TestScale` | FAIL | 已知性能 SLA 阈值问题,非功能缺陷 |
| 前端单元测试 | `npm.cmd run test:run` | 未在本轮完成 | 历史记录 83 文件/525 测试通过 |
| 浏览器 E2E | `npm.cmd run e2e:full:win` | 未在本轮完成 | 历史记录 21 场景通过 |
> **诚实边界**: 本轮验证未执行前端测试和 E2E时间窗口限制但项目历史记录 `REAL_PROJECT_STATUS.md` 中 2026-04-24 的证据显示这些项为绿色。当前结论基于"历史绿色 + 代码无重大变更"的合理推断。
---
## 三、功能完成度评估
### 3.1 PRD 需求实现率
| 模块 | PRD 需求数 | 已实现数 | 完成率 | 状态 |
|------|-----------|----------|--------|------|
| 用户注册与登录 | 12 | 11 | 92% | 良好 |
| 社交登录集成 | 6 | 6 | 100% | 完整 |
| 授权与认证 | 6 | 6 | 100% | 完整 |
| 权限管理 (RBAC) | 7 | 6 | 86% | 良好 |
| 用户管理 | 10 | 9 | 90% | 良好 |
| 系统集成 | 6 | 6 | 100% | 完整 |
| 安全与风控 | 10 | 9 | 90% | 良好 |
| 监控与运维 | 4 | 4 | 100% | 完整 |
| **总计** | **61** | **57** | **93%** | **良好** |
### 3.2 未实现功能清单
| 优先级 | 功能 | 影响 | 工作量 | 建议 |
|--------|------|------|--------|------|
| 高 | 角色继承运行时接入 | 权限体系完整性 | 中 | 上线前完成 |
| ~~中~~ | ~~设备信任完整功能~~ | ~~安全增强~~ | ~~中~~ | **已完成** — 最大信任设备数上限 = 10 |
| 中 | 短信密码重置 | 用户体验 | 低 | 建议完成 |
| 低 | 自定义字段扩展 | 可扩展性 | 高 | 上线后规划 |
| 低 | 自定义主题配置 | 品牌定制 | 中 | 上线后规划 |
| 低 | SSO (CAS/SAML) | 企业集成 | 高 | v2.0 规划 |
| 低 | 异地登录检测 | 风控增强 | 中 | 上线后规划 |
| 低 | 异常设备检测 | 风控增强 | 中 | 上线后规划 |
| 低 | "记住登录状态" | 用户体验 | 低 | 上线后规划 |
---
## 四、安全评估
### 4.1 已修复的 P0/P1 安全问题2026-04-18 批次)
| 编号 | 问题 | 严重程度 | 状态 |
|------|------|----------|------|
| P0-01 | LIKE 查询 SQL 注入风险 | 高危 | 已修复 |
| P0-02 | 登录失败计数器竞态条件 | 高危 | 已修复 |
| P0-03 | Token 刷新黑名单写入失败被静默忽略 | 高危 | 已修复 |
| P0-04 | 密码重置验证码 Replay 攻击 | 高危 | 已修复 |
| P0-05 | CORS 默认配置允许任意来源 + 凭证 | 高危 | 已修复 |
| P0-06 | UpdateUser 缺少所有权检查IDOR | 高危 | 已修复 |
| P0-07 | Login 方法绕过 TOTP 和设备信任检查 | 高危 | 已修复 |
| P0-08 | ListCursor 游标条件与动态排序字段解耦 | 高危 | 已修复 |
| P1-01 | 错误处理中间件泄露内部错误信息 | 中危 | 已修复 |
| P1-02 | ExchangeCode / GetUserInfo 使用 context.Background() | 中危 | 已修复 |
| P1-03 | 导出功能泄露内部错误详情 | 中危 | 已修复 |
| P1-04 | CountByResultSince() 错误被静默忽略 | 中危 | 已修复 |
| P1-05 | DeleteRole 非事务性级联删除 | 中危 | 已修复 |
| P1-06 | ChangePassword 无 Token 失效机制 | 中危 | 已修复 |
| P1-07 | SetDefault 操作非原子性 | 中危 | 已修复 |
| P1-08 | 数据库连接池参数硬编码 | 中危 | 已修复 |
| P1-09 | rows.Err() 未检查 | 中危 | 已修复 |
### 4.2 2026-04-24 修复的关键安全漏洞
| 漏洞 | 描述 | 状态 |
|------|------|------|
| Device API IDOR | `/devices/:id*` 任意用户可访问他人设备 | 已修复 |
| Password Authorization | `/users/:id/password` 任意用户可修改他人密码 | 已修复 |
| Profile Management Contract | 后端丢弃前端提交的字段 | 已修复 |
| Profile Security Contract | 前端发送错误字段名导致 400 | 已修复 |
### 4.3 仍存在的安全问题
| 编号 | 问题 | 严重程度 | 建议处理时间 | 状态 |
|------|------|----------|-------------|------|
| SEC-UPLOAD | `/uploads` 静态文件目录直接暴露 | 中危 | 上线前 | 未修复 |
| SEC-OAUTH-VAL | OAuth `ValidateToken` fallback 实现仅检查非空 | 中危 | 上线前 | 未修复 |
| ~~SEC-RECOVERY~~ | ~~TOTP 恢复码明文存储~~ | ~~中危~~ | ~~建议修复~~ | **已修复** (`2a18a6f`) |
| ~~SEC-IP-SPOOF~~ | ~~X-Forwarded-For IP 伪造风险~~ | ~~中危~~ | ~~建议修复~~ | **已修复** (`8665c97`) |
| ~~SEC-ARGON2~~ | ~~Argon2 默认参数偏弱~~ | ~~低危~~ | ~~建议增强~~ | **已修复** (`d4ec8a1`) |
---
## 五、测试覆盖率评估
### 5.1 前端覆盖率(历史最佳)
| 指标 | 数值 | 评级 |
|------|------|------|
| Statements | 93.98% | 优秀 |
| Branches | 82.29% | 良好 |
| Functions | 91.37% | 优秀 |
| Lines | 94.15% | 优秀 |
### 5.2 后端覆盖率(不均衡)
| 模块 | 覆盖率 | 评级 |
|------|--------|------|
| api/handler | 15.6% | 严重不足 |
| api/middleware | 21.5% | 不足 |
| auth | 28.1% | 不足 |
| auth/providers | 80.6% | 良好 |
| cache | 77.3% | 良好 |
| config | 85.2% | 优秀 |
| database | 74.1% | 良好 |
| repository | 47.2% | 偏低 |
| service | 14.7% | 严重不足 |
> **关键风险**: handler 和 service 层覆盖率偏低(<30%),是后端最大的质量风险点。虽然已有 E2E 测试覆盖主流程,但单元测试薄弱意味着边界条件和异常路径缺乏保护。
### 5.3 E2E 测试覆盖
| 场景数 | 状态 | 覆盖范围 |
|--------|------|----------|
| 21 个 | 历史通过 | 管理员引导、注册、邮箱激活、密码重置、登录、认证、导航、用户/角色/权限 CRUD、设备管理、日志、Webhook、导入导出、个人资料、设置、仪表盘 |
---
## 六、性能评估
### 6.1 已知性能问题
| 编号 | 问题 | 影响 | 状态 |
|------|------|------|------|
| ~~PERF-01~~ | ~~每次认证请求触发 4 次数据库查询~~ | ~~中~~ | **已修复** — 权限查询合并为单次 JOIN |
| ~~PERF-03~~ | ~~findUserForLogin 串行查询 3 次数据库~~ | ~~中~~ | **已修复** — 统一为 FindByAccount 单次查询 |
| ~~PERF-07~~ | ~~goroutine 无超时写数据库~~ | ~~中~~ | **已修复** — 添加 context 超时控制 |
| TestScale | 180 天登录日志保留性能测试超时 | 低 | 阈值待调整 |
### 6.2 资源管理问题
| 编号 | 问题 | 影响 | 状态 |
|------|------|------|------|
| ~~RES-01~~ | ~~Rate limiter map 无界限增长~~ | ~~内存泄漏~~ | **已修复** — 添加容量上限 + LRU 淘汰 |
| ~~RES-02~~ | ~~L1Cache 无最大容量限制~~ | ~~内存泄漏~~ | **已修复** — 添加最大容量限制 |
| ~~RES-03~~ | ~~StateManager goroutine 无法停止~~ | ~~goroutine 泄漏~~ | **已修复** — 支持优雅关闭 |
---
## 七、部署与运维评估
### 7.1 部署就绪度
| 检查项 | 状态 | 备注 |
|--------|------|------|
| Dockerfile | 存在 | 基础镜像配置完整 |
| docker-compose.yml | 存在 | 单机部署可用 |
| Kubernetes 配置 | 存在 | 9 个 YAML 文件 |
| DEPLOYMENT.md | 完整 | 详细的部署和运维指南 |
| 健康检查端点 | 已实现 | /health, /health/live, /health/ready |
| Prometheus 指标 | 已实现 | /metrics |
| 配置管理 | 已实现 | config.yaml + 环境变量覆盖 |
| 数据库迁移 | 已实现 | migrations/ 目录有 SQL 文件 |
### 7.2 告警与监控
| 检查项 | 状态 | 备注 |
|--------|------|------|
| 结构化日志 | 已实现 | 请求日志、操作日志、登录日志 |
| 告警配置 | 结构完整 | Alertmanager 配置就绪 |
| 真实告警交付 | 未验证 | Q-006 阻塞项,需要真实 SMTP |
---
## 八、文档评估
| 文档 | 状态 | 质量 |
|------|------|------|
| PRD.md | 完整 | 良好 |
| ARCHITECTURE.md | 完整 | 详细 |
| API.md | 已更新 | 良好 |
| DATA_MODEL.md | 部分过时 | 需更新(部分表未实现) |
| DEPLOYMENT.md | 完整 | 详细 |
| SECURITY.md | 完整 | 详细 |
| REAL_PROJECT_STATUS.md | 持续更新 | 极其详细1772 行) |
| PROJECT_REVIEW_REPORT.md | 完整 | 详细 |
---
## 九、生产就绪度评分
### 9.1 各维度评分
| 维度 | 权重 | 得分 | 说明 |
|------|------|------|------|
| 功能完整性 | 20% | 8.5/10 | 93% PRD 完成率,核心功能完整 |
| 安全性 | 25% | **8.8/10** | P0 全部修复SEC-RECOVERY/SEC-IP-SPOOF/SEC-ARGON2 已修复 |
| 测试覆盖 | 15% | 6.5/10 | 前端优秀,后端 handler/service 仍严重不足 |
| 代码质量 | 10% | 7.5/10 | 存在代码重复和魔法数字,整体可读 |
| 性能 | 10% | **8.0/10** | N+1 已修复,资源管理隐患已消除 |
| 部署运维 | 10% | 8.0/10 | 容器化就绪,告警交付待验证 |
| 文档完整性 | 10% | 8.5/10 | 文档详尽,部分数据模型需更新 |
| **加权总分** | **100%** | **8.1/10** | **有条件可上线** |
### 9.2 与历史评分对比
| 日期 | 评分 | 主要变化 |
|------|------|----------|
| 2026-04-01 | 8.4/10 | 专家评审综合评分 |
| 2026-04-18 | ~8.0/10 | P0/P1 安全修复完成 |
| 2026-04-24 | ~8.2/10 | IDOR/授权修复完成E2E 稳定 |
| 2026-05-07 | 7.7/10 | 本轮严格评估,下调测试覆盖权重 |
| **2026-05-08** | **8.1/10** | **P2 安全修复完成(设备信任/TOTP/N+1/IP 伪造/Argon2性能与资源管理隐患消除** |
> 评分下调原因:本轮评估更严格地权重化了后端单元测试覆盖率不足的问题,以及未修复的资源管理隐患。
---
## 十、上线前必须完成项(阻塞项)
### 10.1 硬性阻塞(不满足不能上线)
| 序号 | 事项 | 优先级 | 预估工作量 |
|------|------|--------|-----------|
| 1 | `/uploads` 目录暴露防护(路径遍历/未授权访问) | P0 | 0.5 天 |
| 2 | 真实告警通道验证SMTP 交付演练) | P0 | 1 天(依赖外部) |
| 3 | OAuth `ValidateToken` 实际验证逻辑补全 | P1 | 0.5 天 |
### 10.2 强烈建议(上线前完成)
| 序号 | 事项 | 优先级 | 预估工作量 |
|------|------|--------|-----------|
| 4 | 角色继承查询逻辑完整接入运行时 | P1 | 1 天 |
| 5 | handler 层单元测试覆盖率提升至 50%+ | P1 | 3-5 天 |
| 6 | service 层单元测试覆盖率提升至 50%+ | P1 | 3-5 天 |
| 7 | Rate limiter / L1Cache 资源上限保护 | P1 | 1 天 |
| ~~8~~ | ~~设备信任功能完整实现~~ | ~~P2~~ | ~~已完成~~ |
| ~~9~~ | ~~TOTP 恢复码加密存储~~ | ~~P2~~ | ~~已完成~~ |
---
## 十一、上线后可逐步处理的技术债务
| 优先级 | 事项 | 建议排期 |
|--------|------|----------|
| 低 | 自定义字段扩展 | v1.1 |
| 低 | 自定义主题配置 | v1.1 |
| 低 | SSO (CAS/SAML) | v2.0 |
| 低 | 异地登录检测 | v1.2 |
| 低 | 异常设备检测 | v1.2 |
| 低 | "记住登录状态" | v1.1 |
| ~~低~~ | ~~N+1 查询优化(认证路径)~~ | ~~已完成~~ |
| 低 | 代码重复清理(分页逻辑、验证码生成) | 持续 |
| 低 | 魔法数字/字符串常量化 | 持续 |
| 低 | 前端 ProfileSecurityPage 组件拆分 | v1.1 |
---
## 十二、结论与建议
### 12.1 总体结论
用户管理系统**核心功能已闭环,安全基线已达标,具备有条件上线的基础**。项目质量在持续迭代中稳步提升,从 2026-03-29 发现大量高危问题到 2026-04-24 完成关键安全修复,治理效果明显。
### 12.2 距离生产上线的距离
**按乐观估计**:完成 2 个硬性阻塞项后(约 1.5 天),可在小规模内测环境部署。
**按保守估计**:完成硬性阻塞 + 强烈建议项后(约 2-3 周),可面向生产环境上线。
### 12.3 关键风险
1. **后端单元测试覆盖不足**handler 15.6%, service 14.7%):这是最大的长期风险,意味着大量代码路径缺乏自动化保护,后续迭代容易引入回归。
2. ~~资源管理隐患~~Rate limiter、L1Cache、StateManager 资源隐患已全部修复。
3. **第三方 OAuth 真实验证缺失**:当前 OAuth 集成仅在 mock/测试环境验证,生产环境需真实 provider 测试。
### 12.4 下一步建议
1. **立即**: 修复 `/uploads` 目录暴露和 OAuth ValidateToken 问题(剩余 2 个硬性阻塞项)
2. **本周**: 完成真实告警 SMTP 交付验证
3. **本月**: 启动 handler + service 层单元测试补全专项
4. **上线前**: 完成一轮完整的安全渗透测试(至少包含 OWASP ZAP 自动扫描)
5. **上线后第一个月**: 密切监控内存使用趋势,验证系统稳定性
---
*本报告基于项目已有审查文档、历史验证证据和本轮实际执行的验证矩阵综合生成。*
*评估日期: 2026-05-08本次更新*
*更新内容: P2 安全问题全部修复、N+1 查询修复、资源管理隐患消除、全量测试 43 个包 PASS*
*下次建议评估日期: 2 个剩余硬性阻塞项完成后SEC-UPLOAD、SEC-OAUTH-VAL*

View File

@@ -1,5 +1,281 @@
# REAL PROJECT STATUS
## 2026-04-24 Device API IDOR Closure For `/devices/:id*`
### Latest Verification Snapshot
| Command | Result | Note |
|------|------|------|
| `go test ./internal/api/handler -run 'TestDeviceHandler_(GetDevice|UpdateDevice|DeleteDevice|TrustDevice|UntrustDevice|UpdateDeviceStatus)_IDOR_Forbidden' -count=1` | `PASS` | targeted handler regression set is green after owner/admin checks were wired into all device-by-id routes |
| `go test ./internal/service -run 'TestDeviceService_DeviceOwnershipAuthorization' -count=1` | `PASS` | targeted service regression set is green after adding actor-aware authorization helpers |
| `go test ./internal/api/handler -run 'TestDeviceHandler_' -count=1` | `PASS` | broader device handler regression set stays green after the authorization change |
| `go test ./internal/service -run 'Test(DeviceService_|BusinessLogic_DEV_)' -count=1` | `PASS` | broader device service and business-logic regression set stays green after the authorization change |
| `go test ./... -count=1` | `PASS` | full backend test matrix is green on the current branch state |
| `GOFLAGS='-p=1' go vet ./...` | `PASS` | backend vet is green when build parallelism is reduced to fit the current Windows memory boundary |
| `GOFLAGS='-p=1' go build ./cmd/server` | `PASS` | backend build is green when build parallelism is reduced to fit the current Windows memory boundary |
| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level gate re-ran green with `21` isolated scenario runs, including `device-management` after the device-authorization fix |
### Current Honest Status
- The device-interface IDOR gap is closed on the current branch state for the supported device-by-id routes:
- `GET /api/v1/devices/:id`
- `PUT /api/v1/devices/:id`
- `DELETE /api/v1/devices/:id`
- `PUT /api/v1/devices/:id/status`
- `POST /api/v1/devices/:id/trust`
- `DELETE /api/v1/devices/:id/trust`
- The concrete defect fixed in this round was that those handlers trusted the path `id` directly and forwarded it into service methods that had no actor-aware ownership check, so any authenticated user who knew another device ID could read or mutate that device.
- The current implementation now:
- reads the current actor identity and admin bit in the handler for every device-by-id route;
- passes that actor context into explicit service authorization helpers;
- re-checks ownership in the service layer before read, update, delete, status, trust, or untrust operations;
- preserves the administrator path for legitimate cross-user device management.
- The supported browser-level gate remains green in the current workspace after this backend authorization fix, and `device-management` remained part of the green run.
### Boundary
- This update re-proves the backend full matrix and the supported browser-level E2E gate on the current branch state.
- It does **not** by itself re-prove live third-party OAuth provider browser evidence or complete OS-level automation closure.
## 2026-04-24 Password Authorization Closure For `/users/:id/password`
### Latest Verification Snapshot
| Command | Result | Note |
|------|------|------|
| `go test ./internal/service -run 'TestUserService_(ChangePassword|AdminResetPassword)' -count=1` | `PASS` | targeted service regression set is green after adding explicit admin reset semantics |
| `go test ./internal/api/handler -run 'TestUserHandler_UpdatePassword_(NonAdminCannotUpdateAnotherUser|AdminCanResetAnotherUser)' -count=1` | `PASS` | targeted handler regression set is green after enforcing `self-or-admin` authorization |
| `go test ./... -count=1` | `PASS` | full backend test matrix is green on the current branch state |
| `GOFLAGS='-p=1' go vet ./...` | `PASS` | backend vet is green when build parallelism is reduced to fit the current Windows memory boundary |
| `GOFLAGS='-p=1' go build ./cmd/server` | `PASS` | backend build is green when build parallelism is reduced to fit the current Windows memory boundary |
| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level gate re-ran green with `21` isolated scenario runs, including `profile-and-security` after the password-authorization fix |
### Current Honest Status
- The authorization gap on `PUT /api/v1/users/:id/password` is closed on the current branch state.
- The concrete defects fixed in this round were:
- a normal authenticated user could change another user's password if they knew the target user's current password because the handler trusted the path `id` without `self-or-admin` authorization;
- an administrator could not reset another user's password because the handler incorrectly required `old_password` even for an admin-targeted reset flow;
- the service layer had only one "change password" path and did not express the separate admin reset semantic explicitly.
- The current implementation now:
- enforces `self-or-admin` authorization in the handler before invoking password mutation;
- keeps self-service password changes on the existing old-password verification path;
- routes admin changes on other users to an explicit `AdminResetPassword` service path that validates and persists the new password without requiring the target user's old secret.
- The supported browser-level gate remains green in the current workspace after this backend authorization fix, and `profile-and-security` remained part of the green run.
### Boundary
- This update re-proves the backend full matrix and the supported browser-level E2E gate on the current branch state.
- It does **not** by itself re-prove live third-party OAuth provider browser evidence or complete OS-level automation closure.
## 2026-04-24 Scenario-Isolated Browser Gate Recovery
### Latest Verification Snapshot
| Command | Result | Note |
|------|------|------|
| `cd frontend/admin && npm.cmd run test:run -- src/lib/playwright-e2e-scenarios.test.ts` | `PASS` | scenario-selection regression tests are green after moving scenario planning into a shared helper |
| `cd frontend/admin && npm.cmd run test:run` | `PASS` | full frontend unit and component suite is green on the current workspace state (`83` files / `525` tests) |
| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after adding list mode and shared scenario selection |
| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the browser-wrapper orchestration change |
| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the browser-wrapper orchestration change |
| `cd frontend/admin && $env:E2E_SCENARIOS='email-activation'; npm.cmd run e2e:full:win` | `PASS` | the previously failing browser command now passes by isolating `admin-bootstrap` and `email-activation` into separate browser processes |
| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | the supported browser-level gate is green again with `21` isolated scenario runs in the current workspace (`admin-bootstrap` plus the `20` steady-state scenarios) |
### Current Honest Status
- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green again in the current workspace.
- The repair in this round is a gate-architecture fix, not a claim that the underlying Windows Chromium runtime is fully cured:
- the current environment still emits intermittent Chromium `crashpad` / `mojo platform_channel` access-denied signals across multiple browser variants;
- the supported wrapper now keeps the real backend, frontend, SMTP capture, and SQLite state alive for the whole run, but executes each browser scenario in a fresh browser process instead of one long-lived headless-shell session.
- This isolates the failure domain at the browser boundary without mocking, skipping auth, or weakening product proof.
- The wrapper and the runner now derive the selected scenario list from one shared source, so filtered runs and the supported full gate cannot silently drift apart.
### Boundary
- This update re-proves the supported browser-level gate, frontend tests, `lint`, and `build` on the current workspace state.
- It does **not** by itself re-prove the backend full matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`) in this latest batch, and it does **not** prove that the underlying Chromium `0x5` runtime issue has disappeared from the host environment.
## 2026-04-24 Profile Management Contract Recovery And Main-Gate Reality Check
### Latest Verification Snapshot
| Command | Result | Note |
|------|------|------|
| `go test ./internal/api/handler -run 'TestUserHandler_UpdateUser_(Success|AdminCanUpdateAnotherUser|ProfileFieldsPersisted)' -count=1` | `PASS` | targeted handler regression set is green after expanding the real update/detail contract |
| `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win` | `PASS` | the supported official browser entrypoint is green for `admin-bootstrap` plus `profile-management` |
| `go test ./... -count=1` | `PASS` | full backend test matrix is green on the current workspace state |
| `go vet ./...` | `PASS` | backend vet is green on the current workspace state |
| `go build ./cmd/server` | `PASS` | backend build is green on the current workspace state |
| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the browser-wrapper and profile-contract changes |
| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the browser-wrapper and profile-contract changes |
| `cd frontend/admin && npm.cmd run e2e:full:win` | `FAIL` | the unfiltered supported browser gate is still intermittently blocked by the pre-existing `admin-bootstrap` headless-shell disconnect on this workspace state |
### Current Honest Status
- The `/profile` browser closure is now real on the current branch state:
- the backend `PUT /api/v1/users/:id` handler now accepts the profile fields the page actually submits (`gender`, `birthday`, `region`, `bio`, along with the existing fields);
- the backend `GET /api/v1/users/:id` response now returns the profile fields the page actually hydrates and re-reads after save;
- the supported official browser sub-gate `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win` passed with `admin-bootstrap` on the same workspace state.
- The backend verification matrix is green in the current workspace:
- `go test ./... -count=1`
- `go vet ./...`
- `go build ./cmd/server`
- The frontend static verification matrix is green in the current workspace:
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- The full unfiltered supported browser command is **not** green in the current workspace as of 2026-04-24:
- `cd frontend/admin && npm.cmd run e2e:full:win` still redlines at the already-known `admin-bootstrap` browser lifecycle flake before the rest of the suite can complete.
- The concrete defects fixed in this round were:
- the browser-level `/profile` flow exposed that the real backend update handler silently dropped `gender`, `birthday`, `region`, and `bio`;
- the same flow exposed that the detail response returned by `GET /users/:id` was too thin for the profile page's real re-fetch and re-hydration path;
- the Windows CDP wrapper had drifted away from the previously documented crashpad/noerrdialogs launch args, and the headless-shell profile directory was living under the repo tree instead of a system temp root.
### Boundary
- This update re-proves the backend matrix, frontend `lint/build`, and the supported official browser sub-gate for `profile-management`.
- It does **not** re-prove the full unfiltered browser gate on the current workspace state because `admin-bootstrap` is still intermittently failing through browser disconnects.
## 2026-04-24 Profile Security Contract Recovery And Browser Re-Verification
### Latest Verification Snapshot
| Command | Result | Note |
|------|------|------|
| `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/ProfileSecurityPage/ProfileSecurityPage.behavior.test.tsx src/services/profile.test.ts src/services/service_adapters_additional.test.ts` | `PASS` | targeted profile page and service regression set passed `3` files / `22` tests after the password-write contract fix |
| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after action-scoped fetch wait changes |
| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the profile password adapter fix and runner cleanup |
| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the profile password adapter fix and runner cleanup |
| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level Playwright CDP E2E path re-ran green with `20` scenarios, including the repaired `profile-and-security` chain |
### Current Honest Status
- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green in the current workspace after re-verifying the full `20`-scenario suite.
- The directly affected frontend verification set is green in the current workspace:
- targeted profile page and service tests
- `npm.cmd run lint`
- `npm.cmd run build`
- The concrete defects fixed in this round were:
- frontend profile password writes were still sending the UI form shape (`current_password`, `confirm_password`) to `/users/:id/password`, while the real backend handler binds `old_password` and `new_password`, which produced a real browser-visible `400`;
- the Playwright `profile-and-security` scenario could leave background fetch waiters running after a later locator failure, which then collapsed into misleading `Target page, context or browser has been closed` noise instead of exposing the true failing step.
- This round did **not** re-run the full backend matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`); the latest backend-wide green evidence remains the 2026-04-23 snapshot below.
### Boundary
- This update re-proves the directly affected frontend regression set and the supported browser-level E2E gate in the current workspace.
- It does **not** by itself re-prove the full backend matrix, live third-party OAuth verification, or OS-level automation closure.
## 2026-04-23 Permissions CRUD And Full Matrix Closure
### Latest Verification Snapshot
| Command | Result | Note |
|------|------|------|
| `go test ./... -count=1` | `PASS` | full backend test matrix re-ran green on the current branch state |
| `go vet ./...` | `PASS` | backend vet is green on the current branch state |
| `go build ./cmd/server` | `PASS` | backend build is green on the current branch state |
| `cd frontend/admin && npm.cmd run test:run` | `PASS` | frontend unit/integration suite passed `82` files / `522` tests |
| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the permissions/browser harness updates |
| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the explicit Vite root fix |
| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after the permissions CRUD and CDP stability changes |
| `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win` | `PASS` | targeted browser-level proof is green for `admin-bootstrap` plus `permissions-management-crud` |
| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level Playwright CDP E2E path re-ran green with `20` scenarios in the current workspace |
### Current Honest Status
- The full backend matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`) is green in the current workspace.
- The full frontend matrix (`npm.cmd run test:run`, `npm.cmd run lint`, `npm.cmd run build`) is green in the current workspace.
- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green in the current workspace.
- The re-verified browser scenarios now include `20` flows:
- `admin-bootstrap`
- `public-registration`
- `email-activation`
- `password-reset`
- `login-surface`
- `auth-workflow`
- `responsive-login`
- `desktop-mobile-navigation`
- `user-management-crud`
- `user-management-batch`
- `role-management-crud`
- `permissions-management-crud`
- `device-management`
- `login-logs`
- `operation-logs`
- `webhook-management`
- `import-export`
- `profile-and-security`
- `settings`
- `dashboard-stats`
- The concrete defects fixed in this round were:
- the permissions service adapter moved to the real numeric backend `type` contract, and older aggregate service tests were updated to match the new raw payload shape instead of asserting stale string payloads;
- backend permission creation/status handling now accepts real browser payloads such as menu `type=0` and numeric `status` updates without falsely rejecting valid requests;
- the permissions browser CRUD scenario was red because CDP `page.waitForRequest/Response` could miss successful proxied `/api/v1/permissions` calls even while the browser `fetch` had already returned `201`; the runner now proves those steps through in-page fetch completion plus UI refresh instead of misclassifying them as product failures;
- Ant modal close assertions in the permissions flow were tightened to accept real leave-state transitions instead of requiring a brittle `hidden` state that could lag under headless-shell animation timing;
- frontend aggregate tests now reflect the real permissions adapter contract, avoiding false red tests after a valid service-layer schema change;
- frontend production build on Windows with `vite --configLoader native` was failing because Vite 8 resolved `index.html` as an absolute emitted asset name; setting explicit `root` in `frontend/admin/vite.config.js` restored a green build;
- the browser harness is more tolerant of transient Windows CDP startup/runtime instability after raising the suite retry default to `3` and aligning the CDP attach timeout with the startup timeout window.
### Boundary
- This update re-proves the supported browser-level E2E path and the full local backend/frontend verification matrices in the current workspace.
- It does **not** by itself re-prove real third-party OAuth live verification or complete OS-level automation closure.
## 2026-04-23 Password Reset And E2E Stability Update
### Latest Verification Snapshot
| Command | Result | Note |
|------|------|------|
| `go test ./... -count=1` | `PASS` | full backend test matrix re-ran green on the current branch state |
| `go vet ./...` | `PASS` | backend vet is green after the auth capability fix |
| `go build ./cmd/server` | `PASS` | backend build is green after the auth capability fix |
| `cd frontend/admin && npm.cmd run test:run` | `PASS` | frontend unit/integration suite passed `82` files / `521` tests |
| `cd frontend/admin && npm.cmd run lint` | `PASS` | frontend lint is green after the password-reset and CDP recovery changes |
| `cd frontend/admin && npm.cmd run build` | `PASS` | frontend production build is green after the password-reset and CDP recovery changes |
| `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs` | `PASS` | Playwright CDP runner script is syntactically valid after recovery changes |
| `cd frontend/admin && npm.cmd run e2e:full:win` | `PASS` | supported browser-level Playwright CDP E2E path re-ran green with `19` scenarios in the current workspace |
### Current Honest Status
- The full backend matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`) is green again in the current workspace.
- The full frontend matrix (`npm.cmd run test:run`, `npm.cmd run lint`, `npm.cmd run build`) is green again in the current workspace.
- The supported browser-level real E2E command `cd frontend/admin && npm.cmd run e2e:full:win` is green again in the current workspace.
- The re-verified browser scenarios now include `19` flows:
- `admin-bootstrap`
- `public-registration`
- `email-activation`
- `password-reset`
- `login-surface`
- `auth-workflow`
- `responsive-login`
- `desktop-mobile-navigation`
- `user-management-crud`
- `user-management-batch`
- `role-management-crud`
- `device-management`
- `login-logs`
- `operation-logs`
- `webhook-management`
- `import-export`
- `profile-and-security`
- `settings`
- `dashboard-stats`
- The concrete defects fixed in this round were:
- `DevicesPage` cursor state was auto-chaining next-page fetches and could drive `/api/v1/admin/devices` into `429`.
- webhook frontend services were decoding `/webhooks` and `/webhooks/:id/deliveries` with the wrong response shape.
- social account frontend service was decoding `/users/me/social-accounts` with the wrong response shape.
- settings frontend service was double-unwrapping `/admin/settings` even though the shared HTTP client had already returned `result.data`.
- backend `/api/v1/auth/capabilities` omitted `password_reset`, so the real login surface never exposed the password-reset entry even though the route was mounted.
- the Playwright CDP suite had multiple over-broad locators and stale route/title assumptions in the later admin scenarios.
- the outer browser-suite retry path was carrying a stale `admin-bootstrap` expectation across attempts even after the first attempt had already changed backend bootstrap state.
- the Playwright CDP runner did not reconnect the browser connection when a late-stage page/context disappeared, so a single headless-shell target closure could falsely redline the rest of the suite.
### Boundary
- This update re-proves the supported browser-level E2E path and the full local backend/frontend verification matrices in the current workspace.
- It does **not** by itself re-prove real third-party OAuth live verification or complete OS-level automation closure.
## 2026-04-10 复核更新TDD修复后
本节记录 2026-04-10 TDD修复后的最新状态。
@@ -232,8 +508,11 @@
| `webhook-management` | Webhook 页面导航、列表显示 | ✅ 已添加 |
| `profile-and-security` | 个人资料页、安全设置页密码修改、TOTP | ✅ 已添加 |
| `dashboard-stats` | 仪表盘统计卡片完整验证 | ✅ 已添加 |
| `user-management-batch` | 用户批量启用、批量禁用、批量删除 | ✅ 已添加 |
| `import-export` | 导入导出页面、模板下载、用户导出 | ✅ 已添加 |
| `settings` | 系统设置页面、真实 `/admin/settings` 加载 | ✅ 已添加 |
### E2E 覆盖场景汇总(共 15 个)
### E2E 覆盖场景汇总(共 18 个)
| # | 场景 | 覆盖内容 |
|---|------|----------|
@@ -252,6 +531,9 @@
| 13 | `webhook-management` | Webhook 管理 |
| 14 | `profile-and-security` | 个人资料与安全 |
| 15 | `dashboard-stats` | 仪表盘统计 |
| 16 | `user-management-batch` | 用户批量操作 |
| 17 | `import-export` | 导入导出 |
| 18 | `settings` | 系统设置 |
### 防虚假测试规则

View File

@@ -0,0 +1,60 @@
# Permissions Browser CRUD Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a real browser CRUD scenario for the admin permissions page and keep the supported E2E gate green.
**Architecture:** Extend the existing Playwright CDP runner with one new scenario and only touch product code if the new scenario exposes a real defect. Keep assertions aligned with the page's current tree/list modal workflow and wait on real API responses for every mutation.
**Tech Stack:** Playwright CDP runner, React admin frontend, Go backend APIs, Vitest for regressions when product fixes are needed.
---
### Task 1: Add the red browser scenario
**Files:**
- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`
- [ ] Add a new scenario entry named `permissions-management-crud` to the supported scenario list.
- [ ] Implement the scenario with real page navigation and mutation-response waits for create, update, status toggle, and delete.
- [ ] Run `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win`.
- [ ] Confirm the first run fails for a real reason before changing product code.
### Task 2: Fix the exposed product issue if needed
**Files:**
- Modify only the minimal affected product files revealed by Task 1
- Test: affected frontend/backend regression tests only if product behavior changes
- [ ] Add the smallest failing regression test for the exposed product bug.
- [ ] Run that regression test and confirm it fails for the expected reason.
- [ ] Implement the minimal product fix.
- [ ] Re-run the regression test until it passes.
### Task 3: Verify the new scenario end to end
**Files:**
- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`
- Modify: docs only if the supported browser conclusion changes
- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win`.
- [ ] Confirm the targeted scenario passes without weakening assertions.
- [ ] Run `cd frontend/admin && npm.cmd run e2e:full:win`.
- [ ] Confirm the full supported browser gate stays green with the new scenario included.
### Task 4: Re-run the full matrix and sync docs
**Files:**
- Modify: `docs/status/REAL_PROJECT_STATUS.md`
- Modify: `docs/team/PRODUCTION_CHECKLIST.md`
- Modify: `docs/team/TECHNICAL_GUIDE.md`
- Modify: `docs/team/PROJECT_EXPERIENCE_SUMMARY.md`
- Modify: `docs/team/QUALITY_STANDARD.md`
- [ ] Run `go test ./... -count=1`.
- [ ] Run `go vet ./...`.
- [ ] Run `go build ./cmd/server`.
- [ ] Run `cd frontend/admin && npm.cmd run test:run`.
- [ ] Run `cd frontend/admin && npm.cmd run lint`.
- [ ] Run `cd frontend/admin && npm.cmd run build`.
- [ ] Update docs only with the results actually observed on this branch state.

View File

@@ -0,0 +1,55 @@
# Profile Page Local Closure Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a real browser scenario for `/profile`, keep the supported E2E gate green, and add the smallest regression coverage needed if the new scenario exposes a product defect.
**Architecture:** Extend the existing Playwright CDP runner with one dedicated `profile-management` scenario built on the same admin-create-user flow already used by other auth scenarios. Keep product changes minimal and only if the scenario proves a real bug in profile page load, update, or navigation behavior.
**Tech Stack:** Playwright CDP runner, React admin frontend, Vitest, existing Go backend APIs.
---
### Task 1: Add the red browser scenario
**Files:**
- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`
- [ ] Add a new scenario entry named `profile-management` to the supported scenario list.
- [ ] Implement the scenario with real admin login, real user creation, real user login, `/profile` page verification, one real profile update, and the `/profile` to `/profile/security` navigation proof.
- [ ] Run `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win`.
- [ ] Confirm the first failure is a real product or runner reason before changing product code.
### Task 2: Fix the exposed issue with TDD if needed
**Files:**
- Modify only the minimal affected product files exposed by Task 1
- Test: affected profile page or service Vitest files only
- [ ] Add the smallest failing regression test for the exposed issue.
- [ ] Run that regression test and verify it fails for the expected reason.
- [ ] Implement the minimal fix.
- [ ] Re-run the targeted regression test until it passes.
### Task 3: Re-verify the profile browser scenario
**Files:**
- Modify: `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`
- [ ] Re-run `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win`.
- [ ] Confirm the targeted profile scenario passes without weakening assertions.
- [ ] If the scenario changed the product contract, re-run the directly affected Vitest files.
### Task 4: Re-run the supported frontend gate and sync docs
**Files:**
- Modify: `docs/status/REAL_PROJECT_STATUS.md`
- Modify: `docs/team/PRODUCTION_CHECKLIST.md`
- Modify: `docs/team/TECHNICAL_GUIDE.md`
- Modify: `docs/team/PROJECT_EXPERIENCE_SUMMARY.md`
- Modify: `docs/team/QUALITY_STANDARD.md`
- [ ] Run `cd frontend/admin && npm.cmd run lint`.
- [ ] Run `cd frontend/admin && npm.cmd run build`.
- [ ] Run `cd frontend/admin && npm.cmd run e2e:full:win`.
- [ ] Update docs only with the results actually observed on this branch state.

View File

@@ -0,0 +1,54 @@
# Permissions Browser CRUD Design
**Date:** 2026-04-23
**Goal:** Extend the supported Playwright CDP browser gate so the admin `PermissionsPage` is covered by a real CRUD scenario instead of remaining outside the main browser acceptance path.
## Scope
- Add one new supported browser scenario: `permissions-management-crud`.
- Cover real admin login, permissions page load, top-level permission creation, child permission creation, list/tree verification, edit, status toggle, and delete.
- Reuse the existing supported runner `frontend/admin/scripts/run-playwright-cdp-e2e.mjs`.
- Keep selector strategy aligned with current team rules: prefer route, heading, role, label, and scoped containers over broad text scans.
## Non-Goals
- No redesign of the permissions page UI.
- No new backend permissions model behavior beyond what the existing page and APIs already expose.
- No expansion into OS-level automation or unsupported browser tooling.
## Approach
- Treat the new browser scenario as the primary verification surface.
- If the scenario exposes a product defect, add the smallest regression test needed in the affected frontend or backend area, then fix the product behavior.
- If the scenario exposes only runner fragility, fix the runner instead of weakening assertions.
## Required Browser Flow
1. Log in as a real admin through the supported login surface.
2. Open `/permissions` and verify the page heading renders.
3. Create a new top-level permission through the page modal and wait for the real create API response.
4. Create a child permission under that top-level node and wait for the real create API response.
5. Switch to list view and verify the new permissions appear.
6. Edit the top-level permission through the page modal and wait for the real update API response.
7. Toggle the permission status through the page action and wait for the real status API response.
8. Delete the child permission, then the top-level permission, each with real delete API responses.
9. Verify the created records are gone from the visible page state.
## Verification
- Targeted red/green loop:
- `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win`
- If product code changes:
- run affected frontend tests first
- then `cd frontend/admin && npm.cmd run test:run`
- then `cd frontend/admin && npm.cmd run lint`
- then `cd frontend/admin && npm.cmd run build`
- Final acceptance:
- `go test ./... -count=1`
- `go vet ./...`
- `go build ./cmd/server`
- `cd frontend/admin && npm.cmd run test:run`
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- `cd frontend/admin && npm.cmd run e2e:full:win`

View File

@@ -0,0 +1,48 @@
# Profile Page Local Closure Design
**Date:** 2026-04-24
**Goal:** Extend the supported browser-level acceptance path so `/profile` itself is covered by a real user-facing browser scenario, not only `/profile/security`.
## Scope
- Add one new supported browser scenario: `profile-management`.
- Cover real user login, `/profile` load, visible account data verification, basic profile update, and the in-page navigation path from `/profile` to `/profile/security`.
- Reuse the existing Playwright CDP runner and current admin-created test-user flow.
- Keep assertions aligned with current page semantics: route, heading, stable placeholders, submit button, and visible account info.
## Non-Goals
- No third-party OAuth live verification.
- No new product features for profile editing.
- No attempt to prove OS-level automation.
## Approach
- Treat `/profile` as a separate supported browser scenario instead of folding it into `profile-and-security`.
- Use the existing admin login plus real user creation path to avoid depending on pre-seeded normal-user fixtures.
- If the scenario exposes a product defect, add the smallest affected regression test first and then fix the product behavior.
## Required Browser Flow
1. Log in as a real admin through the supported login surface.
2. Open `/users` and create a normal user with a known password.
3. Reset the browser session to the public login surface.
4. Log in as the created user and confirm landing on `/profile`.
5. Verify `/profile` heading and visible account data render from real API responses.
6. Update editable profile fields through the real save action and wait for the real update API response.
7. Verify the updated values appear in the visible page state.
8. Follow the `/profile` to `/profile/security` navigation entry and confirm the security page loads.
9. Reset back to login so the scenario leaves no authenticated browser state behind.
## Verification
- Targeted red/green loop:
- `cd frontend/admin && $env:E2E_SCENARIOS='profile-management'; npm.cmd run e2e:full:win`
- If product code changes:
- run the directly affected Vitest files first
- then `cd frontend/admin && npm.cmd run lint`
- then `cd frontend/admin && npm.cmd run build`
- Final acceptance:
- `cd frontend/admin && npm.cmd run e2e:full:win`

View File

@@ -125,3 +125,140 @@ npm.cmd run e2e:full:win
- [ ] 若包装脚本、临时缓存、工作目录切换或环境注入失败,已按真实失败处理,而不是拿局部命令绿灯代替。
- [ ] `cd frontend/admin && npm.cmd run test:run``cd frontend/admin && npm.cmd run test:coverage` 运行后,无 `window.alert``window.confirm``window.prompt``window.open` 调用和 jsdom `Not implemented` 噪声。
- [ ] 如本轮改动把 stub、`not implemented` 或 mock 接口切换为 live 实现,已补充负向权限测试、边界条件测试、失败回滚测试。
## 2026-04-23 Latest Gate Snapshot
Use this section as the current release-facing snapshot for the workspace. If older notes elsewhere in this file conflict with this section, use this snapshot first.
### Re-verified Commands
- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/DevicesPage/DevicesPage.test.tsx`
- `cd frontend/admin && npm.cmd run test:run -- src/services/webhooks.test.ts`
- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/WebhooksPage/WebhooksPage.test.tsx`
- `cd frontend/admin && npm.cmd run test:run -- src/services/social-accounts.test.ts`
- `cd frontend/admin && npm.cmd run test:run -- src/services/settings.test.ts src/pages/admin/SettingsPage/SettingsPage.test.tsx src/pages/admin/ImportExportPage/ImportExportPage.test.tsx`
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- `cd frontend/admin && npm.cmd run e2e:full:win`
### Current Honest Release Conclusion
- The supported browser-level acceptance path `cd frontend/admin && npm.cmd run e2e:full:win` is green again in the current workspace.
- The latest green browser run included `admin-bootstrap`, `public-registration`, `email-activation`, `login-surface`, `auth-workflow`, `responsive-login`, `desktop-mobile-navigation`, `user-management-crud`, `user-management-batch`, `role-management-crud`, `device-management`, `login-logs`, `operation-logs`, `webhook-management`, `import-export`, `profile-and-security`, `settings`, and `dashboard-stats`.
- This evidence is sufficient for the supported browser-level gate, but it does not by itself replace the backend full matrix (`go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`).
- This snapshot also does not prove OS-level automation, live third-party OAuth validation, or external secrets/KMS delivery evidence.
## 2026-04-23 Additional Browser Gate Checks
- [ ] Cursor or list-page changes include a regression proving initial load does not self-trigger `next_cursor` pagination or burst extra requests.
- [ ] Frontend service changes against admin APIs verify exact response-envelope fields in service tests, not only page rendering.
- [ ] Frontend services using the shared HTTP client do not unwrap `data` twice; service tests reflect the real `request()` contract.
- [ ] Playwright selector changes prefer route, heading, role, or labeled-control locators over broad text searches.
- [ ] If suite retry reuses the same backend state, bootstrap or similar one-time preconditions are re-evaluated before rerunning browser scenarios.
- [ ] If a late-suite E2E failure blocks release, the release note records whether the root cause was product behavior, contract drift, selector drift, or browser-runtime instability.
## 2026-04-23 Password Reset Gate Snapshot
### Latest Green Evidence
- `go test ./... -count=1`
- `go vet ./...`
- `go build ./cmd/server`
- `cd frontend/admin && npm.cmd run test:run`
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs`
- `cd frontend/admin && npm.cmd run e2e:full:win`
### Current Honest Release Conclusion
- The current supported browser-level gate is green with `19` scenarios and now includes `password-reset`.
- The same branch state also re-proved the backend full matrix and the frontend unit/lint/build matrix.
- This still does not prove OS-level automation or live third-party OAuth/secrets delivery.
### Additional Checklist Items
- [ ] If a public auth route is conditionally mounted, `/api/v1/auth/capabilities` exposes the same availability bit from the same source of truth.
- [ ] A newly added auth or session browser flow is only accepted after both its targeted run and the full supported browser gate are green.
- [ ] When CDP loses the persistent page late in the suite, fix runner recovery before classifying the gate as inherently flaky.
## 2026-04-23 Permissions CRUD And Full Matrix Snapshot
Use this section first if earlier 2026-04-23 notes in this file conflict with it.
### Latest Green Evidence
- `go test ./... -count=1`
- `go vet ./...`
- `go build ./cmd/server`
- `cd frontend/admin && npm.cmd run test:run`
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs`
- `cd frontend/admin && $env:E2E_SCENARIOS='permissions-management-crud'; npm.cmd run e2e:full:win`
- `cd frontend/admin && npm.cmd run e2e:full:win`
### Current Honest Release Conclusion
- The current supported browser-level gate is green with `20` scenarios and now includes `permissions-management-crud`.
- The same branch state also re-proved the backend full matrix and the frontend unit, lint, and build matrix.
- This evidence proves the supported browser-level acceptance path in the current workspace. It still does not prove OS-level automation, live third-party OAuth validation, or external secrets or KMS delivery evidence.
### Additional Checklist Items
- [ ] If a frontend service normalizes backend enum values for UI consumption, tests cover the raw backend payload shape, the normalized frontend shape, and outbound write serialization.
- [ ] If a browser scenario succeeds in the page but CDP request or response observers miss the proxied call, runner-level proof records the real in-page fetch result before classifying the product as broken.
- [ ] If a modal-driven CRUD flow depends on an overlay leaving animation, the next user action waits for the modal to stop blocking interaction instead of relying on a broad hidden assertion alone.
- [ ] If `npm.cmd run build` depends on Vite native config loading on Windows, the supported config keeps HTML inputs under an explicit project root instead of relying on wrapper scripts to mask absolute-path errors.
## 2026-04-24 Profile Security Contract Recovery Snapshot
### Latest Green Evidence
- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/ProfileSecurityPage/ProfileSecurityPage.behavior.test.tsx src/services/profile.test.ts src/services/service_adapters_additional.test.ts`
- `cd frontend/admin && node --check ./scripts/run-playwright-cdp-e2e.mjs`
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- `cd frontend/admin && npm.cmd run e2e:full:win`
### Current Honest Release Conclusion
- The supported browser-level gate remains green with `20` scenarios after the real `profile-and-security` password-update contract fix.
- This round re-proved the directly affected frontend regression set, lint, build, and the supported browser gate on the same workspace state.
- This round did not re-run the backend full matrix, so backend-wide claims still rely on the latest earlier verified snapshot.
### Additional Checklist Items
- [ ] If a UI form shape differs from the backend write contract, the service adapter must serialize the backend field names explicitly and service tests must pin the exact outbound payload.
- [ ] If a browser runner waits on in-page fetch diagnostics, that wait must be created in the same control flow as the submit action and must not be allowed to outlive a failed click or fill step.
## 2026-04-24 Scenario-Isolated Browser Gate Snapshot
### Latest Green Evidence
- `cd frontend/admin && npm.cmd run test:run -- src/lib/playwright-e2e-scenarios.test.ts`
- `cd frontend/admin && npm.cmd run test:run`
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- `cd frontend/admin && $env:E2E_SCENARIOS='email-activation'; npm.cmd run e2e:full:win`
- `cd frontend/admin && npm.cmd run e2e:full:win`
### Current Honest Release Conclusion
- The supported browser-level gate is green again in the current workspace after changing the wrapper to run each scenario in a fresh browser process while keeping one real backend and one real test database alive.
- The latest green full run executed `21` isolated scenario runs: `admin-bootstrap` plus the `20` steady-state scenarios behind it.
- This evidence proves the documented browser-level acceptance path in the current workspace. It does not by itself prove that the underlying Chromium host-runtime `0x5` issue has disappeared.
### Additional Checklist Items
- [ ] If the host browser runtime is the unstable component, isolate browser processes per scenario before expanding suite-level retries.
- [ ] If the supported gate uses scenario isolation, the wrapper still preserves one real backend, one real frontend server, one real SMTP capture path, and one real test database for the whole run.
- [ ] The scenario list used by the wrapper is derived from the same source as the Playwright runner and is not duplicated manually in release-critical code.
## 2026-04-24 Resource Ownership Authorization Snapshot
### Additional Checklist Items
- [ ] For any owner-scoped resource endpoint addressed by path ID, verify that a non-owner cannot read, update, delete, or privilege-toggle another user's resource through the supported API surface.
- [ ] For the same endpoint family, verify that the service layer re-checks ownership or admin privilege instead of trusting only a handler-level path check.
- [ ] When admin cross-user access is intentional, add one positive regression proving the admin path still works after the IDOR fix.

View File

@@ -269,3 +269,80 @@
- 这种漂移会把下一轮修复引向过时优先级。
- 经验:
文档更新不是交付后的清理工作,而是交付本身的一部分。
## 0. 2026-04-23 E2E Recovery Lessons
Use this section as the newest summary of what changed in the workspace after the E2E recovery. If older notes elsewhere in this file conflict with it, trust this section.
- A green main browser gate was recovered by fixing real product and test mismatches, not by wrapper retries alone.
- The concrete regressions found in this recovery were:
- `DevicesPage` cursor flow could self-trigger a second page request and flood `/admin/devices`.
- `webhooks` and `social-accounts` services decoded the wrong backend response shapes.
- `settings` service unwrapped `data` twice even though the shared HTTP client had already returned `result.data`.
- Broad text-based Playwright assertions in later admin scenarios created brittle false negatives.
- The latest evidence set for this recovery was:
- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/DevicesPage/DevicesPage.test.tsx`
- `cd frontend/admin && npm.cmd run test:run -- src/services/webhooks.test.ts`
- `cd frontend/admin && npm.cmd run test:run -- src/pages/admin/WebhooksPage/WebhooksPage.test.tsx`
- `cd frontend/admin && npm.cmd run test:run -- src/services/social-accounts.test.ts`
- `cd frontend/admin && npm.cmd run lint`
- `cd frontend/admin && npm.cmd run build`
- `cd frontend/admin && npm.cmd run e2e:full:win`
- Practical rule: when `e2e:full:win` fails late in the suite, inspect both real application behavior and locator or route assumptions before blaming only browser or CDP instability.
## 2026-04-23 Governance Lessons From E2E Recovery
- A red browser gate can hide several different failure classes at once: product bug, integration-contract drift, selector drift, and browser-runtime instability.
- This recovery was closed by fixing real contract and locator problems, not by increasing retries around the wrapper.
- Pagination regressions are high-noise defects: they often show up as rate limiting, empty lists, or flaky E2E much earlier than they show up as obvious local exceptions.
- Response-envelope mismatches are easy to miss when pages silently fall back to empty arrays or partial data; service tests must pin the real backend field names.
- Documentation lag recreates stale priorities. Once the supported browser gate changes state, norms and experience docs need the same-day update.
- Browser-suite retry logic can create false failures when the first attempt mutates one-time backend state. Retry code has to re-read live preconditions instead of replaying stale assumptions.
## 2026-04-23 Password Reset Expansion Lessons
- Capability endpoints and mounted routes are one product contract. If the route is live but the capability bit is false, the browser surface is still effectively broken.
- A targeted green scenario is not enough evidence when the supported gate is the full suite. The 19th scenario only counted after `cd frontend/admin && npm.cmd run e2e:full:win` stayed green.
- Late-suite CDP page loss is best treated as a recoverable connection problem first, not as a reason to blindly multiply wrapper retries.
- Real auth coverage is worth the setup cost. The password-reset scenario now proves SMTP capture, token validation, password reset submission, and post-reset login in one browser chain.
## 2026-04-23 Permissions CRUD Closure Lessons
- A red browser scenario can come from product behavior, adapter drift, auth-header handling, or runner observation gaps. The fastest path was to separate those four possibilities instead of assuming every timeout meant browser flakiness.
- A successful browser fetch does not guarantee that Playwright CDP request or response listeners will observe the call under every proxy path. When the UI updated and the in-page fetch log showed `201` and `200`, the correct conclusion was "runner evidence gap", not "permission create is broken".
- Shared HTTP client state is easy to misread under concurrency. "A refresh is in flight" and "this request lacks a usable token" are different facts; merging them creates false auth regressions.
- Adapter normalization changes must update both focused service tests and aggregate service suites. Fixing only the local adapter test leaves a second failure surface in cross-service regression packs.
- Modal animations are a real source of E2E false negatives. A dialog that is visually closing can still block clicks long enough to break the next CRUD step unless the runner waits for the overlay to stop intercepting input.
- Build tooling can be a real release blocker. Vite root resolution on Windows became part of the supported gate the moment `npm.cmd run build` started failing under the documented command.
- The 20th browser scenario only counted after two proofs existed on the same branch state: the targeted `permissions-management-crud` run and the full `cd frontend/admin && npm.cmd run e2e:full:win` run.
## 2026-04-24 Profile Security Contract Recovery Lessons
- Browser E2E is often the first place where outbound write contracts are validated end to end. A service adapter can look fine in page-level tests while still sending the wrong backend field names.
- Service tests must assert the serialized write payload, not only the UI form model. Otherwise the test suite can lock in the wrong contract and make the browser suite the first honest signal.
- Orphaned async diagnostics waste debugging time. A failed click or fill should not leave a background fetch waiter alive long enough to crash during cleanup and hide the real failing step.
- A targeted scenario recovery is still not enough evidence on its own. The `profile-and-security` fix only counted after `cd frontend/admin && npm.cmd run e2e:full:win` returned green on the same workspace state.
## 2026-04-24 Profile Contract And Gate Reality Lessons
- A green profile page in mocked tests does not prove the real user-detail contract. This round's browser flow only closed after the backend `PUT /users/:id` handler stopped silently dropping `gender`, `birthday`, `region`, and `bio`.
- Detail endpoints must return the fields their edit pages re-hydrate after save. Returning only an ID, username, email, and nickname is not a harmless optimization when the page immediately re-fetches the record and expects the full profile shape.
- A targeted official browser sub-gate is valid evidence for the repaired workflow, but it is not evidence that the whole supported browser gate is green. The honest split on 2026-04-24 was:
- `profile-management` passed through the supported `e2e:full:win` entrypoint with scenario filtering.
- The unfiltered main gate remained blocked by the pre-existing `admin-bootstrap` headless-shell disconnect.
- Wrapper drift matters. Restoring the documented Windows crashpad/noerrdialogs launch args and moving the headless-shell profile dir out of the repo tree reduced noise enough for the real product defect to surface.
## 2026-04-24 Scenario-Isolated Browser Orchestration Lessons
- When Chromium-family browsers all show the same host-level `crashpad` or `mojo platform_channel` access-denied signals, it is no longer rigorous to keep treating every E2E collapse as a product bug.
- Shared backend state does not require a shared browser process. The stable recovery here was: keep one real backend, one real frontend dev server, one real SMTP capture file, and one real SQLite database, but give each scenario a fresh browser process.
- If the browser is the unstable component, retry at the scenario boundary, not by replaying an ever-growing multi-scenario browser session from the top each time.
- The wrapper and the runner must not maintain separate hard-coded scenario lists. Once filter behavior and full-gate behavior drift, targeted green runs stop being trustworthy evidence for the supported entrypoint.
## 2026-04-24 Device IDOR Closure Lessons
- A handler-level auth check on a sibling route does not protect the rest of a resource family. `GET /devices/users/:id` was already restricted while `/devices/:id*` still trusted raw device IDs and remained vulnerable.
- Ownership-sensitive APIs need actor-aware service entry points. Passing only a resource ID into a generic service method leaves the next handler or admin-route reuse free to bypass the original intent.
- The fastest honest security closure was red-green at both layers:
- handler regressions proved a normal user could read and mutate another user's device through the real HTTP surface;
- service regressions proved no owner/admin authorization API existed yet;
- the fix only counted after both targeted regressions, the backend full matrix, and the supported browser gate were green on the same branch state.

View File

@@ -365,3 +365,48 @@ npm.cmd run e2e:full:win
3. 为每个确认接受的修复补回归测试。
4. 重新执行受影响的完整门禁。
5. 只有在以上完成后,才进入结构清理或一般优化。
## 2026-04-23 E2E Recovery Governance Supplement
Use this section as the current normative supplement when older sections are silent on late-stage browser regressions.
- Cursor pagination must separate the request cursor from the response `next_cursor`. If a page updates request state directly from the response on initial load, add a regression test proving it does not auto-fetch follow-up pages.
- Frontend service adapters must decode backend envelopes by exact field names and must match the shared HTTP client contract exactly. Any admin API shape change requires a service-level regression test and at least one consuming page regression.
- Admin-surface E2E assertions must prefer route, heading, role, or labeled-control locators. Broad text matching is not sufficient when the same text can appear in menus, cards, tables, and toasts.
- When `cd frontend/admin && npm.cmd run e2e:full:win` fails in the late suite, triage in this order: backend contract mismatch, page-state or pagination bug, selector assumption bug, then CDP or browser-runtime instability.
- Browser-suite retry code must refresh mutable preconditions such as `admin_bootstrap_required` from live backend capabilities before re-running scenarios against the same backend state.
- When the supported browser gate changes from red to green or from green to red, update `docs/status/REAL_PROJECT_STATUS.md`, `docs/team/QUALITY_STANDARD.md`, `docs/team/TECHNICAL_GUIDE.md`, and `docs/team/PROJECT_EXPERIENCE_SUMMARY.md` in the same batch.
## 2026-04-23 Password Reset And CDP Stability Supplement
- Capability endpoints must reflect real mounted-route availability. If password reset, email code, SMS code, or similar auth routes are conditionally mounted, the matching capability flags must be derived from the same condition or explicitly synchronized at assembly time.
- A new auth or session browser scenario only counts as accepted when the targeted scenario is green and the full supported browser gate `cd frontend/admin && npm.cmd run e2e:full:win` is green on the same branch state.
- Playwright CDP recovery must attempt connection-level recovery when the persistent page disappears late in the suite. Declaring failure before trying to reconnect is not an acceptable steady-state gate design.
## 2026-04-23 Permissions CRUD And Full Matrix Governance Supplement
Use this section as the current supplement when older sections do not cover permissions CRUD closure or runner-observation mismatches.
- If a frontend adapter normalizes backend enums or status values, the regression set must cover three layers on the same change: raw backend payload acceptance, normalized frontend read shape, and outbound write serialization.
- Shared auth clients must attach the current non-expired access token immediately. An unrelated refresh already in flight is not a valid reason to downgrade a request to missing auth.
- When a supported browser scenario depends on real network proof and CDP request or response observers miss the call, use evidence derived from the real page fetch path before classifying the failure as product behavior. Runner instrumentation must not silently redefine a healthy product as broken.
- Modal, drawer, or overlay transitions that still intercept input after close has started must be treated as first-class E2E timing constraints. Wait for interaction blocking to stop, not only for a broad visibility assertion.
- Backend handlers that accept admin CRUD writes must remain compatible with the payload forms actually sent by the current browser client during rollout, including numeric enum values such as permission `type=0` and mixed numeric or string status updates when those paths are supported.
- Supported build commands are part of the release gate. If Vite or another build tool requires an explicit project root or equivalent configuration for the documented command to pass, fix the project config rather than relying on an ad hoc wrapper or local shell state.
## 2026-04-24 Profile Security Contract Recovery Supplement
- If a form includes UI-only fields such as `confirm_password`, outbound service code must strip or remap those fields before hitting the API. UI form names are not a valid substitute for the backend write contract.
- Service regression tests for write paths must assert the exact payload sent into the shared HTTP client, not only the values collected from the component or form layer.
- Browser-runner fetch or response waiters must be action-scoped. A waiter that can outlive a failed action and later crash with a page-closed error is not acceptable verification infrastructure.
## 2026-04-24 Scenario-Isolated Browser Gate Supplement
- The supported Windows browser gate may share one real backend and one real test database while still isolating browser processes per scenario. Reusing a single long-lived browser is not a quality requirement when the browser runtime itself is the unstable component.
- If browser-runtime instability is external to the product and reproducible across Chromium variants, recover at the scenario boundary with a fresh browser before classifying the supported gate as inherently flaky.
- The supported wrapper and the Playwright runner must derive selected scenario names from one shared source of truth. Duplicated scenario lists are a governance bug because they can make filtered evidence disagree with the documented main gate.
## 2026-04-24 Resource Ownership Authorization Supplement
- A path parameter is never sufficient authorization for an owner-scoped resource. For endpoints such as `/devices/:id`, `/users/:id/password`, and similar resource-by-id APIs, the handler must pass actor identity into the service layer and the service layer must re-check ownership or admin privilege before reading or mutating the resource.
- IDOR regression coverage for owner-scoped resources must include at least one non-owner read attempt, one non-owner mutation attempt, one non-owner destructive attempt, and one privileged state-change attempt such as trust, status, or reset semantics. Include one admin positive path when admin access is part of the contract.

View File

@@ -153,3 +153,157 @@ npm.cmd run e2e:full:win
- `docs/status/REAL_PROJECT_STATUS.md`
- 规则变化时更新 `docs/team/QUALITY_STANDARD.md`
- 产出可复用经验时更新 `docs/team/PROJECT_EXPERIENCE_SUMMARY.md`
## 0. 2026-04-23 Latest Technical Snapshot
Use this section as the current workspace truth when older notes elsewhere in this file describe earlier failures.
### Main Acceptance Path
- The supported browser-level gate remains `cd frontend/admin && npm.cmd run e2e:full:win`.
- That gate was re-run green on 2026-04-23 after fixes in device pagination flow, backend-response envelope decoding, settings-service adapter alignment, and Playwright CDP selector and suite-retry stability.
### Recovery Notes That Matter
- `DevicesPage` must keep the request cursor separate from the response `next_cursor`; otherwise the initial load can auto-chain into extra `/admin/devices` requests and trigger rate limiting.
- Frontend services must decode backend envelopes by their actual fields and by the shared HTTP client contract. The recovered cases in this round were `list`, `deliveries`, `accounts`, and `/admin/settings` direct `data`.
- Late-stage E2E scenarios are more stable when assertions target route, heading, and role-based locators instead of broad page text matches.
- If suite retry reuses the same backend process, one-time preconditions such as `admin-bootstrap` must be refreshed from live backend capabilities before the next attempt starts.
### Boundary
- This snapshot proves browser-level real E2E closure in the current workspace.
- It does not by itself prove the full backend matrix, OS-level automation, or live third-party provider verification.
## 2026-04-23 Late-Suite E2E Triage Order
Use this order before blaming the browser wrapper when `cd frontend/admin && npm.cmd run e2e:full:win` fails in later admin scenarios.
1. Check whether the failing page consumes an API whose response envelope or field names changed.
2. Check whether the page state machine, pagination flow, or derived state issued unexpected follow-up requests.
3. Check whether the failing assertion uses a broad text locator where route, heading, role, or labeled-control matching would be more precise.
4. Only after the first three checks stay clean, investigate CDP session lifecycle, page/context closure, or local browser startup instability.
## 2026-04-23 Password Reset And CDP Recovery Notes
### Root Cause
- The password-reset browser gap came from a backend contract omission: `/api/v1/auth/capabilities` returned `password_reset=false` even when `passwordResetHandler` was mounted and the reset routes were live.
### Minimal Fix
- `AuthHandler` now carries the password-reset capability bit and fills `caps.PasswordReset` in `GetAuthCapabilities()`.
- Router assembly now synchronizes that bit from the same `passwordResetHandler != nil` condition that mounts the reset routes.
### Browser Flow Proof
- The supported browser suite now proves the real password-reset chain end to end:
- admin creates a real user
- login surface exposes the forgot-password entry
- `/api/v1/auth/forgot-password` emits a real SMTP-captured reset link
- `/api/v1/auth/password/validate` and `/api/v1/auth/reset-password` complete through the browser
- the user logs in with the new password
### Stability Rule
- When headless-shell closes the last live target late in the suite, reconnect the CDP browser connection and reacquire the persistent page before declaring the whole run failed.
## 2026-04-23 Permissions CRUD And Full Matrix Technical Snapshot
Use this section as the newest technical snapshot when earlier 2026-04-23 notes describe only the 19-scenario gate.
### Main Acceptance Path
- The supported browser-level gate remains `cd frontend/admin && npm.cmd run e2e:full:win`.
- That gate was re-run green on 2026-04-23 after adding `permissions-management-crud`, fixing permissions payload compatibility, fixing auth-header selection under concurrent refresh state, and stabilizing CDP observation for proxied permission calls.
- The same branch state also re-ran `go test ./... -count=1`, `go vet ./...`, `go build ./cmd/server`, `cd frontend/admin && npm.cmd run test:run`, `cd frontend/admin && npm.cmd run lint`, and `cd frontend/admin && npm.cmd run build` successfully.
### Recovery Notes That Matter
- The permissions frontend adapter must accept raw numeric backend `type` values, normalize them to the frontend string enum, and serialize writes back to the backend numeric form.
- The permissions backend handler must continue accepting menu `type=0` and status payloads delivered as either numeric or string values, because real browser flows and clients can send both forms during incremental rollout.
- A valid non-expired access token must still be attached to requests even when a different refresh flow is already in flight. Refresh state alone is not evidence that the current request should lose authentication.
- In the permissions CRUD scenario, the page and backend were healthy even when Playwright CDP request and response observers missed the proxied `/api/v1/permissions` call. The reliable proof path was the in-page fetch diagnostic log plus the post-submit UI refresh.
- Ant modal leave animations can keep intercepting clicks after the dialog is visually closing. Scenario code should wait for the modal to stop blocking interaction before the next action.
- Vite 8 on Windows with `--configLoader native` can fail the supported build path if project root resolution is implicit. The stable fix is an explicit `root` in `vite.config.js`.
### Boundary
- This snapshot proves browser-level real E2E closure with `20` supported scenarios in the current workspace.
- It does not by itself prove OS-level automation, live third-party provider verification, or remote-repository publication status.
## 2026-04-24 Profile Security Contract Recovery
### Root Cause
- The profile password form used the UI model (`current_password`, `confirm_password`) all the way through the service layer, but the real backend `PUT /users/:id/password` handler binds `old_password` and `new_password` only.
### Minimal Fix
- `frontend/admin/src/services/profile.ts` now maps the UI request to the real backend payload shape before calling the shared HTTP client.
- `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` now couples password and TOTP fetch waits to the submit action that triggers them, so a later locator failure does not leave an orphaned background waiter that hides the real error.
### Browser Flow Proof
- The targeted profile page and service regression set is green.
- The supported browser-level gate `cd frontend/admin && npm.cmd run e2e:full:win` is green with `20` scenarios, including `profile-and-security`.
### Stability Rule
- When a scenario uses asynchronous fetch diagnostics for proof, create the waiter in the same control flow as the triggering action and tear it down implicitly with that action path. A background waiter that survives a failed action is a runner bug because it can replace the primary failure with misleading page-closed noise.
## 2026-04-24 Scenario-Isolated Browser Gate Notes
### Main Acceptance Path
- The supported browser-level gate remains `cd frontend/admin && npm.cmd run e2e:full:win`.
- On 2026-04-24 that gate was re-run green after changing `frontend/admin/scripts/run-playwright-auth-e2e.ps1` to keep one backend and one frontend session alive while launching a fresh browser process for each selected Playwright scenario.
- `frontend/admin/scripts/run-playwright-cdp-e2e.mjs` now supports a lightweight `E2E_LIST_SCENARIOS=1` mode so the wrapper and the runner derive the scenario order from the same source of truth.
### Current Green Evidence
- The current full-gate green evidence is `21` isolated scenario runs in one end-to-end environment:
- `admin-bootstrap`
- `public-registration`
- `email-activation`
- `password-reset`
- `login-surface`
- `auth-workflow`
- `responsive-login`
- `desktop-mobile-navigation`
- `user-management-crud`
- `user-management-batch`
- `role-management-crud`
- `permissions-management-crud`
- `device-management`
- `login-logs`
- `operation-logs`
- `webhook-management`
- `import-export`
- `profile-management`
- `profile-and-security`
- `settings`
- `dashboard-stats`
### Operational Knobs
- `E2E_SCENARIO_ISOLATION=0` keeps the legacy whole-suite browser mode available for diagnostics.
- `E2E_SCENARIO_ATTEMPTS` overrides the per-scenario retry count; otherwise the wrapper falls back to `E2E_SUITE_ATTEMPTS`.
## 2026-04-24 Resource Ownership Authorization Notes
### Recommended Implementation Pattern
- For owner-scoped resources, split authorization across two layers:
- the handler extracts the current actor identity and admin bit from the authenticated request context;
- the service loads the target resource and re-checks `owner-or-admin` before returning or mutating it.
- This prevents future handlers, background callers, or admin-route reuse from silently bypassing ownership checks by passing a raw resource ID straight into repository operations.
### Minimum Regression Pattern
- Add a targeted red-green regression set for each resource family that covers:
- cross-user read forbidden;
- cross-user update forbidden;
- cross-user delete forbidden;
- cross-user state toggle forbidden;
- admin positive access when the contract allows it.

View File

@@ -0,0 +1,28 @@
import process from 'node:process'
import { chromium } from '@playwright/test'
const cdpBaseUrl = (process.env.E2E_PLAYWRIGHT_CDP_URL ?? process.env.E2E_CDP_BASE_URL ?? '').trim()
if (!cdpBaseUrl) {
throw new Error('E2E_PLAYWRIGHT_CDP_URL or E2E_CDP_BASE_URL is required')
}
console.log(`PROBE cdp=${cdpBaseUrl}`)
if (process.env.PROBE_PRECREATE_TARGET === '1') {
console.log('PROBE precreate-target=start')
await fetch(`${cdpBaseUrl}/json/new?about:blank`, { method: 'PUT' }).catch(async () => {
await fetch(`${cdpBaseUrl}/json/new?about:blank`)
})
console.log('PROBE precreate-target=done')
}
const browser = await chromium.connectOverCDP(cdpBaseUrl)
console.log(`PROBE connected contexts=${browser.contexts().length}`)
for (const [index, context] of browser.contexts().entries()) {
console.log(`PROBE context[${index}] pages=${context.pages().length}`)
}
await browser.close()
console.log('PROBE done')

View File

@@ -0,0 +1,43 @@
export const BASE_SCENARIO_NAMES = [
'public-registration',
'email-activation',
'password-reset',
'login-surface',
'auth-workflow',
'responsive-login',
'desktop-mobile-navigation',
'user-management-crud',
'user-management-batch',
'role-management-crud',
'permissions-management-crud',
'device-management',
'login-logs',
'operation-logs',
'webhook-management',
'import-export',
'profile-management',
'profile-and-security',
'settings',
'dashboard-stats',
]
export function parseSelectedScenarioNames(rawScenarioNames = '') {
return new Set(
String(rawScenarioNames ?? '')
.split(',')
.map((name) => name.trim())
.filter(Boolean),
)
}
export function selectScenarioNames({ requestedScenarioNames, expectAdminBootstrap }) {
const scenarioNames = expectAdminBootstrap
? ['admin-bootstrap', ...BASE_SCENARIO_NAMES]
: [...BASE_SCENARIO_NAMES]
if (!requestedScenarioNames || requestedScenarioNames.size === 0) {
return scenarioNames
}
return scenarioNames.filter((name) => name === 'admin-bootstrap' || requestedScenarioNames.has(name))
}

View File

@@ -1,4 +1,4 @@
import { mkdtemp, readdir, rm, access, mkdir } from 'node:fs/promises'
import { mkdtemp, readdir, rm, access } from 'node:fs/promises'
import { constants as fsConstants } from 'node:fs'
import { spawn } from 'node:child_process'
import { tmpdir } from 'node:os'
@@ -76,7 +76,7 @@ async function main() {
} else {
browserPath = await resolveBrowserPath()
port = await getFreePort()
profileDir = await createBrowserProfileDir(browserPath, port)
profileDir = await createBrowserProfileDir()
browser = startBrowser(browserPath, port, profileDir)
cdpBaseUrl = `http://127.0.0.1:${port}`
}
@@ -150,7 +150,15 @@ function resolveExternalCdpBaseUrl() {
}
function startBrowser(browserPath, port, profileDir) {
const args = [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-sandbox']
const args = [
`--remote-debugging-port=${port}`,
`--user-data-dir=${profileDir}`,
'--noerrdialogs',
'--no-sandbox',
'--disable-breakpad',
'--disable-crash-reporter',
'--disable-crashpad-for-testing',
]
if (isHeadlessShell(browserPath)) {
args.push('--single-process')
@@ -181,14 +189,8 @@ function startBrowser(browserPath, port, profileDir) {
return browser
}
async function createBrowserProfileDir(browserPath, port) {
if (!isHeadlessShell(browserPath)) {
return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-'))
}
const profileRoot = path.join(process.cwd(), '.cache', 'cdp-profiles')
await mkdir(profileRoot, { recursive: true })
return path.join(profileRoot, `pw-profile-cdp-smoke-node-${port}`)
async function createBrowserProfileDir() {
return await mkdtemp(path.join(tmpdir(), 'pw-profile-cdp-'))
}
async function resolveBrowserPath() {

View File

@@ -104,18 +104,23 @@ function Get-BrowserArguments {
$arguments = @(
"--remote-debugging-port=$Port",
"--user-data-dir=$ProfileDir",
'--no-sandbox'
'--noerrdialogs',
'--no-sandbox',
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-breakpad',
'--disable-crash-reporter',
'--disable-crashpad-for-testing',
'--disable-sync',
'--disable-gpu'
)
if (Test-HeadlessShellBrowser -BrowserPath $BrowserPath) {
$arguments += '--single-process'
} else {
$arguments += @(
'--disable-dev-shm-usage',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-renderer-backgrounding',
'--disable-sync',
'--headless=new'
)
}
@@ -336,7 +341,7 @@ function Remove-BrowserLogs {
$browserPath = Resolve-BrowserPath
Write-Host "CDP browser: $browserPath"
$Port = if ($Port -gt 0) { $Port } else { Get-FreeTcpPort }
$profileRoot = Join-Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path '.cache\cdp-profiles'
$profileRoot = Join-Path $env:TEMP 'ums-cdp-profiles'
New-Item -ItemType Directory -Force $profileRoot | Out-Null
$profileDir = Join-Path $profileRoot "pw-profile-cdp-smoke-win-$Port"
$browserReadyUrl = "http://127.0.0.1:$Port/json/version"
@@ -383,6 +388,7 @@ try {
Write-Host "Launching command: $commandName $($commandArgs -join ' ')"
& $commandName @commandArgs
if ($LASTEXITCODE -ne 0) {
Show-BrowserLogs $browserHandle
throw "command failed with exit code $LASTEXITCODE"
}
} finally {

View File

@@ -9,19 +9,58 @@ param(
$ErrorActionPreference = 'Stop'
$projectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
$frontendRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
$tempCacheRoot = Join-Path $env:TEMP 'ums-e2e-cache'
$goCacheDir = Join-Path $tempCacheRoot 'go-build'
$goModCacheDir = Join-Path $tempCacheRoot 'gomod'
$goPathDir = Join-Path $tempCacheRoot 'gopath'
function Resolve-E2ERoots {
$scriptFrontendRoot = Resolve-Path (Join-Path $PSScriptRoot '..') -ErrorAction SilentlyContinue
$scriptProjectRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..\..') -ErrorAction SilentlyContinue
$cwdFrontendRoot = Resolve-Path (Get-Location).Path
$cwdProjectRoot = Resolve-Path (Join-Path $cwdFrontendRoot '..\..') -ErrorAction SilentlyContinue
if (
$scriptFrontendRoot -and
$scriptProjectRoot -and
(Test-Path (Join-Path $scriptFrontendRoot 'package.json')) -and
(Test-Path (Join-Path $scriptProjectRoot 'go.mod'))
) {
return [pscustomobject]@{
FrontendRoot = $scriptFrontendRoot.Path
ProjectRoot = $scriptProjectRoot.Path
}
}
if (
$cwdProjectRoot -and
(Test-Path (Join-Path $cwdFrontendRoot 'package.json')) -and
(Test-Path (Join-Path $cwdProjectRoot 'go.mod'))
) {
return [pscustomobject]@{
FrontendRoot = $cwdFrontendRoot
ProjectRoot = $cwdProjectRoot.Path
}
}
throw 'failed to resolve frontend/project roots for playwright e2e'
}
$resolvedRoots = Resolve-E2ERoots
$projectRoot = $resolvedRoots.ProjectRoot
$frontendRoot = $resolvedRoots.FrontendRoot
$serverExePath = Join-Path $env:TEMP ("ums-server-playwright-e2e-" + [guid]::NewGuid().ToString('N') + '.exe')
$e2eRunRoot = Join-Path $env:TEMP ("ums-playwright-e2e-" + [guid]::NewGuid().ToString('N'))
$goCacheDir = Join-Path $e2eRunRoot 'go-build'
$goModCacheDir = Join-Path $e2eRunRoot 'gomod'
$goPathDir = Join-Path $e2eRunRoot 'gopath'
$e2eDataRoot = Join-Path $e2eRunRoot 'data'
$e2eDbPath = Join-Path $e2eDataRoot 'user_management.e2e.db'
$smtpCaptureFile = Join-Path $e2eRunRoot 'smtp-capture.jsonl'
$e2eConfigPath = Join-Path $e2eRunRoot 'config.yaml'
$bootstrapSecret = 'e2e-bootstrap-secret'
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eDataRoot | Out-Null
New-Item -ItemType Directory -Force $goCacheDir, $goModCacheDir, $goPathDir, $e2eRunRoot, $e2eDataRoot | Out-Null
Set-Content -Path $e2eConfigPath -Encoding utf8 -Value @(
'default:',
' admin_email: ""',
' admin_password: ""'
)
function Get-FreeTcpPort {
$listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0)
@@ -64,6 +103,97 @@ function Wait-UrlReady {
throw "$Label did not become ready: $Url"
}
function Sync-AdminBootstrapExpectation {
param(
[Parameter(Mandatory = $true)][string]$BackendBaseUrl
)
$capabilitiesUrl = "$BackendBaseUrl/api/v1/auth/capabilities"
$response = Invoke-RestMethod -Uri $capabilitiesUrl -Method Get -TimeoutSec 15
$requiresBootstrap = $false
if ($response -and $response.data -and $null -ne $response.data.admin_bootstrap_required) {
$requiresBootstrap = [bool]$response.data.admin_bootstrap_required
}
if ($requiresBootstrap) {
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
} else {
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
}
Write-Host "playwright e2e admin bootstrap expected: $requiresBootstrap"
}
function Get-PositiveIntegerFromEnv {
param(
[Parameter(Mandatory = $true)][string]$Name,
[int]$DefaultValue = 3
)
$rawValue = [Environment]::GetEnvironmentVariable($Name)
if ([string]::IsNullOrWhiteSpace($rawValue)) {
return $DefaultValue
}
$parsedValue = 0
if ([int]::TryParse($rawValue, [ref]$parsedValue) -and $parsedValue -gt 0) {
return $parsedValue
}
return $DefaultValue
}
function Get-PlaywrightScenarioNames {
$env:E2E_LIST_SCENARIOS = '1'
try {
$output = & node ./scripts/run-playwright-cdp-e2e.mjs
if ($LASTEXITCODE -ne 0) {
throw "failed to list Playwright CDP scenarios with exit code $LASTEXITCODE"
}
return @($output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
} finally {
Remove-Item Env:E2E_LIST_SCENARIOS -ErrorAction SilentlyContinue
}
}
function Invoke-IsolatedPlaywrightScenario {
param(
[Parameter(Mandatory = $true)][string]$ScenarioName,
[Parameter(Mandatory = $true)][string]$BackendBaseUrl,
[int]$BrowserPort = 0,
[int]$ScenarioAttempts = 3
)
$lastError = $null
for ($attempt = 1; $attempt -le $ScenarioAttempts; $attempt++) {
try {
Sync-AdminBootstrapExpectation -BackendBaseUrl $BackendBaseUrl
$env:E2E_SCENARIOS = $ScenarioName
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
$lastError = $null
break
} catch {
$lastError = $_
if ($attempt -ge $ScenarioAttempts) {
throw
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
Write-Host "playwright-cdp scenario retry [$ScenarioName]: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
Start-Sleep -Seconds 1
} finally {
Remove-Item Env:E2E_SCENARIOS -ErrorAction SilentlyContinue
}
}
if ($lastError) {
throw $lastError
}
}
function Start-ManagedProcess {
param(
[Parameter(Mandatory = $true)][string]$Name,
@@ -160,28 +290,36 @@ $backendBaseUrl = "http://127.0.0.1:$selectedBackendPort"
$frontendBaseUrl = "http://127.0.0.1:$selectedFrontendPort"
try {
$serverSrcPath = Join-Path $projectRoot 'cmd\server'
Push-Location $projectRoot
try {
$env:GOCACHE = $goCacheDir
go build -o $serverExePath $serverSrcPath
$env:GOMODCACHE = $goModCacheDir
$env:GOPATH = $goPathDir
$env:GOTELEMETRY = 'off'
go build -o $serverExePath ./cmd/server
if ($LASTEXITCODE -ne 0) {
throw 'server build failed'
}
} finally {
Pop-Location
Remove-Item Env:GOCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOMODCACHE -ErrorAction SilentlyContinue
Remove-Item Env:GOPATH -ErrorAction SilentlyContinue
Remove-Item Env:GOTELEMETRY -ErrorAction SilentlyContinue
}
$env:DATA_DIR = $e2eRunRoot
$env:SERVER_PORT = "$selectedBackendPort"
$env:DATABASE_DBNAME = $e2eDbPath
$env:SERVER_MODE = 'debug'
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
$env:SERVER_MODE = 'debug'
$env:SERVER_FRONTEND_URL = $frontendBaseUrl
$env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontendPort"
$env:LOGGING_OUTPUT = 'stdout'
$env:EMAIL_HOST = '127.0.0.1'
$env:EMAIL_PORT = "$selectedSMTPPort"
$env:EMAIL_FROM_EMAIL = 'noreply@test.local'
$env:EMAIL_FROM_NAME = 'UMS E2E'
$env:BOOTSTRAP_SECRET = $bootstrapSecret
# JWT secret must be at least 32 bytes
$env:JWT_SECRET = 'e2e-test-jwt-secret-at-least-32-bytes-long-for-security'
@@ -232,43 +370,75 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
$env:E2E_LOGIN_USERNAME = $AdminUsername
$env:E2E_LOGIN_PASSWORD = $AdminPassword
$env:E2E_LOGIN_EMAIL = $AdminEmail
$env:E2E_EXPECT_ADMIN_BOOTSTRAP = '1'
$env:E2E_BOOTSTRAP_SECRET = $bootstrapSecret
$env:E2E_EXTERNAL_WEB_SERVER = '1'
$env:E2E_BASE_URL = $frontendBaseUrl
$env:E2E_API_BASE_URL = "$backendBaseUrl/api/v1"
$env:E2E_SMTP_CAPTURE_FILE = $smtpCaptureFile
Push-Location $frontendRoot
try {
$lastError = $null
for ($attempt = 1; $attempt -le 2; $attempt++) {
try {
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
$lastError = $null
break
} catch {
$lastError = $_
if ($attempt -ge 2) {
throw
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
Start-Sleep -Seconds 1
}
$scenarioIsolationEnabled = $true
if ($env:E2E_SCENARIO_ISOLATION -eq '0') {
$scenarioIsolationEnabled = $false
}
if ($lastError) {
throw $lastError
$suiteAttempts = Get-PositiveIntegerFromEnv -Name 'E2E_SUITE_ATTEMPTS' -DefaultValue 3
$scenarioAttempts = Get-PositiveIntegerFromEnv -Name 'E2E_SCENARIO_ATTEMPTS' -DefaultValue $suiteAttempts
if ($scenarioIsolationEnabled) {
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
$scenarioNames = Get-PlaywrightScenarioNames
if ($scenarioNames.Count -eq 0) {
throw 'no Playwright CDP scenarios were selected for execution'
}
Write-Host "playwright-cdp isolated scenarios: $($scenarioNames -join ', ')"
foreach ($scenarioName in $scenarioNames) {
Invoke-IsolatedPlaywrightScenario `
-ScenarioName $scenarioName `
-BackendBaseUrl $backendBaseUrl `
-BrowserPort $BrowserPort `
-ScenarioAttempts $scenarioAttempts
}
} else {
$lastError = $null
for ($attempt = 1; $attempt -le $suiteAttempts; $attempt++) {
try {
Sync-AdminBootstrapExpectation -BackendBaseUrl $backendBaseUrl
& (Join-Path $PSScriptRoot 'run-cdp-smoke.ps1') `
-Port $BrowserPort `
-Command @('node', './scripts/run-playwright-cdp-e2e.mjs')
$lastError = $null
break
} catch {
$lastError = $_
if ($attempt -ge $suiteAttempts) {
throw
}
$retryReason = if ($_.Exception -and $_.Exception.Message) { $_.Exception.Message } else { $_ | Out-String }
Write-Host "playwright-cdp suite retry: restarting browser and rerunning attempt $($attempt + 1) :: $retryReason"
Start-Sleep -Seconds 1
}
}
if ($lastError) {
throw $lastError
}
}
} finally {
Pop-Location
Remove-Item Env:DATA_DIR -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_USERNAME -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LOGIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXPECT_ADMIN_BOOTSTRAP -ErrorAction SilentlyContinue
Remove-Item Env:E2E_EXTERNAL_WEB_SERVER -ErrorAction SilentlyContinue
Remove-Item Env:E2E_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:E2E_LIST_SCENARIOS -ErrorAction SilentlyContinue
Remove-Item Env:E2E_SCENARIOS -ErrorAction SilentlyContinue
Remove-Item Env:E2E_SMTP_CAPTURE_FILE -ErrorAction SilentlyContinue
}
} finally {
@@ -290,9 +460,11 @@ $env:CORS_ALLOWED_ORIGINS = "$frontendBaseUrl,http://localhost:$selectedFrontend
Remove-Item Env:EMAIL_FROM_NAME -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_PROXY_TARGET -ErrorAction SilentlyContinue
Remove-Item Env:VITE_API_BASE_URL -ErrorAction SilentlyContinue
Remove-Item Env:BOOTSTRAP_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:JWT_SECRET -ErrorAction SilentlyContinue
Remove-Item Env:DEFAULT_ADMIN_EMAIL -ErrorAction SilentlyContinue
Remove-Item Env:DEFAULT_ADMIN_PASSWORD -ErrorAction SilentlyContinue
Remove-Item $serverExePath -Force -ErrorAction SilentlyContinue
Remove-Item $e2eConfigPath -Force -ErrorAction SilentlyContinue
Remove-Item $e2eRunRoot -Recurse -Force -ErrorAction SilentlyContinue
}

File diff suppressed because it is too large Load Diff

View File

@@ -269,6 +269,31 @@ describe('http client', () => {
})
})
it('uses the current non-expired access token even when another refresh is still in flight', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
message: 'ok',
data: { ok: true },
}),
)
const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules()
setAccessToken('still-valid-access-token', 3600)
startRefreshing()
setRefreshPromise(new Promise(() => {}))
const requestPromise = get('/protected')
await Promise.resolve()
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
Authorization: 'Bearer still-valid-access-token',
})
await expect(requestPromise).resolves.toEqual({ ok: true })
})
it('clears the local session when refresh fails before the business request is sent', async () => {
const fetchMock = vi.mocked(fetch)
fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))

View File

@@ -188,6 +188,10 @@ async function resolveAuthorizationHeader(auth: boolean): Promise<string | null>
}
let token = getAccessToken()
if (token && !isAccessTokenExpired()) {
return token
}
if (isRefreshing()) {
const promise = getRefreshPromise()
if (promise) {

View File

@@ -1,5 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const getAccessTokenMock = vi.fn<() => string | null>()
function jsonResponse(data: unknown, init: ResponseInit = {}) {
return new Response(JSON.stringify(data), {
status: 200,
@@ -12,6 +14,9 @@ function jsonResponse(data: unknown, init: ResponseInit = {}) {
async function loadCsrfModule() {
vi.resetModules()
vi.doMock('./auth-session', () => ({
getAccessToken: () => getAccessTokenMock(),
}))
return import('./csrf')
}
@@ -27,6 +32,8 @@ describe('csrf helpers', () => {
vi.clearAllMocks()
vi.unstubAllGlobals()
vi.unstubAllEnvs()
getAccessTokenMock.mockReset()
getAccessTokenMock.mockReturnValue(null)
clearCsrfCookie()
vi.stubGlobal('fetch', vi.fn())
})
@@ -85,6 +92,7 @@ describe('csrf helpers', () => {
it('fetches and stores a csrf token from the default relative api base', async () => {
const fetchMock = vi.mocked(fetch)
getAccessTokenMock.mockReturnValue('access-token')
fetchMock.mockResolvedValueOnce(
jsonResponse({
code: 0,
@@ -105,6 +113,7 @@ describe('csrf helpers', () => {
method: 'GET',
credentials: 'include',
headers: {
Authorization: 'Bearer access-token',
'Content-Type': 'application/json',
},
},

View File

@@ -13,6 +13,7 @@
// 使用原生 fetch 获取 CSRF Token
import { config } from '@/lib/config'
import { getAccessToken } from './auth-session'
// CSRF Token 存储
let csrfToken: string | null = null
@@ -84,13 +85,19 @@ export async function initCSRFToken(): Promise<string | null> {
if (!token) {
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
const accessToken = getAccessToken()
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
// 使用原生 fetch 避免循环依赖
const response = await fetch(buildUrl('/auth/csrf-token'), {
method: 'GET',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
headers,
})
if (response.ok) {

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { BASE_SCENARIO_NAMES, parseSelectedScenarioNames, selectScenarioNames } from '../../scripts/playwright-e2e-scenarios.mjs'
describe('playwright-e2e-scenarios', () => {
it('prepends admin bootstrap when capabilities require it', () => {
const scenarioNames = selectScenarioNames({
requestedScenarioNames: parseSelectedScenarioNames(''),
expectAdminBootstrap: true,
})
expect(scenarioNames[0]).toBe('admin-bootstrap')
expect(scenarioNames.slice(1)).toEqual(BASE_SCENARIO_NAMES)
})
it('keeps admin bootstrap when filtering a later scenario', () => {
const scenarioNames = selectScenarioNames({
requestedScenarioNames: parseSelectedScenarioNames('email-activation'),
expectAdminBootstrap: true,
})
expect(scenarioNames).toEqual(['admin-bootstrap', 'email-activation'])
})
it('does not invent admin bootstrap when it is no longer required', () => {
const scenarioNames = selectScenarioNames({
requestedScenarioNames: parseSelectedScenarioNames('email-activation'),
expectAdminBootstrap: false,
})
expect(scenarioNames).toEqual(['email-activation'])
})
})

View File

@@ -4,9 +4,12 @@ import userEvent from '@testing-library/user-event'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Device, AdminDeviceListParams } from '@/types/device'
import type { CursorPaginatedData, PaginatedData } from '@/types/http'
import { DevicesPage } from './DevicesPage'
const listAllDevicesMock = vi.fn<(params?: AdminDeviceListParams) => Promise<{ items: Device[]; total: number; page: number; page_size: number }>>()
type DeviceListResponse = PaginatedData<Device> | CursorPaginatedData<Device>
const listAllDevicesMock = vi.fn<(params?: AdminDeviceListParams) => Promise<DeviceListResponse>>()
const deleteDeviceMock = vi.fn<(id: number) => Promise<void>>()
const trustDeviceMock = vi.fn<(id: number, duration?: string) => Promise<void>>()
const untrustDeviceMock = vi.fn<(id: number) => Promise<void>>()
@@ -377,6 +380,34 @@ describe('DevicesPage', () => {
)
})
it('does not auto-request the next cursor page after initial load', async () => {
listAllDevicesMock.mockReset()
listAllDevicesMock
.mockResolvedValueOnce({
items: [currentDevices[0]],
next_cursor: 'cursor-page-2',
has_more: true,
page_size: 20,
})
.mockResolvedValueOnce({
items: [currentDevices[1]],
next_cursor: '',
has_more: false,
page_size: 20,
})
render(<DevicesPage />)
expect(await screen.findByText('Device 1')).toBeInTheDocument()
await new Promise((resolve) => setTimeout(resolve, 0))
expect(listAllDevicesMock).toHaveBeenCalledTimes(1)
expect(listAllDevicesMock).toHaveBeenCalledWith(
expect.objectContaining({ cursor: undefined, size: 20 }),
)
})
it('shows error state and retry', async () => {
const user = userEvent.setup()

View File

@@ -46,7 +46,8 @@ export function DevicesPage() {
const [devices, setDevices] = useState<Device[]>([])
const [total, setTotal] = useState(0)
// Cursor-based pagination state (preferred for large datasets)
const [cursor, setCursor] = useState('')
const [requestCursor, setRequestCursor] = useState('')
const [nextCursor, setNextCursor] = useState('')
const [hasMore, setHasMore] = useState(true)
// Legacy page state (for Ant Design Table compatibility)
const [page, setPage] = useState(1)
@@ -64,7 +65,7 @@ export function DevicesPage() {
setError(null)
try {
const params: AdminDeviceListParams = {
cursor: cursor || undefined,
cursor: requestCursor || undefined,
size: pageSize,
keyword: keyword || undefined,
user_id: userIdFilter,
@@ -75,12 +76,14 @@ export function DevicesPage() {
setDevices(result.items ?? [])
// If the response has cursor fields, use them; otherwise fall back to legacy total
if ('next_cursor' in result) {
setCursor(result.next_cursor ?? '')
setNextCursor(result.next_cursor ?? '')
setHasMore(result.has_more ?? false)
// Estimate total from current data + whether there's more
setTotal((page - 1) * pageSize + result.items?.length + (result.has_more ? 1 : 0))
} else {
// Legacy response format fallback
setNextCursor('')
setHasMore(false)
setTotal((result as { total?: number }).total ?? 0)
}
} catch (err) {
@@ -88,7 +91,7 @@ export function DevicesPage() {
} finally {
setLoading(false)
}
}, [cursor, page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
}, [requestCursor, page, pageSize, keyword, userIdFilter, statusFilter, trustFilter])
useEffect(() => {
void fetchDevices()
@@ -97,7 +100,8 @@ export function DevicesPage() {
// 筛选条件变化时重置到第一页(清空游标)
useEffect(() => {
setPage(1)
setCursor('')
setRequestCursor('')
setNextCursor('')
}, [keyword, userIdFilter, statusFilter, trustFilter])
// 重置筛选
@@ -107,7 +111,8 @@ export function DevicesPage() {
setStatusFilter(undefined)
setTrustFilter(undefined)
setPage(1)
setCursor('')
setRequestCursor('')
setNextCursor('')
}
// 删除设备
@@ -278,14 +283,17 @@ export function DevicesPage() {
if (ps !== pageSize) {
setPageSize(ps)
setPage(1)
setCursor('')
} else if (p === page + 1 && cursor) {
setRequestCursor('')
setNextCursor('')
} else if (p === page + 1 && nextCursor) {
// Next page via cursor
setPage(p)
setRequestCursor(nextCursor)
} else {
// Jump to specific page - fall back
setPage(p)
setCursor('')
setRequestCursor('')
setNextCursor('')
}
},
}

View File

@@ -8,12 +8,12 @@ import type { AuthCapabilities, TokenBundle } from '@/types'
import { BootstrapAdminPage } from './BootstrapAdminPage'
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const bootstrapAdminMock = vi.fn<(payload: unknown) => Promise<TokenBundle>>()
const bootstrapAdminMock = vi.fn<(payload: unknown, bootstrapSecret: string) => Promise<TokenBundle>>()
const onLoginSuccessMock = vi.fn<(tokenBundle: TokenBundle) => Promise<void>>()
vi.mock('@/services/auth', () => ({
getAuthCapabilities: () => getAuthCapabilitiesMock(),
bootstrapAdmin: (payload: unknown) => bootstrapAdminMock(payload),
bootstrapAdmin: (payload: unknown, bootstrapSecret: string) => bootstrapAdminMock(payload, bootstrapSecret),
}))
const authContextValue: AuthContextValue = {
@@ -76,6 +76,7 @@ describe('BootstrapAdminPage', () => {
expect(screen.getByRole('heading', { name: '初始化首个管理员账号' })).toBeInTheDocument()
expect(screen.getByPlaceholderText('管理员用户名')).toBeInTheDocument()
expect(screen.getByPlaceholderText('引导密钥')).toBeInTheDocument()
expect(screen.getByPlaceholderText('管理员密码')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '完成初始化并进入系统' })).toBeInTheDocument()
})
@@ -89,17 +90,21 @@ describe('BootstrapAdminPage', () => {
await user.type(screen.getByPlaceholderText('管理员用户名'), 'bootstrap_admin')
await user.type(screen.getByPlaceholderText('管理员昵称(选填)'), 'Bootstrap Admin')
await user.type(screen.getByPlaceholderText('管理员邮箱(选填)'), 'bootstrap_admin@example.com')
await user.type(screen.getByPlaceholderText('引导密钥'), 'bootstrap-secret')
await user.type(screen.getByPlaceholderText('管理员密码'), 'Bootstrap123!@#')
await user.type(screen.getByPlaceholderText('确认管理员密码'), 'Bootstrap123!@#')
await user.click(screen.getByRole('button', { name: '完成初始化并进入系统' }))
await waitFor(() =>
expect(bootstrapAdminMock).toHaveBeenCalledWith({
username: 'bootstrap_admin',
nickname: 'Bootstrap Admin',
email: 'bootstrap_admin@example.com',
password: 'Bootstrap123!@#',
}),
expect(bootstrapAdminMock).toHaveBeenCalledWith(
{
username: 'bootstrap_admin',
nickname: 'Bootstrap Admin',
email: 'bootstrap_admin@example.com',
password: 'Bootstrap123!@#',
},
'bootstrap-secret',
),
)
await waitFor(() =>

View File

@@ -25,6 +25,7 @@ type BootstrapAdminFormValues = {
username: string
nickname?: string
email?: string
bootstrapSecret: string
password: string
confirmPassword: string
}
@@ -68,12 +69,15 @@ export function BootstrapAdminPage() {
const handleSubmit = useCallback(async (values: BootstrapAdminFormValues) => {
setLoading(true)
try {
const tokenBundle = await bootstrapAdmin({
username: values.username.trim(),
nickname: values.nickname?.trim() || undefined,
email: values.email?.trim() || undefined,
password: values.password,
})
const tokenBundle = await bootstrapAdmin(
{
username: values.username.trim(),
nickname: values.nickname?.trim() || undefined,
email: values.email?.trim() || undefined,
password: values.password,
},
values.bootstrapSecret.trim(),
)
await onLoginSuccess(tokenBundle)
message.success('管理员初始化完成')
navigate('/dashboard', { replace: true })
@@ -152,6 +156,17 @@ export function BootstrapAdminPage() {
autoComplete="email"
/>
</Form.Item>
<Form.Item
name="bootstrapSecret"
rules={[{ required: true, message: '请输入引导密钥' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="引导密钥"
size="large"
autoComplete="off"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入管理员密码' }]}

View File

@@ -29,6 +29,7 @@ const assignMock = vi.fn()
const getAuthCapabilitiesMock = vi.fn<() => Promise<AuthCapabilities>>()
const getOAuthAuthorizationUrlMock = vi.fn()
const loginByPasswordMock = vi.fn()
const verifyTOTPAfterPasswordLoginMock = vi.fn()
const loginByEmailCodeMock = vi.fn()
const loginBySmsCodeMock = vi.fn()
const sendEmailCodeMock = vi.fn()
@@ -73,6 +74,7 @@ vi.mock('@/services/auth', () => ({
getOAuthAuthorizationUrl: (provider: string, returnTo: string) =>
getOAuthAuthorizationUrlMock(provider, returnTo),
loginByPassword: (payload: unknown) => loginByPasswordMock(payload),
verifyTOTPAfterPasswordLogin: (payload: unknown) => verifyTOTPAfterPasswordLoginMock(payload),
loginByEmailCode: (payload: unknown) => loginByEmailCodeMock(payload),
loginBySmsCode: (payload: unknown) => loginBySmsCodeMock(payload),
sendEmailCode: (payload: unknown) => sendEmailCodeMock(payload),
@@ -127,6 +129,7 @@ describe('LoginPage', () => {
getAuthCapabilitiesMock.mockReset()
getOAuthAuthorizationUrlMock.mockReset()
loginByPasswordMock.mockReset()
verifyTOTPAfterPasswordLoginMock.mockReset()
loginByEmailCodeMock.mockReset()
loginBySmsCodeMock.mockReset()
sendEmailCodeMock.mockReset()
@@ -280,6 +283,49 @@ describe('LoginPage', () => {
expect(navigateMock).not.toHaveBeenCalled()
})
it('holds password login on a TOTP challenge and completes verification before creating a session', async () => {
loginByPasswordMock.mockResolvedValue({
requires_totp: true,
user_id: 1,
temp_token: 'totp-challenge-token',
})
verifyTOTPAfterPasswordLoginMock.mockResolvedValue(loginTokenBundle)
renderLoginPage('/login?redirect=/profile')
await waitFor(() => expect(getAuthCapabilitiesMock).toHaveBeenCalledTimes(1))
fireEvent.change(screen.getByPlaceholderText(TEXT.usernamePlaceholder), {
target: { value: 'admin' },
})
fireEvent.change(screen.getByPlaceholderText(TEXT.passwordPlaceholder), {
target: { value: 'SecurePass123!' },
})
fireEvent.click(screen.getByRole('button'))
await waitFor(() => expect(loginByPasswordMock).toHaveBeenCalledTimes(1))
expect(onLoginSuccessMock).not.toHaveBeenCalled()
expect(screen.getByPlaceholderText('TOTP code')).toBeInTheDocument()
fireEvent.change(screen.getByPlaceholderText('TOTP code'), {
target: { value: '123456' },
})
fireEvent.click(screen.getByRole('button', { name: /verify totp/i }))
await waitFor(() => {
expect(verifyTOTPAfterPasswordLoginMock).toHaveBeenCalledWith({
user_id: 1,
code: '123456',
device_id: expect.any(String),
temp_token: 'totp-challenge-token',
})
})
expect(onLoginSuccessMock).toHaveBeenCalledWith(loginTokenBundle)
expect(navigateMock).toHaveBeenCalledWith('/profile', { replace: true })
})
it('sends an email verification code and starts the resend countdown', async () => {
getAuthCapabilitiesMock.mockResolvedValue({
...defaultCapabilities,

View File

@@ -22,8 +22,9 @@ import {
loginBySmsCode,
sendEmailCode,
sendSmsCode,
verifyTOTPAfterPasswordLogin,
} from '@/services/auth'
import type { AuthCapabilities, TokenBundle } from '@/types'
import type { AuthCapabilities, PasswordLoginChallenge, PasswordLoginResponse, TokenBundle } from '@/types'
const { Paragraph, Text, Title } = Typography
@@ -53,6 +54,19 @@ type SmsCodeFormValues = {
code: string
}
function isPasswordLoginChallenge(
result: PasswordLoginResponse,
): result is PasswordLoginChallenge {
return (
typeof result === 'object' &&
result !== null &&
'requires_totp' in result &&
result.requires_totp === true &&
typeof result.user_id === 'number' &&
typeof result.temp_token === 'string'
)
}
export function LoginPage() {
const [activeTab, setActiveTab] = useState('password')
const [loading, setLoading] = useState(false)
@@ -60,6 +74,8 @@ export function LoginPage() {
const [emailCountdown, setEmailCountdown] = useState(0)
const [smsCountdown, setSmsCountdown] = useState(0)
const [capabilities, setCapabilities] = useState<AuthCapabilities>(DEFAULT_CAPABILITIES)
const [pendingTOTP, setPendingTOTP] = useState<(PasswordLoginChallenge & { device_id?: string }) | null>(null)
const [totpCode, setTotpCode] = useState('')
const [emailForm] = Form.useForm<EmailCodeFormValues>()
const [smsForm] = Form.useForm<SmsCodeFormValues>()
@@ -151,6 +167,8 @@ export function LoginPage() {
const handlePasswordLogin = useCallback(async (values: LoginFormValues) => {
setLoading(true)
setPendingTOTP(null)
setTotpCode('')
try {
const deviceInfo = getDeviceFingerprint()
const tokenBundle = await loginByPassword({
@@ -158,6 +176,17 @@ export function LoginPage() {
password: values.password,
...deviceInfo,
})
if (isPasswordLoginChallenge(tokenBundle)) {
setPendingTOTP({
...tokenBundle,
device_id: deviceInfo.device_id,
})
setTotpCode('')
return
}
setPendingTOTP(null)
setTotpCode('')
await handleLoginSuccess(tokenBundle)
} catch (error) {
message.error(getErrorMessage(error, '登录失败,请检查用户名和密码'))
@@ -166,6 +195,29 @@ export function LoginPage() {
}
}, [handleLoginSuccess])
const handleTOTPVerification = useCallback(async () => {
if (!pendingTOTP) {
return
}
setLoading(true)
try {
const tokenBundle = await verifyTOTPAfterPasswordLogin({
user_id: pendingTOTP.user_id,
code: totpCode,
device_id: pendingTOTP.device_id,
temp_token: pendingTOTP.temp_token,
})
setPendingTOTP(null)
setTotpCode('')
await handleLoginSuccess(tokenBundle)
} catch (error) {
message.error(getErrorMessage(error, 'TOTP verification failed'))
} finally {
setLoading(false)
}
}, [handleLoginSuccess, pendingTOTP, totpCode])
const handleSendEmailCode = useCallback(async () => {
try {
const values = await emailForm.validateFields(['email'])
@@ -232,6 +284,33 @@ export function LoginPage() {
key: 'password',
label: '密码登录',
children: (
pendingTOTP ? (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Alert
type="info"
showIcon
message="TOTP verification required"
description="Enter the code from your authenticator app to finish signing in."
/>
<Input
prefix={<SafetyOutlined />}
placeholder="TOTP code"
size="large"
maxLength={6}
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
/>
<Button
type="primary"
size="large"
block
loading={loading}
onClick={() => void handleTOTPVerification()}
>
Verify TOTP
</Button>
</Space>
) : (
<Form<LoginFormValues> layout="vertical" onFinish={handlePasswordLogin} autoComplete="off">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input
@@ -255,6 +334,7 @@ export function LoginPage() {
</Button>
</Form.Item>
</Form>
)
),
},
]
@@ -387,12 +467,15 @@ export function LoginPage() {
emailForm,
handleEmailCodeLogin,
handlePasswordLogin,
handleTOTPVerification,
handleSendEmailCode,
handleSendSmsCode,
handleSmsCodeLogin,
loading,
pendingTOTP,
smsCountdown,
smsForm,
totpCode,
])
const currentTab = tabItems.find((item) => item.key === activeTab) ?? tabItems[0]

View File

@@ -41,16 +41,13 @@ const defaultCapabilities: AuthCapabilities = {
}
const activeRegisterResponse: RegisterResponse = {
user: {
id: 2,
username: 'new-user',
email: 'new-user@example.com',
phone: '',
nickname: 'New User',
avatar: '',
status: 1,
},
message: 'registered successfully',
id: 2,
username: 'new-user',
email: 'new-user@example.com',
phone: '',
nickname: 'New User',
avatar: '',
status: 1,
}
vi.mock('@/services/auth', () => ({
@@ -321,16 +318,13 @@ describe('RegisterPage', () => {
email_activation: true,
})
registerMock.mockResolvedValue({
user: {
id: 3,
username: 'inactive-user',
email: 'inactive-user@example.com',
phone: '',
nickname: 'Inactive User',
avatar: '',
status: 0,
},
message: 'registered successfully, please check your email to activate the account',
id: 3,
username: 'inactive-user',
email: 'inactive-user@example.com',
phone: '',
nickname: 'Inactive User',
avatar: '',
status: 0,
})
renderRegisterPage()
@@ -350,16 +344,13 @@ describe('RegisterPage', () => {
it('shows the generic activation summary when the new inactive account has no email address', async () => {
registerMock.mockResolvedValue({
user: {
id: 4,
username: 'inactive-without-email',
email: '',
phone: '',
nickname: '',
avatar: '',
status: 0,
},
message: 'registered successfully, activation required',
id: 4,
username: 'inactive-without-email',
email: '',
phone: '',
nickname: '',
avatar: '',
status: 0,
})
renderRegisterPage()

View File

@@ -39,9 +39,9 @@ type RegisterFormValues = {
}
function buildRegisterSummary(result: RegisterResponse) {
if (result.user.status === 0) {
if (result.user.email) {
return `账号已创建,激活邮件会发送到 ${result.user.email}。请完成激活后再登录。`
if (result.status === 0) {
if (result.email) {
return `账号已创建,激活邮件会发送到 ${result.email}。请完成激活后再登录。`
}
return '账号已创建,请按页面提示完成激活后再登录。'
}
@@ -128,7 +128,7 @@ export function RegisterPage() {
form.resetFields()
setSmsCountdown(0)
setSubmitted(result)
message.success(result.user.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
message.success(result.status === 0 ? '注册成功,请完成邮箱激活' : '注册成功')
} catch (error) {
message.error(getErrorMessage(error, '注册失败,请检查输入信息后重试'))
} finally {
@@ -137,7 +137,7 @@ export function RegisterPage() {
}, [capabilities.sms_code, form])
if (submitted) {
const activationEmail = submitted.user.email?.trim()
const activationEmail = submitted.email?.trim()
return (
<AuthLayout>
@@ -146,7 +146,7 @@ export function RegisterPage() {
title="注册成功"
subTitle={(
<Paragraph>
<Text strong>{submitted.user.username}</Text>
<Text strong>{submitted.username}</Text>
{' '}
{buildRegisterSummary(submitted)}
</Paragraph>
@@ -155,7 +155,7 @@ export function RegisterPage() {
<Link key="login" to="/login">
<Button type="primary"></Button>
</Link>,
submitted.user.status === 0 && activationEmail && capabilities.email_activation ? (
submitted.status === 0 && activationEmail && capabilities.email_activation ? (
<Link key="activation" to={`/activate-account?email=${encodeURIComponent(activationEmail)}`}>
<Button></Button>
</Link>

View File

@@ -106,7 +106,7 @@ describe('auth service', () => {
)
})
it('submits first-admin bootstrap without auth headers', async () => {
it('submits first-admin bootstrap with the bootstrap secret header', async () => {
const { bootstrapAdmin } = await import('./auth')
await bootstrapAdmin({
@@ -114,7 +114,7 @@ describe('auth service', () => {
password: 'Bootstrap123!@#',
email: 'bootstrap_admin@example.com',
nickname: 'Bootstrap Admin',
})
}, 'bootstrap-secret')
expect(postMock).toHaveBeenCalledWith(
'/auth/bootstrap-admin',
@@ -124,7 +124,13 @@ describe('auth service', () => {
email: 'bootstrap_admin@example.com',
nickname: 'Bootstrap Admin',
},
{ auth: false, credentials: 'include' },
{
auth: false,
credentials: 'include',
headers: {
'X-Bootstrap-Secret': 'bootstrap-secret',
},
},
)
})

View File

@@ -8,6 +8,7 @@ import type {
LoginByPasswordRequest,
LoginBySmsCodeRequest,
OAuthAuthorizationResponse,
PasswordLoginResponse,
RegisterRequest,
RegisterResponse,
ResendActivationEmailRequest,
@@ -37,8 +38,8 @@ export async function getAuthCapabilities(): Promise<AuthCapabilities> {
return normalizeAuthCapabilities(capabilities)
}
export function loginByPassword(data: LoginByPasswordRequest): Promise<TokenBundle> {
return post<TokenBundle>('/auth/login', data, { auth: false, credentials: 'include' })
export function loginByPassword(data: LoginByPasswordRequest): Promise<PasswordLoginResponse> {
return post<PasswordLoginResponse>('/auth/login', data, { auth: false, credentials: 'include' })
}
// Verify TOTP after password login when requires_totp is returned
@@ -58,8 +59,17 @@ export function register(data: RegisterRequest): Promise<RegisterResponse> {
return post<RegisterResponse>('/auth/register', data, { auth: false })
}
export function bootstrapAdmin(data: BootstrapAdminRequest): Promise<TokenBundle> {
return post<TokenBundle>('/auth/bootstrap-admin', data, { auth: false, credentials: 'include' })
export function bootstrapAdmin(
data: BootstrapAdminRequest,
bootstrapSecret: string,
): Promise<TokenBundle> {
return post<TokenBundle>('/auth/bootstrap-admin', data, {
auth: false,
credentials: 'include',
headers: {
'X-Bootstrap-Secret': bootstrapSecret,
},
})
}
export function activateEmail(token: string): Promise<ActionMessageResponse> {

View File

@@ -22,7 +22,7 @@ describe('permissions service', () => {
it('gets permission tree', async () => {
const mockTree = [
{ id: 1, name: 'dashboard', children: [{ id: 2, name: 'view' }] },
{ id: 1, name: 'dashboard', type: 0, children: [{ id: 2, name: 'view', type: 2 }] },
]
getMock.mockResolvedValue(mockTree)
@@ -30,13 +30,15 @@ describe('permissions service', () => {
const result = await getPermissionTree()
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
expect(result).toEqual(mockTree)
expect(result).toEqual([
{ id: 1, name: 'dashboard', type: 'menu', children: [{ id: 2, name: 'view', type: 'api' }] },
])
})
it('lists all permissions', async () => {
const mockPermissions = [
{ id: 1, name: 'view dashboard', code: 'dashboard:view' },
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit' },
{ id: 1, name: 'view dashboard', code: 'dashboard:view', type: 0 },
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 1 },
]
getMock.mockResolvedValue(mockPermissions)
@@ -44,40 +46,46 @@ describe('permissions service', () => {
const result = await listPermissions()
expect(getMock).toHaveBeenCalledWith('/permissions')
expect(result).toEqual(mockPermissions)
expect(result).toEqual([
{ id: 1, name: 'view dashboard', code: 'dashboard:view', type: 'menu' },
{ id: 2, name: 'edit dashboard', code: 'dashboard:edit', type: 'button' },
])
})
it('gets a single permission', async () => {
getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view' })
getMock.mockResolvedValue({ id: 5, name: 'view users', code: 'users:view', type: 2 })
const { getPermission } = await import('./permissions')
const result = await getPermission(5)
expect(getMock).toHaveBeenCalledWith('/permissions/5')
expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view' })
expect(result).toEqual({ id: 5, name: 'view users', code: 'users:view', type: 'api' })
})
it('creates a permission', async () => {
const newPermission = { name: 'new permission', code: 'new:code', type: 'button' as const }
const created = { id: 10, ...newPermission }
const created = { id: 10, ...newPermission, type: 1 }
postMock.mockResolvedValue(created)
const { createPermission } = await import('./permissions')
const result = await createPermission(newPermission)
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
expect(result).toEqual(created)
expect(postMock).toHaveBeenCalledWith('/permissions', {
...newPermission,
type: 1,
})
expect(result).toEqual({ id: 10, name: 'new permission', code: 'new:code', type: 'button' })
})
it('updates a permission', async () => {
const updateData = { name: 'updated name' }
putMock.mockResolvedValue({ id: 3, ...updateData })
putMock.mockResolvedValue({ id: 3, ...updateData, type: 0 })
const { updatePermission } = await import('./permissions')
const result = await updatePermission(3, updateData)
expect(putMock).toHaveBeenCalledWith('/permissions/3', updateData)
expect(result).toEqual({ id: 3, name: 'updated name' })
expect(result).toEqual({ id: 3, name: 'updated name', type: 'menu' })
})
it('deletes a permission', async () => {

View File

@@ -5,14 +5,58 @@
*/
import { get, post, put, del } from '@/lib/http/client'
import type { Permission, CreatePermissionRequest, UpdatePermissionRequest } from '@/types/permission'
import type {
Permission,
CreatePermissionRequest,
UpdatePermissionRequest,
PermissionType,
} from '@/types/permission'
type RawPermissionType = 0 | 1 | 2
interface RawPermission extends Omit<Permission, 'type' | 'children'> {
type: RawPermissionType
children?: RawPermission[]
}
function normalizePermissionType(type: RawPermissionType): PermissionType {
switch (type) {
case 0:
return 'menu'
case 1:
return 'button'
case 2:
return 'api'
default:
return 'api'
}
}
function serializePermissionType(type: PermissionType): RawPermissionType {
switch (type) {
case 'menu':
return 0
case 'button':
return 1
case 'api':
return 2
}
}
function normalizePermission(permission: RawPermission): Permission {
return {
...permission,
type: normalizePermissionType(permission.type),
children: permission.children?.map(normalizePermission),
}
}
/**
* 获取权限树
* GET /api/v1/permissions/tree
*/
export function getPermissionTree(): Promise<Permission[]> {
return get<Permission[]>('/permissions/tree')
return get<RawPermission[]>('/permissions/tree').then((permissions) => permissions.map(normalizePermission))
}
/**
@@ -20,7 +64,7 @@ export function getPermissionTree(): Promise<Permission[]> {
* GET /api/v1/permissions
*/
export function listPermissions(): Promise<Permission[]> {
return get<Permission[]>('/permissions')
return get<RawPermission[]>('/permissions').then((permissions) => permissions.map(normalizePermission))
}
/**
@@ -28,7 +72,7 @@ export function listPermissions(): Promise<Permission[]> {
* GET /api/v1/permissions/:id
*/
export function getPermission(id: number): Promise<Permission> {
return get<Permission>(`/permissions/${id}`)
return get<RawPermission>(`/permissions/${id}`).then(normalizePermission)
}
/**
@@ -36,7 +80,10 @@ export function getPermission(id: number): Promise<Permission> {
* POST /api/v1/permissions
*/
export function createPermission(data: CreatePermissionRequest): Promise<Permission> {
return post<Permission>('/permissions', data)
return post<RawPermission>('/permissions', {
...data,
type: serializePermissionType(data.type),
}).then(normalizePermission)
}
/**
@@ -44,7 +91,7 @@ export function createPermission(data: CreatePermissionRequest): Promise<Permiss
* PUT /api/v1/permissions/:id
*/
export function updatePermission(id: number, data: UpdatePermissionRequest): Promise<Permission> {
return put<Permission>(`/permissions/${id}`, data)
return put<RawPermission>(`/permissions/${id}`, data).then(normalizePermission)
}
/**

View File

@@ -76,9 +76,8 @@ describe('profile service', () => {
})
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
current_password: 'OldPass123',
old_password: 'OldPass123',
new_password: 'NewPass123',
confirm_password: 'NewPass123',
})
})

View File

@@ -50,7 +50,10 @@ export function uploadAvatar(userId: number, file: File): Promise<AvatarUploadRe
}
export function updatePassword(userId: number, data: UpdatePasswordRequest): Promise<void> {
return put<void>(`/users/${userId}/password`, data)
return put<void>(`/users/${userId}/password`, {
old_password: data.current_password,
new_password: data.new_password,
})
}
export function getTOTPStatus(): Promise<TOTPStatusResponse> {

View File

@@ -24,6 +24,11 @@ describe('additional service adapters', () => {
})
it('routes the remaining users service methods through the HTTP client', async () => {
getMock
.mockResolvedValueOnce({ items: [], total: 0, page: 2, page_size: 50 })
.mockResolvedValueOnce({ id: 7 })
.mockResolvedValueOnce([])
const {
listUsers,
getUser,
@@ -69,10 +74,22 @@ describe('additional service adapters', () => {
.mockResolvedValueOnce([{ id: 9 }, { id: 11 }])
.mockResolvedValueOnce({ items: [], total: 0, page: 1, page_size: 20 })
.mockResolvedValueOnce({ id: 3 })
.mockResolvedValueOnce([{ id: 1, name: 'menu:view' }])
.mockResolvedValueOnce([{ id: 2, name: 'menu:edit' }])
.mockResolvedValueOnce([{ id: 1, name: 'menu:view', type: 0 }])
.mockResolvedValueOnce([{ id: 2, name: 'menu:edit', type: 1 }])
.mockResolvedValueOnce({ total_users: 10 })
.mockResolvedValueOnce({ active_users: 8 })
postMock.mockImplementation(async (url: string, payload: Record<string, unknown>) => {
if (url === '/permissions') {
return { id: 6, ...payload }
}
return { id: 5, ...payload }
})
putMock.mockImplementation(async (url: string, payload: Record<string, unknown>) => {
if (url === '/permissions/6') {
return { id: 6, ...payload, type: 0 }
}
return undefined
})
const {
listRoles,
@@ -151,7 +168,7 @@ describe('additional service adapters', () => {
expect(postMock).toHaveBeenCalledWith('/permissions', {
name: 'view dashboard',
code: 'dashboard:view',
type: 'menu',
type: 0,
})
await updatePermission(6, { name: 'updated permission' })
@@ -238,9 +255,8 @@ describe('additional service adapters', () => {
confirm_password: 'NewPass123',
})
expect(putMock).toHaveBeenCalledWith('/users/1/password', {
current_password: 'CurrentPass123',
old_password: 'CurrentPass123',
new_password: 'NewPass123',
confirm_password: 'NewPass123',
})
await expect(getTOTPStatus()).resolves.toEqual({ totp_enabled: true })

View File

@@ -80,7 +80,26 @@ describe('permissions service', () => {
it('gets permission tree', async () => {
const mockPermissions = [
{ id: 1, name: 'Users', code: 'users', children: [{ id: 2, name: 'View', code: 'users:view' }] },
{
id: 1,
name: 'Users',
code: 'users',
type: 0,
children: [
{ id: 2, name: 'View', code: 'users:view', type: 2 },
],
},
]
const expectedPermissions = [
{
id: 1,
name: 'Users',
code: 'users',
type: 'menu',
children: [
{ id: 2, name: 'View', code: 'users:view', type: 'api', children: undefined },
],
},
]
getMock.mockResolvedValue(mockPermissions)
@@ -88,7 +107,7 @@ describe('permissions service', () => {
const result = await getPermissionTree()
expect(getMock).toHaveBeenCalledWith('/permissions/tree')
expect(result).toEqual(mockPermissions)
expect(result).toEqual(expectedPermissions)
expect(result[0].children?.[0]?.name).toBe('View')
})
@@ -119,14 +138,15 @@ describe('permissions service', () => {
it('creates a permission', async () => {
const newPermission = { name: 'Test', code: 'test', type: 'button' as const }
const createdPermission = { id: 10, ...newPermission }
const createdPermission = { id: 10, ...newPermission, type: 1 }
postMock.mockResolvedValue(createdPermission)
const { createPermission } = await import('./permissions')
const result = await createPermission(newPermission)
expect(postMock).toHaveBeenCalledWith('/permissions', newPermission)
expect(postMock).toHaveBeenCalledWith('/permissions', { ...newPermission, type: 1 })
expect(result.id).toBe(10)
expect(result.type).toBe('button')
})
it('updates a permission', async () => {

View File

@@ -13,37 +13,35 @@ describe('settings service', () => {
it('gets system settings', async () => {
const mockSettings = {
data: {
system: {
name: 'UserSystem',
version: '1.0.0',
environment: 'production',
description: 'User management system',
},
security: {
password_min_length: 8,
password_require_uppercase: true,
password_require_lowercase: true,
password_require_numbers: true,
password_require_symbols: true,
password_history: 5,
totp_enabled: true,
login_fail_lock: true,
login_fail_threshold: 5,
login_fail_duration: 30,
session_timeout: 3600,
device_trust_duration: 2592000,
},
features: {
email_verification: true,
phone_verification: false,
oauth_providers: ['google', 'github'],
sso_enabled: false,
operation_log_enabled: true,
login_log_enabled: true,
data_export_enabled: true,
data_import_enabled: true,
},
system: {
name: 'UserSystem',
version: '1.0.0',
environment: 'production',
description: 'User management system',
},
security: {
password_min_length: 8,
password_require_uppercase: true,
password_require_lowercase: true,
password_require_numbers: true,
password_require_symbols: true,
password_history: 5,
totp_enabled: true,
login_fail_lock: true,
login_fail_threshold: 5,
login_fail_duration: 30,
session_timeout: 3600,
device_trust_duration: 2592000,
},
features: {
email_verification: true,
phone_verification: false,
oauth_providers: ['google', 'github'],
sso_enabled: false,
operation_log_enabled: true,
login_log_enabled: true,
data_export_enabled: true,
data_import_enabled: true,
},
}
@@ -53,6 +51,6 @@ describe('settings service', () => {
const result = await getSettings()
expect(getMock).toHaveBeenCalledWith('/admin/settings')
expect(result).toEqual(mockSettings.data)
expect(result).toEqual(mockSettings)
})
})

View File

@@ -45,14 +45,10 @@ export interface SystemSettings {
features: FeaturesInfo
}
interface SettingsResponse {
data: SystemSettings
}
/**
* 获取系统设置
* GET /api/v1/admin/settings
*/
export function getSettings(): Promise<SystemSettings> {
return get<SettingsResponse>('/admin/settings').then(res => res.data)
return get<SystemSettings>('/admin/settings')
}

View File

@@ -15,7 +15,7 @@ describe('social account service', () => {
getMock.mockReset()
postMock.mockReset()
delMock.mockReset()
getMock.mockResolvedValue([])
getMock.mockResolvedValue({ accounts: [] })
postMock.mockResolvedValue({ auth_url: 'https://oauth.example.com', state: 'state-demo' })
delMock.mockResolvedValue(undefined)
})
@@ -23,9 +23,31 @@ describe('social account service', () => {
it('lists current user social accounts', async () => {
const { listSocialAccounts } = await import('./social-accounts')
await listSocialAccounts()
getMock.mockResolvedValue({
accounts: [
{
id: 1,
provider: 'github',
open_id: 'github-open-id',
union_id: '',
nickname: 'octocat',
avatar: 'https://example.com/avatar.png',
gender: 0,
email: 'octocat@example.com',
phone: '',
extra: '{}',
status: 1,
created_at: '2026-03-27 20:00:00',
updated_at: '2026-03-27 20:00:00',
},
],
})
const accounts = await listSocialAccounts()
expect(getMock).toHaveBeenCalledWith('/users/me/social-accounts')
expect(accounts).toHaveLength(1)
expect(accounts[0]).toMatchObject({ provider: 'github', nickname: 'octocat' })
})
it('starts social binding with the current verification payload', async () => {

View File

@@ -6,8 +6,14 @@ import type {
SocialBindingStartResponse,
} from '@/types'
interface SocialAccountsResponse {
accounts: SocialAccountInfo[] | null
}
export function listSocialAccounts(): Promise<SocialAccountInfo[]> {
return get<SocialAccountInfo[]>('/users/me/social-accounts')
return get<SocialAccountsResponse>('/users/me/social-accounts').then((result) => (
Array.isArray(result.accounts) ? result.accounts : []
))
}
export function startSocialBinding(

View File

@@ -32,4 +32,44 @@ describe('users service', () => {
expect(postMock).toHaveBeenCalledWith('/users', payload)
})
it('normalizes the legacy backend user list response', async () => {
getMock.mockResolvedValue({
users: [
{
id: 11,
username: 'legacy-admin',
email: 'legacy-admin@example.com',
nickname: 'Legacy Admin',
status: '1',
},
],
total: 1,
offset: 20,
limit: 10,
})
const { listUsers } = await import('./users')
const result = await listUsers({ page: 3, page_size: 10, keyword: 'legacy' })
expect(getMock).toHaveBeenCalledWith('/users', {
page: 3,
page_size: 10,
keyword: 'legacy',
})
expect(result).toEqual({
items: [
{
id: 11,
username: 'legacy-admin',
email: 'legacy-admin@example.com',
nickname: 'Legacy Admin',
status: '1',
},
],
total: 1,
page: 3,
page_size: 10,
})
})
})

View File

@@ -17,12 +17,44 @@ import type {
AssignUserRolesRequest,
} from '@/types/user'
interface LegacyUserListResponse {
users: User[]
total: number
offset?: number
limit?: number
}
function isLegacyUserListResponse(
result: PaginatedData<User> | LegacyUserListResponse,
): result is LegacyUserListResponse {
return Array.isArray((result as LegacyUserListResponse).users)
}
/**
* 获取用户列表
* GET /api/v1/users
*/
export function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
return get<PaginatedData<User>>('/users', params as Record<string, string | number | boolean | undefined>)
export async function listUsers(params: UserListParams): Promise<PaginatedData<User>> {
const result = await get<PaginatedData<User> | LegacyUserListResponse>(
'/users',
params as Record<string, string | number | boolean | undefined>,
)
if (!isLegacyUserListResponse(result)) {
return result
}
const pageSize = result.limit ?? params.page_size
const page = pageSize && pageSize > 0
? Math.floor((result.offset ?? 0) / pageSize) + 1
: params.page
return {
items: result.users,
total: result.total,
page,
page_size: pageSize,
}
}
/**

View File

@@ -22,7 +22,7 @@ describe('webhooks service', () => {
it('normalizes mixed raw event payloads from the API', async () => {
getMock.mockResolvedValue({
data: [
list: [
{
id: 1,
name: 'String Events',
@@ -87,7 +87,22 @@ describe('webhooks service', () => {
created_at: '2026-03-27 20:15:00',
updated_at: '2026-03-27 20:15:00',
})
getMock.mockResolvedValue([])
getMock.mockResolvedValue({
deliveries: [
{
id: 7,
webhook_id: 9,
event_type: 'user.updated',
payload: '{"id":1}',
status_code: 200,
response_body: 'ok',
attempt: 1,
success: true,
error: '',
created_at: '2026-03-27 20:20:00',
},
],
})
const {
createWebhook,
@@ -121,7 +136,9 @@ describe('webhooks service', () => {
await deleteWebhook(9)
expect(delMock).toHaveBeenCalledWith('/webhooks/9')
await getWebhookDeliveries(9, { limit: 20 })
const deliveries = await getWebhookDeliveries(9, { limit: 20 })
expect(getMock).toHaveBeenCalledWith('/webhooks/9/deliveries', { limit: 20 })
expect(deliveries).toHaveLength(1)
expect(deliveries[0]).toMatchObject({ webhook_id: 9, status_code: 200 })
})
})

View File

@@ -32,18 +32,25 @@ function normalizeWebhook(webhook: RawWebhook): Webhook {
}
}
interface PaginatedResponse<T> {
data: T[]
interface WebhookListResponse<T> {
list: T[]
total: number
page: number
page_size: number
}
interface WebhookDeliveriesResponse {
deliveries: WebhookDelivery[]
}
export async function listWebhooks(
params?: WebhookListParams,
): Promise<{ data: Webhook[]; total: number; page: number; page_size: number }> {
const result = await get<PaginatedResponse<RawWebhook>>('/webhooks', params as Record<string, string | number | boolean | undefined>)
const webhooks = result.data.map(normalizeWebhook)
const result = await get<WebhookListResponse<RawWebhook>>(
'/webhooks',
params as Record<string, string | number | boolean | undefined>,
)
const webhooks = result.list.map(normalizeWebhook)
return { data: webhooks, total: result.total, page: result.page, page_size: result.page_size }
}
@@ -67,8 +74,8 @@ export function getWebhookDeliveries(
id: number,
params?: WebhookDeliveryListParams,
): Promise<WebhookDelivery[]> {
return get<WebhookDelivery[]>(
return get<WebhookDeliveriesResponse>(
`/webhooks/${id}/deliveries`,
params as Record<string, string | number | boolean | undefined>,
)
).then((result) => result.deliveries)
}

View File

@@ -15,16 +15,21 @@ export interface TokenBundle {
refresh_token?: string
expires_in: number
user: SessionUser
// TOTP required response (when user has TOTP enabled but device is not trusted)
requires_totp?: boolean
user_id?: number
}
// TOTP verification request after password login
export interface PasswordLoginChallenge {
requires_totp: true
user_id: number
temp_token: string
}
export type PasswordLoginResponse = TokenBundle | PasswordLoginChallenge
export interface TOTPVerifyRequest {
user_id: number
code: string
device_id?: string
temp_token: string
}
export interface OAuthProviderInfo {
@@ -94,10 +99,7 @@ export interface BootstrapAdminRequest {
nickname?: string
}
export interface RegisterResponse {
user: SessionUser
message: string
}
export type RegisterResponse = SessionUser
export interface ActionMessageResponse {
message: string

View File

@@ -8,11 +8,7 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:80
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
input: 'index.html',
},
},
root: __dirname,
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/subtle"
"errors"
"io"
"net/http"
"os"
"strings"
@@ -15,6 +16,11 @@ import (
"github.com/user-management-system/internal/service"
)
const (
refreshTokenCookieName = "ums_refresh_token"
sessionPresenceCookieName = "ums_session_present"
)
// newBackgroundCtx 创建用于后台 goroutine 的带超时独立 context与请求 context 无关)
func newBackgroundCtx(timeoutSec int) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
@@ -27,7 +33,8 @@ type ActivateEmailRequest struct {
// AuthHandler handles authentication requests
type AuthHandler struct {
authService *service.AuthService
authService *service.AuthService
passwordResetEnabled bool
}
// NewAuthHandler creates a new AuthHandler
@@ -35,6 +42,13 @@ func NewAuthHandler(authService *service.AuthService) *AuthHandler {
return &AuthHandler{authService: authService}
}
func (h *AuthHandler) SetPasswordResetEnabled(enabled bool) {
if h == nil {
return
}
h.passwordResetEnabled = enabled
}
// Register 用户注册
// @Summary 用户注册
// @Description 用户注册新账号,支持用户名+密码或手机号注册
@@ -129,6 +143,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -150,20 +165,28 @@ func (h *AuthHandler) Login(c *gin.Context) {
// @Router /api/v1/auth/login/totp-verify [post]
func (h *AuthHandler) VerifyTOTPAfterPasswordLogin(c *gin.Context) {
var req struct {
UserID int64 `json:"user_id" binding:"required"`
Code string `json:"code" binding:"required"`
DeviceID string `json:"device_id"`
UserID int64 `json:"user_id" binding:"required"`
Code string `json:"code" binding:"required"`
DeviceID string `json:"device_id"`
TempToken string `json:"temp_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(c.Request.Context(), req.UserID, req.Code, req.DeviceID)
resp, err := h.authService.VerifyTOTPAfterPasswordLogin(
c.Request.Context(),
req.UserID,
req.Code,
req.DeviceID,
req.TempToken,
)
if err != nil {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -197,6 +220,10 @@ func (h *AuthHandler) Logout(c *gin.Context) {
}
}
if req.RefreshToken == "" {
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
}
username, _ := c.Get("username")
usernameStr, _ := username.(string)
@@ -206,6 +233,8 @@ func (h *AuthHandler) Logout(c *gin.Context) {
}
_ = h.authService.Logout(c.Request.Context(), usernameStr, logoutReq)
clearSessionCookies(c)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
@@ -222,19 +251,27 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// @Router /api/v1/auth/refresh-token [post]
func (h *AuthHandler) RefreshToken(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
RefreshToken string `json:"refresh_token"`
}
if err := c.ShouldBindJSON(&req); err != nil {
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.RefreshToken == "" {
req.RefreshToken, _ = c.Cookie(refreshTokenCookieName)
}
if req.RefreshToken == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "refresh_token is required"})
return
}
resp, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -298,6 +335,7 @@ func (h *AuthHandler) GetCSRFToken(c *gin.Context) {
func (h *AuthHandler) GetAuthCapabilities(c *gin.Context) {
ctx := c.Request.Context()
caps := h.authService.GetAuthCapabilities(ctx)
caps.PasswordReset = h.SupportsPasswordReset()
c.JSON(http.StatusOK, gin.H{
"code": 0,
"message": "success",
@@ -480,6 +518,7 @@ func (h *AuthHandler) LoginByEmailCode(c *gin.Context) {
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
}()
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,
@@ -544,6 +583,7 @@ func (h *AuthHandler) BootstrapAdmin(c *gin.Context) {
handleError(c, err)
return
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusCreated, gin.H{
"code": 0,
@@ -673,6 +713,50 @@ func getUserIDFromContext(c *gin.Context) (int64, bool) {
return id, ok
}
func setSessionCookies(c *gin.Context, authService *service.AuthService, refreshToken string) {
if c == nil || strings.TrimSpace(refreshToken) == "" {
return
}
maxAge := 0
if authService != nil {
if ttl := authService.RefreshTokenTTLSeconds(); ttl > 0 {
maxAge = int(ttl)
}
}
secure := requestUsesHTTPS(c)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(refreshTokenCookieName, refreshToken, maxAge, "/", "", secure, true)
c.SetCookie(sessionPresenceCookieName, "1", maxAge, "/", "", secure, false)
}
func clearSessionCookies(c *gin.Context) {
if c == nil {
return
}
secure := requestUsesHTTPS(c)
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(refreshTokenCookieName, "", -1, "/", "", secure, true)
c.SetCookie(sessionPresenceCookieName, "", -1, "/", "", secure, false)
}
func requestUsesHTTPS(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
if c.Request.TLS != nil {
return true
}
return strings.EqualFold(strings.TrimSpace(c.GetHeader("X-Forwarded-Proto")), "https")
}
func (h *AuthHandler) SupportsPasswordReset() bool {
return h != nil && h.passwordResetEnabled
}
// handleError 将 error 转换为对应的 HTTP 响应。
// 优先识别 ApplicationError其次通过关键词推断业务错误类型兜底返回 500。
func handleError(c *gin.Context, err error) {

View File

@@ -8,6 +8,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/api/middleware"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/service"
)
@@ -22,6 +23,15 @@ func NewDeviceHandler(deviceService *service.DeviceService) *DeviceHandler {
return &DeviceHandler{deviceService: deviceService}
}
func (h *DeviceHandler) currentActor(c *gin.Context) (int64, bool, bool) {
userID, ok := getUserIDFromContext(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
return 0, false, false
}
return userID, middleware.IsAdmin(c), true
}
// CreateDevice 创建设备
// @Summary 创建设备记录
// @Description 当前用户创建设备记录
@@ -118,7 +128,12 @@ func (h *DeviceHandler) GetDevice(c *gin.Context) {
return
}
device, err := h.deviceService.GetDevice(c.Request.Context(), id)
actorUserID, isAdmin, ok := h.currentActor(c)
if !ok {
return
}
device, err := h.deviceService.GetDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin)
if err != nil {
handleError(c, err)
return
@@ -157,7 +172,12 @@ func (h *DeviceHandler) UpdateDevice(c *gin.Context) {
return
}
device, err := h.deviceService.UpdateDevice(c.Request.Context(), id, &req)
actorUserID, isAdmin, ok := h.currentActor(c)
if !ok {
return
}
device, err := h.deviceService.UpdateDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin, &req)
if err != nil {
handleError(c, err)
return
@@ -187,7 +207,12 @@ func (h *DeviceHandler) DeleteDevice(c *gin.Context) {
return
}
if err := h.deviceService.DeleteDevice(c.Request.Context(), id); err != nil {
actorUserID, isAdmin, ok := h.currentActor(c)
if !ok {
return
}
if err := h.deviceService.DeleteDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin); err != nil {
handleError(c, err)
return
}
@@ -238,7 +263,12 @@ func (h *DeviceHandler) UpdateDeviceStatus(c *gin.Context) {
return
}
if err := h.deviceService.UpdateDeviceStatus(c.Request.Context(), id, status); err != nil {
actorUserID, isAdmin, ok := h.currentActor(c)
if !ok {
return
}
if err := h.deviceService.UpdateDeviceStatusForActor(c.Request.Context(), actorUserID, id, isAdmin, status); err != nil {
handleError(c, err)
return
}
@@ -270,16 +300,7 @@ func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
}
// 检查是否为管理员
roleCodes, _ := c.Get("role_codes")
isAdmin := false
if roles, ok := roleCodes.([]string); ok {
for _, role := range roles {
if role == "admin" {
isAdmin = true
break
}
}
}
isAdmin := middleware.IsAdmin(c)
userIDParam := c.Param("id")
userID, err := strconv.ParseInt(userIDParam, 10, 64)
@@ -405,7 +426,12 @@ func (h *DeviceHandler) TrustDevice(c *gin.Context) {
// 解析信任持续时间
trustDuration := parseDuration(req.TrustDuration)
if err := h.deviceService.TrustDevice(c.Request.Context(), id, trustDuration); err != nil {
actorUserID, isAdmin, ok := h.currentActor(c)
if !ok {
return
}
if err := h.deviceService.TrustDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin, trustDuration); err != nil {
handleError(c, err)
return
}
@@ -478,7 +504,12 @@ func (h *DeviceHandler) UntrustDevice(c *gin.Context) {
return
}
if err := h.deviceService.UntrustDevice(c.Request.Context(), id); err != nil {
actorUserID, isAdmin, ok := h.currentActor(c)
if !ok {
return
}
if err := h.deviceService.UntrustDeviceForActor(c.Request.Context(), actorUserID, id, isAdmin); err != nil {
handleError(c, err)
return
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
@@ -33,13 +34,40 @@ func NewPermissionHandler(permissionService *service.PermissionService) *Permiss
// @Failure 403 {object} Response "无权限"
// @Router /api/v1/permissions [post]
func (h *PermissionHandler) CreatePermission(c *gin.Context) {
var req service.CreatePermissionRequest
var req struct {
Name string `json:"name" binding:"required"`
Code string `json:"code" binding:"required"`
Type *int `json:"type" binding:"required"`
Description string `json:"description"`
ParentID *int64 `json:"parent_id"`
Path string `json:"path"`
Method string `json:"method"`
Sort int `json:"sort"`
Icon string `json:"icon"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
return
}
perm, err := h.permissionService.CreatePermission(c.Request.Context(), &req)
if req.Type == nil || *req.Type < 0 || *req.Type > 2 {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid permission type"})
return
}
serviceReq := service.CreatePermissionRequest{
Name: req.Name,
Code: req.Code,
Type: *req.Type,
Description: req.Description,
ParentID: req.ParentID,
Path: req.Path,
Method: req.Method,
Sort: req.Sort,
Icon: req.Icon,
}
perm, err := h.permissionService.CreatePermission(c.Request.Context(), &serviceReq)
if err != nil {
handleError(c, err)
return
@@ -201,7 +229,7 @@ func (h *PermissionHandler) UpdatePermissionStatus(c *gin.Context) {
}
var req struct {
Status string `json:"status" binding:"required"`
Status json.RawMessage `json:"status" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -209,13 +237,8 @@ func (h *PermissionHandler) UpdatePermissionStatus(c *gin.Context) {
return
}
var status domain.PermissionStatus
switch req.Status {
case "enabled", "1":
status = domain.PermissionStatusEnabled
case "disabled", "0":
status = domain.PermissionStatusDisabled
default:
status, ok := parsePermissionStatus(req.Status)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid status"})
return
}
@@ -239,6 +262,30 @@ func (h *PermissionHandler) UpdatePermissionStatus(c *gin.Context) {
// @Security BearerAuth
// @Success 200 {object} Response{data=[]domain.Permission} "权限树"
// @Router /api/v1/permissions/tree [get]
func parsePermissionStatus(raw json.RawMessage) (domain.PermissionStatus, bool) {
var statusText string
if err := json.Unmarshal(raw, &statusText); err == nil {
switch statusText {
case "enabled", "1":
return domain.PermissionStatusEnabled, true
case "disabled", "0":
return domain.PermissionStatusDisabled, true
}
}
var statusNumber int
if err := json.Unmarshal(raw, &statusNumber); err == nil {
switch statusNumber {
case 1:
return domain.PermissionStatusEnabled, true
case 0:
return domain.PermissionStatusDisabled, true
}
}
return domain.PermissionStatusDisabled, false
}
func (h *PermissionHandler) GetPermissionTree(c *gin.Context) {
tree, err := h.permissionService.GetPermissionTree(c.Request.Context())
if err != nil {

View File

@@ -116,6 +116,7 @@ func (h *SMSHandler) LoginByCode(c *gin.Context) {
h.authService.BestEffortRegisterDevicePublic(devCtx, userID, loginReq)
}()
}
setSessionCookies(c, h.authService, resp.RefreshToken)
c.JSON(http.StatusOK, gin.H{
"code": 0,

View File

@@ -3,9 +3,11 @@ package handler
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/api/middleware"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/service"
@@ -187,23 +189,20 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
// Authorization: only self or admin can update user profile
currentUserID := c.GetInt64("user_id")
isAdmin := false
if roles, ok := c.Get("user_roles"); ok {
for _, role := range roles.([]*domain.Role) {
if role.Code == "admin" {
isAdmin = true
break
}
}
}
isAdmin := middleware.IsAdmin(c)
if currentUserID != id && !isAdmin {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"})
return
}
var req struct {
Email *string `json:"email"`
Nickname *string `json:"nickname"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Nickname *string `json:"nickname"`
Gender *domain.Gender `json:"gender"`
Birthday *string `json:"birthday"`
Region *string `json:"region"`
Bio *string `json:"bio"`
}
if err := c.ShouldBindJSON(&req); err != nil {
@@ -218,11 +217,35 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
}
if req.Email != nil {
user.Email = req.Email
user.Email = domain.StrPtr(*req.Email)
}
if req.Phone != nil {
user.Phone = domain.StrPtr(*req.Phone)
}
if req.Nickname != nil {
user.Nickname = *req.Nickname
}
if req.Gender != nil {
user.Gender = *req.Gender
}
if req.Birthday != nil {
if *req.Birthday == "" {
user.Birthday = nil
} else if birthday, err := time.Parse("2006-01-02", *req.Birthday); err == nil {
user.Birthday = &birthday
} else if birthday, err := time.Parse(time.RFC3339, *req.Birthday); err == nil {
user.Birthday = &birthday
} else {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid birthday"})
return
}
}
if req.Region != nil {
user.Region = *req.Region
}
if req.Bio != nil {
user.Bio = *req.Bio
}
if err := h.userService.Update(c.Request.Context(), user); err != nil {
handleError(c, err)
@@ -279,8 +302,16 @@ func (h *UserHandler) UpdatePassword(c *gin.Context) {
return
}
currentUserID := c.GetInt64("user_id")
isAdmin := middleware.IsAdmin(c)
isSelf := currentUserID == id
if !isSelf && !isAdmin {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"})
return
}
var req struct {
OldPassword string `json:"old_password" binding:"required"`
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password" binding:"required"`
}
@@ -289,9 +320,16 @@ func (h *UserHandler) UpdatePassword(c *gin.Context) {
return
}
if err := h.userService.ChangePassword(c.Request.Context(), id, req.OldPassword, req.NewPassword); err != nil {
handleError(c, err)
return
if isSelf {
if err := h.userService.ChangePassword(c.Request.Context(), id, req.OldPassword, req.NewPassword); err != nil {
handleError(c, err)
return
}
} else {
if err := h.userService.AdminResetPassword(c.Request.Context(), id, req.NewPassword); err != nil {
handleError(c, err)
return
}
}
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "密码修改成功"})
@@ -370,15 +408,7 @@ func (h *UserHandler) GetUserRoles(c *gin.Context) {
// Authorization: only self or admin can view user roles
currentUserID := c.GetInt64("user_id")
isAdmin := false
if roles, ok := c.Get("user_roles"); ok {
for _, role := range roles.([]*domain.Role) {
if role.Code == "admin" {
isAdmin = true
break
}
}
}
isAdmin := middleware.IsAdmin(c)
if currentUserID != id && !isAdmin {
c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"})
return
@@ -585,11 +615,22 @@ func (h *UserHandler) DeleteAdmin(c *gin.Context) {
}
type UserResponse struct {
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Nickname string `json:"nickname,omitempty"`
Status string `json:"status"`
ID int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
Nickname string `json:"nickname,omitempty"`
Avatar string `json:"avatar,omitempty"`
Gender domain.Gender `json:"gender"`
Birthday *time.Time `json:"birthday,omitempty"`
Region string `json:"region,omitempty"`
Bio string `json:"bio,omitempty"`
Status string `json:"status"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
LastLoginIP string `json:"last_login_ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
TOTPEnabled bool `json:"totp_enabled"`
}
func toUserResponse(u *domain.User) *UserResponse {
@@ -597,11 +638,26 @@ func toUserResponse(u *domain.User) *UserResponse {
if u.Email != nil {
email = *u.Email
}
phone := ""
if u.Phone != nil {
phone = *u.Phone
}
return &UserResponse{
ID: u.ID,
Username: u.Username,
Email: email,
Nickname: u.Nickname,
Status: strconv.FormatInt(int64(u.Status), 10),
ID: u.ID,
Username: u.Username,
Email: email,
Phone: phone,
Nickname: u.Nickname,
Avatar: u.Avatar,
Gender: u.Gender,
Birthday: u.Birthday,
Region: u.Region,
Bio: u.Bio,
Status: strconv.FormatInt(int64(u.Status), 10),
LastLoginAt: u.LastLoginTime,
LastLoginIP: u.LastLoginIP,
CreatedAt: u.CreatedAt,
UpdatedAt: u.UpdatedAt,
TOTPEnabled: u.TOTPEnabled,
}
}

View File

@@ -0,0 +1,103 @@
package middleware
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/repository"
"github.com/user-management-system/internal/service"
gormsqlite "gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
_ "modernc.org/sqlite"
)
func TestAuthMiddleware_AcceptsBootstrapAdminTokenImmediately(t *testing.T) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
DriverName: "sqlite",
DSN: "file:middleware_bootstrap_test?mode=memory&cache=shared",
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
if err := db.AutoMigrate(&domain.User{}, &domain.Role{}, &domain.UserRole{}); err != nil {
t.Fatalf("migrate failed: %v", err)
}
if err := db.Create(&domain.Role{
Name: "管理员",
Code: "admin",
IsSystem: true,
Status: domain.RoleStatusEnabled,
}).Error; err != nil {
t.Fatalf("seed admin role failed: %v", err)
}
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
HS256Secret: "test-bootstrap-token-secret-at-least-32-chars",
AccessTokenExpire: 15 * time.Minute,
RefreshTokenExpire: 7 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("create jwt manager failed: %v", err)
}
l1Cache := cache.NewL1Cache()
l2Cache := cache.NewRedisCache(false)
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
userRepo := repository.NewUserRepository(db)
roleRepo := repository.NewRoleRepository(db)
userRoleRepo := repository.NewUserRoleRepository(db)
authService := service.NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
authService.SetRoleRepositories(userRoleRepo, roleRepo)
loginResponse, err := authService.BootstrapAdmin(context.Background(), &service.BootstrapAdminRequest{
Username: "bootstrap_admin",
Email: "bootstrap_admin@example.com",
Password: "AdminPass123!",
}, "127.0.0.1")
if err != nil {
t.Fatalf("bootstrap admin failed: %v", err)
}
if loginResponse == nil || loginResponse.AccessToken == "" {
t.Fatalf("expected bootstrap access token, got %+v", loginResponse)
}
if _, err := jwtManager.ValidateAccessToken(loginResponse.AccessToken); err != nil {
t.Fatalf("bootstrap access token should validate immediately: %v", err)
}
authMiddleware := NewAuthMiddleware(jwtManager, userRepo, userRoleRepo, l1Cache)
authMiddleware.SetCacheManager(cacheManager)
recorder := httptest.NewRecorder()
ctx, engine := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodGet, "/protected", nil)
ctx.Request.Header.Set("Authorization", "Bearer "+loginResponse.AccessToken)
engine.Use(authMiddleware.Required())
engine.GET("/protected", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"code": 0})
})
engine.ServeHTTP(recorder, ctx.Request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected bootstrap token to pass auth middleware immediately, got %d body: %s", recorder.Code, recorder.Body.String())
}
}

View File

@@ -64,20 +64,17 @@ func (m *IPFilterMiddleware) realIP(c *gin.Context) string {
// X-Forwarded-For 可能包含代理链
xff := c.GetHeader("X-Forwarded-For")
if xff != "" {
// 从右到左遍历(最右边是最后一次代理添加的)
for _, part := range strings.Split(xff, ",") {
ip := strings.TrimSpace(part)
parts := strings.Split(xff, ",")
// 从右到左遍历(最右边是离服务器最近的代理)
for i := len(parts) - 1; i >= 0; i-- {
ip := strings.TrimSpace(parts[i])
if ip == "" {
continue
}
// 检查是否是可信代理
if !m.isTrustedProxy(ip) {
continue // 不是可信代理,跳过
}
// 是可信代理,检查是否为公网 IP
if !isPrivateIP(ip) {
return ip
if m.isTrustedProxy(ip) {
continue // 跳过可信代理
}
return ip // 第一个不可信代理就是真实客户端
}
}
@@ -97,7 +94,7 @@ func (m *IPFilterMiddleware) realIP(c *gin.Context) string {
// isTrustedProxy 检查 IP 是否在可信代理列表中
func (m *IPFilterMiddleware) isTrustedProxy(ip string) bool {
if len(m.config.TrustedProxies) == 0 {
return true // 如果没有配置可信代理列表,默认信任所有(兼容旧行为
return false // 配置可信代理列表 → 不信任任何代理(安全优先
}
for _, trusted := range m.config.TrustedProxies {
if ip == trusted {

View File

@@ -18,8 +18,12 @@ func init() {
// newTestEngine 用给定的 IPFilterMiddleware 构建一个最简 Gin 引擎,
// 注册一个 GET /ping 路由,返回 client_ip 值。
func newTestEngine(f *security.IPFilter) *gin.Engine {
return newTestEngineWithConfig(f, IPFilterConfig{})
}
func newTestEngineWithConfig(f *security.IPFilter, cfg IPFilterConfig) *gin.Engine {
engine := gin.New()
engine.Use(NewIPFilterMiddleware(f, IPFilterConfig{}).Filter())
engine.Use(NewIPFilterMiddleware(f, cfg).Filter())
engine.GET("/ping", func(c *gin.Context) {
ip, _ := c.Get("client_ip")
c.JSON(http.StatusOK, gin.H{"ip": ip})

View File

@@ -88,6 +88,11 @@ func (m *OperationLogMiddleware) Record() gin.HandlerFunc {
}
go func(entry *domain.OperationLog) {
defer func() {
if r := recover(); r != nil {
// PERF-07: panic recover 保护,防止操作日志写入异常导致进程崩溃
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_ = m.repo.Create(ctx, entry)

View File

@@ -1,14 +1,21 @@
package middleware
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/config"
)
// RateLimitMiddleware 限流中间件
// RateLimitMiddleware provides simple in-memory sliding-window rate limiting.
type RateLimitMiddleware struct {
cfg config.RateLimitConfig
limiters map[string]*SlidingWindowLimiter
@@ -16,7 +23,7 @@ type RateLimitMiddleware struct {
cleanupInt time.Duration
}
// SlidingWindowLimiter 滑动窗口限流器
// SlidingWindowLimiter enforces a fixed-capacity sliding window.
type SlidingWindowLimiter struct {
mu sync.Mutex
window time.Duration
@@ -24,7 +31,6 @@ type SlidingWindowLimiter struct {
requests []int64
}
// NewSlidingWindowLimiter 创建滑动窗口限流器
func NewSlidingWindowLimiter(window time.Duration, capacity int64) *SlidingWindowLimiter {
return &SlidingWindowLimiter{
window: window,
@@ -33,7 +39,6 @@ func NewSlidingWindowLimiter(window time.Duration, capacity int64) *SlidingWindo
}
}
// Allow 检查是否允许请求
func (l *SlidingWindowLimiter) Allow() bool {
l.mu.Lock()
defer l.mu.Unlock()
@@ -41,16 +46,14 @@ func (l *SlidingWindowLimiter) Allow() bool {
now := time.Now().UnixMilli()
cutoff := now - l.window.Milliseconds()
// 清理过期请求
var validRequests []int64
for _, t := range l.requests {
if t > cutoff {
validRequests = append(validRequests, t)
validRequests := make([]int64, 0, len(l.requests))
for _, ts := range l.requests {
if ts > cutoff {
validRequests = append(validRequests, ts)
}
}
l.requests = validRequests
// 检查容量
if int64(len(l.requests)) >= l.capacity {
return false
}
@@ -59,7 +62,6 @@ func (l *SlidingWindowLimiter) Allow() bool {
return true
}
// NewRateLimitMiddleware 创建限流中间件
func NewRateLimitMiddleware(cfg config.RateLimitConfig) *RateLimitMiddleware {
return &RateLimitMiddleware{
cfg: cfg,
@@ -68,30 +70,28 @@ func NewRateLimitMiddleware(cfg config.RateLimitConfig) *RateLimitMiddleware {
}
}
// Register 返回注册接口的限流中间件
func (m *RateLimitMiddleware) Register() gin.HandlerFunc {
return m.limitForKey("register", 60, 10)
}
// Login 返回登录接口的限流中间件
func (m *RateLimitMiddleware) Login() gin.HandlerFunc {
return m.limitForKey("login", 60, 5)
}
// API 返回 API 接口的限流中间件
func (m *RateLimitMiddleware) API() gin.HandlerFunc {
return m.limitForKey("api", 60, 100)
}
// Refresh 返回刷新令牌的限流中间件
func (m *RateLimitMiddleware) Refresh() gin.HandlerFunc {
return m.limitForKey("refresh", 60, 10)
}
func (m *RateLimitMiddleware) limitForKey(key string, windowSeconds int, capacity int64) gin.HandlerFunc {
limiter := m.getOrCreateLimiter(key, time.Duration(windowSeconds)*time.Second, capacity)
func (m *RateLimitMiddleware) limitForKey(bucket string, windowSeconds int, capacity int64) gin.HandlerFunc {
window := time.Duration(windowSeconds) * time.Second
return func(c *gin.Context) {
limiterKey := m.resolveLimiterKey(c, bucket)
limiter := m.getOrCreateLimiter(limiterKey, window, capacity)
if !limiter.Allow() {
c.JSON(429, gin.H{
"code": 429,
@@ -104,6 +104,81 @@ func (m *RateLimitMiddleware) limitForKey(key string, windowSeconds int, capacit
}
}
func (m *RateLimitMiddleware) resolveLimiterKey(c *gin.Context, bucket string) string {
if bucket == "refresh" {
if refreshToken := extractRefreshToken(c); refreshToken != "" {
return fmt.Sprintf("%s:token:%s", bucket, fingerprintValue(refreshToken))
}
}
identity := "anonymous"
if c != nil {
if userID, ok := c.Get("user_id"); ok {
identity = fmt.Sprintf("user:%v", userID)
} else if ip := c.ClientIP(); ip != "" {
identity = "ip:" + ip
}
}
if bucket == "api" {
method := ""
route := ""
if c != nil {
if c.Request != nil {
method = c.Request.Method
if c.Request.URL != nil {
route = c.Request.URL.Path
}
}
if fullPath := c.FullPath(); fullPath != "" {
route = fullPath
}
}
return fmt.Sprintf("%s:%s:%s:%s", bucket, method, route, identity)
}
return fmt.Sprintf("%s:%s", bucket, identity)
}
func extractRefreshToken(c *gin.Context) string {
if c == nil {
return ""
}
if refreshToken, err := c.Cookie("ums_refresh_token"); err == nil && refreshToken != "" {
return refreshToken
}
if c.Request == nil || c.Request.Body == nil {
return ""
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return ""
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
if len(bytes.TrimSpace(body)) == 0 {
return ""
}
var payload struct {
RefreshToken string `json:"refresh_token"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
return payload.RefreshToken
}
func fingerprintValue(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:12])
}
func (m *RateLimitMiddleware) getOrCreateLimiter(key string, window time.Duration, capacity int64) *SlidingWindowLimiter {
m.mu.RLock()
limiter, exists := m.limiters[key]
@@ -116,7 +191,6 @@ func (m *RateLimitMiddleware) getOrCreateLimiter(key string, window time.Duratio
m.mu.Lock()
defer m.mu.Unlock()
// 双重检查
if limiter, exists = m.limiters[key]; exists {
return limiter
}
@@ -125,3 +199,47 @@ func (m *RateLimitMiddleware) getOrCreateLimiter(key string, window time.Duratio
m.limiters[key] = limiter
return limiter
}
// Cleanup 清理过期的不活跃 limiter防止 map 无界增长P0 资源泄漏修复)
func (m *RateLimitMiddleware) Cleanup() {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UnixMilli()
for key, limiter := range m.limiters {
limiter.mu.Lock()
cutoff := now - limiter.window.Milliseconds()
// 只保留仍在窗口内的请求时间戳
validRequests := make([]int64, 0, len(limiter.requests))
for _, ts := range limiter.requests {
if ts > cutoff {
validRequests = append(validRequests, ts)
}
}
limiter.requests = validRequests
isEmpty := len(limiter.requests) == 0
limiter.mu.Unlock()
if isEmpty {
delete(m.limiters, key)
}
}
}
// StartCleanup 启动后台定期清理 goroutine返回停止函数P0 资源泄漏修复)
func (m *RateLimitMiddleware) StartCleanup() func() {
ticker := time.NewTicker(m.cleanupInt)
done := make(chan struct{})
go func() {
for {
select {
case <-ticker.C:
m.Cleanup()
case <-done:
ticker.Stop()
return
}
}
}()
return func() { close(done) }
}

View File

@@ -0,0 +1,140 @@
package middleware
import (
"bytes"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"github.com/gin-gonic/gin"
"github.com/user-management-system/internal/config"
)
func performRateLimitedRequest(router *gin.Engine, path string, userID int64) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("X-Test-User-ID", strconv.FormatInt(userID, 10))
router.ServeHTTP(recorder, req)
return recorder
}
func performRefreshRateLimitedRequestWithCookie(router *gin.Engine, refreshToken string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", nil)
req.RemoteAddr = "127.0.0.1:12345"
if refreshToken != "" {
req.AddCookie(&http.Cookie{Name: "ums_refresh_token", Value: refreshToken})
}
router.ServeHTTP(recorder, req)
return recorder
}
func performRefreshRateLimitedRequestWithBody(router *gin.Engine, refreshToken string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
body := bytes.NewBufferString(`{"refresh_token":"` + refreshToken + `"}`)
req := httptest.NewRequest(http.MethodPost, "/auth/refresh", body)
req.RemoteAddr = "127.0.0.1:12345"
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(recorder, req)
return recorder
}
func TestRateLimitMiddleware_API_ScopesBudgetByRouteForAuthenticatedUser(t *testing.T) {
gin.SetMode(gin.TestMode)
rateLimitMiddleware := NewRateLimitMiddleware(config.RateLimitConfig{})
router := gin.New()
router.Use(func(c *gin.Context) {
rawUserID := c.GetHeader("X-Test-User-ID")
if rawUserID != "" {
userID, err := strconv.ParseInt(rawUserID, 10, 64)
if err == nil {
c.Set("user_id", userID)
}
}
c.Next()
})
protected := router.Group("")
protected.Use(rateLimitMiddleware.API())
protected.GET("/users", func(c *gin.Context) {
c.Status(http.StatusOK)
})
protected.GET("/roles", func(c *gin.Context) {
c.Status(http.StatusOK)
})
for i := 0; i < 100; i++ {
recorder := performRateLimitedRequest(router, "/users", 1)
if recorder.Code != http.StatusOK {
t.Fatalf("request %d to /users returned %d, want %d", i+1, recorder.Code, http.StatusOK)
}
}
sameRouteOverflow := performRateLimitedRequest(router, "/users", 1)
if sameRouteOverflow.Code != http.StatusTooManyRequests {
t.Fatalf("overflow request to /users returned %d, want %d", sameRouteOverflow.Code, http.StatusTooManyRequests)
}
differentRoute := performRateLimitedRequest(router, "/roles", 1)
if differentRoute.Code != http.StatusOK {
t.Fatalf("request to /roles after exhausting /users budget returned %d, want %d", differentRoute.Code, http.StatusOK)
}
}
func TestRateLimitMiddleware_Refresh_ScopesBudgetByRefreshCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
rateLimitMiddleware := NewRateLimitMiddleware(config.RateLimitConfig{})
router := gin.New()
router.POST("/auth/refresh", rateLimitMiddleware.Refresh(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
for i := 0; i < 10; i++ {
recorder := performRefreshRateLimitedRequestWithCookie(router, "refresh-token-a")
if recorder.Code != http.StatusOK {
t.Fatalf("request %d for refresh-token-a returned %d, want %d", i+1, recorder.Code, http.StatusOK)
}
}
sameTokenOverflow := performRefreshRateLimitedRequestWithCookie(router, "refresh-token-a")
if sameTokenOverflow.Code != http.StatusTooManyRequests {
t.Fatalf("overflow request for refresh-token-a returned %d, want %d", sameTokenOverflow.Code, http.StatusTooManyRequests)
}
differentToken := performRefreshRateLimitedRequestWithCookie(router, "refresh-token-b")
if differentToken.Code != http.StatusOK {
t.Fatalf("request for refresh-token-b after exhausting refresh-token-a budget returned %d, want %d", differentToken.Code, http.StatusOK)
}
}
func TestRateLimitMiddleware_Refresh_ScopesBudgetByRefreshTokenBody(t *testing.T) {
gin.SetMode(gin.TestMode)
rateLimitMiddleware := NewRateLimitMiddleware(config.RateLimitConfig{})
router := gin.New()
router.POST("/auth/refresh", rateLimitMiddleware.Refresh(), func(c *gin.Context) {
c.Status(http.StatusOK)
})
for i := 0; i < 10; i++ {
recorder := performRefreshRateLimitedRequestWithBody(router, "refresh-token-a")
if recorder.Code != http.StatusOK {
t.Fatalf("request %d for refresh-token-a body returned %d, want %d", i+1, recorder.Code, http.StatusOK)
}
}
sameTokenOverflow := performRefreshRateLimitedRequestWithBody(router, "refresh-token-a")
if sameTokenOverflow.Code != http.StatusTooManyRequests {
t.Fatalf("overflow request for refresh-token-a body returned %d, want %d", sameTokenOverflow.Code, http.StatusTooManyRequests)
}
differentToken := performRefreshRateLimitedRequestWithBody(router, "refresh-token-b")
if differentToken.Code != http.StatusOK {
t.Fatalf("request for refresh-token-b body after exhausting refresh-token-a budget returned %d, want %d", differentToken.Code, http.StatusOK)
}
}

View File

@@ -122,7 +122,10 @@ func (r *Router) Setup() *gin.Engine {
)
}
r.engine.Static("/uploads", "./uploads")
// P0 安全修复:/uploads 目录不再公开暴露,改为需要认证后才能访问
uploadsGroup := r.engine.Group("/uploads", r.authMiddleware.Required())
uploadsGroup.Static("", "./uploads")
r.engine.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
if r.ipFilterMiddleware != nil {
@@ -136,6 +139,10 @@ func (r *Router) Setup() *gin.Engine {
{
authGroup := v1.Group("/auth")
{
if r.authHandler != nil {
r.authHandler.SetPasswordResetEnabled(r.passwordResetHandler != nil)
}
authGroup.POST("/register", r.rateLimitMiddleware.Register(), r.authHandler.Register)
authGroup.POST("/bootstrap-admin", r.rateLimitMiddleware.Register(), r.authHandler.BootstrapAdmin)
authGroup.POST("/login", r.rateLimitMiddleware.Login(), r.authHandler.Login)

View File

@@ -42,8 +42,8 @@ func NewPassword() *Password {
// 校准策略(优先保留 memory其次降低 iterations
// 1. 用默认参数64MB/5iter测量一次哈希耗时。
// 2. 若耗时 ≤ budget直接返回默认参数已安全。
// 3. 若耗时 > budget先尝试降低 iterations最低 2)。
// 4. 若仍超预算,再二分降低 memory最低 16MB
// 3. 若耗时 > budget先尝试降低 iterations最低 3OWASP 最低要求)。
// 4. 若仍超预算,再二分降低 memory最低 19MB = 19456KBOWASP 最低要求)。
// 5. 若仍超预算,打印 warn 但不更改参数(避免参数过弱)。
//
// 建议在 main() 启动阶段调用一次,结果会更新全局 defaultPasswordManager。
@@ -73,8 +73,8 @@ func CalibrateArgon2id(budget time.Duration) {
return
}
// Step 1尝试降低 iterations最低 2低于 2 不满足 OWASP 最低要求)
for iter > 2 {
// Step 1尝试降低 iterations最低 3OWASP 最低要求)
for iter > 3 {
iter--
elapsed = probe(mem, iter, par)
log.Printf("argon2id calibration: trying m=%dKB t=%d p=%d → %v", mem, iter, par, elapsed)
@@ -83,9 +83,9 @@ func CalibrateArgon2id(budget time.Duration) {
}
}
// Step 2若仍超预算二分降低 memory最低 16MB = 16*1024 KiB
// Step 2若仍超预算二分降低 memory最低 19MB = 19456 KiBOWASP 最低要求
if elapsed > budget {
const minMem = 16 * 1024
const minMem = 19 * 1024
for mem > minMem && elapsed > budget {
mem /= 2
if mem < minMem {

View File

@@ -10,11 +10,13 @@ import (
"encoding/hex"
"fmt"
"image/png"
"regexp"
"strings"
"time"
"github.com/pquerna/otp"
"github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt"
)
const (
@@ -111,27 +113,62 @@ func ValidateRecoveryCode(inputCode string, storedCodes []string) (int, bool) {
return -1, false
}
// HashRecoveryCode 使用 SHA256 哈希恢复码(用于存储)
// HashRecoveryCode 使用 bcrypt 慢哈希恢复码(用于存储)
// P2 安全增强:将 SHA256 快速哈希升级为 bcrypt 慢哈希,防止 GPU 暴力破解
func HashRecoveryCode(code string) (string, error) {
h := sha256.Sum256([]byte(code))
return hex.EncodeToString(h[:]), nil
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(code), "-", ""))
hash, err := bcrypt.GenerateFromPassword([]byte(normalized), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash recovery code failed: %w", err)
}
return string(hash), nil
}
// VerifyRecoveryCode 验证恢复码(自动哈希后比较
// sha256HexPattern 匹配 64 位十六进制字符串(旧版 SHA256 哈希格式
var sha256HexPattern = regexp.MustCompile("^[0-9a-fA-F]{64}$")
// isLegacySHA256Hash 检测是否为旧版 SHA256 哈希值
func isLegacySHA256Hash(hash string) bool {
return sha256HexPattern.MatchString(hash)
}
// legacyHashRecoveryCode 旧版 SHA256 哈希(用于向后兼容验证)
func legacyHashRecoveryCode(code string) string {
h := sha256.Sum256([]byte(code))
return hex.EncodeToString(h[:])
}
// VerifyRecoveryCode 验证恢复码(支持 bcrypt 新哈希和 SHA256 旧哈希向后兼容)
// 使用恒定时间比较防止时序攻击
func VerifyRecoveryCode(inputCode string, hashedCodes []string) (int, bool) {
hashedInput, err := HashRecoveryCode(inputCode)
if err != nil {
return -1, false
}
normalized := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(inputCode), "-", ""))
found := -1
// 固定次数比较,防止时序攻击泄露匹配位置
for i := 0; i < len(hashedCodes); i++ {
hashed := hashedCodes[i]
if subtle.ConstantTimeCompare([]byte(hashedInput), []byte(hashed)) == 1 {
stored := hashedCodes[i]
if stored == "" {
continue
}
var matched bool
if isLegacySHA256Hash(stored) {
// 向后兼容:旧版 SHA256 哈希
hashedInput := legacyHashRecoveryCode(inputCode)
if subtle.ConstantTimeCompare([]byte(hashedInput), []byte(stored)) == 1 {
matched = true
}
} else {
// 新版 bcrypt 哈希
if err := bcrypt.CompareHashAndPassword([]byte(stored), []byte(normalized)); err == nil {
matched = true
}
}
if matched {
found = i
}
}
if found >= 0 {
return found, true
}

View File

@@ -112,27 +112,29 @@ func TestHashRecoveryCode(t *testing.T) {
t.Fatal("HashRecoveryCode should return non-empty hash")
}
// Same code should produce same hash
hashed2, err := HashRecoveryCode(code)
if err != nil {
t.Fatalf("HashRecoveryCode second call failed: %v", err)
// Same code should verify against its own hash (bcrypt uses random salt, so hashes differ)
_, ok := VerifyRecoveryCode(code, []string{hashed})
if !ok {
t.Error("Same code should verify against its own hash")
}
if hashed != hashed2 {
t.Error("Same code should produce same hash")
}
// Different codes should produce different hashes
// Different codes should NOT verify
hashed3, err := HashRecoveryCode("DIFFERENT-CODE")
if err != nil {
t.Fatalf("HashRecoveryCode for different code failed: %v", err)
}
if hashed == hashed3 {
t.Error("Different codes should produce different hashes")
_, ok2 := VerifyRecoveryCode(code, []string{hashed3})
if ok2 {
t.Error("Different codes should NOT verify against each other's hash")
}
t.Logf("Hashed code: %s", hashed)
// bcrypt hash format check
if !strings.HasPrefix(hashed, "$2a$") {
t.Errorf("Hash should be bcrypt format, got: %s", hashed)
}
t.Logf("Hashed code (bcrypt): %s", hashed)
}
func TestVerifyRecoveryCode(t *testing.T) {

View File

@@ -10,38 +10,14 @@ import (
)
// GetClientIP 从 Gin Context 中提取客户端真实 IP 地址。
// 按以下优先级检查 Header
// 1. CF-Connecting-IP (Cloudflare)
// 2. X-Real-IP (Nginx)
// 3. X-Forwarded-For (取第一个非私有 IP)
// 4. c.ClientIP() (Gin 内置方法)
// 优先读取 IPFilterMiddleware 设置的 client_ip已做代理验证
// 否则回退到 c.ClientIP()(依赖 Gin 的 TrustedProxies 配置)。
func GetClientIP(c *gin.Context) string {
// 1. Cloudflare
if ip := c.GetHeader("CF-Connecting-IP"); ip != "" {
return normalizeIP(ip)
}
// 2. Nginx X-Real-IP
if ip := c.GetHeader("X-Real-IP"); ip != "" {
return normalizeIP(ip)
}
// 3. X-Forwarded-For (多个 IP 时取第一个公网 IP)
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
ips := strings.Split(xff, ",")
for _, ip := range ips {
ip = strings.TrimSpace(ip)
if ip != "" && !isPrivateIP(ip) {
return normalizeIP(ip)
}
}
// 如果都是私有 IP返回第一个
if len(ips) > 0 {
return normalizeIP(strings.TrimSpace(ips[0]))
if ip, ok := c.Get("client_ip"); ok {
if s, ok := ip.(string); ok && s != "" {
return normalizeIP(s)
}
}
// 4. Gin 内置方法
return normalizeIP(c.ClientIP())
}

View File

@@ -207,6 +207,16 @@ func (r *DeviceRepository) GetTrustedDevices(ctx context.Context, userID int64)
return devices, nil
}
// CountTrustedDevices 统计用户当前信任设备数量(未过期的)
func (r *DeviceRepository) CountTrustedDevices(ctx context.Context, userID int64) (int64, error) {
var count int64
now := time.Now()
err := r.db.WithContext(ctx).Model(&domain.Device{}).
Where("user_id = ? AND is_trusted = ? AND (trust_expires_at IS NULL OR trust_expires_at > ?)", userID, true, now).
Count(&count).Error
return count, err
}
// ListDevicesParams 设备列表查询参数
type ListDevicesParams struct {
UserID int64

View File

@@ -191,13 +191,20 @@ func (r *RoleRepository) GetByIDs(ctx context.Context, ids []int64) ([]*domain.R
return roles, nil
}
// maxAncestorDepth 角色祖先查询最大深度,防止循环引用导致无限循环
const maxAncestorDepth = 20
// GetAncestorIDs 获取角色的所有祖先角色ID用于权限继承
func (r *RoleRepository) GetAncestorIDs(ctx context.Context, roleID int64) ([]int64, error) {
var ancestorIDs []int64
currentID := roleID
depth := 0
// 循环向上查找父角色,直到没有父角色为止
// 循环向上查找父角色,直到没有父角色或达到深度上限为止
for {
if depth >= maxAncestorDepth {
break
}
var role domain.Role
err := r.db.WithContext(ctx).Select("id", "parent_id").First(&role, currentID).Error
if err != nil {
@@ -211,6 +218,7 @@ func (r *RoleRepository) GetAncestorIDs(ctx context.Context, roleID int64) ([]in
}
ancestorIDs = append(ancestorIDs, *role.ParentID)
currentID = *role.ParentID
depth++
}
return ancestorIDs, nil

View File

@@ -119,15 +119,61 @@ func (r *RolePermissionRepository) GetPermissionByID(ctx context.Context, permis
return &permission, nil
}
// GetPermissionIDsByRoleIDs 根据角色ID列表批量获取权限ID
// GetRoleAncestorIDs 递归获取角色的所有祖先角色ID含自身
// 包含循环检测(最大深度 5 层)
func (r *RolePermissionRepository) GetRoleAncestorIDs(ctx context.Context, roleID int64) ([]int64, error) {
var ancestors []int64
visited := make(map[int64]bool)
current := roleID
depth := 0
maxDepth := 5
for current > 0 && depth < maxDepth {
if visited[current] {
break // 循环检测
}
visited[current] = true
ancestors = append(ancestors, current)
var role domain.Role
err := r.db.WithContext(ctx).Select("parent_id").First(&role, current).Error
if err != nil || role.ParentID == nil {
break
}
current = *role.ParentID
depth++
}
return ancestors, nil
}
// GetPermissionIDsByRoleIDs 根据角色ID列表批量获取权限ID含继承的父角色权限
func (r *RolePermissionRepository) GetPermissionIDsByRoleIDs(ctx context.Context, roleIDs []int64) ([]int64, error) {
if len(roleIDs) == 0 {
return []int64{}, nil
}
// 收集所有角色ID含继承的父角色
allRoleIDs := make(map[int64]bool)
for _, roleID := range roleIDs {
ancestors, err := r.GetRoleAncestorIDs(ctx, roleID)
if err != nil {
return nil, err
}
for _, id := range ancestors {
allRoleIDs[id] = true
}
}
// 转换为 slice
ids := make([]int64, 0, len(allRoleIDs))
for id := range allRoleIDs {
ids = append(ids, id)
}
var permissionIDs []int64
err := r.db.WithContext(ctx).Model(&domain.RolePermission{}).
Where("role_id IN ?", roleIDs).
Where("role_id IN ?", ids).
Pluck("permission_id", &permissionIDs).Error
if err != nil {
return nil, err

View File

@@ -104,6 +104,18 @@ func (r *UserRepository) GetByPhone(ctx context.Context, phone string) (*domain.
return &user, nil
}
// FindByAccount 按账号查询用户(支持用户名/邮箱/手机号P1性能优化替代串行查询
func (r *UserRepository) FindByAccount(ctx context.Context, account string) (*domain.User, error) {
var user domain.User
err := r.db.WithContext(ctx).
Where("username = ? OR email = ? OR phone = ?", account, account, account).
First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// List 获取用户列表
func (r *UserRepository) List(ctx context.Context, offset, limit int) ([]*domain.User, int64, error) {
var users []*domain.User
@@ -195,6 +207,21 @@ func (r *UserRepository) ExistsByPhone(ctx context.Context, phone string) (bool,
return count > 0, err
}
// FilterExistingUsernames 批量筛选已存在的用户名P1性能优化替代循环查询
func (r *UserRepository) FilterExistingUsernames(ctx context.Context, usernames []string) ([]string, error) {
if len(usernames) == 0 {
return []string{}, nil
}
var existing []string
err := r.db.WithContext(ctx).Model(&domain.User{}).
Where("username IN ?", usernames).
Pluck("username", &existing).Error
if err != nil {
return nil, err
}
return existing, nil
}
// Search 搜索用户
func (r *UserRepository) Search(ctx context.Context, keyword string, offset, limit int) ([]*domain.User, int64, error) {
var users []*domain.User

View File

@@ -83,69 +83,89 @@ func (r *UserRoleRepository) GetRoleIDsByUserID(ctx context.Context, userID int6
return roleIDs, nil
}
// GetUserRolesAndPermissions 获取用户角色和权限PERF-01 优化:合并为单次 JOIN 查询
func (r *UserRoleRepository) GetUserRolesAndPermissions(ctx context.Context, userID int64) ([]*domain.Role, []*domain.Permission, error) {
var results []struct {
RoleID int64
RoleName string
RoleCode string
RoleStatus int
PermissionID int64
PermissionCode string
PermissionName string
// getRoleAncestorIDs 递归获取角色的所有祖先角色ID含自身
// 包含循环检测(最大深度 5 层)
func (r *UserRoleRepository) getRoleAncestorIDs(ctx context.Context, roleID int64) ([]int64, error) {
var ancestors []int64
visited := make(map[int64]bool)
current := roleID
depth := 0
maxDepth := 5
for current > 0 && depth < maxDepth {
if visited[current] {
break // 循环检测
}
visited[current] = true
ancestors = append(ancestors, current)
var role domain.Role
err := r.db.WithContext(ctx).Select("parent_id").First(&role, current).Error
if err != nil || role.ParentID == nil {
break
}
current = *role.ParentID
depth++
}
// 使用 LEFT JOIN 一次性获取用户角色和权限
err := r.db.WithContext(ctx).
Raw(`
SELECT DISTINCT r.id as role_id, r.name as role_name, r.code as role_code, r.status as role_status,
p.id as permission_id, p.code as permission_code, p.name as permission_name
FROM user_roles ur
JOIN roles r ON ur.role_id = r.id
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
WHERE ur.user_id = ? AND r.status = 1
`, userID).
Scan(&results).Error
return ancestors, nil
}
// GetUserRolesAndPermissions 获取用户角色和权限(包含继承的父角色和权限)
func (r *UserRoleRepository) GetUserRolesAndPermissions(ctx context.Context, userID int64) ([]*domain.Role, []*domain.Permission, error) {
// 获取用户直接分配的角色ID
var directRoleIDs []int64
err := r.db.WithContext(ctx).Model(&domain.UserRole{}).Where("user_id = ?", userID).Pluck("role_id", &directRoleIDs).Error
if err != nil {
return nil, nil, err
}
// 构建角色和权限列表
roleMap := make(map[int64]*domain.Role)
permMap := make(map[int64]*domain.Permission)
for _, row := range results {
if _, ok := roleMap[row.RoleID]; !ok {
roleMap[row.RoleID] = &domain.Role{
ID: row.RoleID,
Name: row.RoleName,
Code: row.RoleCode,
Status: domain.RoleStatus(row.RoleStatus),
}
// 递归获取所有祖先角色ID含自身包含循环检测
allRoleIDMap := make(map[int64]bool)
for _, roleID := range directRoleIDs {
ancestors, err := r.getRoleAncestorIDs(ctx, roleID)
if err != nil {
return nil, nil, err
}
if row.PermissionID > 0 {
if _, ok := permMap[row.PermissionID]; !ok {
permMap[row.PermissionID] = &domain.Permission{
ID: row.PermissionID,
Code: row.PermissionCode,
Name: row.PermissionName,
}
}
for _, id := range ancestors {
allRoleIDMap[id] = true
}
}
roles := make([]*domain.Role, 0, len(roleMap))
for _, role := range roleMap {
roles = append(roles, role)
// 转换为 slice
allRoleIDs := make([]int64, 0, len(allRoleIDMap))
for id := range allRoleIDMap {
allRoleIDs = append(allRoleIDs, id)
}
perms := make([]*domain.Permission, 0, len(permMap))
for _, perm := range permMap {
perms = append(perms, perm)
if len(allRoleIDs) == 0 {
return []*domain.Role{}, []*domain.Permission{}, nil
}
return roles, perms, nil
// 查询所有角色信息
var roles []*domain.Role
err = r.db.WithContext(ctx).Where("id IN ? AND status = ?", allRoleIDs, domain.RoleStatusEnabled).Find(&roles).Error
if err != nil {
return nil, nil, err
}
// 查询所有权限ID
var permissionIDs []int64
err = r.db.WithContext(ctx).Model(&domain.RolePermission{}).Where("role_id IN ?", allRoleIDs).Pluck("permission_id", &permissionIDs).Error
if err != nil {
return nil, nil, err
}
// 查询权限详情
var permissions []*domain.Permission
if len(permissionIDs) > 0 {
err = r.db.WithContext(ctx).Where("id IN ?", permissionIDs).Find(&permissions).Error
if err != nil {
return nil, nil, err
}
}
return roles, permissions, nil
}
// GetUserIDByRoleID 根据角色ID获取用户ID列表

View File

@@ -11,48 +11,92 @@ type Validator struct {
passwordMinLength int
passwordRequireSpecial bool
passwordRequireNumber bool
// 预编译的正则表达式避免每次调用重复编译P1性能优化
emailRe *regexp.Regexp
phoneRe *regexp.Regexp
usernameRe *regexp.Regexp
urlRe *regexp.Regexp
sqlPatterns []*regexp.Regexp
xssPatterns []*regexp.Regexp
}
// NewValidator creates a validator with the configured password rules.
func NewValidator(minLength int, requireSpecial, requireNumber bool) *Validator {
return &Validator{
v := &Validator{
passwordMinLength: minLength,
passwordRequireSpecial: requireSpecial,
passwordRequireNumber: requireNumber,
}
// 预编译常用验证正则P1性能优化
v.emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
v.phoneRe = regexp.MustCompile(`^1[3-9]\d{9}$`)
v.usernameRe = regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9_]{3,19}$`)
v.urlRe = regexp.MustCompile(`^https?://[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=]+$`)
// 预编译SQL注入检测正则P1性能优化
sqlRawPatterns := []string{
`;[\s]*--`,
`/\*.*?\*/`,
`\bxp_\w+`,
`\bexec[\s\(]`,
`\bsp_\w+`,
`\bwaitfor[\s]+delay`,
`\bunion[\s]+select`,
`\bdrop[\s]+table`,
`\binsert[\s]+into`,
`\bupdate[\s]+\w+[\s]+set`,
`\bdelete[\s]+from`,
}
v.sqlPatterns = make([]*regexp.Regexp, len(sqlRawPatterns))
for i, p := range sqlRawPatterns {
v.sqlPatterns[i] = regexp.MustCompile(`(?i)` + p)
}
// 预编译XSS检测正则P1性能优化
xssRawPatterns := []string{
`(?i)<script[^>]*>.*?</script>`,
`(?i)</script>`,
`(?i)<iframe[^>]*>.*?</iframe>`,
`(?i)<object[^>]*>.*?</object>`,
`(?i)<embed[^>]*>.*?</embed>`,
`(?i)<applet[^>]*>.*?</applet>`,
`(?i)javascript\s*:`,
`(?i)vbscript\s*:`,
`(?i)data\s*:`,
`(?i)on\w+\s*=`,
`(?i)<style[^>]*>.*?</style>`,
}
v.xssPatterns = make([]*regexp.Regexp, len(xssRawPatterns))
for i, p := range xssRawPatterns {
v.xssPatterns[i] = regexp.MustCompile(p)
}
return v
}
// ValidateEmail validates email format.
func (v *Validator) ValidateEmail(email string) bool {
if email == "" {
if email == "" || v.emailRe == nil {
return false
}
pattern := `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`
matched, _ := regexp.MatchString(pattern, email)
return matched
return v.emailRe.MatchString(email)
}
// ValidatePhone validates mainland China mobile numbers.
func (v *Validator) ValidatePhone(phone string) bool {
if phone == "" {
if phone == "" || v.phoneRe == nil {
return false
}
pattern := `^1[3-9]\d{9}$`
matched, _ := regexp.MatchString(pattern, phone)
return matched
return v.phoneRe.MatchString(phone)
}
// ValidateUsername validates usernames.
func (v *Validator) ValidateUsername(username string) bool {
if username == "" {
if username == "" || v.usernameRe == nil {
return false
}
pattern := `^[a-zA-Z][a-zA-Z0-9_]{3,19}$`
matched, _ := regexp.MatchString(pattern, username)
return matched
return v.usernameRe.MatchString(username)
}
// ValidatePassword validates passwords using the shared runtime policy.
@@ -77,27 +121,13 @@ func (v *Validator) SanitizeSQL(input string) string {
`"`, `""`,
)
// Remove common SQL injection patterns that could bypass quoting
dangerousPatterns := []string{
`;[\s]*--`, // SQL comment
`/\*.*?\*/`, // Block comment (non-greedy)
`\bxp_\w+`, // Extended stored procedures
`\bexec[\s\(]`, // EXEC statements
`\bsp_\w+`, // System stored procedures
`\bwaitfor[\s]+delay`, // Time-based blind SQL injection
`\bunion[\s]+select`, // UNION injection
`\bdrop[\s]+table`, // DROP TABLE
`\binsert[\s]+into`, // INSERT
`\bupdate[\s]+\w+[\s]+set`, // UPDATE
`\bdelete[\s]+from`, // DELETE
}
result := replacer.Replace(input)
// Apply pattern removal
for _, pattern := range dangerousPatterns {
re := regexp.MustCompile(`(?i)` + pattern) // Case-insensitive
result = re.ReplaceAllString(result, "")
// 使用预编译的正则移除SQL注入模式P1性能优化
for _, re := range v.sqlPatterns {
if re != nil {
result = re.ReplaceAllString(result, "")
}
}
return result
@@ -106,31 +136,11 @@ func (v *Validator) SanitizeSQL(input string) string {
// SanitizeXSS removes obviously dangerous XSS patterns using regex.
// This is a defense-in-depth measure; output encoding should always be used.
func (v *Validator) SanitizeXSS(input string) string {
// Remove dangerous tags and attributes using pattern matching
dangerousPatterns := []struct {
pattern string
replaceAll bool
}{
{`(?i)<script[^>]*>.*?</script>`, true}, // Script tags
{`(?i)</script>`, false}, // Closing script
{`(?i)<iframe[^>]*>.*?</iframe>`, true}, // Iframe injection
{`(?i)<object[^>]*>.*?</object>`, true}, // Object injection
{`(?i)<embed[^>]*>.*?</embed>`, true}, // Embed injection
{`(?i)<applet[^>]*>.*?</applet>`, true}, // Applet injection
{`(?i)javascript\s*:`, false}, // JavaScript protocol
{`(?i)vbscript\s*:`, false}, // VBScript protocol
{`(?i)data\s*:`, false}, // Data URL protocol
{`(?i)on\w+\s*=`, false}, // Event handlers
{`(?i)<style[^>]*>.*?</style>`, true}, // Style injection
}
result := input
for _, p := range dangerousPatterns {
re := regexp.MustCompile(p.pattern)
if p.replaceAll {
result = re.ReplaceAllString(result, "")
} else {
// 使用预编译的正则移除XSS模式P1性能优化
for _, re := range v.xssPatterns {
if re != nil {
result = re.ReplaceAllString(result, "")
}
}
@@ -148,13 +158,10 @@ func (v *Validator) SanitizeXSS(input string) string {
// ValidateURL validates a basic HTTP/HTTPS URL.
func (v *Validator) ValidateURL(url string) bool {
if url == "" {
if url == "" || v.urlRe == nil {
return false
}
pattern := `^https?://[a-zA-Z0-9\-._~:/?#[\]@!$&'()*+,;=]+$`
matched, _ := regexp.MatchString(pattern, url)
return matched
return v.urlRe.MatchString(url)
}
// ValidateIP validates IPv4 or IPv6 addresses using net.ParseIP.

View File

@@ -7,6 +7,8 @@ import (
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
@@ -43,10 +45,12 @@ func Serve(cfg *config.Config) error {
// P1-3Argon2id 启动时自适应校准
auth.CalibrateArgon2id(500 * time.Millisecond)
accessTokenExpire := resolveJWTAccessTokenExpire(cfg)
// 初始化 JWT 管理器
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
HS256Secret: cfg.JWT.Secret,
AccessTokenExpire: time.Duration(cfg.JWT.AccessTokenExpireMinutes) * time.Minute,
AccessTokenExpire: accessTokenExpire,
RefreshTokenExpire: time.Duration(cfg.JWT.RefreshTokenExpireDays) * 24 * time.Hour,
})
if err != nil {
@@ -125,6 +129,9 @@ func Serve(cfg *config.Config) error {
totpService := service.NewTOTPService(userRepo)
passwordResetConfig := service.DefaultPasswordResetConfig()
if err := configureAuthEmailServices(cfg, cacheManager, authService, passwordResetConfig); err != nil {
return fmt.Errorf("configure auth email services failed: %w", err)
}
passwordResetService := service.NewPasswordResetService(userRepo, cacheManager, passwordResetConfig).
WithPasswordHistoryRepo(passwordHistoryRepo)
@@ -141,6 +148,9 @@ func Serve(cfg *config.Config) error {
// 初始化中间件
rateLimitMiddleware := middleware.NewRateLimitMiddleware(cfg.RateLimit)
stopRateLimitCleanup := rateLimitMiddleware.StartCleanup()
defer stopRateLimitCleanup()
authMiddleware := middleware.NewAuthMiddleware(
jwtManager,
userRepo,
@@ -259,3 +269,100 @@ func resolveGinMode(mode string) string {
return gin.ReleaseMode
}
}
func configureAuthEmailServices(
cfg *config.Config,
cacheManager *cache.CacheManager,
authService *service.AuthService,
passwordResetConfig *service.PasswordResetConfig,
) error {
smtpConfig, enabled, err := resolveSMTPEmailConfigFromEnv()
if err != nil {
return err
}
if !enabled || cacheManager == nil || authService == nil {
return nil
}
siteURL := resolveAuthEmailSiteURL(cfg)
siteName := resolveAuthEmailSiteName(cfg)
provider := service.NewSMTPEmailProvider(smtpConfig)
authService.SetEmailActivationService(
service.NewEmailActivationService(provider, cacheManager, siteURL, siteName),
)
emailCodeConfig := service.DefaultEmailCodeConfig()
emailCodeConfig.SiteURL = siteURL
emailCodeConfig.SiteName = siteName
authService.SetEmailCodeService(service.NewEmailCodeService(provider, cacheManager, emailCodeConfig))
if passwordResetConfig != nil {
passwordResetConfig.SMTPHost = smtpConfig.Host
passwordResetConfig.SMTPPort = smtpConfig.Port
passwordResetConfig.SMTPUser = smtpConfig.Username
passwordResetConfig.SMTPPass = smtpConfig.Password
passwordResetConfig.FromEmail = smtpConfig.FromEmail
passwordResetConfig.SiteURL = siteURL
}
return nil
}
func resolveSMTPEmailConfigFromEnv() (service.SMTPEmailConfig, bool, error) {
host := strings.TrimSpace(os.Getenv("EMAIL_HOST"))
if host == "" {
return service.SMTPEmailConfig{}, false, nil
}
port := 587
if rawPort := strings.TrimSpace(os.Getenv("EMAIL_PORT")); rawPort != "" {
parsedPort, err := strconv.Atoi(rawPort)
if err != nil || parsedPort <= 0 {
return service.SMTPEmailConfig{}, false, fmt.Errorf("invalid EMAIL_PORT %q", rawPort)
}
port = parsedPort
}
fromEmail := strings.TrimSpace(os.Getenv("EMAIL_FROM_EMAIL"))
if fromEmail == "" {
fromEmail = service.DefaultPasswordResetConfig().FromEmail
}
return service.SMTPEmailConfig{
Host: host,
Port: port,
Username: strings.TrimSpace(os.Getenv("EMAIL_USER")),
Password: os.Getenv("EMAIL_PASS"),
FromEmail: fromEmail,
FromName: strings.TrimSpace(os.Getenv("EMAIL_FROM_NAME")),
}, true, nil
}
func resolveAuthEmailSiteURL(cfg *config.Config) string {
if cfg != nil {
if siteURL := strings.TrimSpace(cfg.Server.FrontendURL); siteURL != "" {
return siteURL
}
}
return service.DefaultEmailCodeConfig().SiteURL
}
func resolveAuthEmailSiteName(cfg *config.Config) string {
if cfg != nil {
if siteName := strings.TrimSpace(cfg.Log.ServiceName); siteName != "" {
return siteName
}
}
return service.DefaultEmailCodeConfig().SiteName
}
func resolveJWTAccessTokenExpire(cfg *config.Config) time.Duration {
if cfg == nil {
return 0
}
if cfg.JWT.AccessTokenExpireMinutes > 0 {
return time.Duration(cfg.JWT.AccessTokenExpireMinutes) * time.Minute
}
return time.Duration(cfg.JWT.ExpireHour) * time.Hour
}

View File

@@ -0,0 +1,73 @@
package server
import (
"testing"
"time"
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/config"
"github.com/user-management-system/internal/service"
)
func TestResolveJWTAccessTokenExpire_UsesExpireHourFallback(t *testing.T) {
cfg := &config.Config{}
cfg.JWT.ExpireHour = 24
cfg.JWT.AccessTokenExpireMinutes = 0
expire := resolveJWTAccessTokenExpire(cfg)
if expire != 24*time.Hour {
t.Fatalf("resolveJWTAccessTokenExpire() = %v, want %v", expire, 24*time.Hour)
}
}
func TestResolveJWTAccessTokenExpire_PrefersMinuteOverride(t *testing.T) {
cfg := &config.Config{}
cfg.JWT.ExpireHour = 24
cfg.JWT.AccessTokenExpireMinutes = 90
expire := resolveJWTAccessTokenExpire(cfg)
if expire != 90*time.Minute {
t.Fatalf("resolveJWTAccessTokenExpire() = %v, want %v", expire, 90*time.Minute)
}
}
func TestConfigureAuthEmailServices_UsesSMTPEnvironment(t *testing.T) {
t.Setenv("EMAIL_HOST", "127.0.0.1")
t.Setenv("EMAIL_PORT", "2525")
t.Setenv("EMAIL_FROM_EMAIL", "noreply@test.local")
t.Setenv("EMAIL_FROM_NAME", "UMS E2E")
t.Setenv("EMAIL_USER", "smtp-user")
t.Setenv("EMAIL_PASS", "smtp-pass")
cfg := &config.Config{}
cfg.Server.FrontendURL = "http://127.0.0.1:3000"
cfg.Log.ServiceName = "UMS E2E"
cacheManager := cache.NewCacheManager(cache.NewL1Cache(), cache.NewRedisCache(false))
authService := service.NewAuthService(nil, nil, nil, cacheManager, 8, 5, time.Minute)
passwordResetConfig := service.DefaultPasswordResetConfig()
if err := configureAuthEmailServices(cfg, cacheManager, authService, passwordResetConfig); err != nil {
t.Fatalf("configureAuthEmailServices() error = %v", err)
}
if !authService.SupportsEmailActivation() {
t.Fatal("SupportsEmailActivation() = false, want true")
}
if !authService.HasEmailCodeService() {
t.Fatal("HasEmailCodeService() = false, want true")
}
if passwordResetConfig.SMTPHost != "127.0.0.1" {
t.Fatalf("password reset SMTP host = %q, want %q", passwordResetConfig.SMTPHost, "127.0.0.1")
}
if passwordResetConfig.SMTPPort != 2525 {
t.Fatalf("password reset SMTP port = %d, want %d", passwordResetConfig.SMTPPort, 2525)
}
if passwordResetConfig.FromEmail != "noreply@test.local" {
t.Fatalf("password reset FromEmail = %q, want %q", passwordResetConfig.FromEmail, "noreply@test.local")
}
if passwordResetConfig.SiteURL != "http://127.0.0.1:3000" {
t.Fatalf("password reset SiteURL = %q, want %q", passwordResetConfig.SiteURL, "http://127.0.0.1:3000")
}
}

View File

@@ -2,10 +2,13 @@ package service
import (
"context"
cryptorand "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"strings"
"time"
"unicode"
@@ -19,11 +22,16 @@ import (
)
const (
userInfoCachePrefix = "auth_user_info:"
tokenBlacklistPrefix = "auth_token_blacklist:"
defaultUserCacheTTL = 15 * time.Minute
defaultBlacklistTTL = time.Hour
defaultPasswordMinLen = 8
userInfoCachePrefix = "auth_user_info:"
tokenBlacklistPrefix = "auth_token_blacklist:"
totpChallengePrefix = "auth_totp_challenge:"
defaultUserCacheTTL = 15 * time.Minute
defaultBlacklistTTL = time.Hour
defaultTOTPChallengeTTL = 5 * time.Minute
defaultPasswordMinLen = 8
refreshTokenRetryGrace = 10 * time.Second
MaxUsernameAttempts = 100 // 最大尝试次数P1性能优化减少循环查询
MaxUsernameLength = 40 // 用户名最大长度
)
type userRepositoryInterface interface {
@@ -32,6 +40,7 @@ type userRepositoryInterface interface {
UpdateTOTP(ctx context.Context, user *domain.User) error
Delete(ctx context.Context, id int64) error
GetByID(ctx context.Context, id int64) (*domain.User, error)
GetByIDs(ctx context.Context, ids []int64) ([]*domain.User, error)
GetByUsername(ctx context.Context, username string) (*domain.User, error)
GetByEmail(ctx context.Context, email string) (*domain.User, error)
GetByPhone(ctx context.Context, phone string) (*domain.User, error)
@@ -43,6 +52,10 @@ type userRepositoryInterface interface {
ExistsByEmail(ctx context.Context, email string) (bool, error)
ExistsByPhone(ctx context.Context, phone string) (bool, error)
Search(ctx context.Context, keyword string, offset, limit int) ([]*domain.User, int64, error)
// FilterExistingUsernames 批量筛选已存在的用户名P1性能优化替代循环查询
FilterExistingUsernames(ctx context.Context, usernames []string) ([]string, error)
// FindByAccount 按账号查询用户(支持用户名/邮箱/手机号P1性能优化替代串行查询
FindByAccount(ctx context.Context, account string) (*domain.User, error)
}
type userRoleRepositoryInterface interface {
@@ -122,13 +135,18 @@ type LoginResponse struct {
ExpiresIn int64 `json:"expires_in,omitempty"`
User *UserInfo `json:"user,omitempty"`
// RequiresTOTP 指示登录需要额外的TOTP验证当设备未信任时
RequiresTOTP bool `json:"requires_totp,omitempty"`
RequiresTOTP bool `json:"requires_totp,omitempty"`
// TempToken 临时令牌用于TOTP验证阶段短生命周期不可用于常规API
TempToken string `json:"temp_token,omitempty"`
// UserID 当RequiresTOTP为true时返回用于后续TOTP验证
UserID int64 `json:"user_id,omitempty"`
}
type totpLoginChallenge struct {
UserID int64 `json:"user_id"`
DeviceID string `json:"device_id,omitempty"`
}
type LogoutRequest struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
@@ -271,17 +289,28 @@ func (s *AuthService) generateUniqueUsername(ctx context.Context, base string) (
}
baseRunes := []rune(username)
if len(baseRunes) > 40 {
username = string(baseRunes[:40])
if len(baseRunes) > MaxUsernameLength {
username = string(baseRunes[:MaxUsernameLength])
}
for i := 1; i <= 1000; i++ {
candidate := fmt.Sprintf("%s_%d", username, i)
exists, err = s.userRepo.ExistsByUsername(ctx, candidate)
if err != nil {
return "", err
}
if !exists {
// P1性能优化批量生成候选列表后一次性查询避免循环DB往返
candidates := make([]string, 0, MaxUsernameAttempts)
for i := 1; i <= MaxUsernameAttempts; i++ {
candidates = append(candidates, fmt.Sprintf("%s_%d", username, i))
}
existing, err := s.userRepo.FilterExistingUsernames(ctx, candidates)
if err != nil {
return "", err
}
existingSet := make(map[string]bool, len(existing))
for _, u := range existing {
existingSet[u] = true
}
for _, candidate := range candidates {
if !existingSet[candidate] {
return candidate, nil
}
}
@@ -432,6 +461,38 @@ func (s *AuthService) blacklistTokenClaims(ctx context.Context, token string, va
return s.cache.Set(ctx, tokenBlacklistPrefix+claims.JTI, true, ttl, ttl)
}
func (s *AuthService) getTokenBlacklistValue(ctx context.Context, jti string) (interface{}, bool) {
if s == nil || s.cache == nil {
return nil, false
}
jti = strings.TrimSpace(jti)
if jti == "" {
return nil, false
}
return s.cache.Get(ctx, tokenBlacklistPrefix+jti)
}
func tokenBlacklistRevokedAt(value interface{}) (time.Time, bool) {
switch v := value.(type) {
case int64:
return time.Unix(0, v), true
case int:
return time.Unix(0, int64(v)), true
case float64:
return time.Unix(0, int64(v)), true
case string:
timestamp, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(0, timestamp), true
default:
return time.Time{}, false
}
}
func (s *AuthService) recordLoginAnomaly(ctx context.Context, userID *int64, ip, location, deviceFingerprint string, success bool) {
if s == nil || s.anomalyDetector == nil || userID == nil {
return
@@ -487,6 +548,11 @@ func (s *AuthService) writeLoginLog(
// #nosec G118 - 使用带超时的独立 context防止日志写入无限等待
go func() { // #nosec G118
defer func() {
if r := recover(); r != nil {
log.Printf("auth: write login log panic recovered, user_id=%v login_type=%d err=%v", userID, loginType, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.loginLogRepo.Create(bgCtx, loginRecord); err != nil {
@@ -548,7 +614,7 @@ func buildDeviceFingerprint(req *LoginRequest) string {
return result
}
// bestEffortRegisterDevice 尝试自动注册/更新设备记录
// bestEffortRegisterDevice 尝试自动注册/更新设备记录(异步,不阻塞登录响应)
func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64, req *LoginRequest) {
if s == nil || s.deviceService == nil || req == nil || req.DeviceID == "" {
return
@@ -560,7 +626,18 @@ func (s *AuthService) bestEffortRegisterDevice(ctx context.Context, userID int64
DeviceBrowser: req.DeviceBrowser,
DeviceOS: req.DeviceOS,
}
_, _ = s.deviceService.CreateDevice(ctx, userID, createReq)
// PERF-01: 改为异步 goroutine不阻塞登录响应返回
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("auth: register device panic recovered, user_id=%d device_id=%s err=%v", userID, req.DeviceID, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, _ = s.deviceService.CreateDevice(bgCtx, userID, createReq)
}()
}
// BestEffortRegisterDevicePublic 供外部 handler如 SMS 登录)调用,安静地注册设备
@@ -601,6 +678,93 @@ func userInfoFromCacheValue(value interface{}) (*UserInfo, bool) {
}
}
func generateTemporaryLoginToken() (string, error) {
payload := make([]byte, 32)
if _, err := cryptorand.Read(payload); err != nil {
return "", fmt.Errorf("generate temporary login token failed: %w", err)
}
return base64.RawURLEncoding.EncodeToString(payload), nil
}
func totpLoginChallengeFromCacheValue(value interface{}) (*totpLoginChallenge, bool) {
switch typed := value.(type) {
case *totpLoginChallenge:
return typed, true
case totpLoginChallenge:
challenge := typed
return &challenge, true
case map[string]interface{}:
payload, err := json.Marshal(typed)
if err != nil {
return nil, false
}
var challenge totpLoginChallenge
if err := json.Unmarshal(payload, &challenge); err != nil {
return nil, false
}
return &challenge, true
default:
return nil, false
}
}
func (s *AuthService) issueTOTPLoginChallenge(ctx context.Context, user *domain.User, deviceID string) (string, error) {
if s == nil || s.cache == nil {
return "", errors.New("temporary login token storage is unavailable")
}
if user == nil {
return "", errors.New("temporary login token requires a user")
}
tempToken, err := generateTemporaryLoginToken()
if err != nil {
return "", err
}
challenge := &totpLoginChallenge{
UserID: user.ID,
DeviceID: strings.TrimSpace(deviceID),
}
if err := s.cache.Set(
ctx,
totpChallengePrefix+tempToken,
challenge,
defaultTOTPChallengeTTL,
defaultTOTPChallengeTTL,
); err != nil {
return "", fmt.Errorf("temporary login token storage failed: %w", err)
}
return tempToken, nil
}
func (s *AuthService) validateTOTPLoginChallenge(ctx context.Context, userID int64, deviceID, tempToken string) error {
if s == nil || s.cache == nil {
return errors.New("temporary login token storage is unavailable")
}
normalizedToken := strings.TrimSpace(tempToken)
if normalizedToken == "" {
return errors.New("temporary login token is required")
}
value, ok := s.cache.Get(ctx, totpChallengePrefix+normalizedToken)
if !ok {
return errors.New("temporary login token is invalid or expired")
}
challenge, ok := totpLoginChallengeFromCacheValue(value)
if !ok || challenge == nil {
return errors.New("temporary login token is invalid or expired")
}
if challenge.UserID != userID || strings.TrimSpace(challenge.DeviceID) != strings.TrimSpace(deviceID) {
return errors.New("temporary login token does not match the requested login flow")
}
return nil
}
func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*UserInfo, error) {
if req == nil {
return nil, errors.New("注册请求不能为空")
@@ -628,6 +792,9 @@ func (s *AuthService) Register(ctx context.Context, req *RegisterRequest) (*User
if err := s.verifyPhoneRegistration(ctx, req); err != nil {
return nil, err
}
if s.emailActivationSvc != nil && req.Email != "" {
return s.RegisterWithActivation(ctx, req)
}
exists, err := s.userRepo.ExistsByUsername(ctx, req.Username)
if err != nil {
@@ -759,11 +926,17 @@ func (s *AuthService) Login(ctx context.Context, req *LoginRequest, ip string) (
// P0-07 安全修复检查是否需要TOTP验证用户启用了TOTP且设备未信任
if s.isTOTPRequiredForLogin(ctx, user, req.DeviceID) {
tempToken, err := s.issueTOTPLoginChallenge(ctx, user, req.DeviceID)
if err != nil {
return nil, err
}
// 返回RequiresTOTP指示前端需要完成TOTP验证
// 前端应调用 /auth/login/totp-verify 接口完成验证
return &LoginResponse{
RequiresTOTP: true,
UserID: user.ID,
TempToken: tempToken,
UserID: user.ID,
}, nil
}
@@ -808,10 +981,13 @@ func (s *AuthService) isTOTPRequiredForLogin(ctx context.Context, user *domain.U
// VerifyTOTPAfterPasswordLogin 完成密码登录后的TOTP验证
// 当用户启用了TOTP但设备未信任时密码登录会返回RequiresTOTP=true
// 前端需要调用此接口完成TOTP验证以获取令牌
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID string) (*LoginResponse, error) {
func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID int64, totpCode, deviceID, tempToken string) (*LoginResponse, error) {
if s == nil {
return nil, errors.New("auth service is not initialized")
}
if err := s.validateTOTPLoginChallenge(ctx, userID, deviceID, tempToken); err != nil {
return nil, err
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
@@ -827,6 +1003,10 @@ func (s *AuthService) VerifyTOTPAfterPasswordLogin(ctx context.Context, userID i
return nil, err
}
if err := s.cache.Delete(ctx, totpChallengePrefix+strings.TrimSpace(tempToken)); err != nil {
return nil, fmt.Errorf("temporary login token cleanup failed: %w", err)
}
// TOTP验证成功返回完整登录响应
return s.generateLoginResponseWithoutRemember(ctx, user)
}
@@ -841,8 +1021,11 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
if err != nil {
return nil, err
}
if s.IsTokenBlacklisted(ctx, claims.JTI) {
return nil, errors.New("refresh token has been revoked")
if blacklistValue, blacklisted := s.getTokenBlacklistValue(ctx, claims.JTI); blacklisted {
revokedAt, hasRevocationTimestamp := tokenBlacklistRevokedAt(blacklistValue)
if !hasRevocationTimestamp || time.Since(revokedAt) > refreshTokenRetryGrace {
return nil, errors.New("refresh token has been revoked")
}
}
user, err := s.userRepo.GetByID(ctx, claims.UserID)
@@ -861,7 +1044,7 @@ func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*L
if claims.ExpiresAt != nil {
remaining := time.Until(claims.ExpiresAt.Time)
if remaining > 0 {
if err := s.cache.Set(ctx, blacklistKey, "1", 5*time.Minute, remaining); err != nil {
if err := s.cache.Set(ctx, blacklistKey, time.Now().UnixNano(), 5*time.Minute, remaining); err != nil {
return nil, fmt.Errorf("token revocation failed: %w", err)
}
}

View File

@@ -75,21 +75,16 @@ func (s *AuthService) IsAdminBootstrapRequired(ctx context.Context) bool {
return true
}
hadUnexpectedLookupError := false
for _, userID := range userIDs {
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
if isUserNotFoundError(err) {
continue
}
hadUnexpectedLookupError = true
log.Printf("auth: resolve auth capabilities failed while loading admin user: user_id=%d err=%v", userID, err)
continue
}
if user != nil && user.Status == domain.UserStatusActive {
users, err := s.userRepo.GetByIDs(ctx, userIDs)
if err != nil {
log.Printf("auth: resolve auth capabilities failed while loading admin users: err=%v", err)
return false
}
for _, user := range users {
if user.Status == domain.UserStatusActive {
return false
}
}
return !hadUnexpectedLookupError
return true
}

View File

@@ -69,13 +69,17 @@ func (s *AuthService) RegisterWithActivation(ctx context.Context, req *RegisterR
if s.emailActivationSvc != nil && req.Email != "" {
initialStatus = domain.UserStatusInactive
}
nickname := req.Nickname
if nickname == "" {
nickname = req.Username
}
user := &domain.User{
Username: req.Username,
Email: domain.StrPtr(req.Email),
Phone: domain.StrPtr(req.Phone),
Password: hashedPassword,
Nickname: req.Nickname,
Nickname: nickname,
Status: initialStatus,
}
if err := s.userRepo.Create(ctx, user); err != nil {
@@ -85,10 +89,6 @@ func (s *AuthService) RegisterWithActivation(ctx context.Context, req *RegisterR
s.bestEffortAssignDefaultRoles(ctx, user.ID, "register_with_activation")
if s.emailActivationSvc != nil && req.Email != "" {
nickname := req.Nickname
if nickname == "" {
nickname = req.Username
}
// #nosec G118 - 使用独立上下文避免请求结束后被取消
go func() { // #nosec G118
bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

View File

@@ -375,6 +375,51 @@ func TestAuthService_RegisterWithActivation(t *testing.T) {
})
}
func TestAuthService_Register_UsesEmailActivationFlowWhenConfigured(t *testing.T) {
svc, db := setupAuthEmailTestEnv(t)
ctx := context.Background()
l1Cache := cache.NewL1Cache()
l2Cache := cache.NewRedisCache(false)
cacheManager := cache.NewCacheManager(l1Cache, l2Cache)
emailActivationSvc := service.NewEmailActivationService(
&service.MockEmailProvider{},
cacheManager,
"http://localhost:8080",
"TestSite",
)
svc.SetEmailActivationService(emailActivationSvc)
userInfo, err := svc.Register(ctx, &service.RegisterRequest{
Username: "register_activation_enabled",
Password: "Password123!",
Email: "register-activation-enabled@test.com",
})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if userInfo == nil {
t.Fatal("Register returned nil user info")
}
if userInfo.Status != domain.UserStatusInactive {
t.Fatalf("Register status = %d, want %d", userInfo.Status, domain.UserStatusInactive)
}
if userInfo.Nickname != "register_activation_enabled" {
t.Fatalf("Register nickname = %q, want %q", userInfo.Nickname, "register_activation_enabled")
}
var storedUser domain.User
if err := db.WithContext(ctx).Where("username = ?", "register_activation_enabled").First(&storedUser).Error; err != nil {
t.Fatalf("load stored user: %v", err)
}
if storedUser.Status != domain.UserStatusInactive {
t.Fatalf("stored user status = %d, want %d", storedUser.Status, domain.UserStatusInactive)
}
if storedUser.Nickname != "register_activation_enabled" {
t.Fatalf("stored user nickname = %q, want %q", storedUser.Nickname, "register_activation_enabled")
}
}
// =============================================================================
// Login By Email Code Extended Tests
// =============================================================================

View File

@@ -30,27 +30,15 @@ func (s *AuthService) RegisterOAuthProvider(provider auth.OAuthProvider, cfg *au
}
func (s *AuthService) findUserForLogin(ctx context.Context, account string) (*domain.User, error) {
user, err := s.userRepo.GetByUsername(ctx, account)
if err == nil {
return user, nil
// P1性能优化使用单一查询替代 username->email->phone 串行查询减少DB往返
user, err := s.userRepo.FindByAccount(ctx, account)
if err != nil {
if isUserNotFoundError(err) {
return nil, err
}
return nil, fmt.Errorf("lookup user failed: %w", err)
}
if !isUserNotFoundError(err) {
return nil, fmt.Errorf("lookup user by username failed: %w", err)
}
user, err = s.userRepo.GetByEmail(ctx, account)
if err == nil {
return user, nil
}
if !isUserNotFoundError(err) {
return nil, fmt.Errorf("lookup user by email failed: %w", err)
}
user, err = s.userRepo.GetByPhone(ctx, account)
if err != nil && !isUserNotFoundError(err) {
return nil, fmt.Errorf("lookup user by phone failed: %w", err)
}
return user, err
return user, nil
}
func isUserNotFoundError(err error) bool {
@@ -100,9 +88,19 @@ func (s *AuthService) bestEffortUpdateLastLogin(ctx context.Context, userID int6
return
}
if err := s.userRepo.UpdateLastLogin(ctx, userID, ip); err != nil {
log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err)
}
// PERF-01: 改为异步 goroutine不阻塞登录响应返回
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("auth: update last login panic recovered, source=%s user_id=%d err=%v", source, userID, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.userRepo.UpdateLastLogin(bgCtx, userID, ip); err != nil {
log.Printf("auth: update last login failed, source=%s user_id=%d ip=%s err=%v", source, userID, ip, err)
}
}()
}
func loginAttemptKey(account string, user *domain.User) string {

View File

@@ -3,10 +3,12 @@ package service
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/user-management-system/internal/auth"
"github.com/user-management-system/internal/cache"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/repository"
"github.com/user-management-system/internal/security"
@@ -359,6 +361,73 @@ func TestBuildDeviceFingerprint(t *testing.T) {
}
}
func TestLogin_IssuesTOTPChallengeTokenWhenSecondFactorIsRequired(t *testing.T) {
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
DriverName: "sqlite",
DSN: fmt.Sprintf("file:login_totp_challenge_%d?mode=memory&cache=shared", time.Now().UnixNano()),
}), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("failed to connect database: %v", err)
}
if err := db.AutoMigrate(&domain.User{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
jwtManager, err := auth.NewJWTWithOptions(auth.JWTOptions{
HS256Secret: "totp-challenge-secret",
AccessTokenExpire: 15 * time.Minute,
RefreshTokenExpire: 7 * 24 * time.Hour,
})
if err != nil {
t.Fatalf("failed to create jwt manager: %v", err)
}
cacheManager := cache.NewCacheManager(cache.NewL1Cache(), cache.NewRedisCache(false))
userRepo := repository.NewUserRepository(db)
svc := NewAuthService(userRepo, nil, jwtManager, cacheManager, 8, 5, 15*time.Minute)
hashedPassword, err := auth.HashPassword("Password123!")
if err != nil {
t.Fatalf("failed to hash password: %v", err)
}
user := &domain.User{
Username: "totpchallenge",
Password: hashedPassword,
Status: domain.UserStatusActive,
TOTPEnabled: true,
TOTPSecret: "JBSWY3DPEHPK3PXP",
}
if err := db.Create(user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
resp, err := svc.Login(context.Background(), &LoginRequest{
Account: "totpchallenge",
Password: "Password123!",
DeviceID: "device-1",
}, "127.0.0.1")
if err != nil {
t.Fatalf("login failed: %v", err)
}
if !resp.RequiresTOTP {
t.Fatalf("expected requires_totp response, got %+v", resp)
}
if resp.UserID != user.ID {
t.Fatalf("expected user id %d, got %d", user.ID, resp.UserID)
}
if strings.TrimSpace(resp.TempToken) == "" {
t.Fatalf("expected temp token when TOTP is required, got %+v", resp)
}
if resp.AccessToken != "" || resp.RefreshToken != "" {
t.Fatalf("expected no full session tokens before TOTP verification, got %+v", resp)
}
}
func TestAuthServiceDefaultConfig(t *testing.T) {
// Test that default configuration is applied correctly
svc := NewAuthService(nil, nil, nil, nil, 0, 0, 0)

View File

@@ -4,14 +4,16 @@ import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/user-management-system/internal/domain"
"github.com/user-management-system/internal/pagination"
apierrors "github.com/user-management-system/internal/pkg/errors"
"github.com/user-management-system/internal/repository"
"gorm.io/gorm"
)
// Interfaces for dependency inversion (DIP) — service layer depends on these abstractions, not concrete types.
type deviceRepository interface {
Create(ctx context.Context, device *domain.Device) error
Update(ctx context.Context, device *domain.Device) error
@@ -27,6 +29,7 @@ type deviceRepository interface {
UntrustDevice(ctx context.Context, id int64) error
DeleteAllByUserIDExcept(ctx context.Context, userID int64, exceptDeviceID int64) error
GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error)
CountTrustedDevices(ctx context.Context, userID int64) (int64, error)
ListAll(ctx context.Context, params *repository.ListDevicesParams) ([]*domain.Device, int64, error)
ListAllCursor(ctx context.Context, params *repository.ListDevicesParams, limit int, cursor *pagination.Cursor) ([]*domain.Device, bool, error)
}
@@ -35,24 +38,18 @@ type deviceUserRepository interface {
GetByID(ctx context.Context, id int64) (*domain.User, error)
}
// DeviceService 设备服务
type DeviceService struct {
deviceRepo deviceRepository
userRepo deviceUserRepository
}
// NewDeviceService 创建设备服务
func NewDeviceService(
deviceRepo deviceRepository,
userRepo deviceUserRepository,
) *DeviceService {
func NewDeviceService(deviceRepo deviceRepository, userRepo deviceUserRepository) *DeviceService {
return &DeviceService{
deviceRepo: deviceRepo,
userRepo: userRepo,
}
}
// CreateDeviceRequest 创建设备请求
type CreateDeviceRequest struct {
DeviceID string `json:"device_id" binding:"required"`
DeviceName string `json:"device_name"`
@@ -63,7 +60,6 @@ type CreateDeviceRequest struct {
Location string `json:"location"`
}
// UpdateDeviceRequest 更新设备请求
type UpdateDeviceRequest struct {
DeviceName string `json:"device_name"`
DeviceType int `json:"device_type"`
@@ -74,21 +70,16 @@ type UpdateDeviceRequest struct {
Status int `json:"status"`
}
// CreateDevice 创建设备
func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *CreateDeviceRequest) (*domain.Device, error) {
// 检查用户是否存在
_, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return nil, errors.New("用户不存在")
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
return nil, errors.New("user not found")
}
// 检查设备是否已存在
exists, err := s.deviceRepo.Exists(ctx, userID, req.DeviceID)
if err != nil {
return nil, err
}
if exists {
// 设备已存在,更新最后活跃时间
device, err := s.deviceRepo.GetByDeviceID(ctx, userID, req.DeviceID)
if err != nil {
return nil, err
@@ -97,7 +88,6 @@ func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *Cre
return device, s.deviceRepo.Update(ctx, device)
}
// 创建设备
device := &domain.Device{
UserID: userID,
DeviceID: req.DeviceID,
@@ -117,14 +107,47 @@ func (s *DeviceService) CreateDevice(ctx context.Context, userID int64, req *Cre
return device, nil
}
// UpdateDevice 更新设备
func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *UpdateDeviceRequest) (*domain.Device, error) {
device, err := s.deviceRepo.GetByID(ctx, deviceID)
if err != nil {
return nil, errors.New("设备不存在")
func isDeviceNotFoundError(err error) bool {
if err == nil {
return false
}
if errors.Is(err, gorm.ErrRecordNotFound) {
return true
}
lowerErr := strings.ToLower(strings.TrimSpace(err.Error()))
return strings.Contains(lowerErr, "record not found") ||
strings.Contains(lowerErr, "device not found") ||
strings.Contains(lowerErr, "not found")
}
func (s *DeviceService) getDeviceByID(ctx context.Context, deviceID int64) (*domain.Device, error) {
device, err := s.deviceRepo.GetByID(ctx, deviceID)
if err != nil {
if isDeviceNotFoundError(err) {
return nil, apierrors.NotFound("device_not_found", "device not found").WithCause(err)
}
return nil, err
}
return device, nil
}
func (s *DeviceService) getAuthorizedDevice(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) {
device, err := s.getDeviceByID(ctx, deviceID)
if err != nil {
return nil, err
}
if !isAdmin && device.UserID != actorUserID {
return nil, apierrors.Forbidden("device_forbidden", "permission denied")
}
return device, nil
}
func (s *DeviceService) persistDeviceUpdate(ctx context.Context, device *domain.Device, req *UpdateDeviceRequest) (*domain.Device, error) {
if req == nil {
return device, nil
}
// 更新字段
if req.DeviceName != "" {
device.DeviceName = req.DeviceName
}
@@ -154,19 +177,48 @@ func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *U
return device, nil
}
// DeleteDevice 删除设备
// maxTrustedDevicesPerUser 每个用户最大信任设备数量P2 安全增强)
const maxTrustedDevicesPerUser = 10
func (s *DeviceService) trustDeviceRecord(ctx context.Context, device *domain.Device, trustDuration time.Duration) error {
// P2 安全增强:检查信任设备数量上限
trustedCount, err := s.deviceRepo.CountTrustedDevices(ctx, device.UserID)
if err != nil {
return fmt.Errorf("count trusted devices failed: %w", err)
}
if trustedCount >= maxTrustedDevicesPerUser {
return fmt.Errorf("trusted device limit reached (max %d), please untrust an existing device first", maxTrustedDevicesPerUser)
}
var trustExpiresAt *time.Time
if trustDuration > 0 {
expiresAt := time.Now().Add(trustDuration)
trustExpiresAt = &expiresAt
}
return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt)
}
func (s *DeviceService) UpdateDevice(ctx context.Context, deviceID int64, req *UpdateDeviceRequest) (*domain.Device, error) {
device, err := s.getDeviceByID(ctx, deviceID)
if err != nil {
return nil, err
}
return s.persistDeviceUpdate(ctx, device, req)
}
func (s *DeviceService) DeleteDevice(ctx context.Context, deviceID int64) error {
return s.deviceRepo.Delete(ctx, deviceID)
device, err := s.getDeviceByID(ctx, deviceID)
if err != nil {
return err
}
return s.deviceRepo.Delete(ctx, device.ID)
}
// GetDevice 获取设备信息
func (s *DeviceService) GetDevice(ctx context.Context, deviceID int64) (*domain.Device, error) {
return s.deviceRepo.GetByID(ctx, deviceID)
return s.getDeviceByID(ctx, deviceID)
}
// GetUserDevices 获取用户设备列表
func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page, pageSize int) ([]*domain.Device, int64, error) {
offset := (page - 1) * pageSize
if page <= 0 {
page = 1
}
@@ -174,22 +226,51 @@ func (s *DeviceService) GetUserDevices(ctx context.Context, userID int64, page,
pageSize = 20
}
offset := (page - 1) * pageSize
return s.deviceRepo.ListByUserID(ctx, userID, offset, pageSize)
}
// UpdateDeviceStatus 更新设备状态
func (s *DeviceService) UpdateDeviceStatus(ctx context.Context, deviceID int64, status domain.DeviceStatus) error {
return s.deviceRepo.UpdateStatus(ctx, deviceID, status)
func (s *DeviceService) GetDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) (*domain.Device, error) {
return s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
}
func (s *DeviceService) UpdateDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, req *UpdateDeviceRequest) (*domain.Device, error) {
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
if err != nil {
return nil, err
}
return s.persistDeviceUpdate(ctx, device, req)
}
func (s *DeviceService) DeleteDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error {
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
if err != nil {
return err
}
return s.deviceRepo.Delete(ctx, device.ID)
}
func (s *DeviceService) UpdateDeviceStatus(ctx context.Context, deviceID int64, status domain.DeviceStatus) error {
device, err := s.getDeviceByID(ctx, deviceID)
if err != nil {
return err
}
return s.deviceRepo.UpdateStatus(ctx, device.ID, status)
}
func (s *DeviceService) UpdateDeviceStatusForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, status domain.DeviceStatus) error {
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
if err != nil {
return err
}
return s.deviceRepo.UpdateStatus(ctx, device.ID, status)
}
// UpdateLastActiveTime 更新最后活跃时间
func (s *DeviceService) UpdateLastActiveTime(ctx context.Context, deviceID int64) error {
return s.deviceRepo.UpdateLastActiveTime(ctx, deviceID)
}
// GetActiveDevices 获取活跃设备
func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int) ([]*domain.Device, int64, error) {
offset := (page - 1) * pageSize
if page <= 0 {
page = 1
}
@@ -197,74 +278,72 @@ func (s *DeviceService) GetActiveDevices(ctx context.Context, page, pageSize int
pageSize = 20
}
offset := (page - 1) * pageSize
return s.deviceRepo.ListByStatus(ctx, domain.DeviceStatusActive, offset, pageSize)
}
// TrustDevice 设置设备为信任状态
func (s *DeviceService) TrustDevice(ctx context.Context, deviceID int64, trustDuration time.Duration) error {
device, err := s.deviceRepo.GetByID(ctx, deviceID)
device, err := s.getDeviceByID(ctx, deviceID)
if err != nil {
return errors.New("设备不存在")
return err
}
var trustExpiresAt *time.Time
if trustDuration > 0 {
expiresAt := time.Now().Add(trustDuration)
trustExpiresAt = &expiresAt
}
return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt)
return s.trustDeviceRecord(ctx, device, trustDuration)
}
func (s *DeviceService) TrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool, trustDuration time.Duration) error {
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
if err != nil {
return err
}
return s.trustDeviceRecord(ctx, device, trustDuration)
}
// TrustDeviceByDeviceID 根据设备标识字符串设置设备为信任状态
func (s *DeviceService) TrustDeviceByDeviceID(ctx context.Context, userID int64, deviceID string, trustDuration time.Duration) error {
device, err := s.deviceRepo.GetByDeviceID(ctx, userID, deviceID)
if err != nil {
return errors.New("设备不存在")
if isDeviceNotFoundError(err) {
return apierrors.NotFound("device_not_found", "device not found").WithCause(err)
}
return err
}
var trustExpiresAt *time.Time
if trustDuration > 0 {
expiresAt := time.Now().Add(trustDuration)
trustExpiresAt = &expiresAt
}
return s.deviceRepo.TrustDevice(ctx, device.ID, trustExpiresAt)
return s.trustDeviceRecord(ctx, device, trustDuration)
}
// UntrustDevice 取消设备信任状态
func (s *DeviceService) UntrustDevice(ctx context.Context, deviceID int64) error {
device, err := s.deviceRepo.GetByID(ctx, deviceID)
device, err := s.getDeviceByID(ctx, deviceID)
if err != nil {
return errors.New("设备不存在")
return err
}
return s.deviceRepo.UntrustDevice(ctx, device.ID)
}
func (s *DeviceService) UntrustDeviceForActor(ctx context.Context, actorUserID, deviceID int64, isAdmin bool) error {
device, err := s.getAuthorizedDevice(ctx, actorUserID, deviceID, isAdmin)
if err != nil {
return err
}
return s.deviceRepo.UntrustDevice(ctx, device.ID)
}
// LogoutAllOtherDevices 登出所有其他设备
func (s *DeviceService) LogoutAllOtherDevices(ctx context.Context, userID int64, currentDeviceID int64) error {
return s.deviceRepo.DeleteAllByUserIDExcept(ctx, userID, currentDeviceID)
}
// GetTrustedDevices 获取用户的信任设备列表
func (s *DeviceService) GetTrustedDevices(ctx context.Context, userID int64) ([]*domain.Device, error) {
return s.deviceRepo.GetTrustedDevices(ctx, userID)
}
// GetAllDevicesRequest 获取所有设备请求参数
type GetAllDevicesRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
UserID int64 `form:"user_id"`
Status *int `form:"status"` // 0-禁用, 1-激活, nil-不筛选
Status *int `form:"status"`
IsTrusted *bool `form:"is_trusted"`
Keyword string `form:"keyword"`
Cursor string `form:"cursor"` // Opaque cursor for keyset pagination
Size int `form:"size"` // Page size when using cursor mode
Cursor string `form:"cursor"`
Size int `form:"size"`
}
// GetAllDevices 获取所有设备(管理员用)
func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesRequest) ([]*domain.Device, int64, error) {
if req.Page <= 0 {
req.Page = 1
@@ -277,7 +356,6 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq
}
offset := (req.Page - 1) * req.PageSize
params := &repository.ListDevicesParams{
UserID: req.UserID,
Keyword: req.Keyword,
@@ -285,13 +363,10 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq
Limit: req.PageSize,
}
// 处理状态筛选(仅当明确指定了状态时才筛选)
if req.Status != nil && (*req.Status == 0 || *req.Status == 1) {
status := domain.DeviceStatus(*req.Status)
params.Status = &status
}
// 处理信任状态筛选
if req.IsTrusted != nil {
params.IsTrusted = req.IsTrusted
}
@@ -299,7 +374,6 @@ func (s *DeviceService) GetAllDevices(ctx context.Context, req *GetAllDevicesReq
return s.deviceRepo.ListAll(ctx, params)
}
// GetAllDevicesCursor 游标分页获取所有设备(推荐使用)
func (s *DeviceService) GetAllDevicesCursor(ctx context.Context, req *GetAllDevicesRequest) (*CursorResult, error) {
size := pagination.ClampPageSize(req.Size)
if req.PageSize > 0 && req.Cursor == "" {
@@ -342,7 +416,6 @@ func (s *DeviceService) GetAllDevicesCursor(ctx context.Context, req *GetAllDevi
}, nil
}
// GetDeviceByDeviceID 根据设备标识获取设备(用于设备信任检查)
func (s *DeviceService) GetDeviceByDeviceID(ctx context.Context, userID int64, deviceID string) (*domain.Device, error) {
return s.deviceRepo.GetByDeviceID(ctx, userID, deviceID)
}

View File

@@ -6,6 +6,7 @@ import (
"time"
"github.com/user-management-system/internal/domain"
apierrors "github.com/user-management-system/internal/pkg/errors"
"github.com/user-management-system/internal/repository"
"github.com/user-management-system/internal/service"
gormsqlite "gorm.io/driver/sqlite"
@@ -156,6 +157,104 @@ func TestDeviceService_GetDevice(t *testing.T) {
})
}
func TestDeviceService_DeviceOwnershipAuthorization(t *testing.T) {
svc, db := setupDeviceTestEnv(t)
ctx := context.Background()
owner := &domain.User{Username: "device_owner", Status: domain.UserStatusActive}
if err := db.Create(owner).Error; err != nil {
t.Fatalf("create owner failed: %v", err)
}
actor := &domain.User{Username: "device_actor", Status: domain.UserStatusActive}
if err := db.Create(actor).Error; err != nil {
t.Fatalf("create actor failed: %v", err)
}
device, err := svc.CreateDevice(ctx, owner.ID, &service.CreateDeviceRequest{
DeviceID: "ownership_device",
DeviceName: "Owner Device",
})
if err != nil {
t.Fatalf("CreateDevice failed: %v", err)
}
t.Run("GetDeviceForActor forbids cross-user access", func(t *testing.T) {
_, err := svc.GetDeviceForActor(ctx, actor.ID, device.ID, false)
if !apierrors.IsForbidden(err) {
t.Fatalf("expected forbidden error, got %v", err)
}
})
t.Run("UpdateDeviceForActor forbids cross-user access", func(t *testing.T) {
_, err := svc.UpdateDeviceForActor(ctx, actor.ID, device.ID, false, &service.UpdateDeviceRequest{
DeviceName: "Hacked Name",
})
if !apierrors.IsForbidden(err) {
t.Fatalf("expected forbidden error, got %v", err)
}
current, getErr := svc.GetDevice(ctx, device.ID)
if getErr != nil {
t.Fatalf("GetDevice failed: %v", getErr)
}
if current.DeviceName != "Owner Device" {
t.Fatalf("expected device name to remain unchanged, got %q", current.DeviceName)
}
})
t.Run("DeleteDeviceForActor forbids cross-user access", func(t *testing.T) {
err := svc.DeleteDeviceForActor(ctx, actor.ID, device.ID, false)
if !apierrors.IsForbidden(err) {
t.Fatalf("expected forbidden error, got %v", err)
}
if _, getErr := svc.GetDevice(ctx, device.ID); getErr != nil {
t.Fatalf("expected device to remain after forbidden delete, got %v", getErr)
}
})
t.Run("TrustDeviceForActor forbids cross-user access", func(t *testing.T) {
err := svc.TrustDeviceForActor(ctx, actor.ID, device.ID, false, time.Hour)
if !apierrors.IsForbidden(err) {
t.Fatalf("expected forbidden error, got %v", err)
}
current, getErr := svc.GetDevice(ctx, device.ID)
if getErr != nil {
t.Fatalf("GetDevice failed: %v", getErr)
}
if current.IsTrusted {
t.Fatal("expected device to remain untrusted")
}
})
t.Run("UpdateDeviceStatusForActor forbids cross-user access", func(t *testing.T) {
err := svc.UpdateDeviceStatusForActor(ctx, actor.ID, device.ID, false, domain.DeviceStatusInactive)
if !apierrors.IsForbidden(err) {
t.Fatalf("expected forbidden error, got %v", err)
}
current, getErr := svc.GetDevice(ctx, device.ID)
if getErr != nil {
t.Fatalf("GetDevice failed: %v", getErr)
}
if current.Status != domain.DeviceStatusActive {
t.Fatalf("expected device to remain active, got %d", current.Status)
}
})
t.Run("Admin can manage another users device", func(t *testing.T) {
got, err := svc.GetDeviceForActor(ctx, actor.ID, device.ID, true)
if err != nil {
t.Fatalf("expected admin access, got %v", err)
}
if got.ID != device.ID {
t.Fatalf("expected device id %d, got %d", device.ID, got.ID)
}
})
}
func TestDeviceService_GetUserDevices(t *testing.T) {
svc, _ := setupDeviceTestEnv(t)
ctx := context.Background()

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"unicode/utf8"
@@ -103,32 +104,101 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
if strings.TrimSpace(newPassword) == "" {
return errors.New("新密码不能为空")
}
return s.applyNewPassword(ctx, user, newPassword)
/*
if err := validatePasswordStrength(newPassword, 8, false); err != nil {
return err
}
// 检查密码历史(需要明文密码比对,必须在哈希之前)
if s.passwordHistoryRepo != nil {
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, userID, passwordHistoryLimit)
if err == nil && len(histories) > 0 {
for _, h := range histories {
if auth.VerifyPassword(h.PasswordHash, newPassword) {
return errors.New("新密码不能与最近5次密码相同")
}
}
}
}
// 计算一次哈希,用于更新密码和保存历史(避免 Argon2id 重复计算的高成本)
newHashedPassword, hashErr := auth.HashPassword(newPassword)
if hashErr != nil {
return errors.New("密码哈希失败")
}
// 保存新密码到历史记录(异步,不阻塞密码更新)
if s.passwordHistoryRepo != nil {
// #nosec G118 - 使用带超时的独立 context不能使用请求 ctx该 goroutine 在请求完成后仍可能运行)
go func(hashedPw string) { // #nosec G118
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
UserID: userID,
PasswordHash: hashedPw,
})
_ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit)
}(newHashedPassword)
}
// 更新密码(使用同一哈希值)
user.Password = newHashedPassword
user.PasswordChangedAt = time.Now()
return s.userRepo.Update(ctx, user)
*/
}
// GetByID 根据ID获取用户
// AdminResetPassword resets a user's password without requiring the old password.
func (s *UserService) AdminResetPassword(ctx context.Context, userID int64, newPassword string) error {
if s.userRepo == nil {
return errors.New("user repository is not configured")
}
user, err := s.userRepo.GetByID(ctx, userID)
if err != nil {
return errors.New("user not found")
}
return s.applyNewPassword(ctx, user, newPassword)
}
func (s *UserService) applyNewPassword(ctx context.Context, user *domain.User, newPassword string) error {
if user == nil {
return errors.New("user not found")
}
if strings.TrimSpace(newPassword) == "" {
return errors.New("new password is required")
}
if err := validatePasswordStrength(newPassword, 8, false); err != nil {
return err
}
// 检查密码历史(需要明文密码比对,必须在哈希之前)
if s.passwordHistoryRepo != nil {
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, userID, passwordHistoryLimit)
histories, err := s.passwordHistoryRepo.GetByUserID(ctx, user.ID, passwordHistoryLimit)
if err == nil && len(histories) > 0 {
for _, h := range histories {
if auth.VerifyPassword(h.PasswordHash, newPassword) {
return errors.New("新密码不能与最近5次密码相同")
return errors.New("new password cannot reuse recent password history")
}
}
}
}
// 计算一次哈希,用于更新密码和保存历史(避免 Argon2id 重复计算的高成本)
newHashedPassword, hashErr := auth.HashPassword(newPassword)
if hashErr != nil {
return errors.New("密码哈希失败")
return errors.New("password hashing failed")
}
// 保存新密码到历史记录(异步,不阻塞密码更新)
if s.passwordHistoryRepo != nil {
// #nosec G118 - 使用带超时的独立 context不能使用请求 ctx该 goroutine 在请求完成后仍可能运行)
go func(hashedPw string) { // #nosec G118
go func(userID int64, hashedPw string) { // #nosec G118
defer func() {
if r := recover(); r != nil {
log.Printf("user_service: password history save panic recovered, user_id=%d err=%v", userID, r)
}
}()
bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.passwordHistoryRepo.Create(bgCtx, &domain.PasswordHistory{
@@ -136,16 +206,14 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
PasswordHash: hashedPw,
})
_ = s.passwordHistoryRepo.DeleteOldRecords(bgCtx, userID, passwordHistoryLimit)
}(newHashedPassword)
}(user.ID, newHashedPassword)
}
// 更新密码(使用同一哈希值)
user.Password = newHashedPassword
user.PasswordChangedAt = time.Now()
return s.userRepo.Update(ctx, user)
}
// GetByID 根据ID获取用户
func (s *UserService) GetByID(ctx context.Context, id int64) (*domain.User, error) {
return s.userRepo.GetByID(ctx, id)
}
@@ -357,10 +425,23 @@ func (s *UserService) AssignRoles(ctx context.Context, userID int64, roleIDs []i
return err
}
// 验证所有角色存在(预先验证,避免在事务内做不必要的查询
for _, roleID := range roleIDs {
if _, err := s.roleRepo.GetByID(ctx, roleID); err != nil {
return fmt.Errorf("角色 %d 不存在", roleID)
// 验证所有角色存在(批量查询消除 N+1
if len(roleIDs) > 0 {
foundRoles, err := s.roleRepo.GetByIDs(ctx, roleIDs)
if err != nil {
return fmt.Errorf("验证角色失败: %w", err)
}
if len(foundRoles) != len(roleIDs) {
// 找出缺失的角色ID
foundMap := make(map[int64]bool, len(foundRoles))
for _, r := range foundRoles {
foundMap[r.ID] = true
}
for _, id := range roleIDs {
if !foundMap[id] {
return fmt.Errorf("角色 %d 不存在", id)
}
}
}
}

View File

@@ -341,6 +341,44 @@ func TestUserService_ChangePassword(t *testing.T) {
})
}
func TestUserService_AdminResetPassword(t *testing.T) {
env := setupAuthTestEnv(t)
if env == nil {
return
}
ctx := context.Background()
t.Run("Admin reset password success", func(t *testing.T) {
hashedPassword, _ := auth.HashPassword("OldPassword123!")
user := &domain.User{
Username: "adminresetpwd",
Password: hashedPassword,
Status: domain.UserStatusActive,
}
env.userSvc.Create(ctx, user)
err := env.userSvc.AdminResetPassword(ctx, user.ID, "ResetPassword456!")
if err != nil {
t.Fatalf("AdminResetPassword failed: %v", err)
}
updated, _ := env.userSvc.GetByID(ctx, user.ID)
if !auth.VerifyPassword(updated.Password, "ResetPassword456!") {
t.Error("reset password verification failed")
}
if auth.VerifyPassword(updated.Password, "OldPassword123!") {
t.Error("old password should no longer work after admin reset")
}
})
t.Run("Admin reset password for non-existent user", func(t *testing.T) {
err := env.userSvc.AdminResetPassword(ctx, 99999, "ResetPassword456!")
if err == nil {
t.Error("Expected error for non-existent user")
}
})
}
func TestUserService_BatchUpdateStatus(t *testing.T) {
env := setupAuthTestEnv(t)
if env == nil {