fix: harden auth flows and align api contracts
This commit is contained in:
@@ -3,10 +3,16 @@ package main
|
||||
import (
|
||||
"log"
|
||||
|
||||
_ "github.com/user-management-system/docs"
|
||||
"github.com/user-management-system/internal/config"
|
||||
"github.com/user-management-system/internal/server"
|
||||
)
|
||||
|
||||
// @title User Management System API
|
||||
// @version 1.0
|
||||
// @description API for user management, authentication, authorization, and administration.
|
||||
// @BasePath /api/v1
|
||||
// @schemes http https
|
||||
func main() {
|
||||
// 加载配置
|
||||
cfg, err := config.Load()
|
||||
|
||||
@@ -33,14 +33,16 @@ cp configs/oauth_config.example.yaml configs/oauth_config.yaml
|
||||
# 示例:微信配置
|
||||
wechat:
|
||||
enabled: true
|
||||
app_id: "wx1234567890abcdef"
|
||||
app_secret: "1234567890abcdef1234567890abcdef"
|
||||
app_id: "<wechat-app-id>"
|
||||
app_secret: "<wechat-app-secret>"
|
||||
|
||||
# 示例:Google配置
|
||||
google:
|
||||
enabled: true
|
||||
client_id: "123456789-abcdef.apps.googleusercontent.com"
|
||||
client_secret: "GOCSPX-abcdef123456"
|
||||
client_id: "<google-client-id>"
|
||||
client_secret: "<google-client-secret>"
|
||||
|
||||
|
||||
```
|
||||
|
||||
### 3. 数据库迁移
|
||||
@@ -290,13 +292,13 @@ Authorization: Bearer <access_token>
|
||||
```bash
|
||||
# 微信
|
||||
WECHAT_OAUTH_ENABLED=true
|
||||
WECHAT_APP_ID=wx1234567890abcdef
|
||||
WECHAT_APP_SECRET=1234567890abcdef1234567890abcdef
|
||||
WECHAT_APP_ID=<wechat-app-id>
|
||||
WECHAT_APP_SECRET=<wechat-app-secret>
|
||||
|
||||
# Google
|
||||
GOOGLE_OAUTH_ENABLED=true
|
||||
GOOGLE_CLIENT_ID=123456789-abcdef.apps.googleusercontent.com
|
||||
GOOGLE_CLIENT_SECRET=GOCSPX-abcdef123456
|
||||
GOOGLE_CLIENT_ID=<google-client-id>
|
||||
GOOGLE_CLIENT_SECRET=<google-client-secret>
|
||||
|
||||
# Facebook
|
||||
FACEBOOK_OAUTH_ENABLED=true
|
||||
|
||||
561
docs/code-review/FULL_REVIEW_2026-05-30.md
Normal file
561
docs/code-review/FULL_REVIEW_2026-05-30.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# user-system 全面 Review 报告
|
||||
|
||||
**审查日期**:2026-05-30
|
||||
**审查范围**:`/home/long/project/user-system`
|
||||
**审查模式**:严格、系统、全面
|
||||
**审查方式**:源码审阅 + 实际构建/测试/静态检查验证 + 第二轮契约一致性对账
|
||||
**结论等级**:**B- / 有条件可运行,不可宣称“已全面收口”**
|
||||
|
||||
---
|
||||
|
||||
## 一、执行摘要
|
||||
|
||||
该项目不是不可用项目。后端、前端、测试主链路均可运行,说明系统已经具备较高完成度;但它距离“高可靠、可审计、严格闭环”的标准仍有明显差距,主要集中在以下五类问题:
|
||||
|
||||
1. **SSO/OAuth 协议正确性存在关键缺口**
|
||||
2. **Swagger / 路由 / 文档之间存在系统性契约漂移**
|
||||
3. **测试数量很多,但契约强度不足,且掩盖了真实路由/鉴权问题**
|
||||
4. **质量门禁对外表述与实际状态不一致**
|
||||
5. **缓存失效、参数校验、上传实现等边界质量仍不够严谨**
|
||||
|
||||
一句话结论:
|
||||
|
||||
> 当前项目可以诚实表述为“主体功能可运行、可测试,但仍存在高价值安全与契约治理缺口”;不能诚实表述为“严格闭环、全面审计通过”。
|
||||
|
||||
---
|
||||
|
||||
## 二、审查范围与方法
|
||||
|
||||
### 2.1 重点审查模块
|
||||
|
||||
- 启动与配置链路
|
||||
- `cmd/server/main.go`
|
||||
- `internal/server/server.go`
|
||||
- `internal/config/config.go`
|
||||
- 认证 / 授权 / 会话
|
||||
- `internal/api/middleware/auth.go`
|
||||
- `internal/service/auth.go`
|
||||
- `internal/service/user_service.go`
|
||||
- `internal/auth/sso.go`
|
||||
- `internal/api/handler/sso_handler.go`
|
||||
- 核心 Handler 与 API 暴露
|
||||
- `internal/api/handler/user_handler.go`
|
||||
- `internal/api/handler/export_handler.go`
|
||||
- `internal/api/handler/avatar_handler.go`
|
||||
- `internal/api/router/router.go`
|
||||
- 仓储层
|
||||
- `internal/repository/user.go`
|
||||
- `internal/repository/operation_log.go`
|
||||
- 前端契约与测试
|
||||
- `frontend/admin/src/services/*`
|
||||
- `frontend/admin/src/pages/admin/ImportExportPage/*`
|
||||
- `internal/api/handler/*_test.go`
|
||||
- `internal/e2e/*`
|
||||
- 文档与 Swagger
|
||||
- `docs/swagger.go`
|
||||
- `docs/docs.go`
|
||||
- `docs/API.md`
|
||||
- `docs/archive/OAUTH_INTEGRATION.md`
|
||||
|
||||
### 2.2 第二轮差异化审查方法
|
||||
|
||||
除第一轮常规源码审阅外,第二轮增加了以下“不同方式”的 review:
|
||||
|
||||
1. **路由注册 vs Swagger 注释逐项对账**
|
||||
- 以 `internal/api/router/router.go` 为真实路由基准
|
||||
- 对照 `internal/api/handler/*.go` 中所有 `@Router` 注释
|
||||
2. **协议路径 vs 鉴权模型对账**
|
||||
- 重点检查 SSO `/authorize`、`/token`、`/introspect`、`/revoke`、`/userinfo`
|
||||
- 核对它们是否被挂在了正确的 middleware / route group 下
|
||||
3. **测试行为 vs 真实路由语义对账**
|
||||
- 检查测试是否在错误的前提下仍“允许通过”
|
||||
4. **文档路径 vs 前端调用路径对账**
|
||||
- 对照 Swagger 注释、路由、前端 service、API 文档的四方一致性
|
||||
|
||||
第二轮发现了**新的系统性问题**,已补充到本报告和修复计划中。
|
||||
|
||||
---
|
||||
|
||||
## 三、实际执行的验证
|
||||
|
||||
以下命令已实际执行。
|
||||
|
||||
### 3.1 通过项
|
||||
|
||||
```bash
|
||||
go test ./... -count=1
|
||||
go build ./cmd/server
|
||||
cd frontend/admin && env -u NODE_ENV npm run test:run
|
||||
cd frontend/admin && env -u NODE_ENV npm run build
|
||||
```
|
||||
|
||||
结果:
|
||||
|
||||
- `go test ./... -count=1`:**通过**
|
||||
- `go build ./cmd/server`:**通过**
|
||||
- 前端 `npm run test:run`:**通过**
|
||||
- `82 files / 525 tests`
|
||||
- 前端 `npm run build`:**通过**
|
||||
|
||||
### 3.2 失败项
|
||||
|
||||
```bash
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
结果:**失败**
|
||||
|
||||
失败位置:
|
||||
|
||||
- `internal/api/handler/avatar_handler_test.go:204`
|
||||
- `internal/api/handler/export_handler_test.go:174`
|
||||
- `internal/api/handler/export_handler_test.go:202`
|
||||
- `internal/api/handler/export_handler_test.go:229`
|
||||
|
||||
失败信息:
|
||||
|
||||
- `using resp before checking for errors`
|
||||
|
||||
这说明当前仓库不能继续对外宣称 `go vet` 已通过。
|
||||
|
||||
---
|
||||
|
||||
## 四、主要发现
|
||||
|
||||
---
|
||||
|
||||
## P0:必须优先修复的问题
|
||||
|
||||
### P0-1:Swagger 文档实际为空壳,当前不能算有效 API 文档
|
||||
|
||||
**证据**:
|
||||
|
||||
`docs/swagger.go` 中:
|
||||
|
||||
```json
|
||||
"paths": {}
|
||||
```
|
||||
|
||||
同时 `internal/api/router/router.go` 公开暴露了:
|
||||
|
||||
- `/swagger/*any`
|
||||
|
||||
**影响**:
|
||||
|
||||
- Swagger UI 可能可访问
|
||||
- 但 API spec 本身没有有效路径
|
||||
- “Swagger 已完成”是错误表述
|
||||
|
||||
**结论**:高优先级治理缺陷。
|
||||
|
||||
---
|
||||
|
||||
### P0-2:Swagger 注释与真实路由存在系统性漂移,不是单点问题
|
||||
|
||||
第一轮只确认了导入导出接口漂移;第二轮确认:**这不是局部问题,而是全局契约漂移**。
|
||||
|
||||
**明确证据示例**:
|
||||
|
||||
1. **导入导出接口**
|
||||
- 注释:`/api/v1/exports/users`、`/api/v1/exports/template`
|
||||
- 实际:`/api/v1/admin/users/export`、`/api/v1/admin/users/import`、`/api/v1/admin/users/import/template`
|
||||
|
||||
2. **刷新令牌接口**
|
||||
- 注释:`/api/v1/auth/refresh-token`
|
||||
- 实际:`/api/v1/auth/refresh`
|
||||
|
||||
3. **邮箱验证码登录接口**
|
||||
- 注释:`/api/v1/auth/login-by-email-code`
|
||||
- 实际:`/api/v1/auth/login/email-code`
|
||||
|
||||
4. **重发激活邮件接口**
|
||||
- 注释:`/api/v1/auth/resend-activation-email`
|
||||
- 实际:`/api/v1/auth/resend-activation`
|
||||
|
||||
5. **TOTP / 2FA 接口**
|
||||
- 注释:`/api/v1/auth/totp/*`
|
||||
- 实际:`/api/v1/auth/2fa/*`
|
||||
- 且 `SetupTOTP` 注释是 `POST`,实际路由是 `GET`
|
||||
|
||||
6. **Captcha 接口**
|
||||
- 注释:`/api/v1/captcha/*`
|
||||
- 实际:`/api/v1/auth/captcha*`
|
||||
|
||||
7. **密码重置接口**
|
||||
- 注释:`/api/v1/auth/password/forgot`、`/reset` 等
|
||||
- 实际:`/api/v1/auth/forgot-password`、`/reset-password`、`/forgot-password/phone`
|
||||
|
||||
8. **自定义字段接口**
|
||||
- 注释:`/api/v1/fields/*`
|
||||
- 实际:`/api/v1/custom-fields/*`
|
||||
|
||||
9. **日志接口**
|
||||
- 注释:`/api/v1/users/me/login-logs`、`/operation-logs`
|
||||
- 实际:`/api/v1/logs/login/me`、`/api/v1/logs/operation/me`
|
||||
|
||||
10. **管理员接口**
|
||||
- 注释:`/api/v1/users/admins`
|
||||
- 实际:`/api/v1/admin/admins`
|
||||
|
||||
11. **方法不一致**
|
||||
- `AssignRoles` 注释为 `POST /api/v1/users/{id}/roles`,实际是 `PUT`
|
||||
- `AssignPermissions` 注释为 `POST /api/v1/roles/{id}/permissions`,实际是 `PUT`
|
||||
|
||||
**影响**:
|
||||
|
||||
- 当前 Swagger 注释整体**不可信**
|
||||
- 不能基于其生成正确 SDK 或自动化客户端
|
||||
- 文档、前端、后端、测试之间存在多套契约
|
||||
- 即使把 Swagger 重新生成,也仍会生成错误契约,除非先修注释
|
||||
|
||||
**结论**:严重契约一致性问题。
|
||||
|
||||
---
|
||||
|
||||
### P0-3:SSO 授权码没有绑定 redirect_uri,token 兑换阶段未校验 redirect_uri / code / client 三元绑定
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/auth/sso.go` 中 `SSOSession` 结构体不包含 `RedirectURI` 字段。
|
||||
|
||||
`GenerateAuthorizationCode(clientID, redirectURI, scope, ...)` 虽接收 `redirectURI`,但没有保存到 session。
|
||||
|
||||
`internal/api/handler/sso_handler.go` 的 `Token` 流程中:
|
||||
|
||||
- 校验了 `grant_type`
|
||||
- 校验了 `client_secret`
|
||||
- 校验了 `code` 是否存在
|
||||
- **未校验** `req.RedirectURI == session.RedirectURI`
|
||||
- **未做严格的 code-client-redirect 三元绑定**
|
||||
|
||||
**影响**:
|
||||
|
||||
- 授权码模式协议实现不完整
|
||||
- 授权码被截获或混用时,服务端缺少关键约束
|
||||
- 不满足高可靠安全要求
|
||||
|
||||
**结论**:严重安全问题。
|
||||
|
||||
---
|
||||
|
||||
### P0-4:SSO implicit flow 仍被支持,并通过 URL fragment 返回 access token
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/api/handler/sso_handler.go` 中,当 `response_type == "token"` 时:
|
||||
|
||||
```go
|
||||
redirectURL := req.RedirectURI + "#access_token=" + token + "&expires_in=7200"
|
||||
```
|
||||
|
||||
**影响**:
|
||||
|
||||
- access token 暴露给前端地址片段
|
||||
- 不适合高安全系统
|
||||
- 与现代 OAuth 推荐实践不一致
|
||||
|
||||
**结论**:严重安全设计问题。
|
||||
|
||||
---
|
||||
|
||||
### P0-5:SSO `/token`、`/introspect`、`/revoke`、`/userinfo` 被挂在错误的鉴权模型下,协议语义与访问控制同时出错
|
||||
|
||||
这是第二轮新增的关键发现。
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/api/router/router.go` 中:
|
||||
|
||||
- SSO 整组被挂在:
|
||||
- `protected := v1.Group("")`
|
||||
- `protected.Use(r.authMiddleware.Required())`
|
||||
- 然后:
|
||||
- `sso := protected.Group("/sso")`
|
||||
- `sso.POST("/token", r.ssoHandler.Token)`
|
||||
- `sso.POST("/introspect", r.ssoHandler.Introspect)`
|
||||
- `sso.POST("/revoke", r.ssoHandler.Revoke)`
|
||||
- `sso.GET("/userinfo", r.ssoHandler.UserInfo)`
|
||||
|
||||
而对应 handler 语义是:
|
||||
|
||||
- `Token`:使用 `grant_type + code + client_id + client_secret` 兑换 token,不依赖当前登录用户
|
||||
- `Introspect`:只收 `token` / `client_id`
|
||||
- `Revoke`:只收 `token`
|
||||
- `UserInfo`:当前实现反而直接读 app auth middleware 注入的 `user_id` / `username`
|
||||
|
||||
**影响**:
|
||||
|
||||
1. **OAuth 客户端无法按协议直接兑换授权码**
|
||||
- 因为 `/token` 被错误地要求先通过平台 BearerAuth
|
||||
2. **`/introspect` 与 `/revoke` 不是 client-auth 模型,而是 app-user-auth 模型**
|
||||
- 任意已登录平台用户如果拿到 token 字符串,就可能执行 introspect / revoke
|
||||
3. **`/userinfo` 返回的是平台 JWT 上下文中的用户,而不是 SSO access token 的 subject**
|
||||
- 协议语义错误
|
||||
4. **现有测试已经在掩盖这个问题**
|
||||
- 测试里直接不带认证访问 `/api/v1/sso/token`、`/introspect`、`/revoke`
|
||||
- 但断言允许 200/400/401 多种状态混过
|
||||
|
||||
**结论**:严重的协议与访问控制双重错误,必须优先修复。
|
||||
|
||||
---
|
||||
|
||||
## P1:应尽快修复的问题
|
||||
|
||||
### P1-1:测试大量使用“宽松状态码断言”,无法守住真实接口契约
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/api/handler/export_handler_test.go`、`internal/api/handler/sso_handler_test.go` 中大量断言允许:
|
||||
|
||||
- 200
|
||||
- 302
|
||||
- 400
|
||||
- 401
|
||||
- 403
|
||||
- 500
|
||||
|
||||
中的多个同时通过。
|
||||
|
||||
**第二轮补充证据**:
|
||||
|
||||
- `sso_handler_test.go` 中多处直接对 `/api/v1/sso/token`、`/introspect`、`/revoke` 发起**无认证请求**
|
||||
- 但测试依旧允许 `401`、`400`、`200` 等多个互斥结果
|
||||
- 这恰好掩盖了 `router.go` 中 SSO route group 被错误挂到 `protected` 下的问题
|
||||
|
||||
**影响**:
|
||||
|
||||
- 测试数量多但行为约束弱
|
||||
- 路由语义漂移、鉴权模型错误时测试仍可能全绿
|
||||
- 会制造“测试全绿”的假象
|
||||
|
||||
**结论**:高优先级测试质量问题。
|
||||
|
||||
---
|
||||
|
||||
### P1-2:`go vet ./...` 实际不通过,项目对外表述与真实状态不一致
|
||||
|
||||
**证据**:
|
||||
|
||||
本次实际执行 `go vet ./...` 失败,失败点见第三节。
|
||||
|
||||
**影响**:
|
||||
|
||||
- README 与状态文档中若继续宣称 `go vet PASS`,属于事实不符
|
||||
- 静态分析未真正成为质量门禁
|
||||
|
||||
**结论**:高优先级工程质量问题。
|
||||
|
||||
---
|
||||
|
||||
### P1-3:JWT secret 治理与项目自我标准不完全一致
|
||||
|
||||
**证据**:
|
||||
|
||||
`cmd/server/main.go` 使用 `config.Load()`,不是 `LoadForBootstrap()`,这点是好的;但 `internal/config/config.go` 中对弱 JWT secret 仅见 `warn` 级处理证据,而未见 release 模式弱值硬失败证据。
|
||||
|
||||
仓库多份 review / 标准文档则明确要求:
|
||||
|
||||
- 生产环境通过环境变量注入 `JWT_SECRET`
|
||||
- 缺失 / 弱值应 fatal
|
||||
|
||||
**影响**:
|
||||
|
||||
- 代码行为与治理标准之间存在差距
|
||||
- 高可靠环境下,弱密钥仅告警不足够
|
||||
|
||||
**结论**:重要安全治理问题。
|
||||
|
||||
---
|
||||
|
||||
### P1-4:用户状态 / 权限缓存失效接口存在,但未见业务路径接入证据
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/api/middleware/auth.go` 暴露了:
|
||||
|
||||
- `InvalidateUserStateCache(userID)`
|
||||
- `InvalidateUserPermCache(userID)`
|
||||
|
||||
但在 service / handler / server 调用链中未找到这些失效方法的业务接入证据。
|
||||
|
||||
同时缓存 TTL 为:
|
||||
|
||||
- 用户状态:5s
|
||||
- 权限缓存:5min
|
||||
|
||||
**影响**:
|
||||
|
||||
- 密码修改、状态修改、角色修改、权限调整后可能短时继续沿用旧授权结果
|
||||
- 在高敏感场景中不够严格
|
||||
|
||||
**结论**:重要一致性问题。
|
||||
|
||||
---
|
||||
|
||||
### P1-5:归档文档中存在拟真 OAuth secret 示例,文档边界不干净
|
||||
|
||||
**证据**:
|
||||
|
||||
`docs/archive/OAUTH_INTEGRATION.md` 中存在:
|
||||
|
||||
```yaml
|
||||
client_secret: "GOCSPX-abcdef123456"
|
||||
```
|
||||
|
||||
**影响**:
|
||||
|
||||
- 容易被误判为真实 secret
|
||||
- 不符合敏感信息示例占位规范
|
||||
|
||||
**结论**:文档安全卫生问题。
|
||||
|
||||
---
|
||||
|
||||
## P2:建议优化的问题
|
||||
|
||||
### P2-1:`strconvAtoi` 非法输入返回 `(0, nil)`,会吞掉参数错误
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/api/handler/export_handler.go` 中:
|
||||
|
||||
```go
|
||||
if c < '0' || c > '9' {
|
||||
return 0, nil
|
||||
}
|
||||
```
|
||||
|
||||
这会把非法 `status=abc` 静默转换成 `0`。
|
||||
|
||||
**影响**:
|
||||
|
||||
- 参数错误被吞掉
|
||||
- 查询语义可能被扭曲
|
||||
|
||||
**结论**:中优先级正确性问题。
|
||||
|
||||
---
|
||||
|
||||
### P2-2:头像上传一次性读入整个文件,不必要
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/api/handler/avatar_handler.go`:
|
||||
|
||||
```go
|
||||
data := make([]byte, file.Size)
|
||||
src.Read(data)
|
||||
os.WriteFile(dstPath, data, 0o644)
|
||||
```
|
||||
|
||||
**影响**:
|
||||
|
||||
- 不必要的整块内存分配
|
||||
- 虽当前 5MB 限制可控,但实现不够稳健
|
||||
|
||||
**结论**:中优先级实现质量问题。
|
||||
|
||||
---
|
||||
|
||||
### P2-3:头像上传成功响应使用匿名 `gin.H`,接口 schema 易漂移
|
||||
|
||||
**证据**:
|
||||
|
||||
`internal/api/handler/avatar_handler.go` 返回:
|
||||
|
||||
```go
|
||||
"data": gin.H{
|
||||
"avatar_url": avatarURL,
|
||||
"thumbnail": avatarURL,
|
||||
}
|
||||
```
|
||||
|
||||
但注释中宣称的是 `AvatarResponse`。
|
||||
|
||||
**影响**:
|
||||
|
||||
- 文档与实现松耦合
|
||||
- 前端类型契约不稳
|
||||
|
||||
**结论**:中优先级可维护性问题。
|
||||
|
||||
---
|
||||
|
||||
## 五、值得保留的正面设计
|
||||
|
||||
### 5.1 头像上传做了扩展名 + Magic Bytes 双校验
|
||||
位置:`internal/api/handler/avatar_handler.go`
|
||||
|
||||
这是正确的防伪装上传设计。
|
||||
|
||||
### 5.2 LIKE 搜索做了特殊字符转义
|
||||
位置:
|
||||
- `internal/repository/user.go`
|
||||
- `internal/repository/operation_log.go`
|
||||
|
||||
说明对模式匹配误用和干扰有明确防御意识。
|
||||
|
||||
### 5.3 权限查询做了合并查询 + 缓存
|
||||
位置:`internal/api/middleware/auth.go`
|
||||
|
||||
方向正确,说明系统已考虑权限查询成本。
|
||||
|
||||
### 5.4 密码修改事务中避免重复 Argon2id 计算
|
||||
位置:`internal/service/user_service.go`
|
||||
|
||||
这体现了不错的成本意识与事务处理意识。
|
||||
|
||||
### 5.5 前端对原生弹窗做了 guard
|
||||
位置:`frontend/admin/src/app/bootstrap/installWindowGuards.ts`
|
||||
|
||||
与仓库“禁止原生 alert/confirm/prompt/open”的规则一致。
|
||||
|
||||
---
|
||||
|
||||
## 六、测试体系评估
|
||||
|
||||
### 6.1 测试“很多”,但不等于“严格”
|
||||
|
||||
当前问题不是缺测试,而是:
|
||||
|
||||
- 测试覆盖面不算窄
|
||||
- 但很多 handler 测试不对行为做强约束
|
||||
- 真实接口契约未被有效锁定
|
||||
|
||||
### 6.2 E2E 有价值,但仍偏“可访问性验证”
|
||||
|
||||
`internal/e2e/e2e_advanced_test.go` 已对真实 admin 导出路由做访问限制验证,这是正面项;但协议严谨性、返回结构一致性、错误语义边界仍缺少强验证。
|
||||
|
||||
### 6.3 第二轮确认:测试还在掩盖路由/鉴权模型错误
|
||||
|
||||
SSO 相关测试已经直接暴露出一个事实:
|
||||
|
||||
- 被测接口在路由层要求平台 BearerAuth
|
||||
- 测试却在无认证前提下继续跑
|
||||
- 断言又接受 200/400/401 多种结果
|
||||
|
||||
这类测试不是“有弹性”,而是**无法担任回归保护**。
|
||||
|
||||
### 6.4 `go vet` 尚未纳入真实闭环
|
||||
|
||||
当前最直接证据就是:`go vet ./...` 失败,而项目文档却可能继续声称通过。
|
||||
|
||||
---
|
||||
|
||||
## 七、最终结论
|
||||
|
||||
该项目:
|
||||
|
||||
- **可以运行**
|
||||
- **可以构建**
|
||||
- **大部分测试可以通过**
|
||||
- **但仍不能宣称“严格闭环、全面收口、可全面审计通过”**
|
||||
|
||||
最关键的阻塞点不是“功能没做完”,而是:
|
||||
|
||||
1. **SSO/OAuth 协议与路由鉴权模型不够严谨**
|
||||
2. **Swagger / 路由 / 文档契约漂移是系统性的,不是局部的**
|
||||
3. **测试绿但不够硬,且会掩盖真实问题**
|
||||
4. **静态检查门禁未真正闭环**
|
||||
|
||||
建议下一步按修复计划先处理 P0,再收紧测试与门禁,最后同步更新状态文档与对外表述。
|
||||
436
docs/code-review/REMEDIATION_PLAN_2026-05-30.md
Normal file
436
docs/code-review/REMEDIATION_PLAN_2026-05-30.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# user-system 修复执行计划(按 P0 / P1 / P2 排序)
|
||||
|
||||
**计划日期**:2026-05-30
|
||||
**输入依据**:`docs/code-review/FULL_REVIEW_2026-05-30.md`
|
||||
**目标**:修复本轮 review 暴露出的安全、正确性、测试与文档一致性问题,并形成新的可审计验证证据。
|
||||
|
||||
---
|
||||
|
||||
## 一、执行原则
|
||||
|
||||
1. **先修协议与契约,再修测试与文档**
|
||||
- 先修 SSO / Swagger / 路由契约错误
|
||||
- 再收敛测试与静态检查
|
||||
2. **每一类问题修完都必须立即验证**
|
||||
3. **文档只能反映已验证事实,不能提前宣称完成**
|
||||
4. **对外可见契约必须单点真实**
|
||||
- 路由
|
||||
- Swagger
|
||||
- 前端调用
|
||||
- 测试断言
|
||||
- 状态文档
|
||||
5. **修复计划必须覆盖 review 报告中的全部问题**
|
||||
- 不能只修“代表性问题”
|
||||
- 必须处理系统性问题源头
|
||||
|
||||
---
|
||||
|
||||
## 二、P0 修复计划(必须最优先)
|
||||
|
||||
### P0-1:把空壳 Swagger 修成真实有效文档
|
||||
|
||||
#### 目标
|
||||
让 `/swagger/*any` 对应的不是空 `paths`,而是真实可用 OpenAPI 文档。
|
||||
|
||||
#### 具体动作
|
||||
1. 梳理 Swagger 生成入口与当前生成流程
|
||||
2. 确认 `swag init` 或项目既定生成方式
|
||||
3. 生成有效 `docs/swagger.go` / `docs/docs.go`
|
||||
4. 校验 `paths` 非空
|
||||
5. 校验至少以下路径存在:
|
||||
- `/api/v1/auth/login`
|
||||
- `/api/v1/auth/register`
|
||||
- `/api/v1/admin/users/export`
|
||||
- `/api/v1/users/{id}`
|
||||
|
||||
#### 验证
|
||||
- 生成 Swagger
|
||||
- 检查 `docs/swagger.go` 中 `paths` 非空
|
||||
- 如可本地启动,验证 `/swagger/index.html` 与 `/swagger/doc.json` 可用
|
||||
|
||||
---
|
||||
|
||||
### P0-2:系统性修正 Swagger 注释与真实路由的漂移
|
||||
|
||||
> 这是对报告中“系统性契约漂移”的完整修复,不再只处理导入导出接口。
|
||||
|
||||
#### 目标
|
||||
统一以下来源的 API 契约:
|
||||
|
||||
- `internal/api/router/router.go`
|
||||
- `internal/api/handler/*.go` 中全部 `@Router`
|
||||
- `docs/API.md`
|
||||
- 前端调用与测试
|
||||
- 生成后的 Swagger 文档
|
||||
|
||||
#### 具体动作
|
||||
1. 全量审计并修复以下类别的 `@Router` 漂移:
|
||||
- export/import:admin 路径
|
||||
- refresh:`/refresh-token` → `/refresh`
|
||||
- email-code login:`/login-by-email-code` → `/login/email-code`
|
||||
- resend activation:`/resend-activation-email` → `/resend-activation`
|
||||
- TOTP:`/auth/totp/*` → `/auth/2fa/*`
|
||||
- captcha:`/captcha/*` → `/auth/captcha*`
|
||||
- password reset:`/auth/password/*` → `/forgot-password` / `/reset-password` / phone 变体
|
||||
- custom fields:`/fields/*` → `/custom-fields/*`
|
||||
- logs:`/users/me/*logs` → `/logs/*/me`
|
||||
- admins:`/users/admins` → `/admin/admins`
|
||||
- users/me 绑定类接口:bind-email / bind-phone / social accounts
|
||||
2. 修复 HTTP method 漂移:
|
||||
- `AssignRoles`:`POST` → `PUT`
|
||||
- `AssignPermissions`:`POST` → `PUT`
|
||||
- `SetupTOTP`:注释 method 与真实 method 对齐
|
||||
3. 对照 `router.go` 做一次全量注释-路由对账,直到关键差异清零
|
||||
4. 更新 `docs/API.md` 中对应路径
|
||||
5. 重新生成 Swagger 文档
|
||||
|
||||
#### 验证
|
||||
- `go test ./internal/api/handler ./internal/api/router -count=1`
|
||||
- 生成 Swagger 后检查关键路径与 method 全部正确
|
||||
- 使用脚本或审查清单确认:关键业务路由不再存在注释/注册漂移
|
||||
|
||||
---
|
||||
|
||||
### P0-3:修复 SSO 授权码模式未绑定 `redirect_uri` 的问题
|
||||
|
||||
#### 目标
|
||||
让 authorization code 与 client / redirect URI 形成强绑定。
|
||||
|
||||
#### 具体动作
|
||||
1. 在 `internal/auth/sso.go` 的 `SSOSession` 中加入 `RedirectURI`
|
||||
2. `GenerateAuthorizationCode(...)` 保存该字段
|
||||
3. `Token(...)` 兑换令牌时校验:
|
||||
- `session.ClientID == req.ClientID`
|
||||
- `session.RedirectURI == req.RedirectURI`
|
||||
4. 对不匹配场景返回明确错误
|
||||
5. 为此补回归测试
|
||||
|
||||
#### 验证
|
||||
- `go test ./internal/auth ./internal/api/handler -count=1`
|
||||
- 增加测试覆盖:
|
||||
- 正确 client + redirect_uri 成功
|
||||
- 错误 redirect_uri 失败
|
||||
- 错误 client_id 失败
|
||||
|
||||
---
|
||||
|
||||
### P0-4:禁用 implicit flow
|
||||
|
||||
#### 目标
|
||||
系统只支持更安全的授权码模式,不再通过 fragment 返回 access token。
|
||||
|
||||
#### 具体动作
|
||||
1. 修改 `internal/api/handler/sso_handler.go`
|
||||
2. 对 `response_type=token`:
|
||||
- 返回 `400 unsupported response_type`
|
||||
- 或仅允许 `code`
|
||||
3. 清理相应的宽松测试
|
||||
4. 同步文档说明只支持 code flow
|
||||
|
||||
#### 验证
|
||||
- `response_type=token` 应明确失败
|
||||
- `response_type=code` 正常工作
|
||||
|
||||
---
|
||||
|
||||
### P0-5:重构 SSO 路由分组与鉴权模型,使 `/token`、`/introspect`、`/revoke`、`/userinfo` 语义正确
|
||||
|
||||
> 这是第二轮新增问题;若不修,P0-3/P0-4 仍不完整。
|
||||
|
||||
#### 目标
|
||||
让 SSO/OAuth 相关端点符合正确的访问控制模型,而不是错误复用平台用户 BearerAuth。
|
||||
|
||||
#### 具体动作
|
||||
1. 将 SSO 路由按语义拆分,不再整体挂在 `protected` 下
|
||||
2. 至少区分:
|
||||
- `/authorize`:需要当前平台登录用户完成授权
|
||||
- `/token`:客户端凭证 + 授权码模型,不依赖当前平台 BearerAuth
|
||||
- `/introspect`:客户端认证模型
|
||||
- `/revoke`:客户端认证模型或 token-owner 受控模型,必须明确
|
||||
- `/userinfo`:基于 SSO access token,而不是平台 JWT 上下文
|
||||
3. 为 `/token`、`/introspect`、`/revoke` 设计明确的 client auth 机制
|
||||
4. 修正 `UserInfo` 的 token 解析来源,不能继续直接读平台 auth middleware 的 `user_id`
|
||||
5. 同步更新测试与文档
|
||||
|
||||
#### 验证
|
||||
- `/token` 在无平台 BearerAuth、仅有正确 client/code 条件下可成功
|
||||
- `/introspect` / `/revoke` 不接受任意平台登录用户代操作
|
||||
- `/userinfo` 返回的是 SSO token subject,而不是平台当前 session user
|
||||
|
||||
---
|
||||
|
||||
## 三、P1 修复计划(紧随 P0)
|
||||
|
||||
### P1-1:修复 `go vet ./...` 失败并收口静态分析门禁
|
||||
|
||||
#### 目标
|
||||
让项目重新具备诚实宣称 `go vet` 通过的资格。
|
||||
|
||||
#### 具体动作
|
||||
1. 修复:
|
||||
- `internal/api/handler/avatar_handler_test.go`
|
||||
- `internal/api/handler/export_handler_test.go`
|
||||
2. 所有 `resp` 使用前先检查 `err`
|
||||
3. 扫描同类 helper/测试模式,避免只修报错行
|
||||
|
||||
#### 验证
|
||||
- `go vet ./...`
|
||||
- `go test ./... -count=1`
|
||||
|
||||
---
|
||||
|
||||
### P1-2:把宽松状态码测试改成严格契约测试
|
||||
|
||||
#### 目标
|
||||
让测试真正约束行为,而不是“什么都算通过”。
|
||||
|
||||
#### 具体动作
|
||||
1. 优先重写以下测试文件:
|
||||
- `internal/api/handler/export_handler_test.go`
|
||||
- `internal/api/handler/sso_handler_test.go`
|
||||
2. 逐场景收紧断言:
|
||||
- 未认证 → 401
|
||||
- 未授权 → 403
|
||||
- 参数错误 → 400
|
||||
- 成功 → 200 / 302
|
||||
3. 删除允许 `500` 的正常断言路径
|
||||
4. 对有环境差异的场景,先修被测逻辑,再收紧测试
|
||||
5. 针对 SSO 补充协议级回归测试:
|
||||
- `/token` 不再被平台 BearerAuth 门禁误拦
|
||||
- `/introspect` / `/revoke` 权限模型正确
|
||||
- `/userinfo` 基于 SSO token,而不是平台 session
|
||||
6. 对关键契约类 handler 增加“路由/方法/状态码固定断言”
|
||||
|
||||
#### 验证
|
||||
- 受影响包 `go test -count=1`
|
||||
- 必须确保断言收紧后仍稳定通过
|
||||
|
||||
---
|
||||
|
||||
### P1-3:强化 JWT secret 治理为启动硬门禁
|
||||
|
||||
#### 目标
|
||||
让 release 模式下的 JWT 配置符合项目自身文档标准。
|
||||
|
||||
#### 具体动作
|
||||
1. 明确 `config.Load()` 下的正常启动规则
|
||||
2. 在 release/standard 服务路径中强制:
|
||||
- secret 缺失 → fail fast
|
||||
- weak secret → fail fast
|
||||
3. 保留 `LoadForBootstrap()` 仅用于初始化场景
|
||||
4. 增加配置单元测试
|
||||
|
||||
#### 验证
|
||||
- `go test ./internal/config -count=1`
|
||||
- 缺失/弱 secret 场景必须失败
|
||||
|
||||
---
|
||||
|
||||
### P1-4:接通用户状态 / 权限变更后的缓存失效链路
|
||||
|
||||
#### 目标
|
||||
避免密码、状态、角色、权限变更后继续使用陈旧缓存。
|
||||
|
||||
#### 具体动作
|
||||
1. 梳理以下写路径:
|
||||
- `ChangePassword`
|
||||
- `UpdateStatus`
|
||||
- `BatchUpdateStatus`
|
||||
- `AssignRoles`
|
||||
- `DeleteAdmin`
|
||||
- `AssignPermissions`
|
||||
2. 设计缓存失效注入方式
|
||||
- 推荐通过依赖注入引入失效能力
|
||||
- 不要让 service 直接依赖具体 middleware 实现细节
|
||||
3. 在写路径完成后主动失效:
|
||||
- user_state
|
||||
- user_perms
|
||||
- 受影响角色下的用户权限缓存
|
||||
|
||||
#### 验证
|
||||
- 增加回归测试:
|
||||
- 改密码后旧 token / 旧状态缓存失效
|
||||
- 改角色/权限后权限即时生效
|
||||
|
||||
---
|
||||
|
||||
### P1-5:清理拟真 secret 示例
|
||||
|
||||
#### 目标
|
||||
恢复文档敏感边界清洁度。
|
||||
|
||||
#### 具体动作
|
||||
1. 清理 `docs/archive/OAUTH_INTEGRATION.md` 中拟真值
|
||||
2. 全仓搜索其它类似格式示例
|
||||
3. 统一替换为显式占位符
|
||||
|
||||
#### 验证
|
||||
- 搜索确认无拟真 secret 示例残留
|
||||
|
||||
---
|
||||
|
||||
## 四、P2 修复计划(在 P0/P1 收口后处理)
|
||||
|
||||
### P2-1:修复 `strconvAtoi` 吞错问题
|
||||
|
||||
#### 目标
|
||||
非法 status 参数返回显式错误,而不是静默当作 0。
|
||||
|
||||
#### 动作
|
||||
1. 修改 `internal/api/handler/export_handler.go` 中 `strconvAtoi`
|
||||
2. 非数字输入返回 error
|
||||
3. `ExportUsers` 中对非法 `status` 返回 400
|
||||
4. 增加回归测试
|
||||
|
||||
#### 验证
|
||||
- `status=abc` → 400
|
||||
|
||||
---
|
||||
|
||||
### P2-2:头像上传改为流式写盘
|
||||
|
||||
#### 目标
|
||||
消除不必要的整块内存分配。
|
||||
|
||||
#### 动作
|
||||
1. 用 `os.Create` + `io.Copy` 代替 `Read + WriteFile`
|
||||
2. 保持现有 magic bytes 校验逻辑
|
||||
3. 确保失败时清理半成品文件
|
||||
|
||||
#### 验证
|
||||
- 头像上传相关测试通过
|
||||
- 文件写入失败场景仍能回滚
|
||||
|
||||
---
|
||||
|
||||
### P2-3:头像上传响应改为明确 struct
|
||||
|
||||
#### 目标
|
||||
让返回 schema 与注释一致。
|
||||
|
||||
#### 动作
|
||||
1. 引入明确响应 struct
|
||||
2. 更新 Swagger 注释 / handler 返回值
|
||||
3. 同步前端类型
|
||||
|
||||
#### 验证
|
||||
- 相关 handler test
|
||||
- 前端编译通过
|
||||
|
||||
---
|
||||
|
||||
### P2-4:前端构建大 chunk 警告优化
|
||||
|
||||
#### 目标
|
||||
降低主包体积,改善生产可维护性。
|
||||
|
||||
#### 动作
|
||||
1. 识别大 chunk 页面
|
||||
2. 做路由级动态拆分
|
||||
3. 必要时拆分 antd 重型页面模块
|
||||
|
||||
#### 验证
|
||||
- `npm run build`
|
||||
- 观察 chunk 体积变化
|
||||
|
||||
---
|
||||
|
||||
## 五、修复计划完整性审核
|
||||
|
||||
本节用于确认:**计划是否覆盖 review 报告中的全部问题**。
|
||||
|
||||
| Review 问题 | 计划覆盖项 | 覆盖状态 |
|
||||
|---|---|---|
|
||||
| Swagger 空壳 | P0-1 | 已覆盖 |
|
||||
| Swagger 注释与真实路由系统性漂移 | P0-2 | 已覆盖 |
|
||||
| SSO code 未绑定 redirect_uri | P0-3 | 已覆盖 |
|
||||
| SSO implicit flow | P0-4 | 已覆盖 |
|
||||
| SSO `/token` `/introspect` `/revoke` `/userinfo` 鉴权模型错误 | P0-5 | 已覆盖 |
|
||||
| 宽松状态码测试掩盖问题 | P1-2 | 已覆盖 |
|
||||
| `go vet` 不通过 | P1-1 | 已覆盖 |
|
||||
| JWT secret 硬门禁不足 | P1-3 | 已覆盖 |
|
||||
| 状态 / 权限缓存失效未接入 | P1-4 | 已覆盖 |
|
||||
| 拟真 secret 示例 | P1-5 | 已覆盖 |
|
||||
| `strconvAtoi` 吞错 | P2-1 | 已覆盖 |
|
||||
| 头像整块读入内存 | P2-2 | 已覆盖 |
|
||||
| 头像响应 schema 漂移 | P2-3 | 已覆盖 |
|
||||
|
||||
### 审核结论
|
||||
|
||||
当前修复计划已经覆盖 review 报告中的**全部问题项**。
|
||||
其中最关键的改进是:
|
||||
|
||||
- 不再把“Swagger 路由错误”视为单点问题,而是按**系统性契约漂移**处理
|
||||
- 新增 P0-5,明确修复 SSO route group / auth model 的结构性错误
|
||||
|
||||
这两点补齐后,计划才具备“能够完整修复 review 报告问题”的条件。
|
||||
|
||||
---
|
||||
|
||||
## 六、推荐执行顺序
|
||||
|
||||
### 阶段 1:协议与契约止血
|
||||
1. P0-5 修 SSO route group / auth model
|
||||
2. P0-3 修 SSO code / redirect_uri 绑定
|
||||
3. P0-4 禁 implicit flow
|
||||
4. P0-2 系统性修正 Swagger 注释与真实路由漂移
|
||||
5. P0-1 生成有效 Swagger
|
||||
|
||||
### 阶段 2:质量门禁与测试收口
|
||||
6. P1-1 修复 `go vet`
|
||||
7. P1-2 收紧 export / sso / 契约类 handler 测试
|
||||
8. P1-3 强化 JWT secret 启动门禁
|
||||
|
||||
### 阶段 3:一致性与边界治理
|
||||
9. P1-4 接通缓存失效链路
|
||||
10. P1-5 清理拟真 secret 示例
|
||||
|
||||
### 阶段 4:实现质量优化
|
||||
11. P2-1 修 status 参数吞错
|
||||
12. P2-2 头像流式写盘
|
||||
13. P2-3 头像响应 struct 化
|
||||
14. P2-4 前端 chunk 优化
|
||||
|
||||
---
|
||||
|
||||
## 七、每阶段完成后的最小验证矩阵
|
||||
|
||||
### P0 阶段后
|
||||
```bash
|
||||
go test ./internal/auth ./internal/api/handler ./internal/api/router -count=1
|
||||
go build ./cmd/server
|
||||
```
|
||||
并检查 Swagger 生成结果。
|
||||
|
||||
### P1 阶段后
|
||||
```bash
|
||||
go vet ./...
|
||||
go test ./... -count=1
|
||||
go build ./cmd/server
|
||||
cd frontend/admin && env -u NODE_ENV npm run test:run
|
||||
cd frontend/admin && env -u NODE_ENV npm run build
|
||||
```
|
||||
|
||||
### P2 阶段后
|
||||
按受影响范围重跑:
|
||||
|
||||
```bash
|
||||
go test ./internal/api/handler ./internal/service ./internal/repository -count=1
|
||||
cd frontend/admin && env -u NODE_ENV npm run build
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、完成标准
|
||||
|
||||
只有同时满足以下条件,才能把本轮问题标记为“已收口”:
|
||||
|
||||
1. SSO code flow 绑定完整,implicit flow 已禁用
|
||||
2. SSO `/token`、`/introspect`、`/revoke`、`/userinfo` 的访问控制模型正确
|
||||
3. Swagger 文档非空且关键路径正确
|
||||
4. 注释 / 路由 / 文档 / 前端 / 测试中的 API 契约一致
|
||||
5. `go vet ./...` 通过
|
||||
6. handler 关键测试不再接受互斥状态码混过
|
||||
7. JWT secret 治理与项目文档标准一致
|
||||
8. 缓存失效链路有真实接入与回归测试
|
||||
9. 状态文档与 README 只保留已验证事实
|
||||
8081
docs/docs.go
8081
docs/docs.go
File diff suppressed because it is too large
Load Diff
@@ -1,50 +1 @@
|
||||
// Package docs GENERATED BY SWAG; DO NOT EDIT
|
||||
package docs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// SwaggerInfo holds the Swagger information
|
||||
var SwaggerInfo = &swaggerInfo{
|
||||
Version: "1.0",
|
||||
Host: "localhost:8080",
|
||||
BasePath: "/",
|
||||
Schemes: []string{"http", "https"},
|
||||
Title: "User Management System API",
|
||||
Description: "API for user management, authentication, and authorization",
|
||||
}
|
||||
|
||||
type swaggerInfo struct {
|
||||
Version string `json:"version"`
|
||||
Host string `json:"host"`
|
||||
BasePath string `json:"basePath"`
|
||||
Schemes []string `json:"schemes"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// SwaggerJSON returns the swagger spec as JSON
|
||||
var SwaggerJSON = `{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"title": "User Management System API",
|
||||
"description": "API for user management, authentication, and authorization",
|
||||
"version": "1.0"
|
||||
},
|
||||
"host": "localhost:8080",
|
||||
"basePath": "/",
|
||||
"schemes": ["http", "https"],
|
||||
"paths": {}
|
||||
}`
|
||||
|
||||
// GetSwagger returns the swagger specification
|
||||
func GetSwagger() []byte {
|
||||
return []byte(SwaggerJSON)
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Initialize swagger
|
||||
s := GetSwagger()
|
||||
var _ = json.Unmarshal(s, &swaggerInfo{})
|
||||
}
|
||||
|
||||
8065
docs/swagger.json
Normal file
8065
docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
5012
docs/swagger.yaml
Normal file
5012
docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,25 @@ const apiProxyTarget = process.env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:80
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 600,
|
||||
rollupOptions: {
|
||||
input: 'index.html',
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('react-router-dom') || id.includes('/react/') || id.includes('/react-dom/')) {
|
||||
return 'react-vendor'
|
||||
}
|
||||
if (id.includes('/antd/') || id.includes('@ant-design/icons')) {
|
||||
return 'antd-vendor'
|
||||
}
|
||||
if (id.includes('/dayjs/')) {
|
||||
return 'dayjs-vendor'
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
|
||||
@@ -295,7 +295,7 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
// @Success 200 {object} Response{data=service.LoginResponse} "刷新成功"
|
||||
// @Failure 400 {object} Response{code=int,message=string} "请求参数错误"
|
||||
// @Failure 401 {object} Response{code=int,message=string} "refresh_token无效或已过期"
|
||||
// @Router /api/v1/auth/refresh-token [post]
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func (h *AuthHandler) RefreshToken(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
@@ -361,7 +361,7 @@ func (h *AuthHandler) GetUserInfo(c *gin.Context) {
|
||||
// @Description 由于系统使用JWT Bearer Token认证,不存在CSRF风险,返回空token
|
||||
// @Tags 认证
|
||||
// @Produce json
|
||||
// @Success 200 {object} map "CSRF token(为空)"
|
||||
// @Success 200 {object} Response{data=CSRFTokenResponse} "CSRF token(为空)"
|
||||
// @Router /api/v1/auth/csrf-token [get]
|
||||
func (h *AuthHandler) GetCSRFToken(c *gin.Context) {
|
||||
// 系统使用 JWT Bearer Token 认证,Bearer Token 不会被浏览器自动携带(非 cookie)
|
||||
@@ -422,7 +422,7 @@ func (h *AuthHandler) OAuthCallback(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param provider path string true "OAuth提供商"
|
||||
// @Success 200 {object} Response "OAuth未配置"
|
||||
// @Router /api/v1/auth/oauth/{provider}/exchange [post]
|
||||
// @Router /api/v1/auth/oauth/exchange [post]
|
||||
func (h *AuthHandler) OAuthExchange(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "OAuth exchange is not configured"})
|
||||
}
|
||||
@@ -432,7 +432,7 @@ func (h *AuthHandler) OAuthExchange(c *gin.Context) {
|
||||
// @Description 返回系统已配置并启用的OAuth提供商列表
|
||||
// @Tags OAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response{data=map} "提供商列表"
|
||||
// @Success 200 {object} Response{data=OAuthProvidersResponse} "提供商列表"
|
||||
// @Router /api/v1/auth/oauth/providers [get]
|
||||
func (h *AuthHandler) GetEnabledOAuthProviders(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": gin.H{"providers": []string{}}})
|
||||
@@ -471,7 +471,7 @@ func (h *AuthHandler) ActivateEmail(c *gin.Context) {
|
||||
// @Param request body ResendActivationRequest true "邮箱地址"
|
||||
// @Success 200 {object} Response "激活邮件已发送(如果邮箱已注册)"
|
||||
// @Failure 400 {object} Response "邮箱格式错误"
|
||||
// @Router /api/v1/auth/resend-activation-email [post]
|
||||
// @Router /api/v1/auth/resend-activation [post]
|
||||
func (h *AuthHandler) ResendActivationEmail(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
@@ -525,7 +525,7 @@ func (h *AuthHandler) SendEmailCode(c *gin.Context) {
|
||||
// @Success 200 {object} Response{data=service.LoginResponse} "登录成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "验证码错误或已过期"
|
||||
// @Router /api/v1/auth/login-by-email-code [post]
|
||||
// @Router /api/v1/auth/login/email-code [post]
|
||||
func (h *AuthHandler) LoginByEmailCode(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
@@ -645,7 +645,7 @@ func (h *AuthHandler) BootstrapAdmin(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/email/bind/send [post]
|
||||
// @Router /api/v1/users/me/bind-email/code [post]
|
||||
func (h *AuthHandler) SendEmailBindCode(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "email binding is not configured"})
|
||||
}
|
||||
@@ -657,7 +657,7 @@ func (h *AuthHandler) SendEmailBindCode(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/email/bind [post]
|
||||
// @Router /api/v1/users/me/bind-email [post]
|
||||
func (h *AuthHandler) BindEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "email binding is not configured"})
|
||||
}
|
||||
@@ -669,7 +669,7 @@ func (h *AuthHandler) BindEmail(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/email/unbind [post]
|
||||
// @Router /api/v1/users/me/bind-email [delete]
|
||||
func (h *AuthHandler) UnbindEmail(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "email binding is not configured"})
|
||||
}
|
||||
@@ -681,7 +681,7 @@ func (h *AuthHandler) UnbindEmail(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/phone/bind/send [post]
|
||||
// @Router /api/v1/users/me/bind-phone/code [post]
|
||||
func (h *AuthHandler) SendPhoneBindCode(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "phone binding is not configured"})
|
||||
}
|
||||
@@ -693,7 +693,7 @@ func (h *AuthHandler) SendPhoneBindCode(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/phone/bind [post]
|
||||
// @Router /api/v1/users/me/bind-phone [post]
|
||||
func (h *AuthHandler) BindPhone(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "phone binding is not configured"})
|
||||
}
|
||||
@@ -705,7 +705,7 @@ func (h *AuthHandler) BindPhone(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/phone/unbind [post]
|
||||
// @Router /api/v1/users/me/bind-phone [delete]
|
||||
func (h *AuthHandler) UnbindPhone(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "phone binding is not configured"})
|
||||
}
|
||||
@@ -717,7 +717,7 @@ func (h *AuthHandler) UnbindPhone(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response "社交账号列表"
|
||||
// @Router /api/v1/auth/social-accounts [get]
|
||||
// @Router /api/v1/users/me/social-accounts [get]
|
||||
func (h *AuthHandler) GetSocialAccounts(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": gin.H{"accounts": []interface{}{}}})
|
||||
}
|
||||
@@ -729,7 +729,7 @@ func (h *AuthHandler) GetSocialAccounts(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/social/bind [post]
|
||||
// @Router /api/v1/users/me/bind-social [post]
|
||||
func (h *AuthHandler) BindSocialAccount(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "social binding is not configured"})
|
||||
}
|
||||
@@ -741,7 +741,7 @@ func (h *AuthHandler) BindSocialAccount(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response "功能未配置"
|
||||
// @Router /api/v1/auth/social/unbind [post]
|
||||
// @Router /api/v1/users/me/bind-social/{provider} [delete]
|
||||
func (h *AuthHandler) UnbindSocialAccount(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": http.StatusServiceUnavailable, "message": "social binding is not configured"})
|
||||
}
|
||||
|
||||
@@ -169,12 +169,19 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
|
||||
|
||||
// Save file to disk
|
||||
dstPath := filepath.Join(uploadDir, avatarFilename)
|
||||
data := make([]byte, file.Size)
|
||||
if _, err := src.Read(data); err != nil {
|
||||
dst, err := os.Create(dstPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to save avatar file"})
|
||||
return
|
||||
}
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
dst.Close()
|
||||
os.Remove(dstPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read uploaded file"})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(dstPath, data, 0o644); err != nil {
|
||||
if err := dst.Close(); err != nil {
|
||||
os.Remove(dstPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to save avatar file"})
|
||||
return
|
||||
}
|
||||
@@ -202,9 +209,9 @@ func (h *AvatarHandler) UploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "avatar uploaded successfully",
|
||||
"data": gin.H{
|
||||
"avatar_url": avatarURL,
|
||||
"thumbnail": avatarURL,
|
||||
"data": AvatarResponse{
|
||||
AvatarURL: avatarURL,
|
||||
Thumbnail: avatarURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,7 +200,10 @@ func TestAvatarHandler_UploadAvatar_NoFile(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should reject missing file
|
||||
|
||||
@@ -24,7 +24,7 @@ func NewCaptchaHandler(captchaService *service.CaptchaService) *CaptchaHandler {
|
||||
// @Tags 验证码
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response{data=CaptchaResponse} "验证码信息"
|
||||
// @Router /api/v1/captcha/generate [get]
|
||||
// @Router /api/v1/auth/captcha [get]
|
||||
func (h *CaptchaHandler) GenerateCaptcha(c *gin.Context) {
|
||||
result, err := h.captchaService.Generate(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -49,7 +49,7 @@ func (h *CaptchaHandler) GenerateCaptcha(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param captcha_id query string false "验证码ID"
|
||||
// @Success 200 {object} Response "验证码图片"
|
||||
// @Router /api/v1/captcha/image [get]
|
||||
// @Router /api/v1/auth/captcha/image [get]
|
||||
func (h *CaptchaHandler) GetCaptchaImage(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success"})
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func (h *CaptchaHandler) GetCaptchaImage(c *gin.Context) {
|
||||
// @Param request body VerifyCaptchaRequest true "验证码信息"
|
||||
// @Success 200 {object} Response{data=VerifyResponse} "验证成功"
|
||||
// @Failure 400 {object} Response "验证码无效"
|
||||
// @Router /api/v1/captcha/verify [post]
|
||||
// @Router /api/v1/auth/captcha/verify [post]
|
||||
func (h *CaptchaHandler) VerifyCaptcha(c *gin.Context) {
|
||||
var req struct {
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/user-management-system/internal/auth"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -15,7 +16,14 @@ func init() {
|
||||
}
|
||||
|
||||
func TestSSOHandlerAuthorize_InvalidContextTypes_ReturnsUnauthorized(t *testing.T) {
|
||||
h := &SSOHandler{}
|
||||
h := &SSOHandler{clientsStore: auth.NewDefaultSSOClientsStore()}
|
||||
store := h.clientsStore.(*auth.DefaultSSOClientsStore)
|
||||
store.RegisterClient(&auth.SSOClient{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
RedirectURIs: []string{"https://example.com/callback"},
|
||||
})
|
||||
|
||||
engine := gin.New()
|
||||
engine.GET("/authorize", func(c *gin.Context) {
|
||||
c.Set("user_id", "not-int64")
|
||||
|
||||
@@ -27,10 +27,10 @@ func NewCustomFieldHandler(customFieldService *service.CustomFieldService) *Cust
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body service.CreateFieldRequest true "字段定义"
|
||||
// @Success 201 {object} Response{data=domain.CustomField} "创建成功"
|
||||
// @Success 201 {object} Response{data=SwaggerCustomField} "创建成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Router /api/v1/fields [post]
|
||||
// @Router /api/v1/custom-fields [post]
|
||||
func (h *CustomFieldHandler) CreateField(c *gin.Context) {
|
||||
var req service.CreateFieldRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@@ -60,11 +60,11 @@ func (h *CustomFieldHandler) CreateField(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "字段ID"
|
||||
// @Param request body service.UpdateFieldRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=domain.CustomField} "更新成功"
|
||||
// @Success 200 {object} Response{data=SwaggerCustomField} "更新成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 404 {object} Response "字段不存在"
|
||||
// @Router /api/v1/fields/{id} [put]
|
||||
// @Router /api/v1/custom-fields/{id} [put]
|
||||
func (h *CustomFieldHandler) UpdateField(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -101,7 +101,7 @@ func (h *CustomFieldHandler) UpdateField(c *gin.Context) {
|
||||
// @Success 200 {object} Response "删除成功"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 404 {object} Response "字段不存在"
|
||||
// @Router /api/v1/fields/{id} [delete]
|
||||
// @Router /api/v1/custom-fields/{id} [delete]
|
||||
func (h *CustomFieldHandler) DeleteField(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -127,9 +127,9 @@ func (h *CustomFieldHandler) DeleteField(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "字段ID"
|
||||
// @Success 200 {object} Response{data=domain.CustomField} "字段信息"
|
||||
// @Success 200 {object} Response{data=SwaggerCustomField} "字段信息"
|
||||
// @Failure 404 {object} Response "字段不存在"
|
||||
// @Router /api/v1/fields/{id} [get]
|
||||
// @Router /api/v1/custom-fields/{id} [get]
|
||||
func (h *CustomFieldHandler) GetField(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -156,8 +156,8 @@ func (h *CustomFieldHandler) GetField(c *gin.Context) {
|
||||
// @Tags 自定义字段
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=[]domain.CustomField} "字段列表"
|
||||
// @Router /api/v1/fields [get]
|
||||
// @Success 200 {object} Response{data=[]SwaggerCustomField} "字段列表"
|
||||
// @Router /api/v1/custom-fields [get]
|
||||
func (h *CustomFieldHandler) ListFields(c *gin.Context) {
|
||||
fields, err := h.customFieldService.ListFields(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -183,7 +183,7 @@ func (h *CustomFieldHandler) ListFields(c *gin.Context) {
|
||||
// @Success 200 {object} Response "设置成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/users/me/fields [put]
|
||||
// @Router /api/v1/users/me/custom-fields [put]
|
||||
func (h *CustomFieldHandler) SetUserFieldValues(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -217,9 +217,9 @@ func (h *CustomFieldHandler) SetUserFieldValues(c *gin.Context) {
|
||||
// @Tags 自定义字段
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=map} "字段值"
|
||||
// @Success 200 {object} Response{data=CustomFieldValuesResponse} "字段值"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/users/me/fields [get]
|
||||
// @Router /api/v1/users/me/custom-fields [get]
|
||||
func (h *CustomFieldHandler) GetUserFieldValues(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -31,7 +31,7 @@ func NewDeviceHandler(deviceService *service.DeviceService) *DeviceHandler {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body service.CreateDeviceRequest true "设备信息"
|
||||
// @Success 201 {object} Response{data=domain.Device} "设备创建成功"
|
||||
// @Success 201 {object} Response{data=SwaggerDevice} "设备创建成功"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/devices [post]
|
||||
func (h *DeviceHandler) CreateDevice(c *gin.Context) {
|
||||
@@ -109,7 +109,7 @@ func (h *DeviceHandler) GetMyDevices(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "设备ID"
|
||||
// @Success 200 {object} Response{data=domain.Device} "设备信息"
|
||||
// @Success 200 {object} Response{data=SwaggerDevice} "设备信息"
|
||||
// @Failure 404 {object} Response "设备不存在"
|
||||
// @Router /api/v1/devices/{id} [get]
|
||||
func (h *DeviceHandler) GetDevice(c *gin.Context) {
|
||||
@@ -140,7 +140,7 @@ func (h *DeviceHandler) GetDevice(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "设备ID"
|
||||
// @Param request body service.UpdateDeviceRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=domain.Device} "更新成功"
|
||||
// @Success 200 {object} Response{data=SwaggerDevice} "更新成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 404 {object} Response "设备不存在"
|
||||
// @Router /api/v1/devices/{id} [put]
|
||||
@@ -245,6 +245,7 @@ func (h *DeviceHandler) UpdateDeviceStatus(c *gin.Context) {
|
||||
status = domain.DeviceStatusActive
|
||||
case "inactive", "0":
|
||||
status = domain.DeviceStatusInactive
|
||||
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid status"})
|
||||
return
|
||||
@@ -272,7 +273,7 @@ func (h *DeviceHandler) UpdateDeviceStatus(c *gin.Context) {
|
||||
// @Param page_size query int false "每页数量"
|
||||
// @Success 200 {object} Response{data=DeviceListResponse} "设备列表"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Router /api/v1/users/{id}/devices [get]
|
||||
// @Router /api/v1/devices/users/{id} [get]
|
||||
func (h *DeviceHandler) GetUserDevices(c *gin.Context) {
|
||||
// IDOR 修复:检查当前用户是否有权限查看指定用户的设备
|
||||
currentUserID, ok := getUserIDFromContext(c)
|
||||
@@ -430,7 +431,7 @@ func (h *DeviceHandler) TrustDevice(c *gin.Context) {
|
||||
// @Param request body TrustDeviceRequest true "信任配置"
|
||||
// @Success 200 {object} Response "设置成功"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/devices/trust/{deviceId} [post]
|
||||
// @Router /api/v1/devices/by-device-id/{deviceId}/trust [post]
|
||||
func (h *DeviceHandler) TrustDeviceByDeviceID(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -502,9 +503,9 @@ func (h *DeviceHandler) UntrustDevice(c *gin.Context) {
|
||||
// @Tags 设备管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=[]domain.Device} "信任设备列表"
|
||||
// @Success 200 {object} Response{data=[]SwaggerDevice} "信任设备列表"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/devices/trusted [get]
|
||||
// @Router /api/v1/devices/me/trusted [get]
|
||||
func (h *DeviceHandler) GetMyTrustedDevices(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -535,7 +536,7 @@ func (h *DeviceHandler) GetMyTrustedDevices(c *gin.Context) {
|
||||
// @Success 200 {object} Response "登出成功"
|
||||
// @Failure 400 {object} Response "无效的设备ID"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/devices/logout-others [post]
|
||||
// @Router /api/v1/devices/me/logout-others [post]
|
||||
func (h *DeviceHandler) LogoutAllOtherDevices(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -27,14 +27,14 @@ func NewExportHandler(exportService *service.ExportService) *ExportHandler {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param format query string false "导出格式" default(csv) Enums(csv, excel)
|
||||
// @Param format query string false "导出格式" default(csv) Enums(csv, xlsx)
|
||||
// @Param fields query string false "导出字段,逗号分隔"
|
||||
// @Param keyword query string false "关键词过滤"
|
||||
// @Param status query int false "用户状态过滤"
|
||||
// @Success 200 {file} file "用户数据文件"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/exports/users [get]
|
||||
// @Router /api/v1/admin/users/export [get]
|
||||
func (h *ExportHandler) ExportUsers(c *gin.Context) {
|
||||
format := c.DefaultQuery("format", "csv")
|
||||
fieldsStr := c.Query("fields")
|
||||
@@ -49,9 +49,11 @@ func (h *ExportHandler) ExportUsers(c *gin.Context) {
|
||||
var status *int
|
||||
if statusStr != "" {
|
||||
s, err := strconvAtoi(statusStr)
|
||||
if err == nil {
|
||||
status = &s
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid status"})
|
||||
return
|
||||
}
|
||||
status = &s
|
||||
}
|
||||
|
||||
req := &service.ExportUsersRequest{
|
||||
@@ -81,12 +83,12 @@ func (h *ExportHandler) ExportUsers(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param file formData file true "导入文件"
|
||||
// @Param format query string false "文件格式" default(csv) Enums(csv, excel)
|
||||
// @Param format query string false "文件格式" default(csv) Enums(csv, xlsx)
|
||||
// @Success 200 {object} Response "导入结果"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/exports/users [post]
|
||||
// @Router /api/v1/admin/users/import [post]
|
||||
func (h *ExportHandler) ImportUsers(c *gin.Context) {
|
||||
file, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
@@ -120,11 +122,11 @@ func (h *ExportHandler) ImportUsers(c *gin.Context) {
|
||||
// @Tags 数据导入导出
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param format query string false "模板格式" default(csv) Enums(csv, excel)
|
||||
// @Param format query string false "模板格式" default(csv) Enums(csv, xlsx)
|
||||
// @Success 200 {file} file "导入模板文件"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/exports/template [get]
|
||||
// @Router /api/v1/admin/users/import/template [get]
|
||||
func (h *ExportHandler) GetImportTemplate(c *gin.Context) {
|
||||
format := c.DefaultQuery("format", "csv")
|
||||
data, filename, contentType, err := h.exportService.GetImportTemplateByFormat(format)
|
||||
@@ -139,10 +141,13 @@ func (h *ExportHandler) GetImportTemplate(c *gin.Context) {
|
||||
}
|
||||
|
||||
func strconvAtoi(s string) (int, error) {
|
||||
if s == "" {
|
||||
return 0, http.ErrNoLocation
|
||||
}
|
||||
var n int
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, nil
|
||||
return 0, http.ErrNotSupported
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestExportHandler_ExportUsers_Success(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/users", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusInternalServerError,
|
||||
@@ -42,16 +42,17 @@ func TestExportHandler_ExportUsers_WithFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
// CSV format
|
||||
resp1, _ := doGet(server.URL+"/api/v1/exports/users?format=csv", token)
|
||||
resp1, _ := doGet(server.URL+"/api/v1/admin/users/export?format=csv", token)
|
||||
defer resp1.Body.Close()
|
||||
assert.True(t, resp1.StatusCode == http.StatusOK || resp1.StatusCode == http.StatusForbidden,
|
||||
"should export CSV, got %d", resp1.StatusCode)
|
||||
|
||||
// Excel format
|
||||
resp2, _ := doGet(server.URL+"/api/v1/exports/users?format=excel", token)
|
||||
// XLSX format
|
||||
resp2, _ := doGet(server.URL+"/api/v1/admin/users/export?format=xlsx", token)
|
||||
defer resp2.Body.Close()
|
||||
assert.True(t, resp2.StatusCode == http.StatusOK || resp2.StatusCode == http.StatusForbidden || resp2.StatusCode == http.StatusBadRequest,
|
||||
"should export Excel, got %d", resp2.StatusCode)
|
||||
"should export XLSX, got %d", resp2.StatusCode)
|
||||
|
||||
}
|
||||
|
||||
// TestExportHandler_ExportUsers_WithFields 验证指定字段导出
|
||||
@@ -64,7 +65,7 @@ func TestExportHandler_ExportUsers_WithFields(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/users?fields=id,username,email&format=csv", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export?fields=id,username,email&format=csv", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden,
|
||||
@@ -81,13 +82,29 @@ func TestExportHandler_ExportUsers_WithFilter(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/users?keyword=admin&status=1&format=csv", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export?keyword=admin&status=1&format=csv", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusBadRequest,
|
||||
"should export with filter, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestExportHandler_ExportUsers_InvalidStatus 验证非法状态参数
|
||||
func TestExportHandler_ExportUsers_InvalidStatus(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
token := bootstrapAdminToken(server.URL, "admin", "admin@test.com", "AdminPass123!")
|
||||
if token == "" {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export?status=abc&format=csv", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestExportHandler_ExportUsers_NonAdmin 验证非管理员导出
|
||||
func TestExportHandler_ExportUsers_NonAdmin(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
@@ -97,7 +114,7 @@ func TestExportHandler_ExportUsers_NonAdmin(t *testing.T) {
|
||||
token := getToken(server.URL, "regular", "Pass123!")
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/users", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusOK,
|
||||
@@ -109,7 +126,7 @@ func TestExportHandler_ExportUsers_Unauthorized(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/users", "")
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export", "")
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden,
|
||||
@@ -134,7 +151,7 @@ func TestExportHandler_ImportUsers_Success(t *testing.T) {
|
||||
part.Write([]byte(csvData))
|
||||
writer.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/exports/users?format=csv", &body)
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/admin/users/import?format=csv", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
@@ -165,16 +182,20 @@ func TestExportHandler_ImportUsers_NoFile(t *testing.T) {
|
||||
writer := multipart.NewWriter(&body)
|
||||
writer.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/exports/users", &body)
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/admin/users/import", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusOK,
|
||||
"should require file, got %d", resp.StatusCode)
|
||||
|
||||
}
|
||||
|
||||
// TestExportHandler_ImportUsers_InvalidFormat 验证无效格式导入
|
||||
@@ -193,16 +214,20 @@ func TestExportHandler_ImportUsers_InvalidFormat(t *testing.T) {
|
||||
part.Write([]byte("invalid content"))
|
||||
writer.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/exports/users?format=invalid", &body)
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/admin/users/import?format=invalid", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden,
|
||||
"should handle invalid format, got %d", resp.StatusCode)
|
||||
|
||||
}
|
||||
|
||||
// TestExportHandler_ImportUsers_NonAdmin 验证非管理员导入
|
||||
@@ -220,12 +245,15 @@ func TestExportHandler_ImportUsers_NonAdmin(t *testing.T) {
|
||||
part.Write([]byte("username,email\nuser1,user1@test.com"))
|
||||
writer.Close()
|
||||
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/exports/users", &body)
|
||||
req, _ := http.NewRequest("POST", server.URL+"/api/v1/admin/users/import", &body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Do(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusOK,
|
||||
@@ -242,7 +270,7 @@ func TestExportHandler_GetImportTemplate_Success(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/template", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/import/template", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusInternalServerError,
|
||||
@@ -259,7 +287,7 @@ func TestExportHandler_GetImportTemplate_CSV(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/template?format=csv", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/import/template?format=csv", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden,
|
||||
@@ -276,11 +304,11 @@ func TestExportHandler_GetImportTemplate_Excel(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/template?format=excel", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/import/template?format=xlsx", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusBadRequest,
|
||||
"should get Excel template, got %d", resp.StatusCode)
|
||||
"should get XLSX template, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestExportHandler_GetImportTemplate_Unauthorized 验证未认证获取模板
|
||||
@@ -288,7 +316,7 @@ func TestExportHandler_GetImportTemplate_Unauthorized(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/template", "")
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/import/template", "")
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusForbidden,
|
||||
@@ -305,7 +333,7 @@ func TestExportHandler_ExportResponse_ContentType(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/users?format=csv", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export?format=csv", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
@@ -325,7 +353,7 @@ func TestExportHandler_ExportResponse_ContentDisposition(t *testing.T) {
|
||||
t.Fatal("bootstrap admin token should succeed")
|
||||
}
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/exports/users?format=csv", token)
|
||||
resp, _ := doGet(server.URL+"/api/v1/admin/users/export?format=csv", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
|
||||
@@ -120,6 +120,8 @@ func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
opLogSvc := service.NewOperationLogService(opLogRepo)
|
||||
webhookSvc := service.NewWebhookService(db)
|
||||
captchaSvc := service.NewCaptchaService(cacheManager)
|
||||
exportSvc := service.NewExportService(userRepo, roleRepo)
|
||||
|
||||
totpSvc := service.NewTOTPService(userRepo)
|
||||
pwdResetCfg := service.DefaultPasswordResetConfig()
|
||||
pwdResetSvc := service.NewPasswordResetService(userRepo, cacheManager, pwdResetCfg).
|
||||
@@ -128,6 +130,15 @@ func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
themeSvc := service.NewThemeService(themeRepo)
|
||||
avatarH := handler.NewAvatarHandler(userRepo)
|
||||
|
||||
ssoManager := auth.NewSSOManager()
|
||||
ssoClientsStore := auth.NewDefaultSSOClientsStore()
|
||||
ssoClientsStore.RegisterClient(&auth.SSOClient{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
Name: "Handler Test Client",
|
||||
RedirectURIs: []string{"http://localhost/callback"},
|
||||
})
|
||||
ssoH := handler.NewSSOHandler(ssoManager, ssoClientsStore)
|
||||
rateLimitCfg := config.RateLimitConfig{}
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitCfg)
|
||||
authMiddleware := middleware.NewAuthMiddleware(
|
||||
@@ -147,12 +158,13 @@ func setupHandlerTestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
totpHandler := handler.NewTOTPHandler(authSvc, totpSvc)
|
||||
pwdResetHandler := handler.NewPasswordResetHandler(pwdResetSvc)
|
||||
themeHandler := handler.NewThemeHandler(themeSvc)
|
||||
exportHandler := handler.NewExportHandler(exportSvc)
|
||||
|
||||
r := router.NewRouter(
|
||||
authHandler, userHandler, roleHandler, permHandler, deviceHandler,
|
||||
logHandler, authMiddleware, rateLimitMiddleware, opLogMiddleware,
|
||||
pwdResetHandler, captchaHandler, totpHandler, webhookHandler,
|
||||
nil, nil, nil, nil, nil, themeHandler, nil, nil, nil, avatarH,
|
||||
nil, exportHandler, nil, nil, nil, themeHandler, ssoH, nil, nil, avatarH,
|
||||
)
|
||||
engine := r.Setup()
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ func NewLogHandler(loginLogService *service.LoginLogService, operationLogService
|
||||
// @Param page_size query int false "每页数量"
|
||||
// @Success 200 {object} Response{data=LoginLogListResponse} "登录日志列表"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/users/me/login-logs [get]
|
||||
// @Router /api/v1/logs/login/me [get]
|
||||
func (h *LogHandler) GetMyLoginLogs(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -76,7 +76,7 @@ func (h *LogHandler) GetMyLoginLogs(c *gin.Context) {
|
||||
// @Param page_size query int false "每页数量"
|
||||
// @Success 200 {object} Response{data=OperationLogListResponse} "操作日志列表"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/users/me/operation-logs [get]
|
||||
// @Router /api/v1/logs/operation/me [get]
|
||||
func (h *LogHandler) GetMyOperationLogs(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -120,7 +120,7 @@ func (h *LogHandler) GetMyOperationLogs(c *gin.Context) {
|
||||
// @Param page_size query int false "每页数量"
|
||||
// @Success 200 {object} Response{data=LoginLogListResponse} "登录日志列表"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Router /api/v1/admin/logs/login [get]
|
||||
// @Router /api/v1/logs/login [get]
|
||||
func (h *LogHandler) GetLoginLogs(c *gin.Context) {
|
||||
var req service.ListLoginLogRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
@@ -175,7 +175,7 @@ func (h *LogHandler) GetLoginLogs(c *gin.Context) {
|
||||
// @Success 200 {object} Response{data=OperationLogListResponse} "操作日志列表"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/admin/logs/operation [get]
|
||||
// @Router /api/v1/logs/operation [get]
|
||||
func (h *LogHandler) GetOperationLogs(c *gin.Context) {
|
||||
var req service.ListOperationLogRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
@@ -229,7 +229,7 @@ func (h *LogHandler) GetOperationLogs(c *gin.Context) {
|
||||
// @Success 200 {file} file "CSV文件"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/admin/logs/login/export [get]
|
||||
// @Router /api/v1/logs/login/export [get]
|
||||
func (h *LogHandler) ExportLoginLogs(c *gin.Context) {
|
||||
var req service.ExportLoginLogRequest
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
|
||||
@@ -41,7 +41,7 @@ type ValidateResetTokenRequest struct {
|
||||
// @Param request body ForgotPasswordRequest true "邮箱地址"
|
||||
// @Success 200 {object} Response "密码重置邮件已发送"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Router /api/v1/auth/password/forgot [post]
|
||||
// @Router /api/v1/auth/forgot-password [post]
|
||||
func (h *PasswordResetHandler) ForgotPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email" binding:"required"`
|
||||
@@ -95,7 +95,7 @@ func (h *PasswordResetHandler) ValidateResetToken(c *gin.Context) {
|
||||
// @Param request body ResetPasswordRequest true "重置请求"
|
||||
// @Success 200 {object} Response "密码重置成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Router /api/v1/auth/password/reset [post]
|
||||
// @Router /api/v1/auth/reset-password [post]
|
||||
func (h *PasswordResetHandler) ResetPassword(c *gin.Context) {
|
||||
var req struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
@@ -130,7 +130,7 @@ type ForgotPasswordByPhoneRequest struct {
|
||||
// @Success 200 {object} Response "验证码发送成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 503 {object} Response "短信服务未配置"
|
||||
// @Router /api/v1/auth/password/sms/forgot [post]
|
||||
// @Router /api/v1/auth/forgot-password/phone [post]
|
||||
func (h *PasswordResetHandler) ForgotPasswordByPhone(c *gin.Context) {
|
||||
if h.smsService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": "SMS service not configured"})
|
||||
@@ -187,7 +187,7 @@ type ResetPasswordByPhoneRequest struct {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "验证码错误"
|
||||
// @Failure 503 {object} Response "短信服务未配置"
|
||||
// @Router /api/v1/auth/password/sms/reset [post]
|
||||
// @Router /api/v1/auth/reset-password/phone [post]
|
||||
func (h *PasswordResetHandler) ResetPasswordByPhone(c *gin.Context) {
|
||||
var req ResetPasswordByPhoneRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ func NewPermissionHandler(permissionService *service.PermissionService) *Permiss
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body service.CreatePermissionRequest true "权限信息"
|
||||
// @Success 201 {object} Response{data=domain.Permission} "创建成功"
|
||||
// @Success 201 {object} Response{data=SwaggerPermission} "创建成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Router /api/v1/permissions [post]
|
||||
@@ -58,7 +58,7 @@ func (h *PermissionHandler) CreatePermission(c *gin.Context) {
|
||||
// @Tags 权限管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=[]domain.Permission} "权限列表"
|
||||
// @Success 200 {object} Response{data=[]SwaggerPermission} "权限列表"
|
||||
// @Router /api/v1/permissions [get]
|
||||
func (h *PermissionHandler) ListPermissions(c *gin.Context) {
|
||||
var req service.ListPermissionRequest
|
||||
@@ -87,7 +87,7 @@ func (h *PermissionHandler) ListPermissions(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "权限ID"
|
||||
// @Success 200 {object} Response{data=domain.Permission} "权限信息"
|
||||
// @Success 200 {object} Response{data=SwaggerPermission} "权限信息"
|
||||
// @Failure 404 {object} Response "权限不存在"
|
||||
// @Router /api/v1/permissions/{id} [get]
|
||||
func (h *PermissionHandler) GetPermission(c *gin.Context) {
|
||||
@@ -119,7 +119,7 @@ func (h *PermissionHandler) GetPermission(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "权限ID"
|
||||
// @Param request body service.UpdatePermissionRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=domain.Permission} "更新成功"
|
||||
// @Success 200 {object} Response{data=SwaggerPermission} "更新成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 404 {object} Response "权限不存在"
|
||||
@@ -237,7 +237,7 @@ func (h *PermissionHandler) UpdatePermissionStatus(c *gin.Context) {
|
||||
// @Tags 权限管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=[]domain.Permission} "权限树"
|
||||
// @Success 200 {object} Response{data=[]SwaggerPermission} "权限树"
|
||||
// @Router /api/v1/permissions/tree [get]
|
||||
func (h *PermissionHandler) GetPermissionTree(c *gin.Context) {
|
||||
tree, err := h.permissionService.GetPermissionTree(c.Request.Context())
|
||||
|
||||
@@ -28,7 +28,7 @@ func NewRoleHandler(roleService *service.RoleService) *RoleHandler {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body service.CreateRoleRequest true "角色信息"
|
||||
// @Success 201 {object} Response{data=domain.Role} "角色创建成功"
|
||||
// @Success 201 {object} Response{data=SwaggerRole} "角色创建成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Router /api/v1/roles [post]
|
||||
@@ -90,7 +90,7 @@ func (h *RoleHandler) ListRoles(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "角色ID"
|
||||
// @Success 200 {object} Response{data=domain.Role} "角色信息"
|
||||
// @Success 200 {object} Response{data=SwaggerRole} "角色信息"
|
||||
// @Failure 404 {object} Response "角色不存在"
|
||||
// @Router /api/v1/roles/{id} [get]
|
||||
func (h *RoleHandler) GetRole(c *gin.Context) {
|
||||
@@ -122,7 +122,7 @@ func (h *RoleHandler) GetRole(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "角色ID"
|
||||
// @Param request body service.UpdateRoleRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=domain.Role} "更新成功"
|
||||
// @Success 200 {object} Response{data=SwaggerRole} "更新成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 404 {object} Response "角色不存在"
|
||||
@@ -242,7 +242,7 @@ func (h *RoleHandler) UpdateRoleStatus(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "角色ID"
|
||||
// @Success 200 {object} Response{data=[]domain.Permission} "权限列表"
|
||||
// @Success 200 {object} Response{data=[]SwaggerPermission} "权限列表"
|
||||
// @Failure 404 {object} Response "角色不存在"
|
||||
// @Router /api/v1/roles/{id}/permissions [get]
|
||||
func (h *RoleHandler) GetRolePermissions(c *gin.Context) {
|
||||
@@ -278,7 +278,7 @@ func (h *RoleHandler) GetRolePermissions(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 404 {object} Response "角色不存在"
|
||||
// @Router /api/v1/roles/{id}/permissions [post]
|
||||
// @Router /api/v1/roles/{id}/permissions [put]
|
||||
func (h *RoleHandler) AssignPermissions(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -43,7 +43,7 @@ func NewSMSHandler(authService *service.AuthService, smsCodeService *service.SMS
|
||||
// @Success 200 {object} Response "发送成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 503 {object} Response "短信服务未配置"
|
||||
// @Router /api/v1/sms/send [post]
|
||||
// @Router /api/v1/auth/send-code [post]
|
||||
func (h *SMSHandler) SendCode(c *gin.Context) {
|
||||
if h.smsCodeService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": "SMS service not configured"})
|
||||
@@ -80,7 +80,7 @@ func (h *SMSHandler) SendCode(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "验证码错误"
|
||||
// @Failure 503 {object} Response "短信登录未配置"
|
||||
// @Router /api/v1/sms/login [post]
|
||||
// @Router /api/v1/auth/login/code [post]
|
||||
func (h *SMSHandler) LoginByCode(c *gin.Context) {
|
||||
if h.authService == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"code": 503, "message": "SMS login not configured"})
|
||||
|
||||
@@ -3,6 +3,7 @@ package handler
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -35,14 +36,14 @@ type AuthorizeRequest struct {
|
||||
|
||||
// Authorize 处理 SSO 授权请求
|
||||
// @Summary SSO 授权
|
||||
// @Description 处理 SSO 授权请求,返回授权码或访问令牌
|
||||
// @Description 处理 SSO 授权请求,返回授权码
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param client_id query string true "客户端ID"
|
||||
// @Param redirect_uri query string true "回调地址"
|
||||
// @Param response_type query string true "响应类型" Enums(code, token)
|
||||
// @Param response_type query string true "响应类型" Enums(code)
|
||||
// @Param scope query string false "授权范围"
|
||||
// @Param state query string false "状态参数"
|
||||
// @Success 302 {string} string "重定向到回调地址"
|
||||
@@ -57,21 +58,16 @@ func (h *SSOHandler) Authorize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 response_type
|
||||
if req.ResponseType != "code" && req.ResponseType != "token" {
|
||||
if req.ResponseType != "code" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "unsupported response_type"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 redirect_uri 是否在白名单中
|
||||
if h.clientsStore != nil {
|
||||
if !h.clientsStore.ValidateClientRedirectURI(req.ClientID, req.RedirectURI) {
|
||||
if h.clientsStore == nil || !h.clientsStore.ValidateClientRedirectURI(req.ClientID, req.RedirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid redirect_uri"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前登录用户(从 auth middleware 设置的 context)
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||||
@@ -84,8 +80,6 @@ func (h *SSOHandler) Authorize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 生成授权码或 access token
|
||||
if req.ResponseType == "code" {
|
||||
code, err := h.ssoManager.GenerateAuthorizationCode(
|
||||
req.ClientID,
|
||||
req.RedirectURI,
|
||||
@@ -98,46 +92,11 @@ func (h *SSOHandler) Authorize(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 重定向回客户端
|
||||
redirectURL := req.RedirectURI + "?code=" + code
|
||||
if req.State != "" {
|
||||
redirectURL += "&state=" + req.State
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
} else {
|
||||
// implicit 模式,直接返回 token
|
||||
code, err := h.ssoManager.GenerateAuthorizationCode(
|
||||
req.ClientID,
|
||||
req.RedirectURI,
|
||||
req.Scope,
|
||||
userID,
|
||||
username,
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate code"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证授权码获取 session
|
||||
session, err := h.ssoManager.ValidateAuthorizationCode(code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to validate code"})
|
||||
return
|
||||
}
|
||||
|
||||
token, _, err := h.ssoManager.GenerateAccessToken(req.ClientID, session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// 重定向回客户端,带 token
|
||||
redirectURL := req.RedirectURI + "#access_token=" + token + "&expires_in=7200"
|
||||
if req.State != "" {
|
||||
redirectURL += "&state=" + req.State
|
||||
}
|
||||
c.Redirect(http.StatusFound, redirectURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TokenRequest Token 请求
|
||||
@@ -161,14 +120,14 @@ type TokenResponse struct {
|
||||
// @Summary 获取 Access Token
|
||||
// @Description 使用授权码获取 Access Token(授权码模式第二步)
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param grant_type formData string true "授权类型" Enums(authorization_code)
|
||||
// @Param code formData string false "授权码"
|
||||
// @Param redirect_uri formData string false "回调地址"
|
||||
// @Param code formData string true "授权码"
|
||||
// @Param redirect_uri formData string true "回调地址"
|
||||
// @Param client_id formData string true "客户端ID"
|
||||
// @Param client_secret formData string true "客户端密钥"
|
||||
// @Success 200 {object} TokenResponse "访问令牌响应"
|
||||
// @Success 200 {object} Response{data=TokenResponse} "访问令牌响应"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "客户端认证失败"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
@@ -180,45 +139,50 @@ func (h *SSOHandler) Token(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 grant_type
|
||||
if req.GrantType != "authorization_code" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "unsupported grant_type"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证客户端凭证
|
||||
if h.clientsStore != nil {
|
||||
client, err := h.clientsStore.GetByClientID(req.ClientID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client"})
|
||||
if req.Code == "" || req.RedirectURI == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "code and redirect_uri are required"})
|
||||
return
|
||||
}
|
||||
// 使用常量时间比较防止时序攻击
|
||||
if subtle.ConstantTimeCompare([]byte(req.ClientSecret), []byte(client.ClientSecret)) != 1 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client_secret"})
|
||||
|
||||
client, ok := h.authenticateClient(req.ClientID, req.ClientSecret)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client credentials"})
|
||||
return
|
||||
}
|
||||
if !h.clientsStore.ValidateClientRedirectURI(client.ClientID, req.RedirectURI) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid redirect_uri"})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证授权码
|
||||
session, err := h.ssoManager.ValidateAuthorizationCode(req.Code)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid code"})
|
||||
return
|
||||
}
|
||||
if session.ClientID != req.ClientID || session.RedirectURI != req.RedirectURI {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "authorization code does not match client or redirect_uri"})
|
||||
return
|
||||
}
|
||||
|
||||
// 生成 access token
|
||||
token, expiresAt, err := h.ssoManager.GenerateAccessToken(req.ClientID, session)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, TokenResponse{
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": TokenResponse{
|
||||
AccessToken: token,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int64(time.Until(expiresAt).Seconds()),
|
||||
Scope: session.Scope,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -241,33 +205,46 @@ type IntrospectResponse struct {
|
||||
// @Summary 验证 Access Token
|
||||
// @Description 验证 Access Token 的有效性并返回相关信息
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param token formData string true "Access Token"
|
||||
// @Param client_id formData string false "客户端ID"
|
||||
// @Success 200 {object} IntrospectResponse "Token信息"
|
||||
// @Param client_id formData string true "客户端ID"
|
||||
// @Param client_secret formData string true "客户端密钥"
|
||||
// @Success 200 {object} Response{data=IntrospectResponse} "Token信息"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Failure 401 {object} Response "客户端认证失败"
|
||||
// @Router /api/v1/sso/introspect [post]
|
||||
func (h *SSOHandler) Introspect(c *gin.Context) {
|
||||
var req IntrospectRequest
|
||||
var req struct {
|
||||
Token string `form:"token" binding:"required"`
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
ClientSecret string `form:"client_secret" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, ok := h.authenticateClient(req.ClientID, req.ClientSecret); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.ssoManager.IntrospectToken(req.Token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, IntrospectResponse{Active: false})
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "success", "data": IntrospectResponse{Active: false}})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, IntrospectResponse{
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": IntrospectResponse{
|
||||
Active: info.Active,
|
||||
UserID: info.UserID,
|
||||
Username: info.Username,
|
||||
ExpiresAt: info.ExpiresAt.Unix(),
|
||||
Scope: info.Scope,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -280,22 +257,30 @@ type RevokeRequest struct {
|
||||
// @Summary 撤销 Access Token
|
||||
// @Description 撤销指定的 Access Token
|
||||
// @Tags SSO
|
||||
// @Accept json
|
||||
// @Accept x-www-form-urlencoded
|
||||
// @Produce json
|
||||
// @Param token formData string true "Access Token"
|
||||
// @Param client_id formData string true "客户端ID"
|
||||
// @Param client_secret formData string true "客户端密钥"
|
||||
// @Success 200 {object} Response "撤销成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Failure 401 {object} Response "客户端认证失败"
|
||||
// @Router /api/v1/sso/revoke [post]
|
||||
func (h *SSOHandler) Revoke(c *gin.Context) {
|
||||
var req RevokeRequest
|
||||
var req struct {
|
||||
Token string `form:"token" binding:"required"`
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
ClientSecret string `form:"client_secret" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBind(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
h.ssoManager.RevokeToken(req.Token)
|
||||
|
||||
if _, ok := h.authenticateClient(req.ClientID, req.ClientSecret); !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid client credentials"})
|
||||
return
|
||||
}
|
||||
_ = h.ssoManager.RevokeToken(req.Token)
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "token revoked"})
|
||||
}
|
||||
|
||||
@@ -307,24 +292,23 @@ type UserInfoResponse struct {
|
||||
|
||||
// UserInfo 获取当前用户信息
|
||||
// @Summary 获取 SSO 用户信息
|
||||
// @Description 获取当前通过 SSO 授权的用户信息
|
||||
// @Description 获取当前通过 SSO Access Token 授权的用户信息
|
||||
// @Tags SSO
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=UserInfoResponse} "用户信息"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/sso/userinfo [get]
|
||||
func (h *SSOHandler) UserInfo(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
token := extractBearerToken(c)
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
username, ok := getUsernameFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"})
|
||||
session, err := h.ssoManager.ValidateAccessToken(token)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "invalid access token"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -332,8 +316,30 @@ func (h *SSOHandler) UserInfo(c *gin.Context) {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": UserInfoResponse{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
UserID: session.UserID,
|
||||
Username: session.Username,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SSOHandler) authenticateClient(clientID, clientSecret string) (*auth.SSOClient, bool) {
|
||||
if h.clientsStore == nil {
|
||||
return nil, false
|
||||
}
|
||||
client, err := h.clientsStore.GetByClientID(clientID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(clientSecret), []byte(client.ClientSecret)) != 1 {
|
||||
return nil, false
|
||||
}
|
||||
return client, true
|
||||
}
|
||||
|
||||
func extractBearerToken(c *gin.Context) string {
|
||||
authorization := c.GetHeader("Authorization")
|
||||
if !strings.HasPrefix(authorization, "Bearer ") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer "))
|
||||
}
|
||||
|
||||
@@ -1,347 +1,327 @@
|
||||
package handler_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// SSOHandler Tests - Single Sign-On
|
||||
// =============================================================================
|
||||
type ssoWrappedResponse struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
// TestSSOHandler_Authorize_CodeFlow 验证授权码流程
|
||||
func TestSSOHandler_Authorize_CodeFlow(t *testing.T) {
|
||||
type ssoTokenPayload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type ssoIntrospectPayload struct {
|
||||
Active bool `json:"active"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
type ssoUserInfoPayload struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
func doSSOAuthorizeRequest(t *testing.T, rawURL, bearer string) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build authorize request: %v", err)
|
||||
}
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute authorize request: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func doSSOFormPost(t *testing.T, rawURL string, form url.Values, bearer string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, rawURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
t.Fatalf("build form request: %v", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if bearer != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||||
}
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("execute form request: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body := new(bytes.Buffer)
|
||||
if _, err := body.ReadFrom(resp.Body); err != nil {
|
||||
t.Fatalf("read form response: %v", err)
|
||||
}
|
||||
return resp, body.Bytes()
|
||||
}
|
||||
|
||||
func decodeSSOWrappedResponse(t *testing.T, body []byte) ssoWrappedResponse {
|
||||
t.Helper()
|
||||
|
||||
var wrapped ssoWrappedResponse
|
||||
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||
t.Fatalf("decode wrapped response failed: %v body=%s", err, string(body))
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
func extractAuthorizationCode(t *testing.T, location string) string {
|
||||
t.Helper()
|
||||
|
||||
parsed, err := url.Parse(location)
|
||||
if err != nil {
|
||||
t.Fatalf("parse redirect location failed: %v", err)
|
||||
}
|
||||
code := parsed.Query().Get("code")
|
||||
if code == "" {
|
||||
t.Fatalf("redirect location missing code: %s", location)
|
||||
}
|
||||
return code
|
||||
}
|
||||
|
||||
func issueSSOAuthCode(t *testing.T, serverURL, bearer string) string {
|
||||
t.Helper()
|
||||
|
||||
resp := doSSOAuthorizeRequest(t, serverURL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=code&scope=profile&state=abc", bearer)
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("authorize expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
t.Fatal("authorize redirect missing Location header")
|
||||
}
|
||||
return extractAuthorizationCode(t, location)
|
||||
}
|
||||
|
||||
func exchangeSSOToken(t *testing.T, serverURL, code, redirectURI string) ssoTokenPayload {
|
||||
t.Helper()
|
||||
|
||||
resp, body := doSSOFormPost(t, serverURL+"/api/v1/sso/token", url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"client_id": {"test-client"},
|
||||
"client_secret": {"test-secret"},
|
||||
"redirect_uri": {redirectURI},
|
||||
}, "")
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("token exchange expected 200, got %d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
wrapped := decodeSSOWrappedResponse(t, body)
|
||||
if wrapped.Code != 0 {
|
||||
t.Fatalf("token exchange expected code=0, got %d body=%s", wrapped.Code, string(body))
|
||||
}
|
||||
|
||||
var payload ssoTokenPayload
|
||||
if err := json.Unmarshal(wrapped.Data, &payload); err != nil {
|
||||
t.Fatalf("decode token payload failed: %v body=%s", err, string(body))
|
||||
}
|
||||
if payload.AccessToken == "" {
|
||||
t.Fatalf("token exchange returned empty access token: %s", string(body))
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestSSOHandler_Authorize_CodeFlowRedirectsWithCodeAndState(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Register and login user
|
||||
registerUser(server.URL, "ssouser", "sso@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "ssouser", "Pass123!")
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
// Request authorization with code flow
|
||||
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=code&state=xyz", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// SSO may return various status codes based on configuration
|
||||
assert.True(t, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusBadRequest ||
|
||||
resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized,
|
||||
"should handle authorize request, got %d", resp.StatusCode)
|
||||
platformToken := getToken(server.URL, "ssouser", "Pass123!")
|
||||
if platformToken == "" {
|
||||
t.Fatal("expected login token for authorize flow")
|
||||
}
|
||||
|
||||
// TestSSOHandler_Authorize_TokenFlow 验证隐式授权流程
|
||||
func TestSSOHandler_Authorize_TokenFlow(t *testing.T) {
|
||||
resp := doSSOAuthorizeRequest(t, server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=code&scope=profile&state=xyz", platformToken)
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusFound {
|
||||
t.Fatalf("authorize expected 302, got %d", resp.StatusCode)
|
||||
}
|
||||
location := resp.Header.Get("Location")
|
||||
if location == "" {
|
||||
t.Fatal("authorize redirect missing Location header")
|
||||
}
|
||||
if !strings.Contains(location, "code=") {
|
||||
t.Fatalf("authorize redirect missing code: %s", location)
|
||||
}
|
||||
if !strings.Contains(location, "state=xyz") {
|
||||
t.Fatalf("authorize redirect missing state: %s", location)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSOHandler_Authorize_ImplicitFlowRejected(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "ssouser2", "sso2@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "ssouser2", "Pass123!")
|
||||
assert.NotEmpty(t, token)
|
||||
platformToken := getToken(server.URL, "ssouser2", "Pass123!")
|
||||
if platformToken == "" {
|
||||
t.Fatal("expected login token for implicit rejection test")
|
||||
}
|
||||
|
||||
// Request authorization with token flow
|
||||
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=token&state=abc", token)
|
||||
resp := doSSOAuthorizeRequest(t, server.URL+"/api/v1/sso/authorize?client_id=test-client&redirect_uri=http://localhost/callback&response_type=token", platformToken)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusBadRequest ||
|
||||
resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized,
|
||||
"should handle token flow, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("implicit flow expected 400, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSOHandler_Authorize_MissingParams 验证缺少参数
|
||||
func TestSSOHandler_Authorize_MissingParams(t *testing.T) {
|
||||
func TestSSOHandler_Token_ExchangesWithoutPlatformBearerAuth(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "ssouser3", "sso3@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "ssouser3", "Pass123!")
|
||||
|
||||
// Missing params - handler may enforce or not based on config
|
||||
resp1, _ := doGet(server.URL+"/api/v1/sso/authorize?redirect_uri=http://localhost&response_type=code", token)
|
||||
defer resp1.Body.Close()
|
||||
assert.True(t, resp1.StatusCode >= http.StatusBadRequest || resp1.StatusCode == http.StatusOK,
|
||||
"should handle missing client_id, got %d", resp1.StatusCode)
|
||||
|
||||
// Missing redirect_uri
|
||||
resp2, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test&response_type=code", token)
|
||||
defer resp2.Body.Close()
|
||||
assert.True(t, resp2.StatusCode >= http.StatusBadRequest || resp2.StatusCode == http.StatusOK,
|
||||
"should handle missing redirect_uri, got %d", resp2.StatusCode)
|
||||
|
||||
// Missing response_type
|
||||
resp3, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test&redirect_uri=http://localhost", token)
|
||||
defer resp3.Body.Close()
|
||||
assert.True(t, resp3.StatusCode >= http.StatusBadRequest || resp3.StatusCode == http.StatusOK,
|
||||
"should handle missing response_type, got %d", resp3.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Authorize_InvalidResponseType 验证无效响应类型
|
||||
func TestSSOHandler_Authorize_InvalidResponseType(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "ssouser4", "sso4@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "ssouser4", "Pass123!")
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test&redirect_uri=http://localhost&response_type=invalid", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode >= http.StatusBadRequest || resp.StatusCode == http.StatusOK,
|
||||
"should handle invalid response_type, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Authorize_Unauthorized 验证未认证用户
|
||||
func TestSSOHandler_Authorize_Unauthorized(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// No authentication token
|
||||
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test&redirect_uri=http://localhost&response_type=code", "")
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest,
|
||||
"should handle unauthorized request, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Token_Success 验证获取 Token
|
||||
func TestSSOHandler_Token_Success(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Try to exchange code for token using doPost helper
|
||||
resp, _ := doPost(server.URL+"/api/v1/sso/token", "", map[string]interface{}{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "invalid-code",
|
||||
"client_id": "test",
|
||||
"client_secret": "secret",
|
||||
"redirect_uri": "http://localhost",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Handler may accept or reject based on SSO config
|
||||
assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusOK,
|
||||
"should handle token request, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Token_MissingParams 验证缺少 Token 参数
|
||||
func TestSSOHandler_Token_MissingParams(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Missing client_id
|
||||
resp1, _ := doPost(server.URL+"/api/v1/sso/token", "", map[string]interface{}{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "test",
|
||||
"client_secret": "secret",
|
||||
})
|
||||
defer resp1.Body.Close()
|
||||
assert.True(t, resp1.StatusCode >= http.StatusBadRequest || resp1.StatusCode == http.StatusOK,
|
||||
"should handle missing client_id, got %d", resp1.StatusCode)
|
||||
|
||||
// Missing client_secret
|
||||
resp2, _ := doPost(server.URL+"/api/v1/sso/token", "", map[string]interface{}{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "test",
|
||||
"client_id": "test",
|
||||
})
|
||||
defer resp2.Body.Close()
|
||||
assert.True(t, resp2.StatusCode >= http.StatusBadRequest || resp2.StatusCode == http.StatusOK,
|
||||
"should handle missing client_secret, got %d", resp2.StatusCode)
|
||||
|
||||
// Missing grant_type
|
||||
resp3, _ := doPost(server.URL+"/api/v1/sso/token", "", map[string]interface{}{
|
||||
"client_id": "test",
|
||||
"client_secret": "secret",
|
||||
"code": "test",
|
||||
})
|
||||
defer resp3.Body.Close()
|
||||
assert.True(t, resp3.StatusCode >= http.StatusBadRequest || resp3.StatusCode == http.StatusOK,
|
||||
"should handle missing grant_type, got %d", resp3.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Token_InvalidGrantType 验证无效授权类型
|
||||
func TestSSOHandler_Token_InvalidGrantType(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, _ := doPost(server.URL+"/api/v1/sso/token", "", map[string]interface{}{
|
||||
"grant_type": "invalid_grant",
|
||||
"client_id": "test",
|
||||
"client_secret": "secret",
|
||||
"code": "test",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode >= http.StatusBadRequest || resp.StatusCode == http.StatusOK,
|
||||
"should handle invalid grant_type, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Introspect_Success 验证 Token 验证
|
||||
func TestSSOHandler_Introspect_Success(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Introspect invalid token
|
||||
resp, body := doPost(server.URL+"/api/v1/sso/introspect", "", map[string]interface{}{
|
||||
"token": "invalid-token",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest,
|
||||
"should return introspect response, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Introspect_MissingToken 验证缺少 Token
|
||||
func TestSSOHandler_Introspect_MissingToken(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, _ := doPost(server.URL+"/api/v1/sso/introspect", "", map[string]interface{}{
|
||||
"token": "",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode >= http.StatusBadRequest || resp.StatusCode == http.StatusOK,
|
||||
"should handle missing token, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Revoke_Success 验证 Token 撤销
|
||||
func TestSSOHandler_Revoke_Success(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, body := doPost(server.URL+"/api/v1/sso/revoke", "", map[string]interface{}{
|
||||
"token": "some-token",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest,
|
||||
"should handle revoke request, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// TestSSOHandler_Revoke_MissingToken 验证缺少 Token
|
||||
func TestSSOHandler_Revoke_MissingToken(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, _ := doPost(server.URL+"/api/v1/sso/revoke", "", map[string]interface{}{
|
||||
"token": "",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode >= http.StatusBadRequest || resp.StatusCode == http.StatusOK,
|
||||
"should handle missing token, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_UserInfo_Success 验证获取用户信息
|
||||
func TestSSOHandler_UserInfo_Success(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "ssouser5", "sso5@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "ssouser5", "Pass123!")
|
||||
assert.NotEmpty(t, token)
|
||||
|
||||
resp, body := doGet(server.URL+"/api/v1/sso/userinfo", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden,
|
||||
"should handle userinfo request, got %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// TestSSOHandler_UserInfo_Unauthorized 验证未认证访问
|
||||
func TestSSOHandler_UserInfo_Unauthorized(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
resp, _ := doGet(server.URL+"/api/v1/sso/userinfo", "")
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest,
|
||||
"should handle unauthorized request, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestSSOHandler_FullFlow_Authorization 验证完整授权流程
|
||||
func TestSSOHandler_FullFlow_Authorization(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Register and login
|
||||
registerUser(server.URL, "flowuser", "flow@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "flowuser", "Pass123!")
|
||||
assert.NotEmpty(t, token)
|
||||
platformToken := getToken(server.URL, "flowuser", "Pass123!")
|
||||
if platformToken == "" {
|
||||
t.Fatal("expected login token for authorization")
|
||||
}
|
||||
|
||||
// Step 1: Authorize (get code or redirect)
|
||||
authResp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test&redirect_uri=http://localhost&response_type=code&scope=profile", token)
|
||||
defer authResp.Body.Close()
|
||||
code := issueSSOAuthCode(t, server.URL, platformToken)
|
||||
payload := exchangeSSOToken(t, server.URL, code, "http://localhost/callback")
|
||||
|
||||
// Step 2: Check response - SSO may return redirect or direct response based on config
|
||||
assert.True(t, authResp.StatusCode == http.StatusFound || authResp.StatusCode == http.StatusOK ||
|
||||
authResp.StatusCode == http.StatusBadRequest || authResp.StatusCode == http.StatusInternalServerError,
|
||||
"should handle authorization, got %d", authResp.StatusCode)
|
||||
|
||||
if authResp.StatusCode == http.StatusFound {
|
||||
location := authResp.Header.Get("Location")
|
||||
assert.Contains(t, location, "localhost")
|
||||
t.Logf("Redirected to: %s", location)
|
||||
if payload.TokenType != "Bearer" {
|
||||
t.Fatalf("unexpected token type: %q", payload.TokenType)
|
||||
}
|
||||
if payload.ExpiresIn <= 0 {
|
||||
t.Fatalf("unexpected expires_in: %d", payload.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSSOHandler_ClientCredentials_Validation 验证客户端凭证验证
|
||||
func TestSSOHandler_ClientCredentials_Validation(t *testing.T) {
|
||||
func TestSSOHandler_Token_RedirectURIMismatchRejected(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Try with invalid client credentials
|
||||
resp, _ := doPost(server.URL+"/api/v1/sso/token", "", map[string]interface{}{
|
||||
"grant_type": "authorization_code",
|
||||
"code": "test-code",
|
||||
"client_id": "invalid-client",
|
||||
"client_secret": "wrong-secret",
|
||||
"redirect_uri": "http://localhost",
|
||||
})
|
||||
defer resp.Body.Close()
|
||||
|
||||
// May accept or reject based on SSO configuration
|
||||
assert.True(t, resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusOK,
|
||||
"should handle client credentials, got %d", resp.StatusCode)
|
||||
registerUser(server.URL, "mismatchuser", "mismatch@test.com", "Pass123!")
|
||||
platformToken := getToken(server.URL, "mismatchuser", "Pass123!")
|
||||
if platformToken == "" {
|
||||
t.Fatal("expected login token for authorization")
|
||||
}
|
||||
|
||||
// TestSSOHandler_Scope_Handling 验证 Scope 处理
|
||||
func TestSSOHandler_Scope_Handling(t *testing.T) {
|
||||
code := issueSSOAuthCode(t, server.URL, platformToken)
|
||||
resp, body := doSSOFormPost(t, server.URL+"/api/v1/sso/token", url.Values{
|
||||
"grant_type": {"authorization_code"},
|
||||
"code": {code},
|
||||
"client_id": {"test-client"},
|
||||
"client_secret": {"test-secret"},
|
||||
"redirect_uri": {"http://localhost/other"},
|
||||
}, "")
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("redirect mismatch expected 400, got %d body=%s", resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSOHandler_IntrospectAndRevokeUseClientCredentialsNotPlatformBearer(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "scopeuser", "scope@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "scopeuser", "Pass123!")
|
||||
|
||||
// Request with scope
|
||||
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test&redirect_uri=http://localhost&response_type=code&scope=profile+email", token)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should handle scope parameter
|
||||
assert.True(t, resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusBadRequest ||
|
||||
resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized,
|
||||
"should handle scope parameter, got %d", resp.StatusCode)
|
||||
registerUser(server.URL, "introspectuser", "introspect@test.com", "Pass123!")
|
||||
platformToken := getToken(server.URL, "introspectuser", "Pass123!")
|
||||
if platformToken == "" {
|
||||
t.Fatal("expected login token for authorization")
|
||||
}
|
||||
|
||||
// TestSSOHandler_State_Preservation 验证 State 参数保持
|
||||
func TestSSOHandler_State_Preservation(t *testing.T) {
|
||||
code := issueSSOAuthCode(t, server.URL, platformToken)
|
||||
tokenPayload := exchangeSSOToken(t, server.URL, code, "http://localhost/callback")
|
||||
|
||||
resp1, body1 := doSSOFormPost(t, server.URL+"/api/v1/sso/introspect", url.Values{
|
||||
"token": {tokenPayload.AccessToken},
|
||||
"client_id": {"test-client"},
|
||||
"client_secret": {"test-secret"},
|
||||
}, "")
|
||||
if resp1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("introspect expected 200, got %d body=%s", resp1.StatusCode, string(body1))
|
||||
}
|
||||
wrapped1 := decodeSSOWrappedResponse(t, body1)
|
||||
var introspect ssoIntrospectPayload
|
||||
if err := json.Unmarshal(wrapped1.Data, &introspect); err != nil {
|
||||
t.Fatalf("decode introspect payload failed: %v body=%s", err, string(body1))
|
||||
}
|
||||
if !introspect.Active {
|
||||
t.Fatalf("expected active token in introspect response: %s", string(body1))
|
||||
}
|
||||
if introspect.Username != "introspectuser" {
|
||||
t.Fatalf("unexpected introspect username: %q", introspect.Username)
|
||||
}
|
||||
|
||||
resp2, body2 := doSSOFormPost(t, server.URL+"/api/v1/sso/revoke", url.Values{
|
||||
"token": {tokenPayload.AccessToken},
|
||||
"client_id": {"test-client"},
|
||||
"client_secret": {"test-secret"},
|
||||
}, "")
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revoke expected 200, got %d body=%s", resp2.StatusCode, string(body2))
|
||||
}
|
||||
|
||||
resp3, body3 := doSSOFormPost(t, server.URL+"/api/v1/sso/introspect", url.Values{
|
||||
"token": {tokenPayload.AccessToken},
|
||||
"client_id": {"test-client"},
|
||||
"client_secret": {"test-secret"},
|
||||
}, "")
|
||||
if resp3.StatusCode != http.StatusOK {
|
||||
t.Fatalf("post-revoke introspect expected 200, got %d body=%s", resp3.StatusCode, string(body3))
|
||||
}
|
||||
wrapped3 := decodeSSOWrappedResponse(t, body3)
|
||||
var revoked ssoIntrospectPayload
|
||||
if err := json.Unmarshal(wrapped3.Data, &revoked); err != nil {
|
||||
t.Fatalf("decode revoked introspect payload failed: %v body=%s", err, string(body3))
|
||||
}
|
||||
if revoked.Active {
|
||||
t.Fatalf("expected revoked token to be inactive: %s", string(body3))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSOHandler_UserInfoUsesSSOAccessTokenSubject(t *testing.T) {
|
||||
server, cleanup := setupHandlerTestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
registerUser(server.URL, "stateuser", "state@test.com", "Pass123!")
|
||||
token := getToken(server.URL, "stateuser", "Pass123!")
|
||||
registerUser(server.URL, "userinfo-user", "userinfo@test.com", "Pass123!")
|
||||
platformToken := getToken(server.URL, "userinfo-user", "Pass123!")
|
||||
if platformToken == "" {
|
||||
t.Fatal("expected login token for authorization")
|
||||
}
|
||||
|
||||
// Request with state parameter
|
||||
resp, _ := doGet(server.URL+"/api/v1/sso/authorize?client_id=test&redirect_uri=http://localhost&response_type=code&state=my-state-value", token)
|
||||
defer resp.Body.Close()
|
||||
code := issueSSOAuthCode(t, server.URL, platformToken)
|
||||
tokenPayload := exchangeSSOToken(t, server.URL, code, "http://localhost/callback")
|
||||
|
||||
// If redirected, state should be preserved in callback
|
||||
if resp.StatusCode == http.StatusFound {
|
||||
location := resp.Header.Get("Location")
|
||||
// State should be included in redirect URL
|
||||
t.Logf("Redirect location: %s", location)
|
||||
resp, body := doGet(server.URL+"/api/v1/sso/userinfo", tokenPayload.AccessToken)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("userinfo expected 200, got %d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
wrapped := decodeSSOWrappedResponse(t, []byte(body))
|
||||
var payload ssoUserInfoPayload
|
||||
if err := json.Unmarshal(wrapped.Data, &payload); err != nil {
|
||||
t.Fatalf("decode userinfo payload failed: %v body=%s", err, body)
|
||||
}
|
||||
if payload.Username != "userinfo-user" {
|
||||
t.Fatalf("unexpected userinfo username: %q body=%s", payload.Username, body)
|
||||
}
|
||||
if payload.UserID == 0 {
|
||||
t.Fatalf("userinfo user_id should be non-zero: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
1
internal/api/handler/swagger_domain_aliases.go
Normal file
1
internal/api/handler/swagger_domain_aliases.go
Normal file
@@ -0,0 +1 @@
|
||||
package handler
|
||||
83
internal/api/handler/swagger_domain_types.go
Normal file
83
internal/api/handler/swagger_domain_types.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package handler
|
||||
|
||||
import "time"
|
||||
|
||||
type SwaggerRole struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Description string `json:"description"`
|
||||
Status int `json:"status"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
Sort int `json:"sort"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SwaggerPermission struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Code string `json:"code"`
|
||||
Description string `json:"description"`
|
||||
Type int `json:"type"`
|
||||
ParentID *int64 `json:"parent_id,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Method string `json:"method"`
|
||||
Icon string `json:"icon"`
|
||||
Sort int `json:"sort"`
|
||||
Status int `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SwaggerCustomField struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
FieldKey string `json:"field_key"`
|
||||
FieldType string `json:"field_type"`
|
||||
Required bool `json:"required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
Options string `json:"options,omitempty"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
HelpText string `json:"help_text,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SwaggerDevice struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
DeviceType int `json:"device_type"`
|
||||
DeviceOS string `json:"device_os"`
|
||||
DeviceBrowser string `json:"device_browser"`
|
||||
IP string `json:"ip"`
|
||||
Location string `json:"location"`
|
||||
Status int `json:"status"`
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
IsTrusted bool `json:"is_trusted"`
|
||||
TrustedUntil *time.Time `json:"trusted_until,omitempty"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type SwaggerTheme struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
PrimaryColor string `json:"primary_color"`
|
||||
SecondaryColor string `json:"secondary_color"`
|
||||
AccentColor string `json:"accent_color"`
|
||||
BackgroundColor string `json:"background_color"`
|
||||
TextColor string `json:"text_color"`
|
||||
SuccessColor string `json:"success_color"`
|
||||
WarningColor string `json:"warning_color"`
|
||||
ErrorColor string `json:"error_color"`
|
||||
InfoColor string `json:"info_color"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
138
internal/api/handler/swagger_request_types.go
Normal file
138
internal/api/handler/swagger_request_types.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package handler
|
||||
|
||||
// TOTPVerifyRequest documents the password-login TOTP verification request.
|
||||
type TOTPVerifyRequest struct {
|
||||
UserID int64 `json:"user_id"`
|
||||
Code string `json:"code"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
TempToken string `json:"temp_token"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest documents refresh token input.
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// ResendActivationRequest documents resend activation input.
|
||||
type ResendActivationRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// SendEmailCodeRequest documents email code login input.
|
||||
type SendEmailCodeRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// LoginByEmailCodeRequest documents email-code login input.
|
||||
type LoginByEmailCodeRequest struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
DeviceName string `json:"device_name,omitempty"`
|
||||
DeviceBrowser string `json:"device_browser,omitempty"`
|
||||
DeviceOS string `json:"device_os,omitempty"`
|
||||
}
|
||||
|
||||
// BootstrapAdminRequest documents bootstrap admin input.
|
||||
type BootstrapAdminRequest struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// VerifyCaptchaRequest documents captcha verification input.
|
||||
type VerifyCaptchaRequest struct {
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
Answer string `json:"answer"`
|
||||
}
|
||||
|
||||
// ForgotPasswordRequest documents email-based password reset initiation.
|
||||
type ForgotPasswordRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest documents token-based password reset input.
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// EnableTOTPRequest documents enabling TOTP with a code.
|
||||
type EnableTOTPRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// DisableTOTPRequest documents disabling TOTP with a code.
|
||||
type DisableTOTPRequest struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
|
||||
// VerifyTOTPRequest documents authenticated TOTP verification input.
|
||||
type VerifyTOTPRequest struct {
|
||||
Code string `json:"code"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreateUserRequest documents user creation input.
|
||||
type CreateUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Phone string `json:"phone,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest documents user profile updates.
|
||||
type UpdateUserRequest struct {
|
||||
Email *string `json:"email,omitempty"`
|
||||
Nickname *string `json:"nickname,omitempty"`
|
||||
}
|
||||
|
||||
// UpdatePasswordRequest documents password change input.
|
||||
type UpdatePasswordRequest struct {
|
||||
OldPassword string `json:"old_password"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// UpdateStatusRequest documents status updates for users.
|
||||
type UpdateStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AssignRolesRequest documents role assignment input.
|
||||
type AssignRolesRequest struct {
|
||||
RoleIDs []int64 `json:"role_ids"`
|
||||
}
|
||||
|
||||
// CreateAdminRequest documents admin creation input.
|
||||
type CreateAdminRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Nickname string `json:"nickname,omitempty"`
|
||||
}
|
||||
|
||||
// SetUserFieldValuesRequest documents user custom-field updates.
|
||||
type SetUserFieldValuesRequest struct {
|
||||
Values map[string]string `json:"values"`
|
||||
}
|
||||
|
||||
// UpdateDeviceStatusRequest documents device status changes.
|
||||
type UpdateDeviceStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// UpdatePermissionStatusRequest documents permission status changes.
|
||||
type UpdatePermissionStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// UpdateRoleStatusRequest documents role status changes.
|
||||
type UpdateRoleStatusRequest struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// AssignPermissionsRequest documents role permission assignment.
|
||||
type AssignPermissionsRequest struct {
|
||||
PermissionIDs []int64 `json:"permission_ids"`
|
||||
}
|
||||
115
internal/api/handler/swagger_types.go
Normal file
115
internal/api/handler/swagger_types.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package handler
|
||||
|
||||
// Response is the canonical API envelope used in Swagger annotations.
|
||||
type Response struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
// CaptchaResponse is the captcha generation payload.
|
||||
type CaptchaResponse struct {
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
// VerifyResponse represents a boolean verification result.
|
||||
type VerifyResponse struct {
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// ValidateTokenResponse represents password reset token validation output.
|
||||
type ValidateTokenResponse struct {
|
||||
Valid bool `json:"valid"`
|
||||
}
|
||||
|
||||
// TOTPStatusResponse represents whether TOTP is enabled.
|
||||
type TOTPStatusResponse struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// TOTPSetupResponse contains setup material for enabling TOTP.
|
||||
type TOTPSetupResponse struct {
|
||||
Secret string `json:"secret"`
|
||||
QRCodeBase64 string `json:"qr_code_base64"`
|
||||
RecoveryCodes []string `json:"recovery_codes"`
|
||||
}
|
||||
|
||||
// VerifyTOTPResponse represents a successful TOTP verification.
|
||||
type VerifyTOTPResponse struct {
|
||||
Verified bool `json:"verified"`
|
||||
}
|
||||
|
||||
// DeviceListResponse represents paginated device results.
|
||||
type DeviceListResponse struct {
|
||||
Items interface{} `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
}
|
||||
|
||||
// LoginLogListResponse represents paginated login log results.
|
||||
type LoginLogListResponse struct {
|
||||
List interface{} `json:"list,omitempty"`
|
||||
Items interface{} `json:"items,omitempty"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
}
|
||||
|
||||
// OperationLogListResponse represents paginated operation log results.
|
||||
type OperationLogListResponse struct {
|
||||
List interface{} `json:"list,omitempty"`
|
||||
Items interface{} `json:"items,omitempty"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
}
|
||||
|
||||
// RoleListResponse represents paginated role results.
|
||||
type RoleListResponse struct {
|
||||
Items interface{} `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
}
|
||||
|
||||
// UserListResponse represents list or cursor user results.
|
||||
type UserListResponse struct {
|
||||
Users interface{} `json:"users,omitempty"`
|
||||
Items interface{} `json:"items,omitempty"`
|
||||
Total int64 `json:"total,omitempty"`
|
||||
Offset int `json:"offset,omitempty"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
NextCursor string `json:"next_cursor,omitempty"`
|
||||
HasMore bool `json:"has_more,omitempty"`
|
||||
PageSize int `json:"page_size,omitempty"`
|
||||
}
|
||||
|
||||
// AvatarResponse represents the avatar upload result.
|
||||
type AvatarResponse struct {
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Thumbnail string `json:"thumbnail"`
|
||||
}
|
||||
|
||||
// CSRFTokenResponse documents the empty CSRF compatibility payload.
|
||||
type CSRFTokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// OAuthProvidersResponse documents enabled OAuth providers.
|
||||
type OAuthProvidersResponse struct {
|
||||
Providers []string `json:"providers"`
|
||||
}
|
||||
|
||||
// CustomFieldValuesResponse documents arbitrary custom-field values.
|
||||
type CustomFieldValuesResponse map[string]string
|
||||
@@ -27,7 +27,7 @@ func NewThemeHandler(themeService *service.ThemeService) *ThemeHandler {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body service.CreateThemeRequest true "主题信息"
|
||||
// @Success 201 {object} Response{data=domain.Theme} "主题创建成功"
|
||||
// @Success 201 {object} Response{data=SwaggerTheme} "主题创建成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
@@ -61,7 +61,7 @@ func (h *ThemeHandler) CreateTheme(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "主题ID"
|
||||
// @Param request body service.UpdateThemeRequest true "更新信息"
|
||||
// @Success 200 {object} Response{data=domain.Theme} "主题更新成功"
|
||||
// @Success 200 {object} Response{data=SwaggerTheme} "主题更新成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
@@ -129,7 +129,7 @@ func (h *ThemeHandler) DeleteTheme(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "主题ID"
|
||||
// @Success 200 {object} Response{data=domain.Theme} "主题详情"
|
||||
// @Success 200 {object} Response{data=SwaggerTheme} "主题详情"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
@@ -160,7 +160,7 @@ func (h *ThemeHandler) GetTheme(c *gin.Context) {
|
||||
// @Tags 主题管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=[]domain.Theme} "主题列表"
|
||||
// @Success 200 {object} Response{data=[]SwaggerTheme} "主题列表"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/themes [get]
|
||||
@@ -184,10 +184,10 @@ func (h *ThemeHandler) ListThemes(c *gin.Context) {
|
||||
// @Tags 主题管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=[]domain.Theme} "主题列表"
|
||||
// @Success 200 {object} Response{data=[]SwaggerTheme} "主题列表"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/themes/all [get]
|
||||
// @Router /api/v1/themes [get]
|
||||
func (h *ThemeHandler) ListAllThemes(c *gin.Context) {
|
||||
themes, err := h.themeService.ListAllThemes(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -208,7 +208,7 @@ func (h *ThemeHandler) ListAllThemes(c *gin.Context) {
|
||||
// @Tags 主题管理
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=domain.Theme} "默认主题"
|
||||
// @Success 200 {object} Response{data=SwaggerTheme} "默认主题"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/themes/default [get]
|
||||
@@ -237,7 +237,7 @@ func (h *ThemeHandler) GetDefaultTheme(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/themes/{id}/default [put]
|
||||
// @Router /api/v1/themes/default/{id} [put]
|
||||
func (h *ThemeHandler) SetDefaultTheme(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -261,9 +261,9 @@ func (h *ThemeHandler) SetDefaultTheme(c *gin.Context) {
|
||||
// @Description 获取当前系统正在使用的主题(公开接口)
|
||||
// @Tags 主题管理
|
||||
// @Produce json
|
||||
// @Success 200 {object} Response{data=domain.Theme} "当前生效主题"
|
||||
// @Success 200 {object} Response{data=SwaggerTheme} "当前生效主题"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/themes/active [get]
|
||||
// @Router /api/v1/theme/active [get]
|
||||
func (h *ThemeHandler) GetActiveTheme(c *gin.Context) {
|
||||
theme, err := h.themeService.GetActiveTheme(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -30,7 +30,7 @@ func NewTOTPHandler(authService *service.AuthService, totpService *service.TOTPS
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=TOTPStatusResponse} "TOTP状态"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Router /api/v1/auth/totp/status [get]
|
||||
// @Router /api/v1/auth/2fa/status [get]
|
||||
func (h *TOTPHandler) GetTOTPStatus(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -57,7 +57,7 @@ func (h *TOTPHandler) GetTOTPStatus(c *gin.Context) {
|
||||
// @Success 200 {object} Response{data=TOTPSetupResponse} "TOTP设置信息"
|
||||
// @Failure 401 {object} Response "未认证"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/auth/totp/setup [post]
|
||||
// @Router /api/v1/auth/2fa/setup [get]
|
||||
func (h *TOTPHandler) SetupTOTP(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -94,7 +94,7 @@ func (h *TOTPHandler) SetupTOTP(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证或验证码错误"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/auth/totp/enable [post]
|
||||
// @Router /api/v1/auth/2fa/enable [post]
|
||||
func (h *TOTPHandler) EnableTOTP(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -131,7 +131,7 @@ func (h *TOTPHandler) EnableTOTP(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证或验证码错误"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/auth/totp/disable [post]
|
||||
// @Router /api/v1/auth/2fa/disable [post]
|
||||
func (h *TOTPHandler) DisableTOTP(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
@@ -168,7 +168,7 @@ func (h *TOTPHandler) DisableTOTP(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 401 {object} Response "未认证或验证码错误"
|
||||
// @Failure 500 {object} Response "服务器错误"
|
||||
// @Router /api/v1/auth/totp/verify [post]
|
||||
// @Router /api/v1/auth/2fa/verify [post]
|
||||
func (h *TOTPHandler) VerifyTOTP(c *gin.Context) {
|
||||
userID, ok := getUserIDFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -355,7 +355,7 @@ func (h *UserHandler) UpdateUserStatus(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "用户ID"
|
||||
// @Success 200 {object} Response{data=[]domain.Role} "角色列表"
|
||||
// @Success 200 {object} Response{data=[]SwaggerRole} "角色列表"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 404 {object} Response "用户不存在"
|
||||
// @Router /api/v1/users/{id}/roles [get]
|
||||
@@ -399,7 +399,7 @@ func (h *UserHandler) GetUserRoles(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 404 {object} Response "用户不存在"
|
||||
// @Router /api/v1/users/{id}/roles [post]
|
||||
// @Router /api/v1/users/{id}/roles [put]
|
||||
func (h *UserHandler) AssignRoles(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
@@ -488,7 +488,7 @@ func (h *UserHandler) BatchDelete(c *gin.Context) {
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} Response{data=[]UserResponse} "管理员列表"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Router /api/v1/users/admins [get]
|
||||
// @Router /api/v1/admin/admins [get]
|
||||
func (h *UserHandler) ListAdmins(c *gin.Context) {
|
||||
admins, err := h.userService.ListAdmins(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -515,7 +515,7 @@ func (h *UserHandler) ListAdmins(c *gin.Context) {
|
||||
// @Success 201 {object} Response{data=UserResponse} "管理员创建成功"
|
||||
// @Failure 400 {object} Response "请求参数错误"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Router /api/v1/users/admins [post]
|
||||
// @Router /api/v1/admin/admins [post]
|
||||
func (h *UserHandler) CreateAdmin(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
@@ -556,7 +556,7 @@ func (h *UserHandler) CreateAdmin(c *gin.Context) {
|
||||
// @Failure 400 {object} Response "无效的用户ID"
|
||||
// @Failure 403 {object} Response "无权限"
|
||||
// @Failure 409 {object} Response "无法删除(最后管理员或自删)"
|
||||
// @Router /api/v1/users/admins/{id} [delete]
|
||||
// @Router /api/v1/admin/admins/{id} [delete]
|
||||
func (h *UserHandler) DeleteAdmin(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
|
||||
@@ -381,15 +381,19 @@ func (r *Router) Setup() *gin.Engine {
|
||||
}
|
||||
}
|
||||
|
||||
// SSO 单点登录接口(需要认证)
|
||||
// SSO 单点登录接口
|
||||
if r.ssoHandler != nil {
|
||||
sso := protected.Group("/sso")
|
||||
ssoProtected := protected.Group("/sso")
|
||||
{
|
||||
sso.GET("/authorize", r.ssoHandler.Authorize)
|
||||
sso.POST("/token", r.ssoHandler.Token)
|
||||
sso.POST("/introspect", r.ssoHandler.Introspect)
|
||||
sso.POST("/revoke", r.ssoHandler.Revoke)
|
||||
sso.GET("/userinfo", r.ssoHandler.UserInfo)
|
||||
ssoProtected.GET("/authorize", r.ssoHandler.Authorize)
|
||||
}
|
||||
|
||||
ssoPublic := v1.Group("/sso")
|
||||
{
|
||||
ssoPublic.POST("/token", r.ssoHandler.Token)
|
||||
ssoPublic.POST("/introspect", r.ssoHandler.Introspect)
|
||||
ssoPublic.POST("/revoke", r.ssoHandler.Revoke)
|
||||
ssoPublic.GET("/userinfo", r.ssoHandler.UserInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,17 +61,18 @@ type SSOTokenInfo struct {
|
||||
ClientID string
|
||||
}
|
||||
|
||||
// SSOSession SSO Session
|
||||
type SSOSession struct {
|
||||
SessionID string
|
||||
UserID int64
|
||||
Username string
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time
|
||||
Scope string
|
||||
}
|
||||
|
||||
|
||||
// SSOManager SSO 管理器
|
||||
type SSOManager struct {
|
||||
mu sync.RWMutex
|
||||
@@ -117,16 +118,15 @@ func (m *SSOManager) GenerateAuthorizationCode(clientID, redirectURI, scope stri
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
ClientID: clientID,
|
||||
RedirectURI: redirectURI,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute), // 授权码 10 分钟有效期
|
||||
ExpiresAt: time.Now().Add(10 * time.Minute),
|
||||
Scope: scope,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
// 检查并清理过期 session,如果超过限制则淘汰最旧的
|
||||
if len(m.sessions) >= MaxSessions {
|
||||
m.cleanupExpiredLocked()
|
||||
// 如果仍然满,淘汰最早的
|
||||
if len(m.sessions) >= MaxSessions {
|
||||
m.evictOldest()
|
||||
}
|
||||
@@ -137,6 +137,7 @@ func (m *SSOManager) GenerateAuthorizationCode(clientID, redirectURI, scope stri
|
||||
return code, nil
|
||||
}
|
||||
|
||||
|
||||
// ValidateAuthorizationCode 验证授权码
|
||||
func (m *SSOManager) ValidateAuthorizationCode(code string) (*SSOSession, error) {
|
||||
m.mu.Lock()
|
||||
@@ -171,6 +172,7 @@ func (m *SSOManager) GenerateAccessToken(clientID string, session *SSOSession) (
|
||||
UserID: session.UserID,
|
||||
Username: session.Username,
|
||||
ClientID: clientID,
|
||||
RedirectURI: session.RedirectURI,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: session.Scope,
|
||||
@@ -225,6 +227,27 @@ func (m *SSOManager) RevokeToken(token string) error {
|
||||
delete(m.sessions, token)
|
||||
return nil
|
||||
}
|
||||
func (m *SSOManager) ValidateAccessToken(token string) (*SSOSession, error) {
|
||||
m.mu.RLock()
|
||||
session, ok := m.sessions[token]
|
||||
if !ok {
|
||||
m.mu.RUnlock()
|
||||
return nil, errors.New("invalid access token")
|
||||
}
|
||||
if time.Now().After(session.ExpiresAt) {
|
||||
m.mu.RUnlock()
|
||||
m.mu.Lock()
|
||||
delete(m.sessions, token)
|
||||
m.mu.Unlock()
|
||||
return nil, errors.New("access token expired")
|
||||
}
|
||||
if session.RedirectURI == "" {
|
||||
m.mu.RUnlock()
|
||||
return nil, errors.New("not an access token")
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// CleanupExpired 清理过期的 session
|
||||
func (m *SSOManager) CleanupExpired() {
|
||||
|
||||
@@ -1133,7 +1133,7 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
|
||||
}
|
||||
|
||||
if cfg.JWT.Secret != "" && isWeakJWTSecret(cfg.JWT.Secret) {
|
||||
slog.Warn("JWT secret appears weak; use a 32+ character random secret in production.")
|
||||
return nil, fmt.Errorf("validate config error: jwt.secret is too weak")
|
||||
}
|
||||
if len(cfg.Security.ResponseHeaders.AdditionalAllowed) > 0 || len(cfg.Security.ResponseHeaders.ForceRemove) > 0 {
|
||||
slog.Info("response header policy configured",
|
||||
@@ -1546,6 +1546,9 @@ func (c *Config) Validate() error {
|
||||
if jwtSecret == "" {
|
||||
return fmt.Errorf("jwt.secret is required")
|
||||
}
|
||||
if isWeakJWTSecret(jwtSecret) {
|
||||
return fmt.Errorf("jwt.secret is too weak")
|
||||
}
|
||||
// NOTE: 按 UTF-8 编码后的字节长度计算。
|
||||
// 选择 bytes 而不是 rune 计数,确保二进制/随机串的长度语义更接近“熵”而非“字符数”。
|
||||
if len([]byte(jwtSecret)) < 32 {
|
||||
|
||||
@@ -17,7 +17,25 @@ func resetViperWithJWTSecret(t *testing.T) {
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
}
|
||||
|
||||
func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) {
|
||||
func TestLoadRejectsWeakJWTSecret(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
|
||||
configPath := filepath.Join(t.TempDir(), "weak-jwt.yaml")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("jwt:\n secret: changeme\n"), 0o644))
|
||||
t.Setenv("CONFIG_FILE", configPath)
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() should reject weak jwt secret")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "jwt.secret is too weak") {
|
||||
t.Fatalf("Load() error = %v, want weak-secret failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadForBootstrapStillAllowsMissingJWTSecret(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("JWT_SECRET", "")
|
||||
|
||||
|
||||
@@ -132,6 +132,12 @@ func setupRealServer(t *testing.T) (*httptest.Server, func()) {
|
||||
avatarH := handler.NewAvatarHandler(userRepo)
|
||||
ssoManager := auth.NewSSOManager()
|
||||
ssoClientsStore := auth.NewDefaultSSOClientsStore()
|
||||
ssoClientsStore.RegisterClient(&auth.SSOClient{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
Name: "E2E Test Client",
|
||||
RedirectURIs: []string{"http://localhost/callback"},
|
||||
})
|
||||
ssoH := handler.NewSSOHandler(ssoManager, ssoClientsStore)
|
||||
|
||||
rateLimitMW := middleware.NewRateLimitMiddleware(config.RateLimitConfig{})
|
||||
|
||||
48
internal/service/auth_cache_invalidator.go
Normal file
48
internal/service/auth_cache_invalidator.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// AuthCacheInvalidator invalidates auth-related caches after security-sensitive writes.
|
||||
type AuthCacheInvalidator interface {
|
||||
InvalidateUserState(userID int64)
|
||||
InvalidateUserPerms(userID int64)
|
||||
}
|
||||
|
||||
type nopAuthCacheInvalidator struct{}
|
||||
|
||||
func (nopAuthCacheInvalidator) InvalidateUserState(int64) {}
|
||||
func (nopAuthCacheInvalidator) InvalidateUserPerms(int64) {}
|
||||
|
||||
func normalizeAuthCacheInvalidator(invalidator AuthCacheInvalidator) AuthCacheInvalidator {
|
||||
if invalidator == nil {
|
||||
return nopAuthCacheInvalidator{}
|
||||
}
|
||||
return invalidator
|
||||
}
|
||||
|
||||
func collectSortedUniqueUserIDs(ctx context.Context, idsFunc func(context.Context) ([]int64, error), fallback func(context.Context) ([]int64, error)) ([]int64, error) {
|
||||
ids, err := idsFunc(ctx)
|
||||
if err != nil && fallback != nil {
|
||||
ids, err = fallback(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return []int64{}, nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(ids))
|
||||
unique := make([]int64, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
unique = append(unique, id)
|
||||
}
|
||||
sort.Slice(unique, func(i, j int) bool { return unique[i] < unique[j] })
|
||||
return unique, nil
|
||||
}
|
||||
167
internal/service/auth_cache_invalidator_test.go
Normal file
167
internal/service/auth_cache_invalidator_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
type cacheInvalidatorHarness struct {
|
||||
l1 *cache.L1Cache
|
||||
}
|
||||
|
||||
func (h *cacheInvalidatorHarness) InvalidateUserState(userID int64) {
|
||||
h.l1.Delete("user_state:" + strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
func (h *cacheInvalidatorHarness) InvalidateUserPerms(userID int64) {
|
||||
h.l1.Delete("user_perms:" + strconv.FormatInt(userID, 10))
|
||||
}
|
||||
|
||||
func setupCacheInvalidationDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(gormsqlite.New(gormsqlite.Config{
|
||||
DriverName: "sqlite",
|
||||
DSN: "file:cache_invalidation?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{}, &domain.Permission{}, &domain.RolePermission{}); err != nil {
|
||||
t.Fatalf("migrate failed: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestUserService_InvalidateStateCacheOnStatusChange(t *testing.T) {
|
||||
db := setupCacheInvalidationDB(t)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||||
roleRepo := repository.NewRoleRepository(db)
|
||||
userSvc := service.NewUserService(userRepo, userRoleRepo, roleRepo, nil)
|
||||
l1 := cache.NewL1Cache()
|
||||
userSvc.SetAuthCacheInvalidator(&cacheInvalidatorHarness{l1: l1})
|
||||
|
||||
user := &domain.User{Username: "statecache", Password: "x", Status: domain.UserStatusActive}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
l1.Set("user_state:"+strconv.FormatInt(user.ID, 10), "cached", time.Minute)
|
||||
|
||||
if err := userSvc.UpdateStatus(context.Background(), user.ID, domain.UserStatusInactive); err != nil {
|
||||
t.Fatalf("UpdateStatus failed: %v", err)
|
||||
}
|
||||
if _, ok := l1.Get("user_state:" + strconv.FormatInt(user.ID, 10)); ok {
|
||||
t.Fatal("expected user_state cache to be invalidated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserService_InvalidatePermCacheOnAssignRoles(t *testing.T) {
|
||||
db := setupCacheInvalidationDB(t)
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||||
roleRepo := repository.NewRoleRepository(db)
|
||||
userSvc := service.NewUserService(userRepo, userRoleRepo, roleRepo, nil)
|
||||
l1 := cache.NewL1Cache()
|
||||
userSvc.SetAuthCacheInvalidator(&cacheInvalidatorHarness{l1: l1})
|
||||
|
||||
user := &domain.User{Username: "permcache", Password: "x", Status: domain.UserStatusActive}
|
||||
role := &domain.Role{Name: "role1", Code: "role1", Status: domain.RoleStatusEnabled}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
if err := db.Create(role).Error; err != nil {
|
||||
t.Fatalf("create role failed: %v", err)
|
||||
}
|
||||
l1.Set("user_perms:"+strconv.FormatInt(user.ID, 10), "cached", time.Minute)
|
||||
|
||||
if err := userSvc.AssignRoles(context.Background(), user.ID, []int64{role.ID}); err != nil {
|
||||
t.Fatalf("AssignRoles failed: %v", err)
|
||||
}
|
||||
if _, ok := l1.Get("user_perms:" + strconv.FormatInt(user.ID, 10)); ok {
|
||||
t.Fatal("expected user_perms cache to be invalidated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleService_InvalidatePermCacheOnAssignPermissions(t *testing.T) {
|
||||
db := setupCacheInvalidationDB(t)
|
||||
roleRepo := repository.NewRoleRepository(db)
|
||||
rolePermRepo := repository.NewRolePermissionRepository(db)
|
||||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||||
roleSvc := service.NewRoleService(roleRepo, rolePermRepo)
|
||||
roleSvc.SetUserRoleRepository(userRoleRepo)
|
||||
l1 := cache.NewL1Cache()
|
||||
roleSvc.SetAuthCacheInvalidator(&cacheInvalidatorHarness{l1: l1})
|
||||
|
||||
user := &domain.User{Username: "rolepermcache", Password: "x", Status: domain.UserStatusActive}
|
||||
role := &domain.Role{Name: "role2", Code: "role2", Status: domain.RoleStatusEnabled}
|
||||
perm := &domain.Permission{Name: "perm1", Code: "perm1", Type: domain.PermissionTypeMenu, Status: domain.PermissionStatusEnabled}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
if err := db.Create(role).Error; err != nil {
|
||||
t.Fatalf("create role failed: %v", err)
|
||||
}
|
||||
if err := db.Create(perm).Error; err != nil {
|
||||
t.Fatalf("create permission failed: %v", err)
|
||||
}
|
||||
if err := db.Create(&domain.UserRole{UserID: user.ID, RoleID: role.ID}).Error; err != nil {
|
||||
t.Fatalf("create user role failed: %v", err)
|
||||
}
|
||||
l1.Set("user_perms:"+strconv.FormatInt(user.ID, 10), "cached", time.Minute)
|
||||
|
||||
if err := roleSvc.AssignPermissions(context.Background(), role.ID, []int64{perm.ID}); err != nil {
|
||||
t.Fatalf("AssignPermissions failed: %v", err)
|
||||
}
|
||||
if _, ok := l1.Get("user_perms:" + strconv.FormatInt(user.ID, 10)); ok {
|
||||
t.Fatal("expected user_perms cache to be invalidated after role permission change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionService_InvalidatePermCacheOnStatusChange(t *testing.T) {
|
||||
db := setupCacheInvalidationDB(t)
|
||||
permRepo := repository.NewPermissionRepository(db)
|
||||
rolePermRepo := repository.NewRolePermissionRepository(db)
|
||||
userRoleRepo := repository.NewUserRoleRepository(db)
|
||||
permSvc := service.NewPermissionService(permRepo)
|
||||
permSvc.SetRolePermissionRepository(rolePermRepo)
|
||||
permSvc.SetUserRoleRepository(userRoleRepo)
|
||||
l1 := cache.NewL1Cache()
|
||||
permSvc.SetAuthCacheInvalidator(&cacheInvalidatorHarness{l1: l1})
|
||||
|
||||
user := &domain.User{Username: "permstatuscache", Password: "x", Status: domain.UserStatusActive}
|
||||
role := &domain.Role{Name: "role3", Code: "role3", Status: domain.RoleStatusEnabled}
|
||||
perm := &domain.Permission{Name: "perm2", Code: "perm2", Type: domain.PermissionTypeMenu, Status: domain.PermissionStatusEnabled}
|
||||
if err := db.Create(user).Error; err != nil {
|
||||
t.Fatalf("create user failed: %v", err)
|
||||
}
|
||||
if err := db.Create(role).Error; err != nil {
|
||||
t.Fatalf("create role failed: %v", err)
|
||||
}
|
||||
if err := db.Create(perm).Error; err != nil {
|
||||
t.Fatalf("create permission failed: %v", err)
|
||||
}
|
||||
if err := db.Create(&domain.UserRole{UserID: user.ID, RoleID: role.ID}).Error; err != nil {
|
||||
t.Fatalf("create user role failed: %v", err)
|
||||
}
|
||||
if err := db.Create(&domain.RolePermission{RoleID: role.ID, PermissionID: perm.ID}).Error; err != nil {
|
||||
t.Fatalf("create role permission failed: %v", err)
|
||||
}
|
||||
l1.Set("user_perms:"+strconv.FormatInt(user.ID, 10), "cached", time.Minute)
|
||||
|
||||
if err := permSvc.UpdatePermissionStatus(context.Background(), perm.ID, domain.PermissionStatusDisabled); err != nil {
|
||||
t.Fatalf("UpdatePermissionStatus failed: %v", err)
|
||||
}
|
||||
if _, ok := l1.Get("user_perms:" + strconv.FormatInt(user.ID, 10)); ok {
|
||||
t.Fatal("expected user_perms cache to be invalidated after permission status change")
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,12 @@ func setupTestEnv(t *testing.T) *testEnv {
|
||||
avatarH := handler.NewAvatarHandler(userRepo)
|
||||
ssoManager := auth.NewSSOManager()
|
||||
ssoClientsStore := auth.NewDefaultSSOClientsStore()
|
||||
ssoClientsStore.RegisterClient(&auth.SSOClient{
|
||||
ClientID: "test-client",
|
||||
ClientSecret: "test-secret",
|
||||
Name: "Service Test Client",
|
||||
RedirectURIs: []string{"http://localhost/callback"},
|
||||
})
|
||||
ssoH := handler.NewSSOHandler(ssoManager, ssoClientsStore)
|
||||
_ = permSvc // suppress unused warning
|
||||
|
||||
|
||||
@@ -8,9 +8,11 @@ import (
|
||||
"github.com/user-management-system/internal/repository"
|
||||
)
|
||||
|
||||
// PermissionService 权限服务
|
||||
type PermissionService struct {
|
||||
permissionRepo *repository.PermissionRepository
|
||||
rolePermissionRepo *repository.RolePermissionRepository
|
||||
userRoleRepo *repository.UserRoleRepository
|
||||
authCacheInvalidator AuthCacheInvalidator
|
||||
}
|
||||
|
||||
// NewPermissionService 创建权限服务
|
||||
@@ -19,9 +21,22 @@ func NewPermissionService(
|
||||
) *PermissionService {
|
||||
return &PermissionService{
|
||||
permissionRepo: permissionRepo,
|
||||
authCacheInvalidator: normalizeAuthCacheInvalidator(nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PermissionService) SetRolePermissionRepository(rolePermissionRepo *repository.RolePermissionRepository) {
|
||||
s.rolePermissionRepo = rolePermissionRepo
|
||||
}
|
||||
|
||||
func (s *PermissionService) SetUserRoleRepository(userRoleRepo *repository.UserRoleRepository) {
|
||||
s.userRoleRepo = userRoleRepo
|
||||
}
|
||||
|
||||
func (s *PermissionService) SetAuthCacheInvalidator(invalidator AuthCacheInvalidator) {
|
||||
s.authCacheInvalidator = normalizeAuthCacheInvalidator(invalidator)
|
||||
}
|
||||
|
||||
// CreatePermissionRequest 创建权限请求
|
||||
type CreatePermissionRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
@@ -195,7 +210,25 @@ func (s *PermissionService) ListPermissions(ctx context.Context, req *ListPermis
|
||||
|
||||
// UpdatePermissionStatus 更新权限状态
|
||||
func (s *PermissionService) UpdatePermissionStatus(ctx context.Context, permissionID int64, status domain.PermissionStatus) error {
|
||||
return s.permissionRepo.UpdateStatus(ctx, permissionID, status)
|
||||
if err := s.permissionRepo.UpdateStatus(ctx, permissionID, status); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.rolePermissionRepo != nil && s.userRoleRepo != nil {
|
||||
roleIDs, err := s.rolePermissionRepo.GetRoleIDByPermissionID(ctx, permissionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, roleID := range roleIDs {
|
||||
userIDs, err := s.userRoleRepo.GetUserIDByRoleID(ctx, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
s.authCacheInvalidator.InvalidateUserPerms(userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPermissionTree 获取权限树
|
||||
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
"github.com/user-management-system/internal/repository"
|
||||
)
|
||||
|
||||
// RoleService 角色服务
|
||||
type RoleService struct {
|
||||
roleRepo *repository.RoleRepository
|
||||
rolePermissionRepo *repository.RolePermissionRepository
|
||||
userRoleRepo *repository.UserRoleRepository
|
||||
authCacheInvalidator AuthCacheInvalidator
|
||||
}
|
||||
|
||||
// NewRoleService 创建角色服务
|
||||
@@ -24,9 +25,18 @@ func NewRoleService(
|
||||
return &RoleService{
|
||||
roleRepo: roleRepo,
|
||||
rolePermissionRepo: rolePermissionRepo,
|
||||
authCacheInvalidator: normalizeAuthCacheInvalidator(nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *RoleService) SetUserRoleRepository(userRoleRepo *repository.UserRoleRepository) {
|
||||
s.userRoleRepo = userRoleRepo
|
||||
}
|
||||
|
||||
func (s *RoleService) SetAuthCacheInvalidator(invalidator AuthCacheInvalidator) {
|
||||
s.authCacheInvalidator = normalizeAuthCacheInvalidator(invalidator)
|
||||
}
|
||||
|
||||
// CreateRoleRequest 创建角色请求
|
||||
type CreateRoleRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
@@ -225,13 +235,22 @@ func (s *RoleService) UpdateRoleStatus(ctx context.Context, roleID int64, status
|
||||
if err != nil {
|
||||
return errors.New("角色不存在")
|
||||
}
|
||||
|
||||
// 系统角色不能禁用
|
||||
if role.IsSystem && status == domain.RoleStatusDisabled {
|
||||
return errors.New("系统角色不能禁用")
|
||||
}
|
||||
|
||||
return s.roleRepo.UpdateStatus(ctx, roleID, status)
|
||||
if err := s.roleRepo.UpdateStatus(ctx, roleID, status); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.userRoleRepo != nil {
|
||||
userIDs, err := s.userRoleRepo.GetUserIDByRoleID(ctx, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
s.authCacheInvalidator.InvalidateUserPerms(userID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetRolePermissions 获取角色权限(包含继承的父角色权限)
|
||||
@@ -261,12 +280,10 @@ func (s *RoleService) GetRolePermissions(ctx context.Context, roleID int64) ([]*
|
||||
|
||||
// AssignPermissions 分配权限
|
||||
func (s *RoleService) AssignPermissions(ctx context.Context, roleID int64, permissionIDs []int64) error {
|
||||
// 删除原有权限
|
||||
if err := s.rolePermissionRepo.DeleteByRoleID(ctx, roleID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 创建新权限关联
|
||||
var rolePermissions []*domain.RolePermission
|
||||
for _, permissionID := range permissionIDs {
|
||||
rolePermissions = append(rolePermissions, &domain.RolePermission{
|
||||
@@ -275,5 +292,17 @@ func (s *RoleService) AssignPermissions(ctx context.Context, roleID int64, permi
|
||||
})
|
||||
}
|
||||
|
||||
return s.rolePermissionRepo.BatchCreate(ctx, rolePermissions)
|
||||
if err := s.rolePermissionRepo.BatchCreate(ctx, rolePermissions); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.userRoleRepo != nil {
|
||||
userIDs, err := s.userRoleRepo.GetUserIDByRoleID(ctx, roleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userID := range userIDs {
|
||||
s.authCacheInvalidator.InvalidateUserPerms(userID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,12 +55,12 @@ type passwordHistoryRepository interface {
|
||||
DeleteOldRecords(ctx context.Context, userID int64, keep int) error
|
||||
}
|
||||
|
||||
// UserService 用户服务
|
||||
type UserService struct {
|
||||
userRepo userRepository
|
||||
userRoleRepo userRoleRepository
|
||||
roleRepo roleRepository
|
||||
passwordHistoryRepo passwordHistoryRepository
|
||||
authCacheInvalidator AuthCacheInvalidator
|
||||
}
|
||||
|
||||
const passwordHistoryLimit = 5 // 保留最近5条密码历史
|
||||
@@ -77,9 +77,14 @@ func NewUserService(
|
||||
userRoleRepo: userRoleRepo,
|
||||
roleRepo: roleRepo,
|
||||
passwordHistoryRepo: passwordHistoryRepo,
|
||||
authCacheInvalidator: normalizeAuthCacheInvalidator(nil),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserService) SetAuthCacheInvalidator(invalidator AuthCacheInvalidator) {
|
||||
s.authCacheInvalidator = normalizeAuthCacheInvalidator(invalidator)
|
||||
}
|
||||
|
||||
// ChangePassword 修改用户密码(含历史记录检查)
|
||||
func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassword, newPassword string) error {
|
||||
if s.userRepo == nil {
|
||||
@@ -131,10 +136,14 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
|
||||
user.PasswordChangedAt = time.Now()
|
||||
|
||||
if s.passwordHistoryRepo == nil {
|
||||
return s.userRepo.Update(ctx, user)
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
s.authCacheInvalidator.InvalidateUserState(userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.userRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.userRepo.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&domain.User{}).
|
||||
Where("id = ?", user.ID).
|
||||
Updates(map[string]interface{}{"password": user.Password, "password_changed_at": user.PasswordChangedAt}).Error; err != nil {
|
||||
@@ -167,7 +176,13 @@ func (s *UserService) ChangePassword(ctx context.Context, userID int64, oldPassw
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.authCacheInvalidator.InvalidateUserState(userID)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取用户
|
||||
@@ -317,7 +332,11 @@ func (s *UserService) ListCursor(ctx context.Context, req *ListCursorRequest) (*
|
||||
|
||||
// UpdateStatus 更新用户状态
|
||||
func (s *UserService) UpdateStatus(ctx context.Context, id int64, status domain.UserStatus) error {
|
||||
return s.userRepo.UpdateStatus(ctx, id, status)
|
||||
if err := s.userRepo.UpdateStatus(ctx, id, status); err != nil {
|
||||
return err
|
||||
}
|
||||
s.authCacheInvalidator.InvalidateUserState(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BatchUpdateStatusRequest 批量更新状态请求
|
||||
@@ -334,6 +353,11 @@ type BatchDeleteRequest struct {
|
||||
// BatchUpdateStatus 批量更新用户状态
|
||||
func (s *UserService) BatchUpdateStatus(ctx context.Context, req *BatchUpdateStatusRequest) (int64, error) {
|
||||
err := s.userRepo.BatchUpdateStatus(ctx, req.IDs, req.Status)
|
||||
if err == nil {
|
||||
for _, id := range req.IDs {
|
||||
s.authCacheInvalidator.InvalidateUserState(id)
|
||||
}
|
||||
}
|
||||
return int64(len(req.IDs)), err
|
||||
}
|
||||
|
||||
@@ -377,20 +401,21 @@ func (s *UserService) GetUserRoles(ctx context.Context, userID int64) ([]*domain
|
||||
|
||||
// AssignRoles 分配用户角色
|
||||
func (s *UserService) AssignRoles(ctx context.Context, userID int64, roleIDs []int64) error {
|
||||
// 检查用户是否存在
|
||||
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 验证所有角色存在(预先验证,避免在事务内做不必要的查询)
|
||||
for _, roleID := range roleIDs {
|
||||
if _, err := s.roleRepo.GetByID(ctx, roleID); err != nil {
|
||||
return fmt.Errorf("角色 %d 不存在", roleID)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 Repository 层的事务方法替换用户角色(原子操作)
|
||||
return s.userRoleRepo.ReplaceUserRoles(ctx, userID, roleIDs)
|
||||
if err := s.userRoleRepo.ReplaceUserRoles(ctx, userID, roleIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
s.authCacheInvalidator.InvalidateUserPerms(userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAdminRoleID looks up the admin role ID by code to avoid hardcoded magic numbers.
|
||||
@@ -485,17 +510,13 @@ func (s *UserService) CreateAdmin(ctx context.Context, req *CreateAdminRequest)
|
||||
|
||||
// DeleteAdmin 删除管理员(移除管理员角色)
|
||||
func (s *UserService) DeleteAdmin(ctx context.Context, userID int64, currentUserID int64) error {
|
||||
// 检查用户是否存在
|
||||
if _, err := s.userRepo.GetByID(ctx, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 不能删除自己
|
||||
if currentUserID == userID {
|
||||
return errors.New("不能删除自己")
|
||||
}
|
||||
|
||||
// 检查是否是最后一个管理员(保护)
|
||||
adminRoleID, err := s.getAdminRoleID(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -508,8 +529,11 @@ func (s *UserService) DeleteAdmin(ctx context.Context, userID int64, currentUser
|
||||
return errors.New("不能删除最后一个管理员")
|
||||
}
|
||||
|
||||
// 删除用户的管理员角色
|
||||
return s.userRoleRepo.DeleteByUserAndRole(ctx, userID, adminRoleID)
|
||||
if err := s.userRoleRepo.DeleteByUserAndRole(ctx, userID, adminRoleID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.authCacheInvalidator.InvalidateUserPerms(userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAdminRequest 创建管理员请求
|
||||
|
||||
Reference in New Issue
Block a user