From 360a26f109f57c6b6daf39ab8a09a2a4501f198d Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 7 Jun 2026 19:47:24 +0800 Subject: [PATCH] oauth --- Agents.md | 364 ++++ Makefile | 7 - docs/docs.go | 1671 ++++++++++++++++- docs/swagger.json | 1669 +++++++++++++++- docs/swagger.yaml | 1023 +++++++++- frontend/app/(main)/layout.tsx | 94 +- frontend/app/(main)/settings/page.tsx | 32 +- frontend/app/layout.tsx | 30 +- frontend/components/auth/login-form.tsx | 406 ++-- frontend/components/auth/login-page.tsx | 42 +- .../common/settings/auth-source-modal.tsx | 166 ++ .../components/common/settings/profile.tsx | 142 +- .../components/common/settings/security.tsx | 356 +++- frontend/components/layout/sidebar.tsx | 74 +- .../components/providers/auth-provider.tsx | 13 + .../components/providers/query-provider.tsx | 23 + frontend/contexts/user-context.tsx | 15 +- frontend/lib/services/admin/admin.service.ts | 67 +- frontend/lib/services/admin/index.ts | 3 + frontend/lib/services/admin/types.ts | 35 + frontend/lib/services/auth/auth.service.ts | 73 +- frontend/lib/services/auth/index.ts | 7 +- frontend/lib/services/auth/types.ts | 41 + frontend/lib/services/config/types.ts | 6 + frontend/lib/services/index.ts | 12 +- frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 56 +- go.mod | 12 + go.sum | 21 + internal/apps/admin/auth_source/routers.go | 215 +++ .../apps/admin/auth_source/routers_test.go | 345 ++++ internal/apps/admin/system_config/routers.go | 39 +- .../apps/admin/system_config/routers_test.go | 302 +++ internal/apps/admin/task/routers.go | 16 +- internal/apps/admin/task/routers_test.go | 132 ++ internal/apps/admin/user/routers.go | 17 +- internal/apps/admin/user/routers_test.go | 254 +++ internal/apps/config/routers.go | 24 + internal/apps/health/routers.go | 6 +- internal/apps/oauth/config.go | 10 +- internal/apps/oauth/constants.go | 27 + internal/apps/oauth/logics.go | 89 +- internal/apps/oauth/oauth_test.go | 1046 +++++++++++ internal/apps/oauth/routers.go | 158 +- internal/apps/oauth/sources.go | 611 ++++++ internal/apps/upload/file_server.go | 11 +- internal/apps/user/controllers.go | 219 +++ internal/common/bind/bind.go | 48 + internal/common/response/response.go | 82 + internal/config/config.go | 40 +- internal/db/migrator/migrator.go | 20 + internal/db/postgres.go | 5 + internal/model/auth_source.go | 286 +++ internal/model/system_configs.go | 3 + internal/model/users.go | 39 +- internal/router/router.go | 18 +- internal/testhelper/test_helper.go | 147 ++ internal/util/http_clients.go | 4 + internal/util/password.go | 15 + main.go | 9 + 60 files changed, 10063 insertions(+), 637 deletions(-) create mode 100644 Agents.md create mode 100644 frontend/components/common/settings/auth-source-modal.tsx create mode 100644 frontend/components/providers/auth-provider.tsx create mode 100644 frontend/components/providers/query-provider.tsx create mode 100644 internal/apps/admin/auth_source/routers.go create mode 100644 internal/apps/admin/auth_source/routers_test.go create mode 100644 internal/apps/admin/system_config/routers_test.go create mode 100644 internal/apps/admin/task/routers_test.go create mode 100644 internal/apps/admin/user/routers_test.go create mode 100644 internal/apps/oauth/oauth_test.go create mode 100644 internal/apps/oauth/sources.go create mode 100644 internal/apps/user/controllers.go create mode 100644 internal/common/bind/bind.go create mode 100644 internal/common/response/response.go create mode 100644 internal/model/auth_source.go create mode 100644 internal/testhelper/test_helper.go create mode 100644 internal/util/password.go diff --git a/Agents.md b/Agents.md new file mode 100644 index 00000000..4c83892a --- /dev/null +++ b/Agents.md @@ -0,0 +1,364 @@ +# 项目开发规范 + +> 本文档面向 AI 代理(Agent)和开发者,描述项目的目录结构、模块职责与开发规范。 + +--- + +## 一、技术栈 + +### 后端 + +| 技术 | 用途 | +|------|------| +| Go (1.25+) | 主语言 | +| Gin | HTTP 框架 | +| GORM | ORM,主库 PostgreSQL,可选 ClickHouse | +| Redis | 缓存 / Session / 队列 | +| Asynq | 异步任务队列(基于 Redis) | +| Cobra + Viper | CLI 入口 + 配置加载 | +| Swaggo | Swagger 文档生成 | +| OpenTelemetry | 链路追踪 | +| Zap | 结构化日志 | +| AWS SDK v2 | S3 兼容文件存储 | +| Snowflake | 分布式 ID 生成 | + +### 前端 + +| 技术 | 用途 | +|------|------| +| Next.js (App Router) | 前端框架 | +| TypeScript | 主语言 | +| Tailwind CSS | 样式 | +| pnpm | 包管理 | +| shadcn/ui | 组件库 | + +--- + +## 二、顶层目录结构 + +``` +Refreshing/ # 项目根目录(模块名: github.com/linux-do/credit) +├── main.go # 程序入口,调用 internal/cmd +├── go.mod / go.sum # Go 模块依赖 +├── config.yaml # 运行时配置(不提交到 Git) +├── config.example.yaml # 配置模板(需提交) +├── Makefile # 常用命令(swagger/tidy/license) +├── Dockerfile # 后端容器镜像构建 +├── .editorconfig # 编辑器格式规范 +├── .gitignore +├── docs/ # Swagger 自动生成文档(不要手动编辑) +├── frontend/ # Next.js 前端项目 +├── internal/ # 后端核心代码(Go private,不对外暴露) +├── scripts/ # CI/本地工具脚本 +└── support-files/ # 辅助文件(如 nginx 配置等) +``` + +--- + +## 三、后端 `internal/` 目录结构 + +``` +internal/ +├── cmd/ # CLI 命令入口(Cobra) +│ ├── root.go # 根命令,加载配置、初始化依赖 +│ ├── api.go # 启动 HTTP API 服务器子命令 +│ ├── scheduler.go # 启动定时任务调度器子命令 +│ └── worker.go # 启动 Asynq Worker 子命令 +│ +├── config/ # 配置加载与结构定义 +│ ├── model.go # 所有配置结构体(AppConfig / DB / Redis 等) +│ └── config.go # Viper 加载逻辑,暴露全局 config.Config +│ +├── router/ # HTTP 路由注册(唯一路由注册点) +│ ├── router.go # 路由总入口,注册所有分组路由、中间件、启动 HTTP Server +│ └── middlewares.go # 全局中间件(如请求日志) +│ +├── apps/ # 业务功能模块(按功能域划分) +│ ├── oauth/ # OAuth / OIDC 登录、会话、用户信息 +│ ├── user/ # 用户密码登录、注册、登出 +│ ├── upload/ # 文件上传、文件服务、清理任务 +│ ├── health/ # 健康检查端点 +│ ├── config/ # 公开配置接口(前端读取) +│ └── admin/ # 管理后台功能(需 Admin 权限) +│ ├── middlewares.go # Admin 鉴权中间件 +│ ├── errs.go # Admin 错误常量 +│ ├── auth_source/ # 认证源管理(CRUD) +│ ├── system_config/ # 系统配置管理(CRUD) +│ ├── task/ # 任务手动调度接口 +│ └── user/ # 用户管理(列表、状态) +│ +├── model/ # 数据模型(GORM 实体 + 业务方法) +│ ├── users.go # User 实体、OAuthUserInfo、查询/更新方法 +│ ├── auth_source.go # AuthSource 实体(OAuth 接入源) +│ ├── system_configs.go # SystemConfig 实体(KV 系统配置) +│ └── uploads.go # Upload 实体(上传文件记录) +│ +├── db/ # 数据库连接与基础设施 +│ ├── postgres.go # PostgreSQL 初始化、读写分离、GORM 配置 +│ ├── redis.go # Redis 初始化(单机/哨兵/集群) +│ ├── clickhouse.go # ClickHouse 初始化(可选) +│ ├── postgres_logger.go # 自定义 GORM 日志(对接 Zap) +│ ├── idgen/ # Snowflake 分布式 ID 生成器 +│ └── migrator/ # 数据库迁移(AutoMigrate) +│ +├── storage/ # 文件存储抽象层 +│ ├── s3.go # S3 兼容存储(上传/下载/URL 生成) +│ ├── cache.go # 本地磁盘缓存(S3 内容缓存) +│ └── errs.go # 存储层错误常量 +│ +├── task/ # 异步任务定义与调度 +│ ├── constants.go # 任务类型名称常量(TaskType)、队列名 +│ ├── utils.go # 任务工具函数(RedisOpt 等) +│ ├── scheduler/ # Asynq 定时任务调度器(Cron 注册) +│ └── worker/ # Asynq Worker 服务端(任务处理器注册) +│ ├── worker.go # StartWorker 入口,注册 Handler +│ └── middlewares.go # Worker 中间件(日志等) +│ +├── service/ # 复杂业务逻辑服务层(当前占位,待填充) +│ +├── common/ # 跨模块共享代码 +│ ├── constants.go # 全局常量(错误消息字符串等) +│ ├── errs.go # 通用错误定义 +│ ├── bind/ # 请求参数绑定封装(统一处理错误响应) +│ └── response/ # 统一 HTTP 响应格式封装 +│ +├── util/ # 无业务依赖的纯工具函数 +│ ├── crypto.go # 加密/签名工具 +│ ├── password.go # 密码 Hash(bcrypt) +│ ├── http_clients.go # HTTP 客户端封装 +│ ├── context.go # Context 存取工具 +│ ├── response.go # ResponseAny 等响应结构体 +│ ├── session.go # Session 选项构建 +│ ├── uuid.go # UUID / 唯一 ID 生成 +│ ├── strings.go # 字符串工具 +│ ├── validate.go # 参数校验工具 +│ └── custom_types.go # 自定义类型 +│ +├── logger/ # 日志封装(基于 Zap + OTel) +│ ├── logger.go # 全局 Logger 初始化 +│ └── utils.go # InfoF / WarnF / ErrorF 快捷函数 +│ +├── listener/ # 事件监听器(Webhook / 消息消费) +│ +└── otel_trace/ # OpenTelemetry 链路追踪封装 + └── ... # Span 创建、Exporter 配置 +``` + +--- + +## 四、`apps/` 模块内部文件规范 + +每个业务模块(`apps//`)内部按照以下约定组织文件: + +| 文件名 | 职责 | +|--------|------| +| `routers.go` | **HTTP Handler 函数**(业务逻辑入口,对应 Controller 层)| +| `controllers.go` | 可选,当 Handler 较多时拆分(同 `routers.go` 职责)| +| `middlewares.go` | 本模块专属中间件(如 `LoginRequired`、`LoginAdminRequired`)| +| `errs.go` | 本模块专属错误消息字符串常量(`const`)| +| `constants.go` | 本模块专属业务常量(非错误)| + +> **规则**: +> - 路由 **不在** 模块内部注册,统一在 `internal/router/router.go` 中注册。 +> - `errs.go` 只定义字符串常量,不定义 `error` 类型值,错误通过 `response.RespondFailure(c, errMsg)` 输出。 + +### `admin/` 子模块结构示例 + +``` +apps/admin/ +├── middlewares.go # LoginAdminRequired 中间件 +├── errs.go # admin 级别错误常量 +├── auth_source/ # 认证源 CRUD +│ └── routers.go +├── system_config/ # 系统 KV 配置 CRUD +│ └── routers.go +├── task/ # 任务调度接口 +│ └── routers.go +├── user/ # 用户管理 +│ ├── routers.go +│ └── errs.go +└── user_pay_config/ # 用户支付配置 + └── routers.go +``` + +--- + +## 五、前端 `frontend/` 目录结构 + +``` +frontend/ +├── app/ # Next.js App Router 页面目录 +│ ├── layout.tsx # 根布局(全局 Provider、字体、meta) +│ ├── globals.css # 全局样式 +│ ├── page.tsx # 首页重定向 +│ ├── (auth)/ # 认证相关页面组(登录/注册/OAuth 回调) +│ ├── (main)/ # 主应用页面组(用户界面) +│ └── (docs)/ # 文档类页面组 +│ +├── components/ # 可复用 React 组件 +│ ├── ui/ # shadcn/ui 基础组件(Button/Input/Dialog 等) +│ ├── common/ # 通用业务组件(跨页面复用) +│ ├── layout/ # 布局组件(Header / Sidebar / Footer) +│ ├── auth/ # 认证相关组件 +│ ├── home/ # 首页专属组件 +│ ├── animate-ui/ # 动画 UI 组件 +│ └── providers/ # Context Provider 组件 +│ +├── contexts/ # React Context(全局状态) +├── hooks/ # 自定义 React Hooks +├── lib/ # 前端工具函数、API 客户端封装 +├── types/ # TypeScript 类型定义 +├── public/ # 静态资源 +├── proxy.ts # 开发环境代理配置 +├── next.config.ts # Next.js 配置 +├── package.json +├── tsconfig.json +├── .env # 环境变量(不提交) +└── .env.example # 环境变量模板(需提交) +``` + +--- + +## 六、开发规范 + +### 6.1 命名规范 + +| 对象 | 规范 | 示例 | +|------|------|------| +| Go 包名 | 小写,下划线分词(单词) | `auth_source`、`system_config` | +| Go 文件名 | 小写,下划线分词 | `routers.go`、`postgres_logger.go` | +| Go 导出函数 | PascalCase | `ListUsers`、`StartWorker` | +| Go 未导出函数 | camelCase | `buildQueuesFromConfig` | +| Go 结构体请求/响应 | camelCase + 后缀 | `listUsersRequest`、`listUsersResponse` | +| 错误常量 | camelCase 字符串 `const` | `const userNotFound = "用户不存在"` | +| 任务类型常量 | 全大写蛇形 | `CleanupUnusedUploadsTask` | +| 配置 Key | 全小写蛇形(YAML) | `session_cookie_name`、`max_idle_conn` | + +### 6.2 HTTP Handler 规范 + +```go +// Handler 函数命名:动词 + 名词(PascalCase) +func ListUsers(c *gin.Context) { + // 1. 参数绑定(使用 ShouldBindQuery / ShouldBindJSON) + var req listUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + // 2. 业务逻辑 + + // 3. 统一响应 + c.JSON(http.StatusOK, util.OK(data)) +} +``` + +**响应格式约定**: +- 成功:`util.OK(data)` 或 `util.OKNil()` +- 失败:`util.Err(msg)` + 对应 HTTP 状态码 +- 通过 `response.RespondSuccess / RespondFailure` 也可(两套工具共存) + +### 6.3 Swagger 注释规范 + +所有对外 Handler 必须添加 Swaggo 注释: + +```go +// ListUsers 获取用户列表 +// @Summary 获取用户列表 +// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 +// @Tags admin +// @Produce json +// @Param request query listUsersRequest true "查询参数" +// @Success 200 {object} util.ResponseAny +// @Router /api/v1/admin/users [get] +func ListUsers(c *gin.Context) { ... } +``` + +生成文档:`make swagger`(执行 `scripts/swagger.sh`) + +### 6.4 错误处理规范 + +- **模块内错误消息**:定义在本模块 `errs.go` 中,使用 `const` 字符串。 +- **跨模块错误消息**:定义在 `internal/common/errs.go` 或 `common/constants.go`。 +- **数据库错误**:直接 `err.Error()` 返回给响应(开发阶段),生产环境应屏蔽详情。 +- **gorm.ErrRecordNotFound**:显式判断,返回 404。 + +### 6.5 中间件使用规范 + +| 中间件 | 位置 | 作用 | +|--------|------|------| +| `gin.Recovery()` | 全局 | Panic 恢复 | +| `otelgin.Middleware()` | 全局 | OTel 链路追踪 | +| `loggerMiddleware()` | 全局 | 请求日志 | +| `sessions.Sessions()` | 全局 | Session 注入 | +| `oauth.LoginRequired()` | 路由组 | 登录校验 | +| `admin.LoginAdminRequired()` | Admin 路由组 | 管理员校验 | + +### 6.6 配置访问规范 + +- 所有配置通过 `config.Config.
.` 访问(全局单例)。 +- 不允许在业务代码中使用 `os.Getenv()` 读取配置,统一通过 Viper 加载。 +- 新增配置项:先在 `config.example.yaml` 添加注释模板,再在 `internal/config/model.go` 添加结构体字段。 + +### 6.7 数据库访问规范 + +- 直接使用 GORM:`model.DB.Where(...).Find(&result)`(适合简单查询)。 +- 通过 `db.DB(ctx)` 获取带链路追踪的 DB 实例(Admin 模块推荐)。 +- 禁止在 Handler 层直接写复杂 SQL,应封装到 `model/` 层方法或 `service/` 层。 +- 数据库迁移使用 `db/migrator/` 中的 AutoMigrate,不允许手动执行 DDL。 + +### 6.8 异步任务规范 + +**定义任务**: +1. 在 `internal/task/constants.go` 中定义任务类型常量。 +2. 实现 Handler 函数(放在对应 `apps/` 模块的 `tasks.go` 文件中)。 +3. 在 `internal/task/worker/worker.go` 中注册 Handler:`mux.HandleFunc(task.XxxTask, handler)`。 +4. 调度:在 `internal/task/scheduler/` 中按 Cron 表达式调度,或通过 Admin API 手动触发。 + +**队列优先级**(从高到低):`webhook` > `whitelist_only` > `default` + +--- + +## 七、新增功能开发流程 + +以新增 **管理员功能模块** 为例: + +``` +1. 在 internal/model/ 中定义/扩展数据模型 +2. 在 db/migrator/ 中注册 AutoMigrate +3. 在 internal/apps/admin// 中创建: + - routers.go (Handler 实现 + Swagger 注释) + - errs.go (错误常量,按需) +4. 在 internal/router/router.go 中注册路由 +5. 执行 make swagger 更新文档 +``` + +以新增 **异步任务** 为例: + +``` +1. 在 internal/task/constants.go 定义任务类型常量 +2. 在对应 apps//tasks.go 实现 Handle 函数 +3. 在 internal/task/worker/worker.go 注册 Handler +4. 在 internal/task/scheduler/ 添加 Cron 调度(或 Admin API 手动触发) +5. 在 config.example.yaml 的 scheduler 段添加 Cron 配置项 +6. 在 internal/config/model.go 添加配置字段 +``` + +--- + +## 八、关键依赖版本 + +| 依赖 | 版本 | +|------|------| +| Go | 1.25+ | +| Gin | v1.11.0 | +| GORM | v1.31.1 | +| go-redis | v9.16.0 | +| Asynq | v0.25.1 | +| Cobra | v1.10.1 | +| Viper | v1.21.0 | +| Zap | v1.27.0 | +| Snowflake | v0.3.0 | +| OpenTelemetry | v1.36.0 | +| Next.js | (见 frontend/package.json) | +| pnpm | (见 frontend/pnpm-workspace.yaml) | diff --git a/Makefile b/Makefile index e43aa45e..e10fbc49 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,3 @@ swagger: scripts/swagger.sh -tidy: - scripts/tidy.sh - -check_license: - scripts/license.sh - -pre_commit: tidy swagger check_license diff --git a/docs/docs.go b/docs/docs.go index 8145ca87..3471fa1b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -9,14 +9,371 @@ const docTemplate = `{ "info": { "description": "{{escape .Description}}", "title": "{{.Title}}", - "contact": {}, + "contact": { + "name": "LINUX DO Credit", + "url": "https://linux.do" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, "version": "{{.Version}}" }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/admin/auth-sources": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取认证源列表", + "responses": { + "200": { + "description": "认证源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建认证源", + "parameters": [ + { + "description": "创建认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功,返回认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新认证源", + "parameters": [ + { + "type": "integer", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功,返回更新后的认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除认证源", + "parameters": [ + { + "type": "integer", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或删除失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}/toggle": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "切换认证源启用状态", + "parameters": [ + { + "type": "integer", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "启用状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "验证失败或认证源不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/system-configs": { "get": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限", "produces": [ "application/json" @@ -35,7 +392,40 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "系统配置列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -43,6 +433,11 @@ const docTemplate = `{ } }, "post": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限", "consumes": [ "application/json" @@ -67,7 +462,43 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或配置键已存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -77,6 +508,11 @@ const docTemplate = `{ }, "/api/v1/admin/system-configs/{key}": { "get": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "根据配置键获取对应的系统配置详情,需要管理员权限", "produces": [ "application/json" @@ -96,7 +532,43 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "系统配置详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -104,6 +576,11 @@ const docTemplate = `{ } }, "put": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限", "consumes": [ "application/json" @@ -135,7 +612,49 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -143,6 +662,11 @@ const docTemplate = `{ } }, "delete": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限", "produces": [ "application/json" @@ -162,7 +686,43 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -172,6 +732,11 @@ const docTemplate = `{ }, "/api/v1/admin/tasks/dispatch": { "post": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限", "consumes": [ "application/json" @@ -196,7 +761,43 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "任务已入队", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "任务入队失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -206,7 +807,12 @@ const docTemplate = `{ }, "/api/v1/admin/tasks/types": { "get": { - "description": "返回系统支持的所有可调度任务类型列表,需要管理员权限", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限", "produces": [ "application/json" ], @@ -216,7 +822,34 @@ const docTemplate = `{ "summary": "获取支持的任务类型", "responses": { "200": { - "description": "OK", + "description": "任务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskMeta" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -226,6 +859,11 @@ const docTemplate = `{ }, "/api/v1/admin/users": { "get": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限", "produces": [ "application/json" @@ -261,7 +899,43 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "用户列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.listUsersResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -271,6 +945,11 @@ const docTemplate = `{ }, "/api/v1/admin/users/{id}/status": { "put": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限", "consumes": [ "application/json" @@ -285,7 +964,7 @@ const docTemplate = `{ "parameters": [ { "type": "integer", - "description": "用户ID", + "description": "用户 ID", "name": "id", "in": "path", "required": true @@ -302,7 +981,49 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限或尝试禁用管理员", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -335,7 +1056,7 @@ const docTemplate = `{ }, "/api/v1/health": { "get": { - "description": "检查服务是否正常运行", + "description": "检查服务是否正常运行,可用于负载均衡存活探测", "produces": [ "application/json" ], @@ -345,9 +1066,21 @@ const docTemplate = `{ "summary": "健康检查", "responses": { "200": { - "description": "OK", + "description": "服务正常", "schema": { - "$ref": "#/definitions/util.ResponseAny" + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } } } @@ -355,7 +1088,7 @@ const docTemplate = `{ }, "/api/v1/oauth/callback": { "post": { - "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立用户会话", + "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", "consumes": [ "application/json" ], @@ -365,7 +1098,7 @@ const docTemplate = `{ "tags": [ "oauth" ], - "summary": "OAuth 回调", + "summary": "OAuth 回调处理", "parameters": [ { "description": "回调请求参数", @@ -379,7 +1112,147 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "登录或绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthCallbackResult" + } + } + } + ] + } + }, + "400": { + "description": "state 无效、参数错误或认证源错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "绑定场景未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "OAuth 认证失败或内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/external-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取外部帐号列表", + "responses": { + "200": { + "description": "外部帐号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ExternalAccountView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/external-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解除外部帐号绑定", + "parameters": [ + { + "type": "integer", + "description": "外部帐号绑定记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "解除绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或解除失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -389,17 +1262,49 @@ const docTemplate = `{ }, "/api/v1/oauth/login": { "get": { - "description": "生成 OAuth 登录 URL,前端跳转至该地址完成授权", + "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。", "produces": [ "application/json" ], "tags": [ "oauth" ], - "summary": "获取登录地址", + "summary": "获取登录授权地址", + "parameters": [ + { + "type": "string", + "description": "认证源名称,为空使用默认源", + "name": "source", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未配置", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "Redis 异常或构造 URL 失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -409,7 +1314,12 @@ const docTemplate = `{ }, "/api/v1/oauth/logout": { "get": { - "description": "清除当前用户的登录会话,完成退出", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", "produces": [ "application/json" ], @@ -419,7 +1329,25 @@ const docTemplate = `{ "summary": "退出登录", "responses": { "200": { - "description": "OK", + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -427,9 +1355,49 @@ const docTemplate = `{ } } }, + "/api/v1/oauth/sources": { + "get": { + "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取可用登录源", + "responses": { + "200": { + "description": "登录源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/oauth.AuthSourceView" + } + } + } + } + ] + } + } + } + } + }, "/api/v1/oauth/user-info": { "get": { - "description": "返回当前登录用户的基本信息及余额数据,需要登录", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", "produces": [ "application/json" ], @@ -439,7 +1407,243 @@ const docTemplate = `{ "summary": "获取当前登录用户信息", "responses": { "200": { - "description": "OK", + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/{source}/authorize": { + "get": { + "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "发起指定认证源授权", + "parameters": [ + { + "type": "string", + "description": "认证源名称", + "name": "source", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", + "name": "purpose", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未启用", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "Redis 异常或构造 URL 失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/login": { + "post": { + "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户密码登录", + "parameters": [ + { + "description": "登录请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.loginRequest" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "用户名或密码错误、帐号已禁用等", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除用户登录 Session,完成退出", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/register": { + "post": { + "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.registerRequest" + } + } + ], + "responses": { + "200": { + "description": "注册并登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误、用户名已存在或注册已关闭", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -449,16 +1653,18 @@ const docTemplate = `{ }, "/f/{id}": { "get": { + "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", "produces": [ "application/octet-stream" ], "tags": [ "upload" ], + "summary": "获取已上传文件", "parameters": [ { "type": "string", - "description": "Upload ID", + "description": "文件 ID", "name": "id", "in": "path", "required": true @@ -466,15 +1672,272 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK" + "description": "成功获取文件内容", + "schema": { + "type": "file" + } + }, + "400": { + "description": "文件 ID 格式错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件未找到", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } } } } } }, "definitions": { + "auth_source.AuthSourceRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "auth_source.ToggleAuthSourceRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "model.AuthSource": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret_configured": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ExternalAccountView": { + "type": "object", + "properties": { + "auth_source_id": { + "type": "integer" + }, + "auth_source_label": { + "type": "string" + }, + "auth_source_name": { + "type": "string" + }, + "auth_source_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "external_username": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "model.SystemConfig": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "model.TrustLevel": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-varnames": [ + "TrustLevelNewUser", + "TrustLevelBasicUser", + "TrustLevelUser", + "TrustLevelActiveUser", + "TrustLevelLeader" + ] + }, + "oauth.AuthSourceView": { + "type": "object", + "properties": { + "client_secret_configured": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oauth.BasicUserInfo": { + "type": "object", + "properties": { + "available_balance": { + "type": "number" + }, + "avatar_url": { + "type": "string" + }, + "community_balance": { + "type": "number" + }, + "daily_limit": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string" + }, + "pay_level": { + "type": "string" + }, + "pay_score": { + "type": "integer" + }, + "pending_balance": { + "type": "number" + }, + "remain_quota": { + "type": "number" + }, + "total_community": { + "type": "number" + }, + "total_payment": { + "type": "number" + }, + "total_receive": { + "type": "number" + }, + "total_transfer": { + "type": "number" + }, + "trust_level": { + "$ref": "#/definitions/model.TrustLevel" + }, + "username": { + "type": "string" + } + } + }, "oauth.CallbackRequest": { "type": "object", + "required": [ + "code", + "state" + ], "properties": { "code": { "type": "string" @@ -484,6 +1947,25 @@ const docTemplate = `{ } } }, + "oauth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "oauth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + }, "system_config.CreateSystemConfigRequest": { "type": "object", "required": [ @@ -549,6 +2031,74 @@ const docTemplate = `{ } } }, + "task.TaskMeta": { + "type": "object", + "properties": { + "asynqTask": { + "type": "string" + }, + "description": { + "type": "string" + }, + "maxRetry": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "supportsTime": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "user.listUsersResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user.user" + } + } + } + }, + "user.loginRequest": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.registerRequest": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "user.updateUserStatusRequest": { "type": "object", "properties": { @@ -557,6 +2107,62 @@ const docTemplate = `{ } } }, + "user.user": { + "type": "object", + "properties": { + "available_balance": { + "type": "number" + }, + "avatar_url": { + "type": "string" + }, + "community_balance": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "last_login_at": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "pay_score": { + "type": "integer" + }, + "total_community": { + "type": "number" + }, + "total_payment": { + "type": "number" + }, + "total_receive": { + "type": "number" + }, + "total_transfer": { + "type": "number" + }, + "trust_level": { + "$ref": "#/definitions/model.TrustLevel" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "util.ResponseAny": { "type": "object", "properties": { @@ -567,6 +2173,13 @@ const docTemplate = `{ } } } + }, + "securityDefinitions": { + "SessionCookie": { + "type": "apiKey", + "name": "session", + "in": "cookie" + } } }` @@ -574,10 +2187,10 @@ const docTemplate = `{ var SwaggerInfo = &swag.Spec{ Version: "1.0.0", Host: "", - BasePath: "", + BasePath: "/", Schemes: []string{}, Title: "LINUX DO Credit", - Description: "", + Description: "LINUX DO Credit 平台后端 API,提供用户认证、商户 API Key 管理、系统配置等功能。", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index e6d78dfe..0efa579b 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,13 +1,372 @@ { "swagger": "2.0", "info": { + "description": "LINUX DO Credit 平台后端 API,提供用户认证、商户 API Key 管理、系统配置等功能。", "title": "LINUX DO Credit", - "contact": {}, + "contact": { + "name": "LINUX DO Credit", + "url": "https://linux.do" + }, + "license": { + "name": "Apache 2.0", + "url": "http://www.apache.org/licenses/LICENSE-2.0.html" + }, "version": "1.0.0" }, + "basePath": "/", "paths": { + "/api/v1/admin/auth-sources": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取认证源列表", + "responses": { + "200": { + "description": "认证源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建认证源", + "parameters": [ + { + "description": "创建认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "创建成功,返回认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新认证源", + "parameters": [ + { + "type": "integer", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "更新认证源参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.AuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "更新成功,返回更新后的认证源信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.AuthSource" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或验证失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除认证源", + "parameters": [ + { + "type": "integer", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或删除失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/admin/auth-sources/{id}/toggle": { + "put": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "切换认证源启用状态", + "parameters": [ + { + "type": "integer", + "description": "认证源 ID 或名称", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "启用状态", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth_source.ToggleAuthSourceRequest" + } + } + ], + "responses": { + "200": { + "description": "切换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "验证失败或认证源不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/system-configs": { "get": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限", "produces": [ "application/json" @@ -26,7 +385,40 @@ ], "responses": { "200": { - "description": "OK", + "description": "系统配置列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -34,6 +426,11 @@ } }, "post": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "创建一条新的系统配置项,配置键不可重复,同时将新配置同步到 Redis,需要管理员权限", "consumes": [ "application/json" @@ -58,7 +455,43 @@ ], "responses": { "200": { - "description": "OK", + "description": "创建成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或配置键已存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -68,6 +501,11 @@ }, "/api/v1/admin/system-configs/{key}": { "get": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "根据配置键获取对应的系统配置详情,需要管理员权限", "produces": [ "application/json" @@ -87,7 +525,43 @@ ], "responses": { "200": { - "description": "OK", + "description": "系统配置详情", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.SystemConfig" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -95,6 +569,11 @@ } }, "put": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "根据配置键更新对应的配置内容,同时将更新同步到 Redis,需要管理员权限", "consumes": [ "application/json" @@ -126,7 +605,49 @@ ], "responses": { "200": { - "description": "OK", + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -134,6 +655,11 @@ } }, "delete": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限", "produces": [ "application/json" @@ -153,7 +679,43 @@ ], "responses": { "200": { - "description": "OK", + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "配置不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -163,6 +725,11 @@ }, "/api/v1/admin/tasks/dispatch": { "post": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "手动触发指定类型的异步任务,支持指定时间范围和用户,需要管理员权限", "consumes": [ "application/json" @@ -187,7 +754,43 @@ ], "responses": { "200": { - "description": "OK", + "description": "任务已入队", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "任务类型不存在或参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "任务入队失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -197,7 +800,12 @@ }, "/api/v1/admin/tasks/types": { "get": { - "description": "返回系统支持的所有可调度任务类型列表,需要管理员权限", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限", "produces": [ "application/json" ], @@ -207,7 +815,34 @@ "summary": "获取支持的任务类型", "responses": { "200": { - "description": "OK", + "description": "任务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/task.TaskMeta" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -217,6 +852,11 @@ }, "/api/v1/admin/users": { "get": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限", "produces": [ "application/json" @@ -252,7 +892,43 @@ ], "responses": { "200": { - "description": "OK", + "description": "用户列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.listUsersResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -262,6 +938,11 @@ }, "/api/v1/admin/users/{id}/status": { "put": { + "security": [ + { + "SessionCookie": [] + } + ], "description": "启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限", "consumes": [ "application/json" @@ -276,7 +957,7 @@ "parameters": [ { "type": "integer", - "description": "用户ID", + "description": "用户 ID", "name": "id", "in": "path", "required": true @@ -293,7 +974,49 @@ ], "responses": { "200": { - "description": "OK", + "description": "更新成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限或尝试禁用管理员", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "用户不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -326,7 +1049,7 @@ }, "/api/v1/health": { "get": { - "description": "检查服务是否正常运行", + "description": "检查服务是否正常运行,可用于负载均衡存活探测", "produces": [ "application/json" ], @@ -336,9 +1059,21 @@ "summary": "健康检查", "responses": { "200": { - "description": "OK", + "description": "服务正常", "schema": { - "$ref": "#/definitions/util.ResponseAny" + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] } } } @@ -346,7 +1081,7 @@ }, "/api/v1/oauth/callback": { "post": { - "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立用户会话", + "description": "接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。", "consumes": [ "application/json" ], @@ -356,7 +1091,7 @@ "tags": [ "oauth" ], - "summary": "OAuth 回调", + "summary": "OAuth 回调处理", "parameters": [ { "description": "回调请求参数", @@ -370,7 +1105,147 @@ ], "responses": { "200": { - "description": "OK", + "description": "登录或绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthCallbackResult" + } + } + } + ] + } + }, + "400": { + "description": "state 无效、参数错误或认证源错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "绑定场景未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "OAuth 认证失败或内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/external-accounts": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取外部帐号列表", + "responses": { + "200": { + "description": "外部帐号列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.ExternalAccountView" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/external-accounts/{id}/delete": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "解除当前登录用户与指定外部帐号的绑定关系,需要登录", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解除外部帐号绑定", + "parameters": [ + { + "type": "integer", + "description": "外部帐号绑定记录 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "解除绑定成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "ID 无效或解除失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -380,17 +1255,49 @@ }, "/api/v1/oauth/login": { "get": { - "description": "生成 OAuth 登录 URL,前端跳转至该地址完成授权", + "description": "根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。", "produces": [ "application/json" ], "tags": [ "oauth" ], - "summary": "获取登录地址", + "summary": "获取登录授权地址", + "parameters": [ + { + "type": "string", + "description": "认证源名称,为空使用默认源", + "name": "source", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未配置", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "Redis 异常或构造 URL 失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -400,7 +1307,12 @@ }, "/api/v1/oauth/logout": { "get": { - "description": "清除当前用户的登录会话,完成退出", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。", "produces": [ "application/json" ], @@ -410,7 +1322,25 @@ "summary": "退出登录", "responses": { "200": { - "description": "OK", + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -418,9 +1348,49 @@ } } }, + "/api/v1/oauth/sources": { + "get": { + "description": "返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "获取可用登录源", + "responses": { + "200": { + "description": "登录源列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/oauth.AuthSourceView" + } + } + } + } + ] + } + } + } + } + }, "/api/v1/oauth/user-info": { "get": { - "description": "返回当前登录用户的基本信息及余额数据,需要登录", + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。", "produces": [ "application/json" ], @@ -430,7 +1400,243 @@ "summary": "获取当前登录用户信息", "responses": { "200": { - "description": "OK", + "description": "用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/oauth/{source}/authorize": { + "get": { + "description": "根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "发起指定认证源授权", + "parameters": [ + { + "type": "string", + "description": "认证源名称", + "name": "source", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权目的:login(登录)或 bind(绑定账号),默认 login", + "name": "purpose", + "in": "query" + } + ], + "responses": { + "200": { + "description": "授权 URL", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.OAuthAuthorizeResponse" + } + } + } + ] + } + }, + "400": { + "description": "认证源不存在或未启用", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "Redis 异常或构造 URL 失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/login": { + "post": { + "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户密码登录", + "parameters": [ + { + "description": "登录请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.loginRequest" + } + } + ], + "responses": { + "200": { + "description": "登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "用户名或密码错误、帐号已禁用等", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/logout": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "清除用户登录 Session,完成退出", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户退出登录", + "responses": { + "200": { + "description": "退出成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "500": { + "description": "Session 清除失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/register": { + "post": { + "description": "使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "用户注册", + "parameters": [ + { + "description": "注册请求参数", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.registerRequest" + } + } + ], + "responses": { + "200": { + "description": "注册并登录成功,返回用户信息", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + } + ] + } + }, + "400": { + "description": "参数错误、用户名已存在或注册已关闭", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", "schema": { "$ref": "#/definitions/util.ResponseAny" } @@ -440,16 +1646,18 @@ }, "/f/{id}": { "get": { + "description": "根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回", "produces": [ "application/octet-stream" ], "tags": [ "upload" ], + "summary": "获取已上传文件", "parameters": [ { "type": "string", - "description": "Upload ID", + "description": "文件 ID", "name": "id", "in": "path", "required": true @@ -457,15 +1665,272 @@ ], "responses": { "200": { - "description": "OK" + "description": "成功获取文件内容", + "schema": { + "type": "file" + } + }, + "400": { + "description": "文件 ID 格式错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件未找到", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } } } } } }, "definitions": { + "auth_source.AuthSourceRequest": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "auth_source.ToggleAuthSourceRequest": { + "type": "object", + "properties": { + "is_active": { + "type": "boolean" + } + } + }, + "model.AuthSource": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "client_secret_configured": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "openid_discovery_url": { + "type": "string" + }, + "scopes": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "model.ExternalAccountView": { + "type": "object", + "properties": { + "auth_source_id": { + "type": "integer" + }, + "auth_source_label": { + "type": "string" + }, + "auth_source_name": { + "type": "string" + }, + "auth_source_type": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "external_username": { + "type": "string" + }, + "id": { + "type": "integer" + } + } + }, + "model.SystemConfig": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "description": { + "type": "string" + }, + "key": { + "type": "string" + }, + "type": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "model.TrustLevel": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4 + ], + "x-enum-varnames": [ + "TrustLevelNewUser", + "TrustLevelBasicUser", + "TrustLevelUser", + "TrustLevelActiveUser", + "TrustLevelLeader" + ] + }, + "oauth.AuthSourceView": { + "type": "object", + "properties": { + "client_secret_configured": { + "type": "boolean" + }, + "display_name": { + "type": "string" + }, + "icon_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + } + }, + "oauth.BasicUserInfo": { + "type": "object", + "properties": { + "available_balance": { + "type": "number" + }, + "avatar_url": { + "type": "string" + }, + "community_balance": { + "type": "number" + }, + "daily_limit": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "is_admin": { + "type": "boolean" + }, + "nickname": { + "type": "string" + }, + "pay_level": { + "type": "string" + }, + "pay_score": { + "type": "integer" + }, + "pending_balance": { + "type": "number" + }, + "remain_quota": { + "type": "number" + }, + "total_community": { + "type": "number" + }, + "total_payment": { + "type": "number" + }, + "total_receive": { + "type": "number" + }, + "total_transfer": { + "type": "number" + }, + "trust_level": { + "$ref": "#/definitions/model.TrustLevel" + }, + "username": { + "type": "string" + } + } + }, "oauth.CallbackRequest": { "type": "object", + "required": [ + "code", + "state" + ], "properties": { "code": { "type": "string" @@ -475,6 +1940,25 @@ } } }, + "oauth.OAuthAuthorizeResponse": { + "type": "object", + "properties": { + "authorize_url": { + "type": "string" + } + } + }, + "oauth.OAuthCallbackResult": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "user": { + "$ref": "#/definitions/oauth.BasicUserInfo" + } + } + }, "system_config.CreateSystemConfigRequest": { "type": "object", "required": [ @@ -540,6 +2024,74 @@ } } }, + "task.TaskMeta": { + "type": "object", + "properties": { + "asynqTask": { + "type": "string" + }, + "description": { + "type": "string" + }, + "maxRetry": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "queue": { + "type": "string" + }, + "supportsTime": { + "type": "boolean" + }, + "type": { + "type": "string" + } + } + }, + "user.listUsersResponse": { + "type": "object", + "properties": { + "total": { + "type": "integer" + }, + "users": { + "type": "array", + "items": { + "$ref": "#/definitions/user.user" + } + } + } + }, + "user.loginRequest": { + "type": "object", + "properties": { + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "user.registerRequest": { + "type": "object", + "properties": { + "display_name": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "password": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "user.updateUserStatusRequest": { "type": "object", "properties": { @@ -548,6 +2100,62 @@ } } }, + "user.user": { + "type": "object", + "properties": { + "available_balance": { + "type": "number" + }, + "avatar_url": { + "type": "string" + }, + "community_balance": { + "type": "number" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_active": { + "type": "boolean" + }, + "is_admin": { + "type": "boolean" + }, + "last_login_at": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "pay_score": { + "type": "integer" + }, + "total_community": { + "type": "number" + }, + "total_payment": { + "type": "number" + }, + "total_receive": { + "type": "number" + }, + "total_transfer": { + "type": "number" + }, + "trust_level": { + "$ref": "#/definitions/model.TrustLevel" + }, + "updated_at": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, "util.ResponseAny": { "type": "object", "properties": { @@ -558,5 +2166,12 @@ } } } + }, + "securityDefinitions": { + "SessionCookie": { + "type": "apiKey", + "name": "session", + "in": "cookie" + } } } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ba3f4a15..308e9fe3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,10 +1,181 @@ +basePath: / definitions: + auth_source.AuthSourceRequest: + properties: + client_id: + type: string + client_secret: + type: string + display_name: + type: string + icon_url: + type: string + is_active: + type: boolean + name: + type: string + openid_discovery_url: + type: string + scopes: + type: string + type: + type: string + type: object + auth_source.ToggleAuthSourceRequest: + properties: + is_active: + type: boolean + type: object + model.AuthSource: + properties: + client_id: + type: string + client_secret_configured: + type: boolean + created_at: + type: string + display_name: + type: string + icon_url: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + openid_discovery_url: + type: string + scopes: + type: string + type: + type: string + updated_at: + type: string + type: object + model.ExternalAccountView: + properties: + auth_source_id: + type: integer + auth_source_label: + type: string + auth_source_name: + type: string + auth_source_type: + type: string + created_at: + type: string + email: + type: string + external_username: + type: string + id: + type: integer + type: object + model.SystemConfig: + properties: + created_at: + type: string + description: + type: string + key: + type: string + type: + type: string + updated_at: + type: string + value: + type: string + type: object + model.TrustLevel: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + type: integer + x-enum-varnames: + - TrustLevelNewUser + - TrustLevelBasicUser + - TrustLevelUser + - TrustLevelActiveUser + - TrustLevelLeader + oauth.AuthSourceView: + properties: + client_secret_configured: + type: boolean + display_name: + type: string + icon_url: + type: string + id: + type: integer + is_active: + type: boolean + name: + type: string + type: + type: string + type: object + oauth.BasicUserInfo: + properties: + available_balance: + type: number + avatar_url: + type: string + community_balance: + type: number + daily_limit: + type: integer + id: + type: integer + is_admin: + type: boolean + nickname: + type: string + pay_level: + type: string + pay_score: + type: integer + pending_balance: + type: number + remain_quota: + type: number + total_community: + type: number + total_payment: + type: number + total_receive: + type: number + total_transfer: + type: number + trust_level: + $ref: '#/definitions/model.TrustLevel' + username: + type: string + type: object oauth.CallbackRequest: properties: code: type: string state: type: string + required: + - code + - state + type: object + oauth.OAuthAuthorizeResponse: + properties: + authorize_url: + type: string + type: object + oauth.OAuthCallbackResult: + properties: + status: + type: string + user: + $ref: '#/definitions/oauth.BasicUserInfo' type: object system_config.CreateSystemConfigRequest: properties: @@ -51,11 +222,92 @@ definitions: required: - task_type type: object + task.TaskMeta: + properties: + asynqTask: + type: string + description: + type: string + maxRetry: + type: integer + name: + type: string + queue: + type: string + supportsTime: + type: boolean + type: + type: string + type: object + user.listUsersResponse: + properties: + total: + type: integer + users: + items: + $ref: '#/definitions/user.user' + type: array + type: object + user.loginRequest: + properties: + password: + type: string + username: + type: string + type: object + user.registerRequest: + properties: + display_name: + type: string + nickname: + type: string + password: + type: string + username: + type: string + type: object user.updateUserStatusRequest: properties: is_active: type: boolean type: object + user.user: + properties: + available_balance: + type: number + avatar_url: + type: string + community_balance: + type: number + created_at: + type: string + id: + type: integer + is_active: + type: boolean + is_admin: + type: boolean + last_login_at: + type: string + nickname: + type: string + pay_score: + type: integer + total_community: + type: number + total_payment: + type: number + total_receive: + type: number + total_transfer: + type: number + trust_level: + $ref: '#/definitions/model.TrustLevel' + updated_at: + type: string + username: + type: string + type: object util.ResponseAny: properties: data: {} @@ -64,10 +316,223 @@ definitions: type: string type: object info: - contact: {} + contact: + name: LINUX DO Credit + url: https://linux.do + description: LINUX DO Credit 平台后端 API,提供用户认证、商户 API Key 管理、系统配置等功能。 + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html title: LINUX DO Credit version: 1.0.0 paths: + /api/v1/admin/auth-sources: + get: + description: 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 + produces: + - application/json + responses: + "200": + description: 认证源列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + items: + $ref: '#/definitions/model.AuthSource' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取认证源列表 + tags: + - admin + post: + consumes: + - application/json + description: 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 + parameters: + - description: 创建认证源参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.AuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 创建成功,返回认证源信息 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/model.AuthSource' + type: object + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 创建认证源 + tags: + - admin + /api/v1/admin/auth-sources/{id}: + delete: + description: 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: ID 无效或删除失败 + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 删除认证源 + tags: + - admin + put: + consumes: + - application/json + description: 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + in: path + name: id + required: true + type: integer + - description: 更新认证源参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.AuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 更新成功,返回更新后的认证源信息 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/model.AuthSource' + type: object + "400": + description: 参数错误或验证失败 + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 更新认证源 + tags: + - admin + /api/v1/admin/auth-sources/{id}/toggle: + put: + consumes: + - application/json + description: 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限 + parameters: + - description: 认证源 ID 或名称 + in: path + name: id + required: true + type: integer + - description: 启用状态 + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth_source.ToggleAuthSourceRequest' + produces: + - application/json + responses: + "200": + description: 切换成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: 验证失败或认证源不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 切换认证源启用状态 + tags: + - admin /api/v1/admin/system-configs: get: description: 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 @@ -80,9 +545,30 @@ paths: - application/json responses: "200": - description: OK + description: 系统配置列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + items: + $ref: '#/definitions/model.SystemConfig' + type: array + type: object + "401": + description: 未登录 schema: $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 获取系统配置列表 tags: - admin @@ -101,9 +587,32 @@ paths: - application/json responses: "200": - description: OK + description: 创建成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: 参数错误或配置键已存在 schema: $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 创建系统配置 tags: - admin @@ -120,9 +629,32 @@ paths: - application/json responses: "200": - description: OK + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "401": + description: 未登录 schema: $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 配置不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 删除系统配置 tags: - admin @@ -138,9 +670,32 @@ paths: - application/json responses: "200": - description: OK + description: 系统配置详情 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/model.SystemConfig' + type: object + "401": + description: 未登录 schema: $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 配置不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 获取单个系统配置 tags: - admin @@ -164,9 +719,36 @@ paths: - application/json responses: "200": - description: OK + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 schema: $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 配置不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 更新系统配置 tags: - admin @@ -186,22 +768,62 @@ paths: - application/json responses: "200": - description: OK + description: 任务已入队 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: 任务类型不存在或参数错误 schema: $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 任务入队失败 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 下发异步任务 tags: - admin /api/v1/admin/tasks/types: get: - description: 返回系统支持的所有可调度任务类型列表,需要管理员权限 + description: 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 produces: - application/json responses: "200": - description: OK + description: 任务类型列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + items: + $ref: '#/definitions/task.TaskMeta' + type: array + type: object + "401": + description: 未登录 schema: $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 获取支持的任务类型 tags: - admin @@ -228,9 +850,32 @@ paths: - application/json responses: "200": - description: OK + description: 用户列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/user.listUsersResponse' + type: object + "400": + description: 参数错误 schema: $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 获取用户列表 tags: - admin @@ -240,7 +885,7 @@ paths: - application/json description: 启用或禁用指定用户,管理员账号无法被禁用,需要管理员权限 parameters: - - description: 用户ID + - description: 用户 ID in: path name: id required: true @@ -255,9 +900,36 @@ paths: - application/json responses: "200": - description: OK + description: 更新成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 schema: $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限或尝试禁用管理员 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 用户不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 更新用户状态 tags: - admin @@ -278,22 +950,63 @@ paths: - config /api/v1/health: get: - description: 检查服务是否正常运行 + description: 检查服务是否正常运行,可用于负载均衡存活探测 produces: - application/json responses: "200": - description: OK + description: 服务正常 schema: - $ref: '#/definitions/util.ResponseAny' + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object summary: 健康检查 tags: - health + /api/v1/oauth/{source}/authorize: + get: + description: 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 + parameters: + - description: 认证源名称 + in: path + name: source + required: true + type: string + - description: 授权目的:login(登录)或 bind(绑定账号),默认 login + in: query + name: purpose + type: string + produces: + - application/json + responses: + "200": + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/oauth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未启用 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: Redis 异常或构造 URL 失败 + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 发起指定认证源授权 + tags: + - oauth /api/v1/oauth/callback: post: consumes: - application/json - description: 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立用户会话 + description: 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。 parameters: - description: 回调请求参数 in: body @@ -305,55 +1018,291 @@ paths: - application/json responses: "200": - description: OK + description: 登录或绑定成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/oauth.OAuthCallbackResult' + type: object + "400": + description: state 无效、参数错误或认证源错误 schema: $ref: '#/definitions/util.ResponseAny' - summary: OAuth 回调 + "401": + description: 绑定场景未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: OAuth 认证失败或内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + summary: OAuth 回调处理 + tags: + - oauth + /api/v1/oauth/external-accounts: + get: + description: 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录 + produces: + - application/json + responses: + "200": + description: 外部帐号列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + items: + $ref: '#/definitions/model.ExternalAccountView' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取外部帐号列表 + tags: + - oauth + /api/v1/oauth/external-accounts/{id}/delete: + post: + description: 解除当前登录用户与指定外部帐号的绑定关系,需要登录 + parameters: + - description: 外部帐号绑定记录 ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: 解除绑定成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: ID 无效或解除失败 + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 解除外部帐号绑定 tags: - oauth /api/v1/oauth/login: get: - description: 生成 OAuth 登录 URL,前端跳转至该地址完成授权 + description: 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。 + parameters: + - description: 认证源名称,为空使用默认源 + in: query + name: source + type: string produces: - application/json responses: "200": - description: OK + description: 授权 URL + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/oauth.OAuthAuthorizeResponse' + type: object + "400": + description: 认证源不存在或未配置 schema: $ref: '#/definitions/util.ResponseAny' - summary: 获取登录地址 + "500": + description: Redis 异常或构造 URL 失败 + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取登录授权地址 tags: - oauth /api/v1/oauth/logout: get: - description: 清除当前用户的登录会话,完成退出 + description: 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。 produces: - application/json responses: "200": - description: OK + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 schema: $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 退出登录 tags: - oauth + /api/v1/oauth/sources: + get: + description: 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用 + produces: + - application/json + responses: + "200": + description: 登录源列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + items: + $ref: '#/definitions/oauth.AuthSourceView' + type: array + type: object + summary: 获取可用登录源 + tags: + - oauth /api/v1/oauth/user-info: get: - description: 返回当前登录用户的基本信息及余额数据,需要登录 + description: 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 produces: - application/json responses: "200": - description: OK + description: 用户信息 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "401": + description: 未登录 schema: $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] summary: 获取当前登录用户信息 tags: - oauth + /api/v1/user/login: + post: + consumes: + - application/json + description: 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 + parameters: + - description: 登录请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.loginRequest' + produces: + - application/json + responses: + "200": + description: 登录成功,返回用户信息 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 用户名或密码错误、帐号已禁用等 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 用户密码登录 + tags: + - user + /api/v1/user/logout: + get: + description: 清除用户登录 Session,完成退出 + produces: + - application/json + responses: + "200": + description: 退出成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "500": + description: Session 清除失败 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 用户退出登录 + tags: + - user + /api/v1/user/register: + post: + consumes: + - application/json + description: 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。 + parameters: + - description: 注册请求参数 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.registerRequest' + produces: + - application/json + responses: + "200": + description: 注册并登录成功,返回用户信息 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/oauth.BasicUserInfo' + type: object + "400": + description: 参数错误、用户名已存在或注册已关闭 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 用户注册 + tags: + - user /f/{id}: get: + description: 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回 parameters: - - description: Upload ID + - description: 文件 ID in: path name: id required: true @@ -362,7 +1311,27 @@ paths: - application/octet-stream responses: "200": - description: OK + description: 成功获取文件内容 + schema: + type: file + "400": + description: 文件 ID 格式错误 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 文件未找到 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + summary: 获取已上传文件 tags: - upload +securityDefinitions: + SessionCookie: + in: cookie + name: session + type: apiKey swagger: "2.0" diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx index 704d3883..dec380c9 100644 --- a/frontend/app/(main)/layout.tsx +++ b/frontend/app/(main)/layout.tsx @@ -1,13 +1,13 @@ "use client" -import { motion } from "motion/react" -import { usePathname } from "next/navigation" -import { useState } from "react" -import { AppSidebar } from "@/components/layout/sidebar" -import { SiteHeader } from "@/components/layout/header" -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar" - -import { UserProvider } from "@/contexts/user-context" +import {motion} from "motion/react" +import {usePathname, useRouter} from "next/navigation" +import {useEffect, useState} from "react" +import {AppSidebar} from "@/components/layout/sidebar" +import {SiteHeader} from "@/components/layout/header" +import {SidebarInset, SidebarProvider} from "@/components/ui/sidebar" +import {LoadingPage} from "@/components/layout/loading" +import {useUser} from "@/contexts/user-context" export default function MainLayout({ @@ -15,40 +15,56 @@ export default function MainLayout({ }: { children: React.ReactNode }) { + const router = useRouter() const pathname = usePathname() + const {user, loading} = useUser() const [isFullWidth, setIsFullWidth] = useState(false) + useEffect(() => { + if (loading || user) return + + const queryString = window.location.search + const callbackUrl = queryString ? `${pathname}${queryString}` : pathname + const loginUrl = new URL("/login", window.location.origin) + + loginUrl.searchParams.set("callbackUrl", callbackUrl) + sessionStorage.setItem("redirect_after_login", callbackUrl) + router.replace(loginUrl.toString()) + }, [loading, pathname, router, user]) + + if (loading || !user) { + return + } + return ( - - - - - -
-
- - {children} - -
-
-
-
-
+ + + + +
+
+ + {children} + +
+
+
+
) } diff --git a/frontend/app/(main)/settings/page.tsx b/frontend/app/(main)/settings/page.tsx index 4802d9d1..ed581087 100644 --- a/frontend/app/(main)/settings/page.tsx +++ b/frontend/app/(main)/settings/page.tsx @@ -1,7 +1,9 @@ -import Link from "next/link" -import { Card, CardTitle, CardDescription, CardContent } from "@/components/ui/card" -import { UserRound, Bell, Shield, Palette } from "lucide-react" +"use client" +import Link from "next/link" +import {Card, CardContent, CardDescription, CardTitle} from "@/components/ui/card" +import {Bell, Loader2, Palette, Shield, UserRound} from "lucide-react" +import {useAuth} from "@/components/providers/auth-provider" /* 设置项 */ const settingsItems = [ @@ -28,7 +30,7 @@ const settingsItems = [ }, { title: "外观设置", - description: "自定义界面主题和显示", + description: "自定义界面主题 and 显示", icon: Palette, href: "/settings/appearance", category: "个人设置", @@ -36,7 +38,25 @@ const settingsItems = [ ] export default function SettingsPage() { - const groupedSettings = settingsItems.reduce((acc, item) => { + const { user, loading } = useAuth() + + if (loading) { + return ( +
+ +
+ ) + } + + // 非管理员过滤掉安全设置 + const filteredItems = settingsItems.filter((item) => { + if (item.href === "/settings/security") { + return !!user?.is_admin + } + return true + }) + + const groupedSettings = filteredItems.reduce((acc, item) => { if (!acc[item.category]) { acc[item.category] = [] } @@ -46,7 +66,7 @@ export default function SettingsPage() { return (
-
设置
+
设置
{Object.entries(groupedSettings).map(([category, items]) => (
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index f18cf43a..330c6021 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,9 +1,11 @@ -import type { Metadata } from "next"; -import { Toaster } from "@/components/ui/sonner"; -import { ThemeProvider } from "@/components/layout/theme-provider"; -import { CustomThemeProvider } from "@/lib/theme"; -import { BellRingProvider } from "@/contexts/bell-ring-context"; -import { NotificationSettingsProvider } from "@/contexts/notification-settings-context"; +import type {Metadata} from "next"; +import {Toaster} from "@/components/ui/sonner"; +import {ThemeProvider} from "@/components/layout/theme-provider"; +import {CustomThemeProvider} from "@/lib/theme"; +import {BellRingProvider} from "@/contexts/bell-ring-context"; +import {NotificationSettingsProvider} from "@/contexts/notification-settings-context"; +import {UserProvider} from "@/contexts/user-context"; +import {AppQueryProvider} from "@/components/providers/query-provider"; import "./globals.css"; export const metadata: Metadata = { @@ -32,12 +34,16 @@ export default function RootLayout({ disableTransitionOnChange > - - - {children} - - - + + + + + {children} + + + + + diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index d48dcb91..66a67281 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -1,182 +1,268 @@ "use client" -import { useState, useEffect } from "react" -import { toast } from "sonner" -import { Spinner } from "@/components/ui/spinner" -import { Button } from "@/components/ui/button" -import { Checkbox } from "@/components/ui/checkbox" -import { motion, useAnimation } from "motion/react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog" -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion" -import { SquareArrowUpRight } from 'lucide-react'; +import {useMemo, useState} from "react" +import {useMutation, useQuery} from "@tanstack/react-query" +import {useRouter, useSearchParams} from "next/navigation" +import {KeyRound, ShieldCheck, UserPlus} from "lucide-react" +import {toast} from "sonner" -import { cn } from "@/lib/utils" +import {useAuth} from "@/components/providers/auth-provider" +import {Button} from "@/components/ui/button" +import {Input} from "@/components/ui/input" +import {Separator} from "@/components/ui/separator" +import {Spinner} from "@/components/ui/spinner" +import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" +import {Card, CardContent} from "@/components/ui/card" import services from "@/lib/services" -import { termsSections } from "@/components/common/docs/terms" -import { privacySections } from "@/components/common/docs/privacy" +function getRedirectTarget(searchParams: ReturnType) { + const callbackUrl = searchParams.get("callbackUrl") + const storedRedirect = sessionStorage.getItem("redirect_after_login") + const target = callbackUrl || storedRedirect || "/home" -/** - * 登录表单组件 - * 显示登录表单和登录按钮 - * - * @example - * ```tsx - * - * ``` - * @param {React.ComponentProps<"div">} props - 组件属性 - * @param {string} className - 组件类名 - * @returns {React.ReactNode} 登录表单组件 - */ -export function LoginForm({ - className, - ...props -}: React.ComponentProps<"div">) { - const [isLoading, setIsLoading] = useState(false) - const [hasAgreed, setHasAgreed] = useState(false) - const controls = useAnimation() - - - useEffect(() => { - const agreed = localStorage.getItem("loginPromptAgreed") === "true" - if (agreed) { - setHasAgreed(true) - } - }, []) - - const handleAgreementChange = (checked: boolean | string) => { - const isChecked = checked === true - setHasAgreed(isChecked) - if (isChecked) { - localStorage.setItem("loginPromptAgreed", "true") - } else { - localStorage.removeItem("loginPromptAgreed") - } + if (storedRedirect) { + sessionStorage.removeItem("redirect_after_login") } - /* 处理登录 */ - const handleLogin = async () => { - if (!hasAgreed) { - toast.error("请先阅读并勾选服务条款和隐私政策") - controls.start({ - x: [0, -4, 4, -4, 4, 0], - color: ["#ef4444", "inherit"], - transition: { duration: 0.5 } - }) - return - } + return target +} - setIsLoading(true) +function persistRedirectTarget(searchParams: ReturnType) { + const callbackUrl = searchParams.get("callbackUrl") + if (callbackUrl) { + sessionStorage.setItem("redirect_after_login", callbackUrl) + } +} + +export function LoginForm() { + const router = useRouter() + const searchParams = useSearchParams() + const { setUser } = useAuth() + const [mode, setMode] = useState<"login" | "register">("login") + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + const [nickname, setNickname] = useState("") + const [errorMessage, setErrorMessage] = useState("") + + const publicConfigQuery = useQuery({ + queryKey: ["public-config"], + queryFn: services.config.getPublicConfig, + }) + + const authSourcesQuery = useQuery({ + queryKey: ["auth-sources"], + queryFn: services.auth.getAuthSources, + enabled: publicConfigQuery.data?.oidc_login_enabled ?? true, + }) + + const redirectTarget = useMemo( + () => getRedirectTarget(searchParams), + [searchParams], + ) + + const loginMutation = useMutation({ + mutationFn: services.auth.login, + onSuccess: (user) => { + setUser(user) + router.replace(redirectTarget) + }, + onError: (error: Error) => { + setErrorMessage(error.message || "登录失败,请重试") + }, + }) + + const registerMutation = useMutation({ + mutationFn: services.auth.register, + onSuccess: (user) => { + setUser(user) + router.replace(redirectTarget) + }, + onError: (error: Error) => { + setErrorMessage(error.message || "注册失败,请重试") + }, + }) + + const handlePasswordLogin = () => { + setErrorMessage("") + loginMutation.mutate({ + username: username.trim(), + password, + }) + } + + const handleRegister = () => { + setErrorMessage("") + registerMutation.mutate({ + username: username.trim(), + password, + nickname: nickname.trim() || undefined, + }) + } + + const handleOAuthLogin = async (sourceName: string) => { try { - await services.auth.initiateLogin() + setErrorMessage("") + persistRedirectTarget(searchParams) + const { authorize_url } = await services.auth.getAuthorizeUrl(sourceName) + window.location.href = authorize_url } catch (error) { - setIsLoading(false) - console.error('Login error:', error) - const message = error instanceof Error ? error.message : "登录失败,请重试" - toast.error(message, { - duration: 5000, - description: error instanceof Error && error.name === 'NetworkError' - ? '请确认后端服务已启动' - : undefined - }) + toast.error(error instanceof Error ? error.message : "第三方登录失败") } } + const registrationEnabled = + (publicConfigQuery.data?.registration_enabled ?? true) && + (publicConfigQuery.data?.password_register_enabled ?? true) + + const passwordLoginEnabled = publicConfigQuery.data?.password_login_enabled ?? true + + const authSources = authSourcesQuery.data ?? [] + return ( -
-
- -
+ + +
+

+ 账号登录 +

+

+ 使用账号密码或第三方 OIDC 认证源登录 +

+
- - -
+ + + +
+
+ setUsername(e.target.value)} + placeholder="用户名" + autoComplete="username" + /> + setNickname(e.target.value)} + placeholder="昵称(可选)" + autoComplete="nickname" + /> + setPassword(e.target.value)} + type="password" + placeholder="密码(至少 8 位)" + autoComplete="new-password" + /> +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + + - - - - 隐私政策 - 我们重视您的隐私,以下说明信息如何收集与使用。 - - - {privacySections.map((section) => ( - - {section.title} - {section.content} - - ))} - - - - - -
+ {registerMutation.isPending ? ( + <> + + 注册中... + + ) : ( + <> + + 创建账号 + + )} + +
+ + + + + +
+
+ + 第三方认证源 +
+
+ {authSources.length > 0 ? ( + authSources.map((source) => ( + + )) + ) : ( +
+ 暂无可用认证源 +
+ )} +
+
+ + ) } diff --git a/frontend/components/auth/login-page.tsx b/frontend/components/auth/login-page.tsx index 7be24683..daa11ea8 100644 --- a/frontend/components/auth/login-page.tsx +++ b/frontend/components/auth/login-page.tsx @@ -1,23 +1,22 @@ "use client" -import { useCallback, useEffect, useState } from "react" -import { motion, AnimatePresence } from "motion/react" -import { useRouter, useSearchParams } from "next/navigation" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" -import { Spinner } from "@/components/ui/spinner" -import { LoginForm } from "@/components/auth/login-form" -import { Check } from "lucide-react" +import {useCallback, useEffect, useState} from "react" +import {AnimatePresence, motion} from "motion/react" +import {useRouter, useSearchParams} from "next/navigation" +import {toast} from "sonner" +import {Spinner} from "@/components/ui/spinner" +import {LoginForm} from "@/components/auth/login-form" +import {Check} from "lucide-react" -import { AuroraBackground } from "@/components/ui/aurora-background" +import {AuroraBackground} from "@/components/ui/aurora-background" import services from "@/lib/services" -import type { ApiResponse } from "@/lib/services/core/types" +import {useAuth} from "@/components/providers/auth-provider" /** * 登录页面组件 * 显示登录表单和登录按钮 - * + * * @example * ```tsx * @@ -27,6 +26,7 @@ import type { ApiResponse } from "@/lib/services/core/types" export function LoginPage() { const router = useRouter() const searchParams = useSearchParams() + const { setUser } = useAuth() /* 处理OAuth回调 */ const [isProcessingCallback, setIsProcessingCallback] = useState(() => { @@ -74,14 +74,13 @@ export function LoginPage() { if (cancelled) return if (response.ok) { - await response.json() as ApiResponse + const payload = await response.json() + if (payload?.data) { + setUser(payload.data) + } router.replace(resolveRedirectTarget()) return } - - if (response.status !== 401) { - console.error('Session probe failed:', response.status) - } } catch (error) { if (!cancelled) { console.error('Session probe error:', error) @@ -98,7 +97,7 @@ export function LoginPage() { return () => { cancelled = true } - }, [router, searchParams, resolveRedirectTarget]) + }, [router, searchParams, resolveRedirectTarget, setUser]) /* 回调逻辑 */ useEffect(() => { @@ -109,9 +108,12 @@ export function LoginPage() { if (state && code) { setIsProcessingCallback(true) try { - await services.auth.handleCallback({ state, code }) + const result = await services.auth.handleCallback({ state, code }) + if (result.user) { + setUser(result.user) + } setLoginSuccess(true) - toast.success("登录成功") + toast.success(result.status === "bound" ? "绑定成功" : "登录成功") setTimeout(() => { router.replace(resolveRedirectTarget()) @@ -125,7 +127,7 @@ export function LoginPage() { } } handleOAuthCallback() - }, [searchParams, router, resolveRedirectTarget]) + }, [searchParams, router, resolveRedirectTarget, setUser]) return ( diff --git a/frontend/components/common/settings/auth-source-modal.tsx b/frontend/components/common/settings/auth-source-modal.tsx new file mode 100644 index 00000000..d4a00b81 --- /dev/null +++ b/frontend/components/common/settings/auth-source-modal.tsx @@ -0,0 +1,166 @@ +"use client" + +import {useEffect, useState} from "react" +import {toast} from "sonner" +import {Button} from "@/components/ui/button" +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog" +import {Input} from "@/components/ui/input" +import {Label} from "@/components/ui/label" +import {Switch} from "@/components/ui/switch" +import {AdminService} from "@/lib/services" +import type {AuthSource, AuthSourceRequest} from "@/lib/services/admin" + +const emptyForm: AuthSourceRequest = { + name: "", + type: "oidc", + display_name: "", + is_active: false, + client_id: "", + client_secret: "", + openid_discovery_url: "", + scopes: "openid profile email", + icon_url: "", +} + +export function AuthSourceModal({ + isOpen, + source, + onClose, + onChanged, +}: { + isOpen: boolean + source: AuthSource | null + onClose: () => void + onChanged: () => Promise +}) { + const [form, setForm] = useState(emptyForm) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (isOpen) { + if (source) { + setForm({ + name: source.name, + type: source.type, + display_name: source.display_name, + is_active: source.is_active, + client_id: source.client_id, + client_secret: "", + openid_discovery_url: source.openid_discovery_url, + scopes: source.scopes || "openid profile email", + icon_url: source.icon_url, + }) + } else { + setForm(emptyForm) + } + } else { + setForm(emptyForm) + setSaving(false) + } + }, [isOpen, source]) + + const saveSource = async () => { + setSaving(true) + try { + if (source) { + await AdminService.updateAuthSource(source.id, form) + } else { + await AdminService.createAuthSource(form) + } + await onChanged() + toast.success("认证源已保存") + onClose() + } catch (error) { + toast.error(error instanceof Error ? error.message : "保存认证源失败") + } finally { + setSaving(false) + } + } + + return ( + !open && onClose()}> + + + {source ? "编辑认证源" : "新增认证源"} + + {source ? "修改系统自定义的 OIDC 认证源参数。" : "配置新的自定义 OIDC 认证源。"} + + + +
+
+ + setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="例如: github" + /> +
+
+ + setForm((prev) => ({ ...prev, display_name: e.target.value }))} + placeholder="例如: GitHub 登录" + /> +
+
+ + setForm((prev) => ({ ...prev, client_id: e.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, client_secret: e.target.value }))} + placeholder={source ? "留空则保留原值" : ""} + /> +
+
+ + setForm((prev) => ({ ...prev, openid_discovery_url: e.target.value }))} + placeholder="https://..." + /> +
+
+ + setForm((prev) => ({ ...prev, scopes: e.target.value }))} + /> +
+
+ + setForm((prev) => ({ ...prev, icon_url: e.target.value }))} + placeholder="https://... 或留空" + /> +
+
+
+
启用认证源
+
启用后会立即显示在登录页和账号绑定中。
+
+ setForm((prev) => ({ ...prev, is_active: checked }))} /> +
+
+ + +
+
+
+
+ ) +} + diff --git a/frontend/components/common/settings/profile.tsx b/frontend/components/common/settings/profile.tsx index 5c19ecac..f3e14b1b 100644 --- a/frontend/components/common/settings/profile.tsx +++ b/frontend/components/common/settings/profile.tsx @@ -2,26 +2,60 @@ import * as React from "react" import Link from "next/link" -import { motion, useAnimation } from "motion/react" -import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" -import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb" -import { useUser } from "@/contexts/user-context" -import { Shield } from "lucide-react" +import {motion, useAnimation} from "motion/react" +import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" +import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@/components/ui/breadcrumb" +import {useUser} from "@/contexts/user-context" +import {ArrowRight, Link2, Loader2, Shield, Unlink} from "lucide-react" +import {Button} from "@/components/ui/button" +import {Separator} from "@/components/ui/separator" +import {AuthService} from "@/lib/services" +import {toast} from "sonner" export function ProfileMain() { const { user, loading, getTrustLevelLabel } = useUser() const controls = useAnimation() const isAnimatingRef = React.useRef(false) + const queryClient = useQueryClient() + + const externalAccountBindingsQuery = useQuery({ + queryKey: ["auth", "external-accounts"], + queryFn: () => AuthService.getExternalAccountBindings(), + }) + + const publicAuthSourcesQuery = useQuery({ + queryKey: ["auth", "public-sources"], + queryFn: () => AuthService.getAuthSources(), + }) + + const bindSourceMutation = useMutation({ + mutationFn: async (sourceName: string) => { + const { authorize_url } = await AuthService.getAuthorizeUrl(sourceName, "bind") + sessionStorage.setItem("redirect_after_login", `${window.location.pathname}${window.location.search}`) + window.location.href = authorize_url + }, + onError: (error: Error) => { + toast.error(error.message || "绑定认证源失败") + }, + }) const handleAvatarClick = () => { if (isAnimatingRef.current) return - + isAnimatingRef.current = true controls.start({ rotate: [0, -20, 20, -20, 20, 0], transition: { duration: 0.5, ease: "easeInOut" } }) - + setTimeout(() => { isAnimatingRef.current = false }, 650) @@ -123,6 +157,100 @@ export function ProfileMain() {
+ + {/* 账号绑定面板 */} +
+
+
+ +
+
+

第三方账号绑定

+

管理并关联您的第三方授权账户,便于快捷登录与验证

+
+
+ + {/* 已绑定账号列表 */} +
+

已绑定账号

+ {externalAccountBindingsQuery.isPending ? ( +
+ +
+ ) : (externalAccountBindingsQuery.data ?? []).length > 0 ? ( +
+ {(externalAccountBindingsQuery.data ?? []).map((binding) => ( +
+
+ {binding.auth_source_label} + + {binding.external_username || binding.email || "未提供账号标识"} + +
+ +
+ ))} +
+ ) : ( +
+ + 暂无绑定的第三方账号 +
+ )} +
+ + + + {/* 绑定新账号列表 */} +
+

绑定新账号

+ {publicAuthSourcesQuery.isPending ? ( +
+ +
+ ) : (publicAuthSourcesQuery.data ?? []).length > 0 ? ( +
+ {(publicAuthSourcesQuery.data ?? []).map((source) => ( + + ))} +
+ ) : ( +
+ 暂无可用第三方认证源 +
+ )} +
+
) } diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx index 2fefbd38..801ea097 100644 --- a/frontend/components/common/settings/security.tsx +++ b/frontend/components/common/settings/security.tsx @@ -1,14 +1,168 @@ "use client" -import * as React from "react" +import {useEffect, useMemo, useState} from "react" +import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" +import {Fingerprint, Globe, Loader2, Lock, Pencil, Plus, Settings, ShieldCheck, Trash2, UserPlus} from "lucide-react" import Link from "next/link" -import { ShieldCheck } from "lucide-react" +import {useRouter} from "next/navigation" +import {motion} from "motion/react" -import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb" +import {Button} from "@/components/ui/button" +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import {Switch} from "@/components/ui/switch" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@/components/ui/breadcrumb" +import {useAuth} from "@/components/providers/auth-provider" +import {AuthSourceModal} from "@/components/common/settings/auth-source-modal" +import {AdminService} from "@/lib/services" +import type {AuthSource, SystemConfig} from "@/lib/services/admin" +import {toast} from "sonner" + +const SECURITY_KEYS = [ + { + key: "password_login_enabled", + title: "允许密码登录", + description: "关闭后仅保留第三方 OIDC 认证源进行系统登录。", + icon: Lock, + }, + { + key: "registration_enabled", + title: "允许注册", + description: "关闭后系统将禁止新用户进行自主账号注册。", + icon: UserPlus, + }, + { + key: "password_register_enabled", + title: "允许密码注册", + description: "关闭后只能通过管理员创建或第三方认证关联建号。", + icon: Fingerprint, + }, + { + key: "oidc_login_enabled", + title: "允许 OIDC 登录", + description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。", + icon: Globe, + }, +] as const + +type SecurityKey = (typeof SECURITY_KEYS)[number]["key"] + +function systemConfigMap(configs: SystemConfig[]) { + return configs.reduce>((accumulator, config) => { + accumulator[config.key] = config + return accumulator + }, {}) +} export function SecurityMain() { + const queryClient = useQueryClient() + const { user, loading } = useAuth() + const router = useRouter() + const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false) + const [selectedSource, setSelectedSource] = useState(null) + + const systemConfigsQuery = useQuery({ + queryKey: ["admin", "system-configs"], + queryFn: () => AdminService.listSystemConfigs("system"), + enabled: !!user?.is_admin, + }) + + const authSourcesQuery = useQuery({ + queryKey: ["auth", "sources"], + queryFn: () => AdminService.listAuthSources(), + enabled: !!user?.is_admin, + }) + + const configs = useMemo( + () => systemConfigMap(systemConfigsQuery.data ?? []), + [systemConfigsQuery.data], + ) + + useEffect(() => { + if (!loading && (!user || !user.is_admin)) { + router.replace("/settings/profile") + } + }, [user, loading, router]) + + useEffect(() => { + if (user?.is_admin) { + void systemConfigsQuery.refetch() + } + }, [systemConfigsQuery, user]) + + const updateConfigMutation = useMutation({ + mutationFn: async ({ key, value }: { key: SecurityKey; value: boolean }) => { + const config = configs[key] + if (!config) { + throw new Error(`缺少配置项: ${key}`) + } + await AdminService.updateSystemConfig(key, { + value: value ? "true" : "false", + description: config.description, + }) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }) + toast.success("系统安全配置已更新") + }, + onError: (error: Error) => { + toast.error(error.message || "更新配置失败") + }, + }) + + const toggleSourceMutation = useMutation({ + mutationFn: async (source: AuthSource) => { + await AdminService.toggleAuthSource(source.id, { is_active: !source.is_active }) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] }) + await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] }) + toast.success("认证源状态已更新") + }, + onError: (error: Error) => { + toast.error(error.message || "切换状态失败") + }, + }) + + const deleteSourceMutation = useMutation({ + mutationFn: async (sourceId: string) => { + await AdminService.deleteAuthSource(sourceId) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] }) + await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] }) + toast.success("认证源已删除") + }, + onError: (error: Error) => { + toast.error(error.message || "删除认证源失败") + }, + }) + + const handleToggle = (key: SecurityKey, checked: boolean) => { + updateConfigMutation.mutate({ key, value: checked }) + } + + if (loading || !user || !user.is_admin) { + return ( +
+ +
+ ) + } + return ( -
+
@@ -25,16 +179,192 @@ export function SecurityMain() {
-
-
- - 安全设置 -
- -
- 当前暂无安全配置选项 +
+
+
+ +
+
+

系统安全设置

+

管理系统安全控制及身份验证源

+
-
+ +
+ {/* 系统登录与注册控制 */} + + +
+
+ +
+
+ 系统安全与注册控制 + 配置系统的登录限制与用户自主注册权限 +
+
+
+ +
+ {SECURITY_KEYS.map((item) => { + const config = configs[item.key] + const checked = config ? config.value === "true" : false + const Icon = item.icon + return ( +
+
+
+ {Icon && } + {item.title} +
+

{item.description}

+
+ handleToggle(item.key, value)} + /> +
+ ) + })} +
+
+
+ + {/* 认证源配置管理 */} + + +
+
+ +
+
+ 认证源管理 + 添加、修改并启用系统自定义的 OIDC 认证源 +
+
+ +
+ + {authSourcesQuery.isPending ? ( +
+ +
+ ) : (authSourcesQuery.data ?? []).length > 0 ? ( + (authSourcesQuery.data ?? []).map((source) => ( +
+
+
+ {source.display_name || source.name} + + {source.is_active ? "已启用" : "已禁用"} + +
+
+ 标识符: {source.name} · 类型: {source.type.toUpperCase()} +
+
+
+ + +
+ toggleSourceMutation.mutate(source)} + /> + + +
+
+
+ )) + ) : ( +
+ 暂无配置的认证源,点击上方按钮新增 + +
+ )} +
+
+
+ + setAuthSourceModalOpen(false)} + onChanged={async () => { + await queryClient.invalidateQueries({ queryKey: ["auth", "sources"] }) + await queryClient.invalidateQueries({ queryKey: ["auth", "public-sources"] }) + await authSourcesQuery.refetch() + }} + /> + ) } + diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index 29a1a569..a9a687ec 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -2,24 +2,24 @@ import * as React from "react" import Link from "next/link" -import { usePathname, useRouter } from "next/navigation" +import {usePathname, useRouter} from "next/navigation" import packageJson from "../../package.json" -import { toast } from "sonner" -import { Button } from "@/components/ui/button" -import { Spinner } from "@/components/ui/spinner" -import { AnimateIcon } from "@/components/animate-ui/icons/icon" -import { ChevronLeft } from "@/components/animate-ui/icons/chevron-left" -import { ChevronRight } from "@/components/animate-ui/icons/chevron-right" -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import {toast} from "sonner" +import {Button} from "@/components/ui/button" +import {Spinner} from "@/components/ui/spinner" +import {AnimateIcon} from "@/components/animate-ui/icons/icon" +import {ChevronLeft} from "@/components/animate-ui/icons/chevron-left" +import {ChevronRight} from "@/components/animate-ui/icons/chevron-right" +import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar" import { DropdownMenu, - DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { getCurrentTheme } from "@/components/layout/avater-style/registry" +import {getCurrentTheme} from "@/components/layout/avater-style/registry" import { Sidebar, SidebarContent, @@ -44,28 +44,30 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog" import { - Home, - CreditCard, - Settings, - FileText, - LogOut, - ChevronDown, - UserRound, - FileQuestionMark, - ShieldCheck, - Layers, - Key, - Palette, ArrowUpRight, + ChevronDown, + CreditCard, + FileQuestionMark, + FileText, + Home, + Layers, + LogOut, + Palette, + Settings, + ShieldCheck, + UserRound, } from "lucide-react" -import { useUser } from "@/contexts/user-context" +import {useUser} from "@/contexts/user-context" /* 导航数据 */ const data = { navMain: [ { title: "首页", url: "/home", icon: Home }, ], + systemSettings: [ + { title: "系统设置", url: "/settings/security", icon: Settings }, + ], admin: [ { title: "系统配置", url: "/admin/system", icon: ShieldCheck }, { title: "用户管理", url: "/admin/users", icon: UserRound }, @@ -279,6 +281,32 @@ export function AppSidebar({ ...props }: React.ComponentProps) { + {user?.is_admin && ( + + + 设置 + + + + {data.systemSettings.map((item) => ( + + + + {item.icon && } + {item.title} + + + + ))} + + + + )} + {user?.is_admin && ( diff --git a/frontend/components/providers/auth-provider.tsx b/frontend/components/providers/auth-provider.tsx new file mode 100644 index 00000000..77b7b614 --- /dev/null +++ b/frontend/components/providers/auth-provider.tsx @@ -0,0 +1,13 @@ +"use client" + +import { useUser } from "@/contexts/user-context" + +/** + * Auth provider bridge hook + * + * Provides a stable interface for components that use the useAuth() pattern. + * Delegates to the canonical UserContext under the hood. + */ +export function useAuth() { + return useUser() +} diff --git a/frontend/components/providers/query-provider.tsx b/frontend/components/providers/query-provider.tsx new file mode 100644 index 00000000..ca96e52b --- /dev/null +++ b/frontend/components/providers/query-provider.tsx @@ -0,0 +1,23 @@ +"use client" + +import {QueryClient, QueryClientProvider} from "@tanstack/react-query" +import {useState} from "react" + +export function AppQueryProvider({ children }: { children: React.ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + refetchOnWindowFocus: false, + retry: 1, + }, + }, + }), + ) + + return ( + {children} + ) +} diff --git a/frontend/contexts/user-context.tsx b/frontend/contexts/user-context.tsx index c561435e..cec9510f 100644 --- a/frontend/contexts/user-context.tsx +++ b/frontend/contexts/user-context.tsx @@ -1,9 +1,9 @@ "use client" -import { createContext, useContext, useState, useEffect, useRef, useCallback, ReactNode } from 'react' +import {createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState} from 'react' import services from '@/lib/services' -import { User, TrustLevel, PayLevel } from '@/lib/services/auth/types' +import {PayLevel, TrustLevel, User} from '@/lib/services/auth/types' /** 用户状态接口 */ @@ -15,6 +15,7 @@ interface UserState { /** 用户上下文接口 */ interface UserContextValue extends UserState { + setUser: (user: User) => void refetch: () => Promise getTrustLevelLabel: (trustLevel: TrustLevel) => string getPayLevelLabel: (payLevel: PayLevel) => string @@ -43,7 +44,7 @@ const UserContext = createContext(undefined) /** * 用户Provider组件 - * + * * @param {React.ReactNode} children - 用户 Provider 的子元素 * @returns {React.ReactNode} 用户 Provider 组件 * @example @@ -99,6 +100,11 @@ export function UserProvider({ children }: { children: ReactNode }) { await fetchUser() }, [fetchUser]) + /** 直接设置用户信息(登录/注册后免二次请求) */ + const setUser = useCallback((user: User) => { + setState({ user, loading: false, error: null }) + }, []) + /** 用户登出 */ const logout = useCallback(async () => { try { @@ -136,6 +142,7 @@ export function UserProvider({ children }: { children: ReactNode }) { (`/system-configs/${ key }`); } + // ==================== 认证源管理 ==================== + + static async listAuthSources(): Promise { + return this.get('/auth-sources'); + } + + static async createAuthSource(request: AuthSourceRequest): Promise { + return this.post('/auth-sources', request); + } + + static async updateAuthSource(id: string, request: AuthSourceRequest): Promise { + return this.put(`/auth-sources/${ id }`, request); + } + + static async toggleAuthSource(id: string, request: ToggleAuthSourceRequest): Promise { + return this.put(`/auth-sources/${ id }/toggle`, request); + } + + static async deleteAuthSource(id: string): Promise { + return this.delete(`/auth-sources/${ id }`); + } + // ==================== 任务管理 ==================== @@ -134,7 +159,7 @@ export class AdminService extends BaseService { * @returns 任务类型列表 * @throws {UnauthorizedError} 当未登录时 * @throws {ForbiddenError} 当无管理员权限时 - * + * * @example * ```typescript * const taskTypes = await AdminService.getTaskTypes(); @@ -162,7 +187,7 @@ export class AdminService extends BaseService { * @throws {UnauthorizedError} 当未登录时 * @throws {ForbiddenError} 当无管理员权限时 * @throws {ValidationError} 当参数验证失败时 - * + * * @example * ```typescript * // 下发订单同步任务(带时间范围) @@ -171,19 +196,19 @@ export class AdminService extends BaseService { * start_time: '2025-12-01T00:00:00Z', * end_time: '2025-12-27T23:59:59Z' * }); - * + * * // 下发用户积分更新任务 * await AdminService.dispatchTask({ * task_type: 'user_gamification', * user_id: 123 * }); - * + * * // 下发争议自动退款任务 * await AdminService.dispatchTask({ * task_type: 'dispute_auto_refund' * }); * ``` - * + * * @remarks * - 不同任务类型需要不同的参数 * - order_sync 支持 start_time 和 end_time 参数 @@ -203,7 +228,7 @@ export class AdminService extends BaseService { * @throws {UnauthorizedError} 当未登录时 * @throws {ForbiddenError} 当无管理员权限时 * @throws {ValidationError} 当参数验证失败时 - * + * * @example * ```typescript * const result = await AdminService.listUsers({ @@ -215,7 +240,7 @@ export class AdminService extends BaseService { * console.log('用户总数:', result.total); * console.log('用户列表:', result.users); * ``` - * + * * @remarks * - page 从 1 开始 * - page_size 范围 1-100 @@ -234,16 +259,16 @@ export class AdminService extends BaseService { * @throws {UnauthorizedError} 当未登录时 * @throws {ForbiddenError} 当无管理员权限或禁用管理员用户时 * @throws {NotFoundError} 当用户不存在时 - * + * * @example * ```typescript * // 禁用用户 * await AdminService.updateUserStatus(123, { is_active: false }); - * + * * // 启用用户 * await AdminService.updateUserStatus(123, { is_active: true }); * ``` - * + * * @remarks * - 不能禁用管理员用户 */ diff --git a/frontend/lib/services/admin/index.ts b/frontend/lib/services/admin/index.ts index 9d904148..383b1798 100644 --- a/frontend/lib/services/admin/index.ts +++ b/frontend/lib/services/admin/index.ts @@ -24,6 +24,9 @@ export type { SystemConfig, CreateSystemConfigRequest, UpdateSystemConfigRequest, + AuthSource, + AuthSourceRequest, + ToggleAuthSourceRequest, TaskMeta, DispatchTaskRequest, AdminUser, diff --git a/frontend/lib/services/admin/types.ts b/frontend/lib/services/admin/types.ts index 80d34baa..0ded9646 100644 --- a/frontend/lib/services/admin/types.ts +++ b/frontend/lib/services/admin/types.ts @@ -171,3 +171,38 @@ export interface UpdateUserStatusRequest { /** 是否激活 */ is_active: boolean; } + +/** + * 认证源信息 + */ +export interface AuthSource { + id: string; + name: string; + type: 'oidc'; + display_name: string; + is_active: boolean; + client_id: string; + client_secret?: string; + client_secret_configured?: boolean; + openid_discovery_url: string; + scopes: string; + icon_url: string; + created_at: string; + updated_at: string; +} + +export interface AuthSourceRequest { + name: string; + type: 'oidc'; + display_name: string; + is_active: boolean; + client_id: string; + client_secret: string; + openid_discovery_url: string; + scopes: string; + icon_url: string; +} + +export interface ToggleAuthSourceRequest { + is_active: boolean; +} diff --git a/frontend/lib/services/auth/auth.service.ts b/frontend/lib/services/auth/auth.service.ts index e42d493b..9a5f60a0 100644 --- a/frontend/lib/services/auth/auth.service.ts +++ b/frontend/lib/services/auth/auth.service.ts @@ -1,7 +1,13 @@ -import { BaseService } from '../core/base.service'; +import {BaseService} from '../core/base.service'; import type { - OAuthLoginUrlResponse, + AuthSource, + ExternalAccountBinding, + LoginRequest, + OAuthAuthorizeResponse, OAuthCallbackRequest, + OAuthCallbackResult, + OAuthLoginUrlResponse, + RegisterRequest, User, } from './types'; @@ -10,13 +16,13 @@ import type { * 处理 OAuth 认证、用户信息获取、登出等 */ export class AuthService extends BaseService { - protected static readonly basePath = '/api/v1/oauth'; + protected static readonly basePath = '/api/v1'; /** * 获取 OAuth 登录 URL * @returns OAuth 授权 URL * @throws {ApiErrorBase} 当获取失败时 - * + * * @example * ```typescript * const url = await AuthService.getLoginUrl(); @@ -24,7 +30,15 @@ export class AuthService extends BaseService { * ``` */ static async getLoginUrl(): Promise { - return this.get('/login'); + return this.get('/oauth/login'); + } + + static async getAuthSources(): Promise { + return this.get('/oauth/sources'); + } + + static async getAuthorizeUrl(source: string, purpose: 'login' | 'bind' = 'login'): Promise { + return this.get(`/oauth/${encodeURIComponent(source)}/authorize?purpose=${purpose}`); } /** @@ -32,14 +46,14 @@ export class AuthService extends BaseService { * @param request - OAuth 回调参数(state 和 code) * @throws {ApiErrorBase} 当回调处理失败时 * @throws {ValidationError} 当 state 无效时 - * + * * @example * ```typescript * // 在回调页面获取 URL 参数 * const params = new URLSearchParams(window.location.search); * const state = params.get('state'); * const code = params.get('code'); - * + * * if (state && code) { * await AuthService.handleCallback({ state, code }); * // 登录成功,跳转到首页 @@ -47,15 +61,15 @@ export class AuthService extends BaseService { * } * ``` */ - static async handleCallback(request: OAuthCallbackRequest): Promise { - return this.post('/callback', request); + static async handleCallback(request: OAuthCallbackRequest): Promise { + return this.post('/oauth/callback', request); } /** * 获取当前登录用户信息 * @returns 用户信息 * @throws {UnauthorizedError} 当未登录时 - * + * * @example * ```typescript * try { @@ -76,7 +90,7 @@ export class AuthService extends BaseService { /** * 用户登出 * @throws {ApiErrorBase} 当登出失败时 - * + * * @example * ```typescript * await AuthService.logout(); @@ -85,33 +99,22 @@ export class AuthService extends BaseService { * ``` */ static async logout(): Promise { - await this.get('/logout'); + await this.get('/oauth/logout'); } - /** - * 发起登录流程 - * 直接获取登录 URL 并重定向 - * - * @example - * ```typescript - * // 在登录按钮点击时调用 - * await AuthService.initiateLogin(); - * ``` - */ - static async initiateLogin(): Promise { - if (typeof window !== 'undefined') { - const params = new URLSearchParams(window.location.search); - const callbackUrl = params.get('callbackUrl'); + static async login(request: LoginRequest): Promise { + return this.post('/user/login', request); + } - if (callbackUrl) { - sessionStorage.setItem('redirect_after_login', callbackUrl); - } - } + static async register(request: RegisterRequest): Promise { + return this.post('/user/register', request); + } - const url = await this.getLoginUrl(); - if (typeof window !== 'undefined' && url) { - window.location.href = url; - } + static async getExternalAccountBindings(): Promise { + return this.get('/oauth/external-accounts'); + } + + static async deleteExternalAccountBinding(id: string): Promise { + return this.delete(`/oauth/external-accounts/${encodeURIComponent(id)}/delete`); } } - diff --git a/frontend/lib/services/auth/index.ts b/frontend/lib/services/auth/index.ts index 4dc3cf63..7488ea0e 100644 --- a/frontend/lib/services/auth/index.ts +++ b/frontend/lib/services/auth/index.ts @@ -31,5 +31,10 @@ export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, + LoginRequest, + RegisterRequest, + OAuthAuthorizeResponse, + OAuthCallbackResult, + AuthSource, + ExternalAccountBinding, } from './types'; - diff --git a/frontend/lib/services/auth/types.ts b/frontend/lib/services/auth/types.ts index c1edf4b0..196305ac 100644 --- a/frontend/lib/services/auth/types.ts +++ b/frontend/lib/services/auth/types.ts @@ -81,3 +81,44 @@ export interface OAuthCallbackRequest { /** 授权码 */ code: string; } + +export interface LoginRequest { + username: string; + password: string; +} + +export interface RegisterRequest { + username: string; + password: string; + nickname?: string; +} + +export interface OAuthAuthorizeResponse { + authorize_url: string; +} + +export interface OAuthCallbackResult { + status: 'logged_in' | 'bound'; + user?: User; +} + +export interface AuthSource { + id: string; + name: string; + type: 'oidc'; + display_name: string; + is_active: boolean; + icon_url: string; + client_secret_configured: boolean; +} + +export interface ExternalAccountBinding { + id: string; + auth_source_id: string; + auth_source_name: string; + auth_source_type: string; + auth_source_label: string; + external_username: string; + email: string; + created_at: string; +} diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts index 9211ff4e..4dc723c9 100644 --- a/frontend/lib/services/config/types.ts +++ b/frontend/lib/services/config/types.ts @@ -6,8 +6,14 @@ export interface PublicConfigResponse { upload_allowed_extensions: string; /** 站点名称 */ site_name: string; + /** 是否允许密码登录 */ + password_login_enabled: boolean; /** 是否允许注册 */ registration_enabled: boolean; + /** 是否允许密码注册 */ + password_register_enabled: boolean; + /** 是否允许 OIDC 登录 */ + oidc_login_enabled: boolean; /** 每个用户最大 API Key 数量 */ max_api_keys_per_user: number; } diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index 5e28a8f8..ef2ed82b 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -20,11 +20,11 @@ * ``` */ -import { AuthService } from './auth'; -import { AdminService } from './admin'; -import { UserService } from './user'; -import { ConfigService } from './config'; -import { UploadService } from './upload'; +import {AuthService} from './auth'; +import {AdminService} from './admin'; +import {UserService} from './user'; +import {ConfigService} from './config'; +import {UploadService} from './upload'; /** * 服务对象 @@ -82,7 +82,7 @@ export type { // 认证服务 export { AuthService, TrustLevel } from './auth'; -export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest } from './auth'; +export type { User, OAuthLoginUrlResponse, OAuthCallbackRequest, AuthSource, ExternalAccountBinding } from './auth'; // 配置服务 export { ConfigService } from './config'; diff --git a/frontend/package.json b/frontend/package.json index 0e967f75..750a7f1e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -37,6 +37,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", "@tabler/icons-react": "^3.35.0", + "@tanstack/react-query": "^5.101.0", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.16", "axios": "^1.15.0", @@ -81,6 +82,6 @@ "eslint-config-next": "15.5.6", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", - "typescript": "^5" + "typescript": "^5.9.3" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1fd51fb7..33e4ff04 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: '@tabler/icons-react': specifier: ^3.35.0 version: 3.35.0(react@19.2.3) + '@tanstack/react-query': + specifier: ^5.101.0 + version: 5.101.0(react@19.2.3) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -217,7 +220,7 @@ importers: specifier: ^1.4.0 version: 1.4.0 typescript: - specifier: ^5 + specifier: ^5.9.3 version: 5.9.3 packages: @@ -389,78 +392,92 @@ packages: resolution: {integrity: sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.3': resolution: {integrity: sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.3': resolution: {integrity: sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.3': resolution: {integrity: sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.3': resolution: {integrity: sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.3': resolution: {integrity: sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.3': resolution: {integrity: sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.4': resolution: {integrity: sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.4': resolution: {integrity: sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.4': resolution: {integrity: sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.4': resolution: {integrity: sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.4': resolution: {integrity: sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.4': resolution: {integrity: sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.4': resolution: {integrity: sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.4': resolution: {integrity: sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==} @@ -527,24 +544,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.1': resolution: {integrity: sha512-MFHrgL4TXNQbBPzkKKur4Fb5ICEJa87HM7fczFs2+HWblM7mMLdco3dvyTI+QmLBU9xgns/EeeINSZD6Ar+oLg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.1': resolution: {integrity: sha512-20bYDfgOQAPUkkKBnyP9PTuHiJGM7HzNBbuqmD0jiFVZ0aOldz+VnJhbxzjcSabYsnNjMPsE0cyzEudpYxsrUQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.1': resolution: {integrity: sha512-9pRbK3M4asAHQRkwaXwu601oPZHghuSC8IXNENgbBSyImHv/zY4K5udBusgdHkvJ/Tcr96jJwQYOll0qU8+fPA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.1': resolution: {integrity: sha512-bdfQkggaLgnmYrFkSQfsHfOhk/mCYmjnrbRCGgkMcoOBZ4n+TRRSLmT/CU5SATzlBJ9TpioUyBW/vWFXTqQRiA==} @@ -1418,24 +1439,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.16': resolution: {integrity: sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.16': resolution: {integrity: sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.16': resolution: {integrity: sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.16': resolution: {integrity: sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==} @@ -1473,6 +1498,14 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/query-core@5.101.0': + resolution: {integrity: sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==} + + '@tanstack/react-query@5.101.0': + resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} @@ -1637,6 +1670,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1677,41 +1711,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2725,24 +2767,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -3255,6 +3301,7 @@ packages: recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -4871,6 +4918,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.16 + '@tanstack/query-core@5.101.0': {} + + '@tanstack/react-query@5.101.0(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.101.0 + react: 19.2.3 + '@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/table-core': 8.21.3 diff --git a/go.mod b/go.mod index 58214dab..885c1773 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/ClickHouse/ch-go v0.66.1 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/alicebob/miniredis/v2 v2.38.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect @@ -67,10 +68,13 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.11 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/glebarez/sqlite v1.11.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect @@ -111,6 +115,7 @@ require ( github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/paulmach/orb v0.12.0 // indirect @@ -119,6 +124,7 @@ require ( github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.55.0 // indirect github.com/redis/go-redis/extra/rediscmd/v9 v9.16.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -129,6 +135,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/uptrace/opentelemetry-go-extra/otelutil v0.3.2 // indirect + github.com/yuin/gopher-lua v1.1.1 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 // indirect go.opentelemetry.io/otel/log v0.12.2 // indirect @@ -152,4 +159,9 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect gorm.io/driver/mysql v1.6.0 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect ) diff --git a/go.sum b/go.sum index f1504139..9ce55ffa 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/ClickHouse/clickhouse-go/v2 v2.37.2 h1:wRLNKoynvHQEN4znnVHNLaYnrqVc9s github.com/ClickHouse/clickhouse-go/v2 v2.37.2/go.mod h1:pH2zrBGp5Y438DMwAxXMm1neSXPPjSI7tD4MURVULw8= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw= +github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= @@ -74,6 +76,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= @@ -90,6 +94,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= @@ -230,6 +238,9 @@ github.com/redis/go-redis/extra/redisotel/v9 v9.16.0 h1:+a9h9qxFXdf3gX0FXnDcz7X4 github.com/redis/go-redis/extra/redisotel/v9 v9.16.0/go.mod h1:EtTTC7vnKWgznfG6kBgl9ySLqd7NckRCFUBzVXdeHeI= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -291,6 +302,8 @@ github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7Jul github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= @@ -429,3 +442,11 @@ gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= gorm.io/plugin/opentelemetry v0.1.14 h1:xivP39t/0JgcceDl+BLwVAJHihjFEUj0ZocMSBwZ7ZY= gorm.io/plugin/opentelemetry v0.1.14/go.mod h1:ZAp4v5vU1CCcK9Oo8/va5rl6NStrzpSU+a70evd+W/g= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/internal/apps/admin/auth_source/routers.go b/internal/apps/admin/auth_source/routers.go new file mode 100644 index 00000000..b6b34944 --- /dev/null +++ b/internal/apps/admin/auth_source/routers.go @@ -0,0 +1,215 @@ +package auth_source + +import ( + "errors" + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" +) + +type AuthSourceRequest struct { + Name string `json:"name"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + IsActive bool `json:"is_active"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + OpenIDDiscoveryURL string `json:"openid_discovery_url"` + Scopes string `json:"scopes"` + IconURL string `json:"icon_url"` +} + +type ToggleAuthSourceRequest struct { + IsActive bool `json:"is_active"` +} + +// ListAuthSources 获取认证源列表 +// @Summary 获取认证源列表 +// @Description 返回所有已配置的 OAuth/OIDC 认证源列表,包括已启用和未启用的,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=[]model.AuthSource} "认证源列表" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "内部错误" +// @Router /api/v1/admin/auth-sources [get] +func ListAuthSources(c *gin.Context) { + sources, err := model.GetAuthSources() + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OK(sources)) +} + +// CreateAuthSource 创建认证源 +// @Summary 创建认证源 +// @Description 创建一个新的 OAuth/OIDC 认证源配置,认证源名称必须唯一且符合命名规范,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param request body auth_source.AuthSourceRequest true "创建认证源参数" +// @Success 200 {object} util.ResponseAny{data=model.AuthSource} "创建成功,返回认证源信息" +// @Failure 400 {object} util.ResponseAny "参数错误或验证失败" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/auth-sources [post] +func CreateAuthSource(c *gin.Context) { + var req AuthSourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + source := model.AuthSource{ + Name: req.Name, + Type: req.Type, + DisplayName: req.DisplayName, + IsActive: req.IsActive, + ClientID: req.ClientID, + ClientSecret: req.ClientSecret, + OpenIDDiscoveryURL: req.OpenIDDiscoveryURL, + Scopes: req.Scopes, + IconURL: req.IconURL, + } + if err := model.CreateAuthSource(&source); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + source.Sanitize() + c.JSON(http.StatusOK, util.OK(source)) +} + +// UpdateAuthSource 更新认证源 +// @Summary 更新认证源 +// @Description 更新指定 ID 的认证源配置。若 client_secret 字段为空,则保留原有密钥不变,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path uint64 true "认证源 ID 或名称" +// @Param request body auth_source.AuthSourceRequest true "更新认证源参数" +// @Success 200 {object} util.ResponseAny{data=model.AuthSource} "更新成功,返回更新后的认证源信息" +// @Failure 400 {object} util.ResponseAny "参数错误或验证失败" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "内部错误" +// @Router /api/v1/admin/auth-sources/{id} [put] +func UpdateAuthSource(c *gin.Context) { + id, err := parseSourceID(c) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + var req AuthSourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + source := model.AuthSource{ + ID: id, + Name: req.Name, + Type: req.Type, + DisplayName: req.DisplayName, + IsActive: req.IsActive, + ClientID: req.ClientID, + ClientSecret: req.ClientSecret, + OpenIDDiscoveryURL: req.OpenIDDiscoveryURL, + Scopes: req.Scopes, + IconURL: req.IconURL, + } + keepSecret := source.ClientSecret == "" + if err := model.UpdateAuthSource(&source, keepSecret); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + updated, err := model.GetAuthSourceByID(id) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + updated.Sanitize() + c.JSON(http.StatusOK, util.OK(updated)) +} + +// ToggleAuthSource 切换认证源启用状态 +// @Summary 切换认证源启用状态 +// @Description 启用或禁用指定认证源。尝试启用时将验证 Client ID 和 Client Secret 是否已配置,需要管理员权限 +// @Tags admin +// @Accept json +// @Produce json +// @Security SessionCookie +// @Param id path uint64 true "认证源 ID 或名称" +// @Param request body auth_source.ToggleAuthSourceRequest true "启用状态" +// @Success 200 {object} util.ResponseAny{data=string} "切换成功" +// @Failure 400 {object} util.ResponseAny "验证失败或认证源不存在" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/auth-sources/{id}/toggle [put] +func ToggleAuthSource(c *gin.Context) { + id, err := parseSourceID(c) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + var req ToggleAuthSourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + if err := model.ToggleAuthSource(id, req.IsActive); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OKNil()) +} + +// DeleteAuthSource 删除认证源 +// @Summary 删除认证源 +// @Description 删除指定认证源及其关联的所有外部帐号绑定记录,警告:删除后相关用户将无法通过该源登录,需要管理员权限 +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Param id path uint64 true "认证源 ID 或名称" +// @Success 200 {object} util.ResponseAny{data=string} "删除成功" +// @Failure 400 {object} util.ResponseAny "ID 无效或删除失败" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Router /api/v1/admin/auth-sources/{id} [delete] +func DeleteAuthSource(c *gin.Context) { + id, err := parseSourceID(c) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + if err := model.DeleteAuthSource(id); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OKNil()) +} + +func parseSourceID(c *gin.Context) (uint64, error) { + raw := c.Param("id") + if raw == "" { + return 0, errors.New("认证源 ID 无效") + } + source, err := model.GetAuthSourceByName(raw) + if err == nil { + return source.ID, nil + } + var id uint64 + if _, scanErr := fmt.Sscanf(raw, "%d", &id); scanErr != nil || id == 0 { + return 0, errors.New("认证源 ID 无效") + } + return id, nil +} diff --git a/internal/apps/admin/auth_source/routers_test.go b/internal/apps/admin/auth_source/routers_test.go new file mode 100644 index 00000000..8df2fe76 --- /dev/null +++ b/internal/apps/admin/auth_source/routers_test.go @@ -0,0 +1,345 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package auth_source + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/testhelper" + "github.com/linux-do/credit/internal/util" +) + +func setupTestRouter(authUser *model.User) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + adminGroup := r.Group("/api/v1/admin") + + // Mock authentication middleware + adminGroup.Use(func(c *gin.Context) { + if authUser != nil { + util.SetToContext(c, oauth.UserObjKey, authUser) + } + c.Next() + }) + + adminGroup.GET("/auth-sources", ListAuthSources) + adminGroup.POST("/auth-sources", CreateAuthSource) + adminGroup.PUT("/auth-sources/:id", UpdateAuthSource) + adminGroup.PUT("/auth-sources/:id/toggle", ToggleAuthSource) + adminGroup.DELETE("/auth-sources/:id", DeleteAuthSource) + return r +} + +func TestListAuthSources(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + // Seed source + source := model.AuthSource{ + ID: 1, + Name: "google", + Type: "oidc", + DisplayName: "Google Auth", + IsActive: true, + ClientID: "client_id_123", + ClientSecret: "client_secret_456", + OpenIDDiscoveryURL: "https://accounts.google.com", + } + dbConn.Create(&source) + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + req, _ := http.NewRequest("GET", "/api/v1/admin/auth-sources", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d", w.Code) + } + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + + dataBytes, _ := json.Marshal(resp.Data) + var sources []model.AuthSource + json.Unmarshal(dataBytes, &sources) + + if len(sources) != 1 { + t.Errorf("expected 1 auth source, got %d", len(sources)) + } + if sources[0].Name != "google" { + t.Errorf("expected name 'google', got '%s'", sources[0].Name) + } + // Verify sanitize removed the secret + if sources[0].ClientSecret != "" { + t.Error("client secret should be sanitized") + } + if !sources[0].ClientSecretConfigured { + t.Error("client secret configured flag should be true") + } +} + +func TestCreateAuthSource(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("create successfully", func(t *testing.T) { + reqPayload := AuthSourceRequest{ + Name: "github", + Type: "oidc", + DisplayName: "GitHub OIDC", + IsActive: true, + ClientID: "client_id_gh", + ClientSecret: "client_secret_gh", + OpenIDDiscoveryURL: "https://github.com", + } + body, _ := json.Marshal(reqPayload) + req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify database + var src model.AuthSource + dbConn.Where("name = ?", "github").First(&src) + if src.ClientID != "client_id_gh" { + t.Errorf("expected client_id_gh, got '%s'", src.ClientID) + } + }) + + t.Run("create invalid validation failure", func(t *testing.T) { + reqPayload := AuthSourceRequest{ + Name: "invalid name!", + Type: "oidc", + DisplayName: "Invalid", + IsActive: true, + ClientID: "client_id_val", + ClientSecret: "client_secret_val", + OpenIDDiscoveryURL: "https://discovery.url", + } + body, _ := json.Marshal(reqPayload) + req, _ := http.NewRequest("POST", "/api/v1/admin/auth-sources", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request, got %d", w.Code) + } + }) +} + +func TestUpdateAuthSource(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + // Seed source + source := model.AuthSource{ + ID: 1, + Name: "microsoft", + Type: "oidc", + DisplayName: "Microsoft", + IsActive: true, + ClientID: "old_client_id", + ClientSecret: "old_secret", + OpenIDDiscoveryURL: "https://login.microsoftonline.com", + } + dbConn.Create(&source) + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("update keep client secret", func(t *testing.T) { + reqPayload := AuthSourceRequest{ + Name: "microsoft", + Type: "oidc", + DisplayName: "Microsoft Updated", + IsActive: true, + ClientID: "new_client_id", + ClientSecret: "", // empty implies keeping existing secret + OpenIDDiscoveryURL: "https://login.microsoftonline.com", + } + body, _ := json.Marshal(reqPayload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + var src model.AuthSource + dbConn.First(&src, 1) + if src.DisplayName != "Microsoft Updated" { + t.Errorf("expected display name update, got '%s'", src.DisplayName) + } + if src.ClientSecret != "old_secret" { + t.Errorf("expected old secret to be preserved, got '%s'", src.ClientSecret) + } + }) + + t.Run("update new client secret", func(t *testing.T) { + reqPayload := AuthSourceRequest{ + Name: "microsoft", + Type: "oidc", + DisplayName: "Microsoft Updated Again", + IsActive: true, + ClientID: "new_client_id", + ClientSecret: "brand_new_secret", + OpenIDDiscoveryURL: "https://login.microsoftonline.com", + } + body, _ := json.Marshal(reqPayload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/microsoft", bytes.NewBuffer(body)) // Using Name instead of ID + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + var src model.AuthSource + dbConn.First(&src, 1) + if src.ClientSecret != "brand_new_secret" { + t.Errorf("expected secret update, got '%s'", src.ClientSecret) + } + }) +} + +func TestToggleAuthSource(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + source := model.AuthSource{ + ID: 1, + Name: "test_source", + Type: "oidc", + DisplayName: "Test Source", + IsActive: false, + ClientID: "", + ClientSecret: "", + OpenIDDiscoveryURL: "https://test.discovery.url", + } + dbConn.Create(&source) + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("cannot activate without credentials", func(t *testing.T) { + payload := ToggleAuthSourceRequest{IsActive: true} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request when activating without client_id/secret, got %d", w.Code) + } + }) + + t.Run("toggle success after setting credentials", func(t *testing.T) { + // Set credentials first + dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Updates(map[string]interface{}{ + "client_id": "id", + "client_secret": "secret", + }) + + payload := ToggleAuthSourceRequest{IsActive: true} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/auth-sources/1/toggle", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + var src model.AuthSource + dbConn.First(&src, 1) + if !src.IsActive { + t.Error("auth source should be activated") + } + }) +} + +func TestDeleteAuthSource(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + source := model.AuthSource{ + ID: 1, + Name: "delete_me", + Type: "oidc", + DisplayName: "Delete Me", + IsActive: true, + ClientID: "id", + ClientSecret: "secret", + OpenIDDiscoveryURL: "https://delete.me", + } + dbConn.Create(&source) + + externalAccount := model.ExternalAccount{ + ID: 10, + AuthSourceID: 1, + UserID: 50, + ExternalID: "ext_50", + } + dbConn.Create(&externalAccount) + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + req, _ := http.NewRequest("DELETE", "/api/v1/admin/auth-sources/1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d", w.Code) + } + + // Verify AuthSource is deleted + var srcCount int64 + dbConn.Model(&model.AuthSource{}).Where("id = ?", 1).Count(&srcCount) + if srcCount != 0 { + t.Error("AuthSource should be deleted from the database") + } + + // Verify ExternalAccount bindings are also deleted + var extCount int64 + dbConn.Model(&model.ExternalAccount{}).Where("auth_source_id = ?", 1).Count(&extCount) + if extCount != 0 { + t.Error("related ExternalAccount bindings should be deleted") + } +} diff --git a/internal/apps/admin/system_config/routers.go b/internal/apps/admin/system_config/routers.go index 9755b1ff..335fe364 100644 --- a/internal/apps/admin/system_config/routers.go +++ b/internal/apps/admin/system_config/routers.go @@ -47,8 +47,13 @@ type UpdateSystemConfigRequest struct { // @Tags admin // @Accept json // @Produce json -// @Param request body CreateSystemConfigRequest true "创建请求参数" -// @Success 200 {object} util.ResponseAny +// @Security SessionCookie +// @Param request body system_config.CreateSystemConfigRequest true "创建请求参数" +// @Success 200 {object} util.ResponseAny{data=string} "创建成功" +// @Failure 400 {object} util.ResponseAny "参数错误或配置键已存在" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/admin/system-configs [post] func CreateSystemConfig(c *gin.Context) { var req CreateSystemConfigRequest @@ -98,8 +103,12 @@ func CreateSystemConfig(c *gin.Context) { // @Description 返回所有系统配置列表,支持按配置类型(system/business)过滤,需要管理员权限 // @Tags admin // @Produce json +// @Security SessionCookie // @Param type query string false "配置类型(system/business)" -// @Success 200 {object} util.ResponseAny +// @Success 200 {object} util.ResponseAny{data=[]model.SystemConfig} "系统配置列表" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/admin/system-configs [get] func ListSystemConfigs(c *gin.Context) { configType := c.Query("type") @@ -122,8 +131,13 @@ func ListSystemConfigs(c *gin.Context) { // @Description 根据配置键获取对应的系统配置详情,需要管理员权限 // @Tags admin // @Produce json +// @Security SessionCookie // @Param key path string true "配置键" -// @Success 200 {object} util.ResponseAny +// @Success 200 {object} util.ResponseAny{data=model.SystemConfig} "系统配置详情" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 404 {object} util.ResponseAny "配置不存在" +// @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/admin/system-configs/{key} [get] func GetSystemConfig(c *gin.Context) { var config model.SystemConfig @@ -145,9 +159,15 @@ func GetSystemConfig(c *gin.Context) { // @Tags admin // @Accept json // @Produce json +// @Security SessionCookie // @Param key path string true "配置键" -// @Param request body UpdateSystemConfigRequest true "更新请求参数" -// @Success 200 {object} util.ResponseAny +// @Param request body system_config.UpdateSystemConfigRequest true "更新请求参数" +// @Success 200 {object} util.ResponseAny{data=string} "更新成功" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 404 {object} util.ResponseAny "配置不存在" +// @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/admin/system-configs/{key} [put] func UpdateSystemConfig(c *gin.Context) { var req UpdateSystemConfigRequest @@ -197,8 +217,13 @@ func UpdateSystemConfig(c *gin.Context) { // @Description 根据配置键删除对应配置,同时从 Redis 中移除对应缓存,需要管理员权限 // @Tags admin // @Produce json +// @Security SessionCookie // @Param key path string true "配置键" -// @Success 200 {object} util.ResponseAny +// @Success 200 {object} util.ResponseAny{data=string} "删除成功" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 404 {object} util.ResponseAny "配置不存在" +// @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/admin/system-configs/{key} [delete] func DeleteSystemConfig(c *gin.Context) { key := c.Param("key") diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go new file mode 100644 index 00000000..8e39db8a --- /dev/null +++ b/internal/apps/admin/system_config/routers_test.go @@ -0,0 +1,302 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package system_config + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/testhelper" + "github.com/linux-do/credit/internal/util" +) + +func setupTestRouter(authUser *model.User) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + adminGroup := r.Group("/api/v1/admin") + + // Mock authentication middleware + adminGroup.Use(func(c *gin.Context) { + if authUser != nil { + util.SetToContext(c, oauth.UserObjKey, authUser) + } + c.Next() + }) + + adminGroup.POST("/system-configs", CreateSystemConfig) + adminGroup.GET("/system-configs", ListSystemConfigs) + + systemConfigRouter := adminGroup.Group("/system-configs/:key") + { + systemConfigRouter.GET("", GetSystemConfig) + systemConfigRouter.PUT("", UpdateSystemConfig) + systemConfigRouter.DELETE("", DeleteSystemConfig) + } + + return r +} + +func TestCreateSystemConfig(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("create successfully", func(t *testing.T) { + payload := CreateSystemConfigRequest{ + Key: "custom_key", + Value: "custom_value", + Type: "system", + Description: "desc", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify database + var cfg model.SystemConfig + err := dbConn.Where("key = ?", "custom_key").First(&cfg).Error + if err != nil { + t.Fatalf("failed to find system config in DB: %v", err) + } + + // Verify Redis Cache + var redisConfig model.SystemConfig + err = db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, "custom_key", &redisConfig) + if err != nil { + t.Fatalf("failed to find system config in Redis: %v", err) + } + if redisConfig.Value != "custom_value" { + t.Errorf("expected value 'custom_value', got '%s'", redisConfig.Value) + } + }) + + t.Run("create duplicate key error", func(t *testing.T) { + // Key "custom_key" already exists from previous test + payload := CreateSystemConfigRequest{ + Key: "custom_key", + Value: "another_value", + Type: "system", + Description: "desc", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/admin/system-configs", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request on duplicate key, got %d", w.Code) + } + }) +} + +func TestListSystemConfigs(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("list all seeded configurations", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d", w.Code) + } + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + + dataBytes, _ := json.Marshal(resp.Data) + var configs []model.SystemConfig + json.Unmarshal(dataBytes, &configs) + + // Defaults seed 7 configurations + if len(configs) != 7 { + t.Errorf("expected 7 default configs, got %d", len(configs)) + } + }) + + t.Run("filter by type business", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs?type=business", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + + dataBytes, _ := json.Marshal(resp.Data) + var configs []model.SystemConfig + json.Unmarshal(dataBytes, &configs) + + if len(configs) != 1 || configs[0].Key != model.ConfigKeyMaxAPIKeysPerUser { + t.Errorf("expected 1 business config (max_api_keys_per_user), got %d: %v", len(configs), configs) + } + }) +} + +func TestGetSystemConfig(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("get existing configuration", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d", w.Code) + } + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + + dataBytes, _ := json.Marshal(resp.Data) + var cfg model.SystemConfig + json.Unmarshal(dataBytes, &cfg) + + if cfg.Value != "Antigravity Project" { + t.Errorf("expected 'Antigravity Project', got '%s'", cfg.Value) + } + }) + + t.Run("get non-existent config", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/system-configs/non_existent_key", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 Not Found, got %d", w.Code) + } + }) +} + +func TestUpdateSystemConfig(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("update successfully", func(t *testing.T) { + payload := UpdateSystemConfigRequest{ + Value: "Super Site Name", + Description: "Updated Description", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify database + var cfg model.SystemConfig + dbConn.Where("key = ?", model.ConfigKeySiteName).First(&cfg) + if cfg.Value != "Super Site Name" || cfg.Description != "Updated Description" { + t.Errorf("database values not updated: %+v", cfg) + } + + // Verify Redis + var redisConfig model.SystemConfig + db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeySiteName, &redisConfig) + if redisConfig.Value != "Super Site Name" { + t.Errorf("redis cache value not updated, got '%s'", redisConfig.Value) + } + }) + + t.Run("update non-existent config", func(t *testing.T) { + payload := UpdateSystemConfigRequest{ + Value: "New Value", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/system-configs/invalid_key", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 Not Found, got %d", w.Code) + } + }) +} + +func TestDeleteSystemConfig(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("delete successfully", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/api/v1/admin/system-configs/"+model.ConfigKeySiteName, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify database + var count int64 + dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeySiteName).Count(&count) + if count != 0 { + t.Error("config still exists in DB") + } + + // Verify Redis Cache removal + var redisConfig model.SystemConfig + err := db.HGetJSON(context.Background(), model.SystemConfigRedisHashKey, model.ConfigKeySiteName, &redisConfig) + if err == nil { + t.Error("config should have been deleted from Redis cache") + } + }) + + t.Run("delete non-existent config", func(t *testing.T) { + req, _ := http.NewRequest("DELETE", "/api/v1/admin/system-configs/invalid_key", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 Not Found, got %d", w.Code) + } + }) +} diff --git a/internal/apps/admin/task/routers.go b/internal/apps/admin/task/routers.go index 01167fa4..6bbbaa9a 100644 --- a/internal/apps/admin/task/routers.go +++ b/internal/apps/admin/task/routers.go @@ -30,10 +30,13 @@ import ( // ListTaskTypes 获取支持的任务类型列表 // @Summary 获取支持的任务类型 -// @Description 返回系统支持的所有可调度任务类型列表,需要管理员权限 +// @Description 返回系统支持的所有可调度任务类型列表,包括任务名称、描述、是否支持时间范围等元数据,需要管理员权限 // @Tags admin // @Produce json -// @Success 200 {object} util.ResponseAny +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=[]task.TaskMeta} "任务类型列表" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" // @Router /api/v1/admin/tasks/types [get] func ListTaskTypes(c *gin.Context) { c.JSON(http.StatusOK, util.OK(task.DispatchableTasks)) @@ -53,8 +56,13 @@ type DispatchTaskRequest struct { // @Tags admin // @Accept json // @Produce json -// @Param request body DispatchTaskRequest true "任务请求参数" -// @Success 200 {object} util.ResponseAny +// @Security SessionCookie +// @Param request body task.DispatchTaskRequest true "任务请求参数" +// @Success 200 {object} util.ResponseAny{data=string} "任务已入队" +// @Failure 400 {object} util.ResponseAny "任务类型不存在或参数错误" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "任务入队失败" // @Router /api/v1/admin/tasks/dispatch [post] func DispatchTask(c *gin.Context) { var req DispatchTaskRequest diff --git a/internal/apps/admin/task/routers_test.go b/internal/apps/admin/task/routers_test.go new file mode 100644 index 00000000..8830c6d8 --- /dev/null +++ b/internal/apps/admin/task/routers_test.go @@ -0,0 +1,132 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package task + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/task" + "github.com/linux-do/credit/internal/testhelper" + "github.com/linux-do/credit/internal/util" +) + +func setupTestRouter(authUser *model.User) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + adminGroup := r.Group("/api/v1/admin") + + // Mock authentication middleware + adminGroup.Use(func(c *gin.Context) { + if authUser != nil { + util.SetToContext(c, oauth.UserObjKey, authUser) + } + c.Next() + }) + + adminGroup.GET("/tasks/types", ListTaskTypes) + adminGroup.POST("/tasks/dispatch", DispatchTask) + return r +} + +func TestListTaskTypes(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + req, _ := http.NewRequest("GET", "/api/v1/admin/tasks/types", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d", w.Code) + } + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + + dataBytes, _ := json.Marshal(resp.Data) + var taskMetas []task.TaskMeta + json.Unmarshal(dataBytes, &taskMetas) + + if len(taskMetas) == 0 { + t.Error("expected at least one dispatchable task type") + } + + foundCleanup := false + for _, m := range taskMetas { + if m.Type == task.TaskTypeCleanupUploads { + foundCleanup = true + break + } + } + if !foundCleanup { + t.Errorf("expected task type %s to be listed", task.TaskTypeCleanupUploads) + } +} + +func TestDispatchTask(t *testing.T) { + _, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + adminUser := &model.User{ID: 1001, Username: "admin", IsAdmin: true, SignKey: "admin_key"} + router := setupTestRouter(adminUser) + + t.Run("dispatch valid task successfully", func(t *testing.T) { + payload := DispatchTaskRequest{ + TaskType: task.TaskTypeCleanupUploads, + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + }) + + t.Run("dispatch invalid task type failure", func(t *testing.T) { + payload := DispatchTaskRequest{ + TaskType: "invalid_task_type", + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", "/api/v1/admin/tasks/dispatch", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request, got %d", w.Code) + } + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + if resp.ErrorMsg != InvalidTaskType { + t.Errorf("expected error message '%s', got '%s'", InvalidTaskType, resp.ErrorMsg) + } + }) +} diff --git a/internal/apps/admin/user/routers.go b/internal/apps/admin/user/routers.go index 835c460a..40c7dc26 100644 --- a/internal/apps/admin/user/routers.go +++ b/internal/apps/admin/user/routers.go @@ -68,8 +68,13 @@ type listUsersResponse struct { // @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 // @Tags admin // @Produce json +// @Security SessionCookie // @Param request query listUsersRequest true "查询参数" -// @Success 200 {object} util.ResponseAny +// @Success 200 {object} util.ResponseAny{data=user.listUsersResponse} "用户列表" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/admin/users [get] func ListUsers(c *gin.Context) { var req listUsersRequest @@ -129,9 +134,15 @@ type updateUserStatusRequest struct { // @Tags admin // @Accept json // @Produce json -// @Param id path int true "用户ID" +// @Security SessionCookie +// @Param id path int true "用户 ID" // @Param request body updateUserStatusRequest true "状态参数" -// @Success 200 {object} util.ResponseAny +// @Success 200 {object} util.ResponseAny{data=string} "更新成功" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限或尝试禁用管理员" +// @Failure 404 {object} util.ResponseAny "用户不存在" +// @Failure 500 {object} util.ResponseAny "内部错误" // @Router /api/v1/admin/users/{id}/status [put] func UpdateUserStatus(c *gin.Context) { var req updateUserStatusRequest diff --git a/internal/apps/admin/user/routers_test.go b/internal/apps/admin/user/routers_test.go new file mode 100644 index 00000000..6f658f34 --- /dev/null +++ b/internal/apps/admin/user/routers_test.go @@ -0,0 +1,254 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package user + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/testhelper" + "github.com/linux-do/credit/internal/util" + "github.com/shopspring/decimal" +) + +func setupTestRouter(authUser *model.User) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + adminGroup := r.Group("/api/v1/admin") + + // Mock authentication middleware + adminGroup.Use(func(c *gin.Context) { + if authUser != nil { + util.SetToContext(c, oauth.UserObjKey, authUser) + } + c.Next() + }) + + adminGroup.GET("/users", ListUsers) + adminGroup.PUT("/users/:id/status", UpdateUserStatus) + return r +} + +func TestListUsers(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + // Seed users + users := []model.User{ + { + ID: 1001, + Username: "alice", + Nickname: "Alice Nickname", + IsActive: true, + IsAdmin: false, + AvailableBalance: decimal.NewFromFloat(100.0), + LastLoginAt: time.Now(), + SignKey: "alice_sign_key", + }, + { + ID: 1002, + Username: "bob", + Nickname: "Bob Nickname", + IsActive: true, + IsAdmin: false, + AvailableBalance: decimal.NewFromFloat(50.0), + LastLoginAt: time.Now(), + SignKey: "bob_sign_key", + }, + { + ID: 1003, + Username: "charlie", + Nickname: "Charlie Nickname", + IsActive: false, + IsAdmin: true, + AvailableBalance: decimal.NewFromFloat(9999.0), + LastLoginAt: time.Now(), + SignKey: "charlie_sign_key", + }, + } + + for _, u := range users { + if err := dbConn.Create(&u).Error; err != nil { + t.Fatalf("failed to seed user: %v", err) + } + } + + adminUser := &model.User{ID: 1003, Username: "charlie", IsAdmin: true, SignKey: "charlie_sign_key"} + router := setupTestRouter(adminUser) + + t.Run("basic pagination list", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=2", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp util.ResponseAny + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse response: %v", err) + } + + // Parse data map to our structure + dataBytes, _ := json.Marshal(resp.Data) + var listResp listUsersResponse + if err := json.Unmarshal(dataBytes, &listResp); err != nil { + t.Fatalf("failed to parse list response: %v", err) + } + + if len(listResp.Users) != 2 { + t.Errorf("expected 2 users, got %d", len(listResp.Users)) + } + if listResp.Total != 3 { + t.Errorf("expected total 3, got %d", listResp.Total) + } + // Ordered by ID DESC + if listResp.Users[0].ID != 1003 || listResp.Users[1].ID != 1002 { + t.Errorf("expected ordered DESC, got first ID %d, second ID %d", listResp.Users[0].ID, listResp.Users[1].ID) + } + }) + + t.Run("filter by user_id", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=10&user_id=1001", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + + dataBytes, _ := json.Marshal(resp.Data) + var listResp listUsersResponse + json.Unmarshal(dataBytes, &listResp) + + if len(listResp.Users) != 1 || listResp.Users[0].ID != 1001 { + t.Errorf("expected 1 user with ID 1001, got total %d", len(listResp.Users)) + } + }) + + t.Run("filter by username prefix", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=1&page_size=10&username=bo", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + + dataBytes, _ := json.Marshal(resp.Data) + var listResp listUsersResponse + json.Unmarshal(dataBytes, &listResp) + + if len(listResp.Users) != 1 || listResp.Users[0].Username != "bob" { + t.Errorf("expected bob, got %v", listResp.Users) + } + }) + + t.Run("invalid pagination parameter", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/admin/users?page=0&page_size=10", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 Bad Request, got %d", w.Code) + } + }) +} + +func TestUpdateUserStatus(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + // Seed users + regularUser := model.User{ + ID: 1001, + Username: "alice", + IsActive: true, + IsAdmin: false, + SignKey: "alice_sign_key", + } + adminUser := model.User{ + ID: 1002, + Username: "bob", + IsActive: true, + IsAdmin: true, + SignKey: "bob_sign_key", + } + + dbConn.Create(®ularUser) + dbConn.Create(&adminUser) + + router := setupTestRouter(&adminUser) + + t.Run("disable regular user successfully", func(t *testing.T) { + payload := updateUserStatusRequest{IsActive: false} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/users/1001/status", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String()) + } + + // Verify DB status + var u model.User + dbConn.First(&u, 1001) + if u.IsActive { + t.Error("user should be deactivated in the database") + } + }) + + t.Run("cannot disable admin user", func(t *testing.T) { + payload := updateUserStatusRequest{IsActive: false} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/users/1002/status", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("expected 403 Forbidden, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp util.ResponseAny + json.Unmarshal(w.Body.Bytes(), &resp) + if resp.ErrorMsg != cannotDisable { + t.Errorf("expected error message '%s', got '%s'", cannotDisable, resp.ErrorMsg) + } + }) + + t.Run("cannot enable/disable non-existent user", func(t *testing.T) { + payload := updateUserStatusRequest{IsActive: false} + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", "/api/v1/admin/users/9999/status", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404 Not Found, got %d. Body: %s", w.Code, w.Body.String()) + } + }) +} diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go index 60d37d24..0295cd7f 100644 --- a/internal/apps/config/routers.go +++ b/internal/apps/config/routers.go @@ -28,7 +28,10 @@ import ( type PublicConfigResponse struct { UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名 SiteName string `json:"site_name"` // 站点名称 + PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录 RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册 + PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册 + OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录 MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量 } @@ -62,6 +65,24 @@ func GetPublicConfig(c *gin.Context) { registrationEnabled = val } + // 3.1 password_login_enabled + var passwordLoginEnabled bool + if val, err := model.GetBoolByKey(ctx, model.ConfigKeyPasswordLoginEnabled); err == nil { + passwordLoginEnabled = val + } + + // 3.2 password_register_enabled + var passwordRegisterEnabled bool + if val, err := model.GetBoolByKey(ctx, model.ConfigKeyPasswordRegisterEnabled); err == nil { + passwordRegisterEnabled = val + } + + // 3.3 oidc_login_enabled + var oidcLoginEnabled bool + if val, err := model.GetBoolByKey(ctx, model.ConfigKeyOIDCLoginEnabled); err == nil { + oidcLoginEnabled = val + } + // 4. max_api_keys_per_user var maxAPIKeys int if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil { @@ -71,7 +92,10 @@ func GetPublicConfig(c *gin.Context) { response := PublicConfigResponse{ UploadAllowedExtensions: uploadExtensions, SiteName: siteName, + PasswordLoginEnabled: passwordLoginEnabled, RegistrationEnabled: registrationEnabled, + PasswordRegisterEnabled: passwordRegisterEnabled, + OIDCLoginEnabled: oidcLoginEnabled, MaxAPIKeysPerUser: maxAPIKeys, } diff --git a/internal/apps/health/routers.go b/internal/apps/health/routers.go index 6115f545..d5b854d3 100644 --- a/internal/apps/health/routers.go +++ b/internal/apps/health/routers.go @@ -23,12 +23,12 @@ import ( "github.com/linux-do/credit/internal/util" ) -// Health godoc +// Health 健康检查 // @Summary 健康检查 -// @Description 检查服务是否正常运行 +// @Description 检查服务是否正常运行,可用于负载均衡存活探测 // @Tags health // @Produce json -// @Success 200 {object} util.ResponseAny +// @Success 200 {object} util.ResponseAny{data=string} "服务正常" // @Router /api/v1/health [get] func Health(c *gin.Context) { c.JSON(http.StatusOK, util.OKNil()) diff --git a/internal/apps/oauth/config.go b/internal/apps/oauth/config.go index 5bbb3c5b..cd6e0954 100644 --- a/internal/apps/oauth/config.go +++ b/internal/apps/oauth/config.go @@ -19,6 +19,7 @@ package oauth import ( "context" "log" + "strings" "github.com/coreos/go-oidc/v3/oidc" "github.com/linux-do/credit/internal/config" @@ -35,14 +36,19 @@ func init() { if cfg.Issuer != "" { ctx := context.Background() - provider, err := oidc.NewProvider(ctx, cfg.Issuer) + // Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake) + issuer := strings.TrimSuffix(strings.TrimSpace(cfg.Issuer), "/") + issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration") + issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server") + + provider, err := oidc.NewProvider(ctx, issuer) if err != nil { log.Printf("[OAuth] 初始化 OIDC Provider 失败: %v,将仅使用 OAuth2", err) } else { oidcVerifier = provider.Verifier(&oidc.Config{ ClientID: cfg.ClientID, }) - log.Printf("[OAuth] OIDC Provider 初始化成功: %s", cfg.Issuer) + log.Printf("[OAuth] OIDC Provider 初始化成功: %s", issuer) } } diff --git a/internal/apps/oauth/constants.go b/internal/apps/oauth/constants.go index 67032e2b..ef0955f4 100644 --- a/internal/apps/oauth/constants.go +++ b/internal/apps/oauth/constants.go @@ -17,6 +17,7 @@ limitations under the License. package oauth import ( + "encoding/json" "time" ) @@ -30,3 +31,29 @@ const ( OAuthStateCacheKeyFormat = "oauth:state:%s" OAuthStateCacheKeyExpiration = 10 * time.Minute ) + +const ( + OAuthPurposeLogin = "login" + OAuthPurposeBind = "bind" +) + +type oauthStatePayload struct { + SourceName string `json:"source_name"` + Purpose string `json:"purpose"` +} + +func encodeOAuthStatePayload(payload oauthStatePayload) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", err + } + return string(data), nil +} + +func decodeOAuthStatePayload(value string) (oauthStatePayload, error) { + var payload oauthStatePayload + if err := json.Unmarshal([]byte(value), &payload); err != nil { + return oauthStatePayload{}, err + } + return payload, nil +} diff --git a/internal/apps/oauth/logics.go b/internal/apps/oauth/logics.go index d00f8144..56a910d0 100644 --- a/internal/apps/oauth/logics.go +++ b/internal/apps/oauth/logics.go @@ -55,60 +55,49 @@ func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error var userInfo model.OAuthUserInfo - if config.Config.App.Env == "development" && code == "dev_mock_code" { - userInfo = model.OAuthUserInfo{ - Id: 999999, - Username: "dev_user", - Name: "Developer User", - Active: true, - AvatarUrl: "https://linux.do/user_avatar/linux.do/system/45/1_2.png", - TrustLevel: 3, - } - } else { - // 使用授权码换取 Token - token, err := oauthConf.Exchange(ctx, code) - if err != nil { - span.SetStatus(codes.Error, err.Error()) - return nil, err - } + // 使用授权码换取 Token + token, err := oauthConf.Exchange(ctx, code) + if err != nil { + span.SetStatus(codes.Error, err.Error()) + return nil, err + } - if oidcVerifier != nil { - if rawIDToken, ok := token.Extra("id_token").(string); ok { - idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken) - if verifyErr != nil { - err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr) - span.SetStatus(codes.Error, err.Error()) - return nil, err - } - if nonce != "" && idToken.Nonce != nonce { - span.SetStatus(codes.Error, NonceMismatch) - return nil, errors.New(NonceMismatch) - } - if claimsErr := idToken.Claims(&userInfo); claimsErr != nil { - span.SetStatus(codes.Error, claimsErr.Error()) - return nil, claimsErr - } + if oidcVerifier != nil { + if rawIDToken, ok := token.Extra("id_token").(string); ok { + idToken, verifyErr := oidcVerifier.Verify(ctx, rawIDToken) + if verifyErr != nil { + err := fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr) + span.SetStatus(codes.Error, err.Error()) + return nil, err + } + if nonce != "" && idToken.Nonce != nonce { + span.SetStatus(codes.Error, NonceMismatch) + return nil, errors.New(NonceMismatch) + } + if claimsErr := idToken.Claims(&userInfo); claimsErr != nil { + span.SetStatus(codes.Error, claimsErr.Error()) + return nil, claimsErr } } + } - if userInfo.GetID() == 0 { - client := oauthConf.Client(ctx, token) - resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint) - if httpErr != nil { - span.SetStatus(codes.Error, httpErr.Error()) - return nil, httpErr - } - defer resp.Body.Close() + if userInfo.GetID() == 0 { + client := oauthConf.Client(ctx, token) + resp, httpErr := client.Get(config.Config.OAuth2.UserEndpoint) + if httpErr != nil { + span.SetStatus(codes.Error, httpErr.Error()) + return nil, httpErr + } + defer resp.Body.Close() - responseData, readErr := io.ReadAll(resp.Body) - if readErr != nil { - span.SetStatus(codes.Error, readErr.Error()) - return nil, readErr - } - if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil { - span.SetStatus(codes.Error, unmarshalErr.Error()) - return nil, unmarshalErr - } + responseData, readErr := io.ReadAll(resp.Body) + if readErr != nil { + span.SetStatus(codes.Error, readErr.Error()) + return nil, readErr + } + if unmarshalErr := json.Unmarshal(responseData, &userInfo); unmarshalErr != nil { + span.SetStatus(codes.Error, unmarshalErr.Error()) + return nil, unmarshalErr } } @@ -119,7 +108,7 @@ func doOAuth(ctx context.Context, code string, nonce string) (*model.User, error } var user model.User - err := db.DB(ctx).Transaction(func(tx *gorm.DB) error { + err = db.DB(ctx).Transaction(func(tx *gorm.DB) error { var holder model.User if conflictErr := tx.Where("username = ? AND id != ?", userInfo.Username, userInfo.GetID()).First(&holder).Error; conflictErr == nil { // 存在冲突 -> 将占用者改名并注销 diff --git a/internal/apps/oauth/oauth_test.go b/internal/apps/oauth/oauth_test.go new file mode 100644 index 00000000..1a402068 --- /dev/null +++ b/internal/apps/oauth/oauth_test.go @@ -0,0 +1,1046 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package oauth + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/go-jose/go-jose/v4" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "golang.org/x/oauth2" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/linux-do/credit/internal/config" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" +) + +// ----------------------------------------------------------------------------- +// Mocks Setup +// ----------------------------------------------------------------------------- + +type mockRedisClient struct { + redis.UniversalClient + store map[string]string +} + +func newMockRedisClient() *mockRedisClient { + return &mockRedisClient{ + store: make(map[string]string), + } +} + +func (m *mockRedisClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd { + cmd := redis.NewStatusCmd(ctx) + m.store[key] = fmt.Sprintf("%v", value) + cmd.SetVal("OK") + return cmd +} + +func (m *mockRedisClient) Get(ctx context.Context, key string) *redis.StringCmd { + cmd := redis.NewStringCmd(ctx) + val, ok := m.store[key] + if !ok { + cmd.SetErr(redis.Nil) + } else { + cmd.SetVal(val) + } + return cmd +} + +func (m *mockRedisClient) Del(ctx context.Context, keys ...string) *redis.IntCmd { + cmd := redis.NewIntCmd(ctx) + var count int64 + for _, key := range keys { + if _, ok := m.store[key]; ok { + delete(m.store, key) + count++ + } + } + cmd.SetVal(count) + return cmd +} + +func (m *mockRedisClient) HSet(ctx context.Context, key string, values ...interface{}) *redis.IntCmd { + cmd := redis.NewIntCmd(ctx) + if len(values) >= 2 { + field := fmt.Sprintf("%v", values[0]) + val := fmt.Sprintf("%v", values[1]) + compositeKey := key + ":" + field + m.store[compositeKey] = val + cmd.SetVal(1) + } else { + cmd.SetVal(0) + } + return cmd +} + +func (m *mockRedisClient) HGet(ctx context.Context, key string, field string) *redis.StringCmd { + cmd := redis.NewStringCmd(ctx) + compositeKey := key + ":" + field + val, ok := m.store[compositeKey] + if !ok { + cmd.SetErr(redis.Nil) + } else { + cmd.SetVal(val) + } + return cmd +} + +type mockRoundTripper struct { + roundTripFunc func(req *http.Request) (*http.Response, error) +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return m.roundTripFunc(req) +} + +// Global cryptographic tools for custom OIDC mocking +var ( + testRSAPrivateKey *rsa.PrivateKey + testJWKS jose.JSONWebKeySet +) + +func init() { + var err error + testRSAPrivateKey, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(fmt.Sprintf("failed to generate RSA key: %v", err)) + } + jwk := jose.JSONWebKey{ + Key: &testRSAPrivateKey.PublicKey, + KeyID: "test-key-id", + Algorithm: string(jose.RS256), + Use: "sig", + } + testJWKS = jose.JSONWebKeySet{ + Keys: []jose.JSONWebKey{jwk}, + } +} + +type mockClaims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Expiry int64 `json:"exp"` + IssuedAt int64 `json:"iat"` + Nonce string `json:"nonce"` + Username string `json:"preferred_username"` + Email string `json:"email"` + Name string `json:"name"` + Active bool `json:"active"` +} + +func generateMockIDToken(issuer, sub, aud, nonce, username, email, name string) string { + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: testRSAPrivateKey}, (&jose.SignerOptions{}).WithType("JWT")) + if err != nil { + panic(err) + } + claims := mockClaims{ + Issuer: issuer, + Subject: sub, + Audience: aud, + Expiry: time.Now().Add(time.Hour).Unix(), + IssuedAt: time.Now().Unix(), + Nonce: nonce, + Username: username, + Email: email, + Name: name, + Active: true, + } + payload, _ := json.Marshal(claims) + object, err := signer.Sign(payload) + if err != nil { + panic(err) + } + tokenStr, _ := object.CompactSerialize() + return tokenStr +} + +// ----------------------------------------------------------------------------- +// Test Helpers +// ----------------------------------------------------------------------------- + +func setupTestDB(t *testing.T) *gorm.DB { + dbConn, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("failed to open sqlite in memory: %v", err) + } + err = dbConn.AutoMigrate( + &model.User{}, + &model.AuthSource{}, + &model.ExternalAccount{}, + &model.SystemConfig{}, + ) + if err != nil { + t.Fatalf("failed to migrate schema: %v", err) + } + return dbConn +} + +func mockContextMiddleware(mockClient *http.Client) gin.HandlerFunc { + return func(c *gin.Context) { + ctx := c.Request.Context() + ctx = context.WithValue(ctx, oauth2.HTTPClient, mockClient) + ctx = oidc.ClientContext(ctx, mockClient) + c.Request = c.Request.WithContext(ctx) + c.Next() + } +} + +func setupTestRouter(dbConn *gorm.DB, mockRedis *mockRedisClient, mockClient *http.Client) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(gin.Recovery()) + + // Inject context mock middleware + r.Use(mockContextMiddleware(mockClient)) + + store := cookie.NewStore([]byte(config.Config.App.SessionSecret)) + store.Options(util.GetSessionOptions(3600)) + r.Use(sessions.Sessions(config.Config.App.SessionCookieName, store)) + + db.SetDB(dbConn) + db.Redis = mockRedis + + // Setup oauth vars + oauthConf = &oauth2.Config{ + ClientID: config.Config.OAuth2.ClientID, + ClientSecret: config.Config.OAuth2.ClientSecret, + RedirectURL: config.Config.OAuth2.RedirectURI, + Scopes: []string{"profile", "email"}, + Endpoint: oauth2.Endpoint{ + AuthURL: config.Config.OAuth2.AuthorizationEndpoint, + TokenURL: config.Config.OAuth2.TokenEndpoint, + }, + } + oidcVerifier = nil + + api := r.Group("/api/v1") + { + api.GET("/oauth/sources", GetLoginSources) + api.GET("/oauth/login", GetLoginURL) + api.GET("/oauth/:source/authorize", Authorize) + api.GET("/oauth/logout", Logout) + api.POST("/oauth/callback", Callback) + api.GET("/oauth/user-info", LoginRequired(), UserInfo) + api.GET("/oauth/external-accounts", LoginRequired(), ListExternalAccounts) + api.POST("/oauth/external-accounts/:id/delete", LoginRequired(), DeleteExternalAccount) + } + + return r +} + +func performRequest(r http.Handler, method, path string, body []byte, headers map[string]string, cookies []*http.Cookie) *httptest.ResponseRecorder { + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + req, _ := http.NewRequest(method, path, bodyReader) + for k, v := range headers { + req.Header.Set(k, v) + } + for _, cookie := range cookies { + if cookie != nil { + req.AddCookie(cookie) + } + } + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func initializeTestConfig() { + config.Config.App.Env = "testing" + config.Config.App.SessionCookieName = "test_session_id" + config.Config.App.SessionSecret = "test_session_secret" + config.Config.App.APIPrefix = "/api" + config.Config.OAuth2.ClientID = "test_client_id" + config.Config.OAuth2.ClientSecret = "test_client_secret" + config.Config.OAuth2.RedirectURI = "http://localhost:3000/callback" + config.Config.OAuth2.AuthorizationEndpoint = "https://connect.linux.do/oauth2/authorize" + config.Config.OAuth2.TokenEndpoint = "https://connect.linux.do/oauth2/token" + config.Config.OAuth2.UserEndpoint = "https://connect.linux.do/api/user" + config.Config.OpenAPIRisk.Enabled = false + config.Config.OpenAPIRisk.BaseURL = "" + config.Config.OpenAPIRisk.BlockRiskLevels = []string{} + config.Config.OpenAPIRisk.PromptRiskLevels = []string{} +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +func TestGetLoginSources(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + + // Setup empty HTTP Mock + httpMock := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected request") + }, + }, + } + util.SetHTTPClient(httpMock) + router := setupTestRouter(dbConn, mockRedis, httpMock) + + // Inject OIDC login enabled config + dbConn.Create(&model.SystemConfig{ + Key: model.ConfigKeyOIDCLoginEnabled, + Value: "true", + }) + + // Inject active DB auth source + dbConn.Create(&model.AuthSource{ + ID: 101, + Name: "github", + Type: model.AuthSourceTypeOIDC, + DisplayName: "GitHub OAuth", + IsActive: true, + ClientID: "gh_client", + ClientSecret: "gh_secret", + OpenIDDiscoveryURL: "https://github.com", + }) + + // Perform GET request + w := performRequest(router, http.MethodGet, "/api/v1/oauth/sources", nil, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp struct { + Data []AuthSourceView `json:"data"` + } + err := json.Unmarshal(w.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if len(resp.Data) != 2 { + t.Fatalf("expected 2 active sources, got %d", len(resp.Data)) + } + + names := []string{resp.Data[0].Name, resp.Data[1].Name} + if !strings.Contains(strings.Join(names, ","), "default") || !strings.Contains(strings.Join(names, ","), "github") { + t.Errorf("expected default and github sources, got %v", names) + } + + // Test disabling OIDC + dbConn.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyOIDCLoginEnabled).Update("value", "false") + mockRedis.store = make(map[string]string) + + w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/sources", nil, nil, nil) + var resp2 struct { + Data []AuthSourceView `json:"data"` + } + _ = json.Unmarshal(w2.Body.Bytes(), &resp2) + if len(resp2.Data) != 0 { + t.Errorf("expected 0 sources when OIDC is disabled, got %d", len(resp2.Data)) + } +} + +func TestGetLoginURL(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + + httpMock := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + return nil, fmt.Errorf("unexpected request") + }, + }, + } + util.SetHTTPClient(httpMock) + router := setupTestRouter(dbConn, mockRedis, httpMock) + + // Case 1: Default Login URL + w := performRequest(router, http.MethodGet, "/api/v1/oauth/login", nil, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp struct { + Data OAuthAuthorizeResponse `json:"data"` + } + err := json.Unmarshal(w.Body.Bytes(), &resp) + if err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if !strings.Contains(resp.Data.AuthorizeURL, config.Config.OAuth2.AuthorizationEndpoint) { + t.Errorf("invalid authorize URL: %s", resp.Data.AuthorizeURL) + } + + parsedURL, _ := url.Parse(resp.Data.AuthorizeURL) + state := parsedURL.Query().Get("state") + if state == "" { + t.Error("missing state in URL") + } + + redisKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)) + stateVal, err := mockRedis.Get(context.Background(), redisKey).Result() + if err != nil { + t.Fatalf("state not found in redis: %v", err) + } + + payload, err := decodeOAuthStatePayload(stateVal) + if err != nil { + t.Fatalf("failed to decode state payload: %v", err) + } + if payload.SourceName != "default" || payload.Purpose != OAuthPurposeLogin { + t.Errorf("unexpected payload: %+v", payload) + } + + // Case 2: Unknown Source Login URL + w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/login?source=nonexistent", nil, nil, nil) + if w2.Code != http.StatusBadRequest { + t.Errorf("expected 400 for unknown source, got %d", w2.Code) + } +} + +func TestAuthorize(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + + // Setup GitHub active source + dbConn.Create(&model.AuthSource{ + ID: 101, + Name: "github", + Type: model.AuthSourceTypeOIDC, + DisplayName: "GitHub OAuth", + IsActive: true, + ClientID: "gh_client", + ClientSecret: "gh_secret", + OpenIDDiscoveryURL: "https://github.com", + }) + + // Mock OIDC Discovery request + httpMock := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") { + body := `{ + "issuer": "https://github.com", + "authorization_endpoint": "https://github.com/login/oauth/authorize", + "token_endpoint": "https://github.com/login/oauth/access_token", + "jwks_uri": "https://github.com/oauth/keys", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"] + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL) + }, + }, + } + util.SetHTTPClient(httpMock) + router := setupTestRouter(dbConn, mockRedis, httpMock) + + // Case 1: Active Source Authorize with purpose=bind + w := performRequest(router, http.MethodGet, "/api/v1/oauth/github/authorize?purpose=bind", nil, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body: %s", w.Code, w.Body.String()) + } + + var resp struct { + Data OAuthAuthorizeResponse `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &resp) + + parsedURL, _ := url.Parse(resp.Data.AuthorizeURL) + state := parsedURL.Query().Get("state") + redisKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)) + stateVal, _ := mockRedis.Get(context.Background(), redisKey).Result() + payload, _ := decodeOAuthStatePayload(stateVal) + + if payload.SourceName != "github" || payload.Purpose != OAuthPurposeBind { + t.Errorf("expected source github with purpose bind, got %+v", payload) + } + + // Case 2: Inactive Source Authorize + dbConn.Model(&model.AuthSource{}).Where("id = ?", 101).Update("is_active", false) + w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/github/authorize", nil, nil, nil) + if w2.Code != http.StatusBadRequest { + t.Errorf("expected 400 for inactive source, got %d", w2.Code) + } +} + +func TestLogout(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + httpMock := &http.Client{} + router := setupTestRouter(dbConn, mockRedis, httpMock) + + w := performRequest(router, http.MethodGet, "/api/v1/oauth/logout", nil, nil, nil) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestCallbackLoginAndUserInfo(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + + // 1. Mock the outgoing HTTP client for token exchange and user info fetching + httpMock := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + // Handle Token Exchange + if req.Method == http.MethodPost && req.URL.String() == config.Config.OAuth2.TokenEndpoint { + body := `{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + } + // Handle User Info fetch + if req.Method == http.MethodGet && req.URL.String() == config.Config.OAuth2.UserEndpoint { + body := `{"id":88888,"sub":"oauth_user_88888","username":"test_oauth_user","email":"oauth@linux.do","name":"Oauth Test User","active":true,"trust_level":3}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + }, nil + } + return nil, fmt.Errorf("unexpected outgoing request: %s %s", req.Method, req.URL) + }, + }, + } + util.SetHTTPClient(httpMock) + router := setupTestRouter(dbConn, mockRedis, httpMock) + + // 2. Setup state in Redis + state := uuid.NewString() + payloadValue, _ := encodeOAuthStatePayload(oauthStatePayload{ + SourceName: "default", + Purpose: OAuthPurposeLogin, + }) + mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration) + + // 3. Trigger Callback (Login flow - new user) + reqBody := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state) + w := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody), map[string]string{ + "Content-Type": "application/json", + }, nil) + + if w.Code != http.StatusOK { + t.Fatalf("callback failed with status %d, body: %s", w.Code, w.Body.String()) + } + + var callbackResp struct { + Data OAuthCallbackResult `json:"data"` + } + _ = json.Unmarshal(w.Body.Bytes(), &callbackResp) + + if callbackResp.Data.Status != "logged_in" { + t.Errorf("expected logged_in status, got %s", callbackResp.Data.Status) + } + + if callbackResp.Data.User.Username != "test_oauth_user" || callbackResp.Data.User.ID != 1 { + t.Errorf("unexpected user returned: %+v", callbackResp.Data.User) + } + + // Verify user is created in database + var user model.User + if err := dbConn.First(&user, "id = ?", 1).Error; err != nil { + t.Fatalf("user was not created in DB: %v", err) + } + + // Extract session cookie + cookies := w.Result().Cookies() + var sessionCookie *http.Cookie + for _, cookie := range cookies { + if cookie.Name == config.Config.App.SessionCookieName { + sessionCookie = cookie + break + } + } + if sessionCookie == nil { + t.Fatal("session cookie not found in response") + } + + // 4. Test GET user-info + w2 := performRequest(router, http.MethodGet, "/api/v1/oauth/user-info", nil, nil, []*http.Cookie{sessionCookie}) + if w2.Code != http.StatusOK { + t.Fatalf("failed to fetch user info, status %d", w2.Code) + } + + // 5. Test Callback (Login flow - existing user, username collision check) + state2 := uuid.NewString() + mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state2)), payloadValue, OAuthStateCacheKeyExpiration) + + // Callback with same username but different external ID (99999) + httpMock2 := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodPost && req.URL.String() == config.Config.OAuth2.TokenEndpoint { + body := `{"access_token":"mock_access_token","token_type":"Bearer","expires_in":3600}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + if req.Method == http.MethodGet && req.URL.String() == config.Config.OAuth2.UserEndpoint { + body := `{"id":99999,"sub":"oauth_user_99999","username":"test_oauth_user","email":"another@linux.do","name":"Another User","active":true,"trust_level":1}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + return nil, fmt.Errorf("unexpected outgoing request") + }, + }, + } + util.SetHTTPClient(httpMock2) + + // Create another router for this mock client + router2 := setupTestRouter(dbConn, mockRedis, httpMock2) + + reqBody2 := fmt.Sprintf(`{"state":"%s","code":"test_auth_code"}`, state2) + w3 := performRequest(router2, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody2), map[string]string{ + "Content-Type": "application/json", + }, nil) + + if w3.Code != http.StatusOK { + t.Fatalf("callback for collision failed: %d, body: %s", w3.Code, w3.Body.String()) + } + + var collisionResp struct { + Data OAuthCallbackResult `json:"data"` + } + _ = json.Unmarshal(w3.Body.Bytes(), &collisionResp) + + if collisionResp.Data.User.Username != "test_oauth_user-1" { + t.Errorf("expected collision renamed username, got %s", collisionResp.Data.User.Username) + } +} + +func TestCallbackBind(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + + // Create user + user := model.User{ + ID: 777, + Username: "existing_member", + Nickname: "Existing Member", + SignKey: "key", + IsActive: true, + LastLoginAt: time.Now(), + } + dbConn.Create(&user) + + // Create auth source "github" + dbConn.Create(&model.AuthSource{ + ID: 2, + Name: "github", + Type: model.AuthSourceTypeOIDC, + DisplayName: "GitHub", + IsActive: true, + ClientID: "gh_client", + ClientSecret: "gh_secret", + OpenIDDiscoveryURL: "https://github.com", + }) + + // Inject state in Redis with purpose=bind + state := uuid.NewString() + payloadValue, _ := encodeOAuthStatePayload(oauthStatePayload{ + SourceName: "github", + Purpose: OAuthPurposeBind, + }) + mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration) + + // Mock OIDC discovery, JWKS, and Token exchange for custom source (GitHub) + httpMock := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + // Handle OIDC Discovery Document + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") { + body := `{ + "issuer": "https://github.com", + "authorization_endpoint": "https://github.com/login/oauth/authorize", + "token_endpoint": "https://github.com/login/oauth/access_token", + "jwks_uri": "https://github.com/oauth/keys", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"] + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + // Handle JWKS Key Set Fetch + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/oauth/keys") { + jwksJSON, _ := json.Marshal(testJWKS) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jwksJSON)), + }, nil + } + // Handle Token Exchange + if req.Method == http.MethodPost && strings.Contains(req.URL.String(), "/login/oauth/access_token") { + // Generate signed RS256 token matching issuer and aud + idToken := generateMockIDToken("https://github.com", "github_user_123", "gh_client", state, "github_tester", "tester@github.com", "GitHub Tester") + body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","id_token":"%s"}`, idToken) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL) + }, + }, + } + util.SetHTTPClient(httpMock) + router := setupTestRouter(dbConn, mockRedis, httpMock) + + // Case 1: Bind attempt without session -> 401 + reqBody := fmt.Sprintf(`{"state":"%s","code":"test_code"}`, state) + w1 := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody), map[string]string{ + "Content-Type": "application/json", + }, nil) + + if w1.Code != http.StatusUnauthorized { + t.Errorf("expected 401 for unauthenticated bind, got %d, body: %s", w1.Code, w1.Body.String()) + } + + // Case 2: Bind success (authenticated) + router.GET("/test-helper/login-777", func(c *gin.Context) { + session := sessions.Default(c) + session.Set(UserIDKey, uint64(777)) + session.Save() + c.String(200, "ok") + }) + + wLogin := performRequest(router, http.MethodGet, "/test-helper/login-777", nil, nil, nil) + var activeCookie *http.Cookie + for _, cookie := range wLogin.Result().Cookies() { + if cookie.Name == config.Config.App.SessionCookieName { + activeCookie = cookie + break + } + } + + // Restore state key in redis + mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration) + + w2 := performRequest(router, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody), map[string]string{ + "Content-Type": "application/json", + }, []*http.Cookie{activeCookie}) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 for bind callback, got %d, body: %s", w2.Code, w2.Body.String()) + } + + var bindResult struct { + Data OAuthCallbackResult `json:"data"` + } + _ = json.Unmarshal(w2.Body.Bytes(), &bindResult) + if bindResult.Data.Status != "bound" { + t.Errorf("expected status bound, got %s", bindResult.Data.Status) + } + + // Verify DB binding + var binding model.ExternalAccount + if err := dbConn.First(&binding, "user_id = ? AND external_id = ?", 777, "github_user_123").Error; err != nil { + t.Fatalf("DB binding record not found: %v", err) + } + + // Case 3: Bind already bound account to another user + // Create another user + user2 := model.User{ + ID: 888, + Username: "another_member", + Nickname: "Another Member", + SignKey: "key2", + IsActive: true, + LastLoginAt: time.Now(), + } + dbConn.Create(&user2) + + router.GET("/test-helper/login-888", func(c *gin.Context) { + session := sessions.Default(c) + session.Set(UserIDKey, uint64(888)) + session.Save() + c.String(200, "ok") + }) + + wLogin2 := performRequest(router, http.MethodGet, "/test-helper/login-888", nil, nil, nil) + var activeCookie2 *http.Cookie + for _, cookie := range wLogin2.Result().Cookies() { + if cookie.Name == config.Config.App.SessionCookieName { + activeCookie2 = cookie + break + } + } + + state3 := uuid.NewString() + mockRedis.Set(context.Background(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state3)), payloadValue, OAuthStateCacheKeyExpiration) + + // Re-sign token for new state (since state serves as OIDC Nonce) + httpMock3 := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/.well-known/openid-configuration") { + body := `{ + "issuer": "https://github.com", + "authorization_endpoint": "https://github.com/login/oauth/authorize", + "token_endpoint": "https://github.com/login/oauth/access_token", + "jwks_uri": "https://github.com/oauth/keys", + "response_types_supported": ["code"], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256"] + }` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/oauth/keys") { + jwksJSON, _ := json.Marshal(testJWKS) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(jwksJSON)), + }, nil + } + if req.Method == http.MethodPost && strings.Contains(req.URL.String(), "/login/oauth/access_token") { + idToken := generateMockIDToken("https://github.com", "github_user_123", "gh_client", state3, "github_tester", "tester@github.com", "GitHub Tester") + body := fmt.Sprintf(`{"access_token":"mock_access_token","token_type":"Bearer","id_token":"%s"}`, idToken) + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + return nil, fmt.Errorf("unexpected request") + }, + }, + } + util.SetHTTPClient(httpMock3) + router3 := setupTestRouter(dbConn, mockRedis, httpMock3) + + reqBody3 := fmt.Sprintf(`{"state":"%s","code":"test_code"}`, state3) + w3 := performRequest(router3, http.MethodPost, "/api/v1/oauth/callback", []byte(reqBody3), map[string]string{ + "Content-Type": "application/json", + }, []*http.Cookie{activeCookie2}) + + if w3.Code != http.StatusBadRequest { + t.Errorf("expected 400 for already bound account, got %d, body: %s", w3.Code, w3.Body.String()) + } +} + +func TestExternalAccountsListAndDelete(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + httpMock := &http.Client{} + router := setupTestRouter(dbConn, mockRedis, httpMock) + + // Create user and external accounts + dbConn.Create(&model.User{ + ID: 555, + Username: "account_holder", + IsActive: true, + SignKey: "key5", + }) + + dbConn.Create(&model.AuthSource{ + ID: 10, + Name: "gitlab", + Type: model.AuthSourceTypeOIDC, + IsActive: true, + }) + + dbConn.Create(&model.ExternalAccount{ + ID: 2001, + AuthSourceID: 10, + UserID: 555, + ExternalID: "gitlab_123", + ExternalUsername: "gitlab_user", + }) + + router.GET("/test-helper/login-555", func(c *gin.Context) { + session := sessions.Default(c) + session.Set(UserIDKey, uint64(555)) + session.Save() + c.String(200, "ok") + }) + + wLogin := performRequest(router, http.MethodGet, "/test-helper/login-555", nil, nil, nil) + var activeCookie *http.Cookie + for _, cookie := range wLogin.Result().Cookies() { + if cookie.Name == config.Config.App.SessionCookieName { + activeCookie = cookie + break + } + } + + // 1. List accounts + wList := performRequest(router, http.MethodGet, "/api/v1/oauth/external-accounts", nil, nil, []*http.Cookie{activeCookie}) + if wList.Code != http.StatusOK { + t.Fatalf("failed to list external accounts: %d", wList.Code) + } + + var listResp struct { + Data []model.ExternalAccountView `json:"data"` + } + _ = json.Unmarshal(wList.Body.Bytes(), &listResp) + if len(listResp.Data) != 1 || listResp.Data[0].ExternalUsername != "gitlab_user" { + t.Errorf("unexpected list response: %+v", listResp.Data) + } + + // 2. Delete/Unbind account + wDelete := performRequest(router, http.MethodPost, "/api/v1/oauth/external-accounts/2001/delete", nil, nil, []*http.Cookie{activeCookie}) + if wDelete.Code != http.StatusOK { + t.Fatalf("failed to delete external account binding: %d, body: %s", wDelete.Code, wDelete.Body.String()) + } + + var count int64 + dbConn.Model(&model.ExternalAccount{}).Where("id = ?", 2001).Count(&count) + if count != 0 { + t.Error("binding record was not deleted from DB") + } +} + +func TestLoginRequiredAndRiskChecks(t *testing.T) { + initializeTestConfig() + dbConn := setupTestDB(t) + mockRedis := newMockRedisClient() + + // Create user + dbConn.Create(&model.User{ + ID: 1122, + Username: "risk_tester", + IsActive: true, + SignKey: "keysign", + }) + + // Enable risk checks + config.Config.OpenAPIRisk.Enabled = true + config.Config.OpenAPIRisk.BaseURL = "https://risk.test" + config.Config.OpenAPIRisk.BlockRiskLevels = []string{"BLOCK"} + config.Config.OpenAPIRisk.PromptRiskLevels = []string{"WARN"} + + // Mock Risk HTTP Endpoint (Returns BLOCK) + httpMock := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/api/open/v1/risk/users/1122") { + body := `{"risky":true,"risk_level":"BLOCK","risks":[{"label":"IP_ABUSE","value":"high","desc":"Blocked IP address"}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + return nil, fmt.Errorf("unexpected request: %s", req.URL) + }, + }, + } + util.SetHTTPClient(httpMock) + router := setupTestRouter(dbConn, mockRedis, httpMock) + + router.GET("/test-helper/login-1122", func(c *gin.Context) { + session := sessions.Default(c) + session.Set(UserIDKey, uint64(1122)) + session.Save() + c.String(200, "ok") + }) + + wLogin := performRequest(router, http.MethodGet, "/test-helper/login-1122", nil, nil, nil) + var activeCookie *http.Cookie + for _, cookie := range wLogin.Result().Cookies() { + if cookie.Name == config.Config.App.SessionCookieName { + activeCookie = cookie + break + } + } + + // Trigger request - UserInfo requires LoginRequired middleware, which will check risk + wBlock := performRequest(router, http.MethodGet, "/api/v1/oauth/user-info", nil, nil, []*http.Cookie{activeCookie}) + if wBlock.Code != http.StatusForbidden { + t.Fatalf("expected 403 Forbidden when user is risk-blocked, got %d, body: %s", wBlock.Code, wBlock.Body.String()) + } + + if !strings.Contains(wBlock.Body.String(), "RISK_BLOCKED") { + t.Errorf("response body should contain RISK_BLOCKED: %s", wBlock.Body.String()) + } + + // Clear risk cache in redis + mockRedis.store = make(map[string]string) + + // Mock Risk HTTP Endpoint (Returns WARN) + httpMock2 := &http.Client{ + Transport: &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet && strings.Contains(req.URL.String(), "/api/open/v1/risk/users/1122") { + body := `{"risky":true,"risk_level":"WARN","risks":[{"label":"VPN_DETECTED","value":"yes","desc":"Warning VPN"}]}` + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + } + return nil, fmt.Errorf("unexpected request") + }, + }, + } + util.SetHTTPClient(httpMock2) + router2 := setupTestRouter(dbConn, mockRedis, httpMock2) + + wWarn := performRequest(router2, http.MethodGet, "/api/v1/oauth/user-info", nil, nil, []*http.Cookie{activeCookie}) + if wWarn.Code != http.StatusOK { + t.Fatalf("expected 200 OK when user is only warned, got %d, body: %s", wWarn.Code, wWarn.Body.String()) + } + + // Check if risk headers are present + hLevel := wWarn.Header().Get("X-Credit-Risk-Level") + if hLevel != "WARN" { + t.Errorf("expected X-Credit-Risk-Level WARN, got %s", hLevel) + } +} diff --git a/internal/apps/oauth/routers.go b/internal/apps/oauth/routers.go index 931df198..233e753b 100644 --- a/internal/apps/oauth/routers.go +++ b/internal/apps/oauth/routers.go @@ -17,104 +17,15 @@ limitations under the License. package oauth import ( - "fmt" "net/http" - "github.com/coreos/go-oidc/v3/oidc" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" - "github.com/google/uuid" - "github.com/linux-do/credit/internal/config" - "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/model" "github.com/linux-do/credit/internal/util" "github.com/shopspring/decimal" ) -// GetLoginURL godoc -// @Summary 获取登录地址 -// @Description 生成 OAuth 登录 URL,前端跳转至该地址完成授权 -// @Tags oauth -// @Produce json -// @Success 200 {object} util.ResponseAny -// @Router /api/v1/oauth/login [get] -func GetLoginURL(c *gin.Context) { - ctx := c.Request.Context() - - // 生成 state - state := uuid.NewString() - cmd := db.Redis.Set(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), state, OAuthStateCacheKeyExpiration) - if cmd.Err() != nil { - c.JSON(http.StatusInternalServerError, util.Err(cmd.Err().Error())) - return - } - - // 构造登录 URL - var authURL string - if config.Config.App.Env == "development" { - authURL = fmt.Sprintf("%s/login?code=dev_mock_code&state=%s", config.Config.App.FrontendURL, state) - } else if oidcVerifier != nil { - // OIDC 模式:state 同时用作 nonce - authURL = oauthConf.AuthCodeURL(state, oidc.Nonce(state)) - } else { - // 纯 OAuth2 模式 - authURL = oauthConf.AuthCodeURL(state) - } - c.JSON(http.StatusOK, util.OK(authURL)) -} - -type CallbackRequest struct { - State string `json:"state"` - Code string `json:"code"` -} - -// Callback godoc -// @Summary OAuth 回调 -// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立用户会话 -// @Tags oauth -// @Accept json -// @Param request body CallbackRequest true "回调请求参数" -// @Produce json -// @Success 200 {object} util.ResponseAny -// @Router /api/v1/oauth/callback [post] -func Callback(c *gin.Context) { - // 解析请求 - var req CallbackRequest - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, util.Err(err.Error())) - return - } - - ctx := c.Request.Context() - - // 验证 state - cmd := db.Redis.Get(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State))) - if cmd.Val() != req.State { - c.JSON(http.StatusBadRequest, util.Err(InvalidState)) - return - } - db.Redis.Del(ctx, db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State))) - - // 执行 OAuth/OIDC 认证 - user, err := doOAuth(ctx, req.Code, req.State) - if err != nil { - c.JSON(http.StatusInternalServerError, util.Err(err.Error())) - return - } - - session := sessions.Default(c) - session.Set(UserIDKey, user.ID) - session.Set(UserNameKey, user.Username) - if err := session.Save(); err != nil { - c.JSON(http.StatusInternalServerError, util.Err(err.Error())) - return - } - - LogForAudit(ctx, user, c) - - c.JSON(http.StatusOK, util.OKNil()) -} - type BasicUserInfo struct { ID uint64 `json:"id"` Username string `json:"username"` @@ -135,46 +46,63 @@ type BasicUserInfo struct { DailyLimit *int64 `json:"daily_limit"` } -// UserInfo godoc +func BuildBasicUserInfo(user *model.User) BasicUserInfo { + return BasicUserInfo{ + ID: user.ID, + Username: user.Username, + Nickname: user.Nickname, + TrustLevel: user.TrustLevel, + AvatarUrl: user.AvatarUrl, + TotalReceive: user.TotalReceive, + TotalPayment: user.TotalPayment, + TotalTransfer: user.TotalTransfer, + TotalCommunity: user.TotalCommunity, + CommunityBalance: user.CommunityBalance, + AvailableBalance: user.AvailableBalance, + PendingBalance: user.PendingBalance, + PayScore: user.PayScore, + IsAdmin: user.IsAdmin, + RemainQuota: decimal.NewFromInt(-1), + PayLevel: "Free", + DailyLimit: nil, + } +} + +// UserInfo 获取当前登录用户信息 // @Summary 获取当前登录用户信息 -// @Description 返回当前登录用户的基本信息及余额数据,需要登录 +// @Description 返回当前登录用户的基本信息及余额数据,需要登录。包括用户 ID、用户名、信任等级、各类余额信息等。 // @Tags oauth // @Produce json -// @Success 200 {object} util.ResponseAny +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "用户信息" +// @Failure 401 {object} util.ResponseAny "未登录" // @Router /api/v1/oauth/user-info [get] func UserInfo(c *gin.Context) { user, _ := util.GetFromContext[*model.User](c, UserObjKey) c.JSON( http.StatusOK, - util.OK(BasicUserInfo{ - ID: user.ID, - Username: user.Username, - Nickname: user.Nickname, - TrustLevel: user.TrustLevel, - AvatarUrl: user.AvatarUrl, - TotalReceive: user.TotalReceive, - TotalPayment: user.TotalPayment, - TotalTransfer: user.TotalTransfer, - TotalCommunity: user.TotalCommunity, - CommunityBalance: user.CommunityBalance, - AvailableBalance: user.AvailableBalance, - PendingBalance: user.PendingBalance, - PayScore: user.PayScore, - IsAdmin: user.IsAdmin, - RemainQuota: decimal.NewFromInt(-1), - PayLevel: "Free", - DailyLimit: nil, - }), + util.OK(BuildBasicUserInfo(user)), ) } -// Logout godoc -// @Summary 退出登录 -// @Description 清除当前用户的登录会话,完成退出 +// GetLoginURL 获取登录地址 +// @Summary 获取登录地址 +// @Description 生成 OAuth 登录 URL,前端跳转至该地址完成授权。返回的 URL 中包含 state 参数用于 CSRF 防护。 // @Tags oauth // @Produce json -// @Success 200 {object} util.ResponseAny +// @Success 200 {object} util.ResponseAny{data=string} "OAuth 登录 URL" +// @Failure 500 {object} util.ResponseAny "Redis 异常或内部错误" +// @Router /api/v1/oauth/login [get] + +// Logout 退出登录 +// @Summary 退出登录 +// @Description 清除当前用户的登录会话,完成退出。清除 Cookie 中的 Session 数据。 +// @Tags oauth +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=string} "退出成功" +// @Failure 500 {object} util.ResponseAny "Session 清除失败" // @Router /api/v1/oauth/logout [get] func Logout(c *gin.Context) { session := sessions.Default(c) diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go new file mode 100644 index 00000000..ce2838f9 --- /dev/null +++ b/internal/apps/oauth/sources.go @@ -0,0 +1,611 @@ +package oauth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "strconv" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/linux-do/credit/internal/common" + "github.com/linux-do/credit/internal/config" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" + "golang.org/x/oauth2" + "gorm.io/gorm" +) + +// AuthSourceView 登录源展示信息 +type AuthSourceView struct { + ID uint64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + DisplayName string `json:"display_name"` + IsActive bool `json:"is_active"` + IconURL string `json:"icon_url"` + ClientSecretConfigured bool `json:"client_secret_configured"` +} + +// OAuthAuthorizeResponse 授权 URL 响应 +type OAuthAuthorizeResponse struct { + AuthorizeURL string `json:"authorize_url"` +} + +// OAuthCallbackResult 回调处理结果 +type OAuthCallbackResult struct { + Status string `json:"status"` + User *BasicUserInfo `json:"user,omitempty"` +} + +// CallbackRequest OAuth 回调请求参数 +type CallbackRequest struct { + State string `json:"state" binding:"required"` + Code string `json:"code" binding:"required"` +} + +func defaultAuthSource() *model.AuthSource { + if config.Config.OAuth2.ClientID == "" || config.Config.OAuth2.RedirectURI == "" { + return nil + } + source := &model.AuthSource{ + Name: "default", + Type: model.AuthSourceTypeOIDC, + DisplayName: "默认认证源", + IsActive: true, + ClientID: config.Config.OAuth2.ClientID, + ClientSecret: config.Config.OAuth2.ClientSecret, + OpenIDDiscoveryURL: config.Config.OAuth2.Issuer, + } + if source.DisplayName == "" { + source.DisplayName = "默认认证源" + } + return source +} + +func resolveAuthSource(sourceName string) (*model.AuthSource, error) { + name := strings.TrimSpace(strings.ToLower(sourceName)) + if name == "" || name == "default" { + source := defaultAuthSource() + if source == nil { + return nil, errors.New("默认认证源未配置") + } + return source, nil + } + return model.GetAuthSourceByName(name) +} + +func activeLoginSources() []AuthSourceView { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyOIDCLoginEnabled) + if err == nil && !enabled { + return nil + } + + sources := make([]AuthSourceView, 0, 4) + if source := defaultAuthSource(); source != nil { + source.Sanitize() + sources = append(sources, AuthSourceView{ + ID: source.ID, + Name: source.Name, + Type: source.Type, + DisplayName: source.DisplayName, + IsActive: source.IsActive, + IconURL: source.IconURL, + ClientSecretConfigured: source.ClientSecretConfigured, + }) + } + + dbSources, err := model.GetActiveAuthSources() + if err != nil { + return sources + } + for _, source := range dbSources { + sources = append(sources, AuthSourceView{ + ID: source.ID, + Name: source.Name, + Type: source.Type, + DisplayName: source.DisplayName, + IsActive: source.IsActive, + IconURL: source.IconURL, + ClientSecretConfigured: source.ClientSecretConfigured, + }) + } + return sources +} + +func frontendLoginRedirectURL() string { + if config.Config.App.FrontendURL != "" { + return strings.TrimRight(config.Config.App.FrontendURL, "/") + "/login" + } + if config.Config.OAuth2.RedirectURI != "" { + return config.Config.OAuth2.RedirectURI + } + return "/login" +} + +func buildOAuthConfig(ctx context.Context, source *model.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) { + if source == nil { + return nil, nil, errors.New("认证源不能为空") + } + + if source.Name == "default" { + scopes := []string{"profile", "email"} + if oidcVerifier != nil { + scopes = append([]string{oidc.ScopeOpenID}, scopes...) + } + return &oauth2.Config{ + ClientID: config.Config.OAuth2.ClientID, + ClientSecret: config.Config.OAuth2.ClientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: config.Config.OAuth2.AuthorizationEndpoint, + TokenURL: config.Config.OAuth2.TokenEndpoint, + AuthStyle: oauth2.AuthStyleAutoDetect, + }, + }, oidcVerifier, nil + } + + if source.OpenIDDiscoveryURL == "" { + return nil, nil, errors.New("OIDC 认证源必须配置 Discovery URL") + } + + // Clean the issuer URL (trim /.well-known/openid-configuration if configured by mistake) + issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/") + issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration") + issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server") + + provider, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, nil, err + } + verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID}) + scopes := strings.Fields(source.Scopes) + if len(scopes) == 0 { + scopes = []string{oidc.ScopeOpenID, "profile", "email"} + } + if !containsScope(scopes, oidc.ScopeOpenID) { + scopes = append([]string{oidc.ScopeOpenID}, scopes...) + } + + return &oauth2.Config{ + ClientID: source.ClientID, + ClientSecret: source.ClientSecret, + RedirectURL: redirectURL, + Scopes: scopes, + Endpoint: provider.Endpoint(), + }, verifier, nil +} + +func containsScope(scopes []string, scope string) bool { + for _, item := range scopes { + if item == scope { + return true + } + } + return false +} + +func setLoginSession(c *gin.Context, user *model.User) error { + session := sessions.Default(c) + session.Set(UserIDKey, user.ID) + session.Set(UserNameKey, user.Username) + return session.Save() +} + +func uniqueUsername(ctx context.Context, base string) (string, error) { + candidate := strings.TrimSpace(base) + if candidate == "" { + candidate = "user" + } + for i := 0; i < 1000; i++ { + var count int64 + if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", candidate).Count(&count).Error; err != nil { + return "", err + } + if count == 0 { + return candidate, nil + } + candidate = fmt.Sprintf("%s-%d", base, i+1) + } + return "", errors.New("无法生成可用用户名") +} + +func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code string, nonce string, redirectURL string) (*model.OAuthUserInfo, error) { + authConfig, verifier, err := buildOAuthConfig(ctx, source, redirectURL) + if err != nil { + return nil, err + } + + token, err := authConfig.Exchange(ctx, code) + if err != nil { + return nil, err + } + + userInfo := &model.OAuthUserInfo{Active: true} + if verifier != nil { + if rawIDToken, ok := token.Extra("id_token").(string); ok { + idToken, verifyErr := verifier.Verify(ctx, rawIDToken) + if verifyErr != nil { + return nil, fmt.Errorf("%s: %w", IDTokenVerifyFailed, verifyErr) + } + if nonce != "" && idToken.Nonce != nonce { + return nil, errors.New(NonceMismatch) + } + if claimsErr := idToken.Claims(userInfo); claimsErr != nil { + return nil, claimsErr + } + } + } + + if userInfo.Username == "" && userInfo.PreferredUsername != "" { + userInfo.Username = userInfo.PreferredUsername + } + if userInfo.Username == "" && userInfo.Email != "" { + userInfo.Username = strings.Split(userInfo.Email, "@")[0] + } + if userInfo.Username == "" && userInfo.Sub != "" { + userInfo.Username = userInfo.Sub + } + if userInfo.Name == "" { + userInfo.Name = userInfo.Username + } + + if userInfo.Username == "" { + client := authConfig.Client(ctx, token) + userEndpoint := config.Config.OAuth2.UserEndpoint + if source.Name != "default" { + userEndpoint = "" + } + if userEndpoint != "" { + resp, httpErr := client.Get(userEndpoint) + if httpErr != nil { + return nil, httpErr + } + defer resp.Body.Close() + + responseData, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return nil, readErr + } + if unmarshalErr := json.Unmarshal(responseData, userInfo); unmarshalErr != nil { + return nil, unmarshalErr + } + } + } + + return userInfo, nil +} + +func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error { + userInfo.Username = strings.TrimSpace(userInfo.Username) + userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername) + userInfo.Email = strings.TrimSpace(userInfo.Email) + userInfo.Name = strings.TrimSpace(userInfo.Name) + userInfo.AvatarUrl = strings.TrimSpace(userInfo.AvatarUrl) + + if userInfo.Username == "" && userInfo.PreferredUsername != "" { + userInfo.Username = userInfo.PreferredUsername + } + if userInfo.Username == "" && userInfo.Email != "" { + userInfo.Username = strings.Split(userInfo.Email, "@")[0] + } + if userInfo.Username == "" && userInfo.Sub != "" { + userInfo.Username = userInfo.Sub + } + if userInfo.Username == "" { + return errors.New("无法从认证源获取用户名") + } + if userInfo.Name == "" { + userInfo.Name = userInfo.Username + } + if !userInfo.Active { + userInfo.Active = true + } + return nil +} + +func buildCallbackResult(user *model.User, status string) OAuthCallbackResult { + result := OAuthCallbackResult{Status: status} + if user != nil { + info := BuildBasicUserInfo(user) + result.User = &info + } + return result +} + +// GetLoginSources 获取可用登录源列表 +// @Summary 获取可用登录源 +// @Description 返回当前系统已启用的所有 OAuth 登录源,前端展示登录按钮列表时调用 +// @Tags oauth +// @Produce json +// @Success 200 {object} util.ResponseAny{data=[]oauth.AuthSourceView} "登录源列表" +// @Router /api/v1/oauth/sources [get] +func GetLoginSources(c *gin.Context) { + c.JSON(http.StatusOK, util.OK(activeLoginSources())) +} + +// GetLoginURL 获取登录授权地址 +// @Summary 获取登录授权地址 +// @Description 根据指定认证源生成 OAuth 授权 URL,前端跳转到该 URL 完成 OAuth 登录授权。source 参数为空时使用默认认证源。 +// @Tags oauth +// @Produce json +// @Param source query string false "认证源名称,为空使用默认源" +// @Success 200 {object} util.ResponseAny{data=oauth.OAuthAuthorizeResponse} "授权 URL" +// @Failure 400 {object} util.ResponseAny "认证源不存在或未配置" +// @Failure 500 {object} util.ResponseAny "Redis 异常或构造 URL 失败" +// @Router /api/v1/oauth/login [get] +func GetLoginURL(c *gin.Context) { + source, err := resolveAuthSource(c.Query("source")) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + state := uuid.NewString() + payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ + SourceName: source.Name, + Purpose: OAuthPurposeLogin, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL})) +} + +func buildAuthorizeURL(ctx context.Context, source *model.AuthSource, state string) (string, error) { + authConfig, verifier, err := buildOAuthConfig(ctx, source, frontendLoginRedirectURL()) + if err != nil { + return "", err + } + if verifier != nil { + return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil + } + return authConfig.AuthCodeURL(state), nil +} + +// Authorize 发起指定认证源授权 +// @Summary 发起指定认证源授权 +// @Description 根据指定认证源名称发起 OAuth 授权,支持 purpose 参数用于区分登录和账号绑定场景。认证源必须已启用。 +// @Tags oauth +// @Produce json +// @Param source path string true "认证源名称" +// @Param purpose query string false "授权目的:login(登录)或 bind(绑定账号),默认 login" +// @Success 200 {object} util.ResponseAny{data=oauth.OAuthAuthorizeResponse} "授权 URL" +// @Failure 400 {object} util.ResponseAny "认证源不存在或未启用" +// @Failure 500 {object} util.ResponseAny "Redis 异常或构造 URL 失败" +// @Router /api/v1/oauth/{source}/authorize [get] +func Authorize(c *gin.Context) { + source, err := resolveAuthSource(c.Param("source")) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + if !source.IsActive { + c.JSON(http.StatusBadRequest, util.Err("认证源未启用")) + return + } + purpose := strings.ToLower(strings.TrimSpace(c.Query("purpose"))) + if purpose != OAuthPurposeBind { + purpose = OAuthPurposeLogin + } + state := uuid.NewString() + payloadValue, err := encodeOAuthStatePayload(oauthStatePayload{ + SourceName: source.Name, + Purpose: purpose, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if err := db.Redis.Set(c.Request.Context(), db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, state)), payloadValue, OAuthStateCacheKeyExpiration).Err(); err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + authorizeURL, err := buildAuthorizeURL(c.Request.Context(), source, state) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OK(OAuthAuthorizeResponse{AuthorizeURL: authorizeURL})) +} + +// Callback OAuth 回调处理 +// @Summary OAuth 回调处理 +// @Description 接收前端传回的 state 和 code,完成 OAuth/OIDC 认证并建立会话。支持登录(login)和账号绑定(bind)两种场景。 +// @Tags oauth +// @Accept json +// @Produce json +// @Param request body oauth.CallbackRequest true "回调请求参数" +// @Success 200 {object} util.ResponseAny{data=oauth.OAuthCallbackResult} "登录或绑定成功" +// @Failure 400 {object} util.ResponseAny "state 无效、参数错误或认证源错误" +// @Failure 401 {object} util.ResponseAny "绑定场景未登录" +// @Failure 500 {object} util.ResponseAny "OAuth 认证失败或内部错误" +// @Router /api/v1/oauth/callback [post] +func Callback(c *gin.Context) { + var req CallbackRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + ctx := c.Request.Context() + stateKey := db.PrefixedKey(fmt.Sprintf(OAuthStateCacheKeyFormat, req.State)) + payloadRaw, err := db.Redis.Get(ctx, stateKey).Result() + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(InvalidState)) + return + } + _ = db.Redis.Del(ctx, stateKey) + + payload, err := decodeOAuthStatePayload(payloadRaw) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + source, err := resolveAuthSource(payload.SourceName) + if err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + userInfo, err := buildOAuthUserInfo(ctx, source, req.Code, req.State, frontendLoginRedirectURL()) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if err := normalizeOAuthUserInfo(userInfo); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + if userInfo.Sub == "" { + userInfo.Sub = userInfo.Username + } + + var user model.User + if payload.Purpose == OAuthPurposeBind { + userID := GetUserIDFromContext(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized)) + return + } + if err := db.DB(ctx).First(&user, "id = ?", userID).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if err := model.BindExternalAccount(&model.ExternalAccount{ + AuthSourceID: source.ID, + UserID: user.ID, + ExternalID: userInfo.Sub, + ExternalUsername: userInfo.Username, + Email: userInfo.Email, + }); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + user.LastLoginAt = time.Now() + _ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error + c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "bound"))) + return + } + + account, err := model.FindExternalAccount(source.ID, userInfo.Sub) + switch { + case err == nil: + if err := db.DB(ctx).First(&user, "id = ?", account.UserID).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + case errors.Is(err, gorm.ErrRecordNotFound): + username, uniqueErr := uniqueUsername(ctx, userInfo.Username) + if uniqueErr != nil { + c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error())) + return + } + user = model.User{ + Username: username, + Nickname: userInfo.Name, + AvatarUrl: userInfo.AvatarUrl, + TrustLevel: userInfo.TrustLevel, + SignKey: util.GenerateUniqueIDSimple(), + IsActive: true, + LastLoginAt: time.Now(), + } + if err := db.DB(ctx).Create(&user).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + if err := model.BindExternalAccount(&model.ExternalAccount{ + AuthSourceID: source.ID, + UserID: user.ID, + ExternalID: userInfo.Sub, + ExternalUsername: userInfo.Username, + Email: userInfo.Email, + }); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + default: + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + user.LastLoginAt = time.Now() + _ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error + if err := setLoginSession(c, &user); err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "logged_in"))) +} + +// ListExternalAccounts 获取当前用户的外部帐号绑定列表 +// @Summary 获取外部帐号列表 +// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录 +// @Tags oauth +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=[]model.ExternalAccountView} "外部帐号列表" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 500 {object} util.ResponseAny "内部错误" +// @Router /api/v1/oauth/external-accounts [get] +func ListExternalAccounts(c *gin.Context) { + userID := GetUserIDFromContext(c) + accounts, err := model.ListExternalAccountsByUserID(userID) + if err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OK(accounts)) +} + +// DeleteExternalAccount 解除外部帐号绑定 +// @Summary 解除外部帐号绑定 +// @Description 解除当前登录用户与指定外部帐号的绑定关系,需要登录 +// @Tags oauth +// @Produce json +// @Security SessionCookie +// @Param id path uint64 true "外部帐号绑定记录 ID" +// @Success 200 {object} util.ResponseAny{data=string} "解除绑定成功" +// @Failure 400 {object} util.ResponseAny "ID 无效或解除失败" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Router /api/v1/oauth/external-accounts/{id}/delete [post] +func DeleteExternalAccount(c *gin.Context) { + userID := GetUserIDFromContext(c) + if userID == 0 { + c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized)) + return + } + rawID := strings.TrimSpace(c.Param("id")) + id, err := strconv.ParseUint(rawID, 10, 64) + if err != nil || id == 0 { + c.JSON(http.StatusBadRequest, util.Err("绑定记录 ID 无效")) + return + } + if err := model.DeleteExternalAccountForUser(id, userID); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + c.JSON(http.StatusOK, util.OKNil()) +} diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index 20de311b..9ef97d3e 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -28,11 +28,16 @@ import ( "gorm.io/gorm" ) -// ServeFileByID serves an uploaded file by its ID +// ServeFileByID 根据 ID 获取并提供已上传的文件 +// @Summary 获取已上传文件 +// @Description 根据文件 ID 获取并提供已上传的临时或正式文件,若配置了缓存则优先走本地缓存,否则从 S3 等后端存储读取并流式返回 // @Tags upload // @Produce octet-stream -// @Param id path string true "Upload ID" -// @Success 200 +// @Param id path string true "文件 ID" +// @Success 200 {file} file "成功获取文件内容" +// @Failure 400 {object} util.ResponseAny "文件 ID 格式错误" +// @Failure 404 {object} util.ResponseAny "文件未找到" +// @Failure 500 {object} util.ResponseAny "服务内部错误" // @Router /f/{id} [get] func ServeFileByID(c *gin.Context) { idStr := c.Param("id") diff --git a/internal/apps/user/controllers.go b/internal/apps/user/controllers.go new file mode 100644 index 00000000..ffbfbcbd --- /dev/null +++ b/internal/apps/user/controllers.go @@ -0,0 +1,219 @@ +package user + +import ( + "context" + "strings" + "time" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/common" + "github.com/linux-do/credit/internal/common/bind" + "github.com/linux-do/credit/internal/common/response" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" +) + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type registerRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Nickname string `json:"nickname"` + DisplayName string `json:"display_name"` +} + +func isPasswordLoginEnabled() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled) + if err != nil { + return true + } + return enabled +} + +func isPasswordRegisterEnabled() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordRegisterEnabled) + if err != nil { + return true + } + return enabled +} + +func isRegistrationEnabled() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyRegistrationEnabled) + if err != nil { + return true + } + return enabled +} + +func setLoginSession(c *gin.Context, user *model.User) error { + session := sessions.Default(c) + session.Set(oauth.UserIDKey, user.ID) + session.Set(oauth.UserNameKey, user.Username) + if err := session.Save(); err != nil { + return err + } + return nil +} + +// Login 用户密码登录 +// @Summary 用户密码登录 +// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。 +// @Tags user +// @Accept json +// @Produce json +// @Param request body user.loginRequest true "登录请求参数" +// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "登录成功,返回用户信息" +// @Failure 400 {object} util.ResponseAny "用户名或密码错误、帐号已禁用等" +// @Failure 500 {object} util.ResponseAny "服务内部错误" +// @Router /api/v1/user/login [post] +func Login(c *gin.Context) { + if !isPasswordLoginEnabled() { + response.RespondFailure(c, "管理员关闭了密码登录") + return + } + var req loginRequest + if !bind.JSON(c, &req) { + return + } + req.Username = strings.TrimSpace(req.Username) + if req.Username == "" || req.Password == "" { + response.RespondFailure(c, "无效的参数") + return + } + + var user model.User + ctx := c.Request.Context() + if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil { + response.RespondFailure(c, "用户名或密码错误") + return + } + if !user.IsActive { + response.RespondFailure(c, common.BannedAccount) + return + } + if !user.CheckPassword(req.Password) { + response.RespondFailure(c, "用户名或密码错误") + return + } + + user.LastLoginAt = time.Now() + if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil { + response.RespondFailure(c, err.Error()) + return + } + if err := setLoginSession(c, &user); err != nil { + response.RespondFailure(c, "无法保存会话信息,请重试") + return + } + + response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user)) +} + +// Register 用户注册 +// @Summary 用户注册 +// @Description 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。 +// @Tags user +// @Accept json +// @Produce json +// @Param request body user.registerRequest true "注册请求参数" +// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "注册并登录成功,返回用户信息" +// @Failure 400 {object} util.ResponseAny "参数错误、用户名已存在或注册已关闭" +// @Failure 500 {object} util.ResponseAny "服务内部错误" +// @Router /api/v1/user/register [post] +func Register(c *gin.Context) { + if !isRegistrationEnabled() || !isPasswordRegisterEnabled() { + response.RespondFailure(c, "管理员关闭了注册") + return + } + + var req registerRequest + if !bind.JSON(c, &req) { + return + } + + req.Username = strings.TrimSpace(req.Username) + req.Password = strings.TrimSpace(req.Password) + req.Nickname = strings.TrimSpace(req.Nickname) + req.DisplayName = strings.TrimSpace(req.DisplayName) + + if req.Username == "" || req.Password == "" { + response.RespondFailure(c, "无效的参数") + return + } + if len(req.Password) < 8 { + response.RespondFailure(c, "密码长度不能少于 8 位") + return + } + + ctx := c.Request.Context() + var count int64 + if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil { + response.RespondFailure(c, err.Error()) + return + } + if count > 0 { + response.RespondFailure(c, "用户名已存在") + return + } + + user := model.User{ + Username: req.Username, + Nickname: req.Nickname, + AvatarUrl: "", + TrustLevel: model.TrustLevelNewUser, + PayScore: 0, + SignKey: util.GenerateUniqueIDSimple(), + IsActive: true, + IsAdmin: false, + LastLoginAt: time.Now(), + } + if user.Nickname == "" { + user.Nickname = req.DisplayName + } + if user.Nickname == "" { + user.Nickname = req.Username + } + if err := user.SetPassword(req.Password); err != nil { + response.RespondFailure(c, err.Error()) + return + } + + if err := db.DB(ctx).Create(&user).Error; err != nil { + response.RespondFailure(c, err.Error()) + return + } + + if err := setLoginSession(c, &user); err != nil { + response.RespondFailure(c, "无法保存会话信息,请重试") + return + } + + response.RespondSuccess(c, oauth.BuildBasicUserInfo(&user)) +} + +// Logout 用户退出登录 +// @Summary 用户退出登录 +// @Description 清除用户登录 Session,完成退出 +// @Tags user +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=string} "退出成功" +// @Failure 500 {object} util.ResponseAny "Session 清除失败" +// @Router /api/v1/user/logout [get] +func Logout(c *gin.Context) { + session := sessions.Default(c) + session.Options(util.GetSessionOptions(-1)) + session.Clear() + if err := session.Save(); err != nil { + response.RespondFailure(c, err.Error()) + return + } + response.RespondSuccessMessage(c, "") +} diff --git a/internal/common/bind/bind.go b/internal/common/bind/bind.go new file mode 100644 index 00000000..e832f40a --- /dev/null +++ b/internal/common/bind/bind.go @@ -0,0 +1,48 @@ +package bind + +import ( + "encoding/json" + "errors" + "io" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/common/response" +) + +// DecodeJSONBody decodes JSON reader to target +func DecodeJSONBody(body io.Reader, target any) error { + return json.NewDecoder(body).Decode(target) +} + +// OptionalJSON decodes optional JSON body of reader to target, allowing EOF +func OptionalJSON(body io.Reader, target any) error { + if err := json.NewDecoder(body).Decode(target); err != nil && !errors.Is(err, io.EOF) { + return err + } + return nil +} + +// IDParam parses "id" parameter from context path +func IDParam(c *gin.Context) (uint, bool) { + return IDParamByName(c, "id") +} + +// IDParamByName parses target parameter from context path +func IDParamByName(c *gin.Context, name string) (uint, bool) { + id, err := strconv.ParseUint(c.Param(name), 10, 64) + if err != nil || id == 0 { + response.RespondBadRequest(c, "") + return 0, false + } + return uint(id), true +} + +// JSON binds JSON body of context request to target +func JSON(c *gin.Context, target any) bool { + if err := DecodeJSONBody(c.Request.Body, target); err != nil { + response.RespondBadRequest(c, "") + return false + } + return true +} diff --git a/internal/common/response/response.go b/internal/common/response/response.go new file mode 100644 index 00000000..dcae0d23 --- /dev/null +++ b/internal/common/response/response.go @@ -0,0 +1,82 @@ +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +const invalidParamsMessage = "参数错误" + +// RespondSuccess sends a successful response with data +func RespondSuccess(c *gin.Context, data any) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": data, + }) +} + +// RespondSuccessWithExtras sends a successful response with data and extra fields +func RespondSuccessWithExtras(c *gin.Context, data any, extras gin.H) { + payload := gin.H{ + "success": true, + "message": "", + "data": data, + } + for key, value := range extras { + payload[key] = value + } + c.JSON(http.StatusOK, payload) +} + +// RespondSuccessMessage sends a successful response with a custom message +func RespondSuccessMessage(c *gin.Context, message string) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": message, + }) +} + +// RespondFailure sends a failed response with http.StatusOK and a failure message +func RespondFailure(c *gin.Context, message string) { + c.JSON(http.StatusOK, gin.H{ + "success": false, + "message": message, + }) +} + +// RespondBadRequest sends a bad request response (400) +func RespondBadRequest(c *gin.Context, message string) { + if message == "" { + message = invalidParamsMessage + } + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": message, + }) +} + +// RespondUnauthorized sends an unauthorized response (401) +func RespondUnauthorized(c *gin.Context, message string) { + c.JSON(http.StatusUnauthorized, gin.H{ + "success": false, + "message": message, + }) +} + +// RespondForbidden sends a forbidden response (403) +func RespondForbidden(c *gin.Context, message string) { + c.JSON(http.StatusForbidden, gin.H{ + "success": false, + "message": message, + }) +} + +// RespondErrorWithStatus sends a response with target HTTP status code and a message +func RespondErrorWithStatus(c *gin.Context, code int, message string) { + c.JSON(code, gin.H{ + "success": false, + "message": message, + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 3a034de2..f2dd97a0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -18,19 +18,50 @@ package config import ( "encoding/json" + "flag" "log" "os" + "strings" "github.com/spf13/viper" ) var Config *configModel +// findConfigPath searches upward for the config file to handle tests running in subdirectories. +func findConfigPath(configPath string) string { + if _, err := os.Stat(configPath); err == nil { + return configPath + } + dir := "." + for i := 0; i < 5; i++ { + dir = dir + "/.." + path := dir + "/" + configPath + if _, err := os.Stat(path); err == nil { + return path + } + } + return configPath +} + +// isTest checks if the current execution context is within 'go test'. +func isTest() bool { + if flag.Lookup("test.v") != nil { + return true + } + for _, arg := range os.Args { + if strings.HasPrefix(arg, "-test.") || strings.HasSuffix(arg, ".test") { + return true + } + } + return false +} + func init() { // 加载配置文件路径 configPath := os.Getenv("CONFIG_PATH") if configPath == "" { - configPath = "config.yaml" + configPath = findConfigPath("config.yaml") } // 设置配置文件 @@ -48,6 +79,13 @@ func init() { log.Fatalf("[Config] parse config failed: %v\n", err) } + // Disable standard DB/Redis initializations during tests to prevent connection attempts. + if isTest() { + c.Database.Enabled = false + c.Redis.Enabled = false + c.ClickHouse.Enabled = false + } + // 设置全局配置 Config = &c diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 88aa4b26..412099f2 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -33,6 +33,8 @@ func Migrate() { if err := db.DB(context.Background()).AutoMigrate( &model.User{}, + &model.AuthSource{}, + &model.ExternalAccount{}, &model.SystemConfig{}, &model.Upload{}, ); err != nil { @@ -71,12 +73,30 @@ func initSystemConfigs() { Type: "system", Description: "系统平台的展示名称", }, + { + Key: model.ConfigKeyPasswordLoginEnabled, + Value: "true", + Type: "system", + Description: "是否允许使用账号密码登录", + }, { Key: model.ConfigKeyRegistrationEnabled, Value: "true", Type: "system", Description: "控制普通用户是否可以自主注册(true/false)", }, + { + Key: model.ConfigKeyPasswordRegisterEnabled, + Value: "true", + Type: "system", + Description: "是否允许通过密码创建本地账号", + }, + { + Key: model.ConfigKeyOIDCLoginEnabled, + Value: "true", + Type: "system", + Description: "是否允许使用第三方 OIDC 认证源登录", + }, { Key: model.ConfigKeyMaxAPIKeysPerUser, Value: "5", diff --git a/internal/db/postgres.go b/internal/db/postgres.go index 99a5add4..4bef57f9 100644 --- a/internal/db/postgres.go +++ b/internal/db/postgres.go @@ -175,3 +175,8 @@ func buildDSN(host string, port int, username, password string) string { func DB(ctx context.Context) *gorm.DB { return db.WithContext(ctx) } + +// SetDB sets the package-level database instance for testing. +func SetDB(d *gorm.DB) { + db = d +} diff --git a/internal/model/auth_source.go b/internal/model/auth_source.go new file mode 100644 index 00000000..a706bd2e --- /dev/null +++ b/internal/model/auth_source.go @@ -0,0 +1,286 @@ +package model + +import ( + "context" + "errors" + "regexp" + "strings" + "time" + + "github.com/linux-do/credit/internal/db" + "gorm.io/gorm" +) + +const ( + AuthSourceTypeOIDC = "oidc" +) + +var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`) + +type AuthSource struct { + ID uint64 `json:"id" gorm:"primaryKey"` + Name string `json:"name" gorm:"uniqueIndex;size:80;not null"` + Type string `json:"type" gorm:"size:20;not null"` + DisplayName string `json:"display_name" gorm:"size:100"` + IsActive bool `json:"is_active" gorm:"index;not null;default:false"` + ClientID string `json:"client_id" gorm:"size:255"` + ClientSecret string `json:"-" gorm:"size:1024"` + OpenIDDiscoveryURL string `json:"openid_discovery_url" gorm:"column:openid_discovery_url;size:1024"` + Scopes string `json:"scopes" gorm:"size:255"` + IconURL string `json:"icon_url" gorm:"size:1024"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ClientSecretConfigured bool `json:"client_secret_configured" gorm:"-"` +} + +type ExternalAccount struct { + ID uint64 `json:"id" gorm:"primaryKey"` + AuthSourceID uint64 `json:"auth_source_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:1;index"` + UserID uint64 `json:"user_id" gorm:"index;not null"` + ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_accounts_source_external,priority:2;size:255;not null"` + ExternalUsername string `json:"external_username" gorm:"size:255"` + Email string `json:"email" gorm:"size:255"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ExternalAccountView struct { + ID uint64 `json:"id"` + AuthSourceID uint64 `json:"auth_source_id"` + AuthSourceName string `json:"auth_source_name"` + AuthSourceType string `json:"auth_source_type"` + AuthSourceLabel string `json:"auth_source_label"` + ExternalUsername string `json:"external_username"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` +} + +func (source *AuthSource) Normalize() { + source.Type = strings.ToLower(strings.TrimSpace(source.Type)) + source.Name = strings.TrimSpace(source.Name) + source.DisplayName = strings.TrimSpace(source.DisplayName) + source.ClientID = strings.TrimSpace(source.ClientID) + source.ClientSecret = strings.TrimSpace(source.ClientSecret) + source.OpenIDDiscoveryURL = strings.TrimSpace(source.OpenIDDiscoveryURL) + source.Scopes = strings.TrimSpace(source.Scopes) + source.IconURL = strings.TrimSpace(source.IconURL) + if source.DisplayName == "" { + source.DisplayName = source.Name + } + if source.Type == AuthSourceTypeOIDC && source.Scopes == "" { + source.Scopes = "openid profile email" + } +} + +func (source *AuthSource) Validate() error { + source.Normalize() + if source.Name == "" { + return errors.New("认证源名称不能为空") + } + if !authSourceNamePattern.MatchString(source.Name) { + return errors.New("认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头") + } + if source.Type != AuthSourceTypeOIDC { + return errors.New("认证源类型仅支持 oidc") + } + if source.OpenIDDiscoveryURL == "" { + return errors.New("OIDC 认证源必须配置 Discovery URL") + } + if source.IsActive && (source.ClientID == "" || source.ClientSecret == "") { + return errors.New("启用认证源前必须配置 Client ID 和 Client Secret") + } + return nil +} + +func (source *AuthSource) Sanitize() { + source.ClientSecretConfigured = source.ClientSecret != "" + source.ClientSecret = "" +} + +func GetAuthSources() ([]AuthSource, error) { + var sources []AuthSource + if err := db.DB(context.Background()).Order("id asc").Find(&sources).Error; err != nil { + return nil, err + } + for i := range sources { + sources[i].Sanitize() + } + return sources, nil +} + +func GetActiveAuthSources() ([]AuthSource, error) { + var sources []AuthSource + if err := db.DB(context.Background()).Where("is_active = ?", true).Order("id asc").Find(&sources).Error; err != nil { + return nil, err + } + for i := range sources { + sources[i].Sanitize() + } + return sources, nil +} + +func GetAuthSourceByID(id uint64) (*AuthSource, error) { + if id == 0 { + return nil, errors.New("认证源 ID 不能为空") + } + var source AuthSource + if err := db.DB(context.Background()).First(&source, "id = ?", id).Error; err != nil { + return nil, err + } + source.ClientSecretConfigured = source.ClientSecret != "" + return &source, nil +} + +func GetAuthSourceByName(name string) (*AuthSource, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, errors.New("认证源名称不能为空") + } + var source AuthSource + if err := db.DB(context.Background()).First(&source, "name = ?", name).Error; err != nil { + return nil, err + } + source.ClientSecretConfigured = source.ClientSecret != "" + return &source, nil +} + +func CreateAuthSource(source *AuthSource) error { + if err := source.Validate(); err != nil { + return err + } + return db.DB(context.Background()).Create(source).Error +} + +func UpdateAuthSource(source *AuthSource, keepSecret bool) error { + if source.ID == 0 { + return errors.New("认证源 ID 不能为空") + } + var current AuthSource + if err := db.DB(context.Background()).First(¤t, "id = ?", source.ID).Error; err != nil { + return err + } + if keepSecret { + source.ClientSecret = current.ClientSecret + } + if err := source.Validate(); err != nil { + return err + } + return db.DB(context.Background()).Model(¤t).Updates(map[string]any{ + "name": source.Name, + "type": source.Type, + "display_name": source.DisplayName, + "is_active": source.IsActive, + "client_id": source.ClientID, + "client_secret": source.ClientSecret, + "openid_discovery_url": source.OpenIDDiscoveryURL, + "scopes": source.Scopes, + "icon_url": source.IconURL, + }).Error +} + +func ToggleAuthSource(id uint64, isActive bool) error { + source, err := GetAuthSourceByID(id) + if err != nil { + return err + } + source.IsActive = isActive + if err := source.Validate(); err != nil { + return err + } + return db.DB(context.Background()).Model(&AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error +} + +func DeleteAuthSource(id uint64) error { + if id == 0 { + return errors.New("认证源 ID 不能为空") + } + return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("auth_source_id = ?", id).Delete(&ExternalAccount{}).Error; err != nil { + return err + } + return tx.Delete(&AuthSource{}, "id = ?", id).Error + }) +} + +func FindExternalAccount(sourceID uint64, externalID string) (*ExternalAccount, error) { + var account ExternalAccount + if err := db.DB(context.Background()).Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error; err != nil { + return nil, err + } + return &account, nil +} + +func BindExternalAccount(account *ExternalAccount) error { + if account.UserID == 0 || strings.TrimSpace(account.ExternalID) == "" { + return errors.New("外部账号绑定信息不完整") + } + account.ExternalID = strings.TrimSpace(account.ExternalID) + account.ExternalUsername = strings.TrimSpace(account.ExternalUsername) + account.Email = strings.TrimSpace(account.Email) + + return db.DB(context.Background()).Transaction(func(tx *gorm.DB) error { + var current ExternalAccount + err := tx.Where("auth_source_id = ? AND external_id = ?", account.AuthSourceID, account.ExternalID).First(¤t).Error + if err == nil { + if current.UserID != account.UserID { + return errors.New("该外部账号已绑定到其他用户") + } + return tx.Model(¤t).Updates(map[string]any{ + "external_username": account.ExternalUsername, + "email": account.Email, + }).Error + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + return tx.Create(account).Error + }) +} + +func ListExternalAccountsByUserID(userID uint64) ([]ExternalAccountView, error) { + if userID == 0 { + return nil, errors.New("用户 ID 不能为空") + } + var accounts []ExternalAccount + if err := db.DB(context.Background()).Where("user_id = ?", userID).Order("id asc").Find(&accounts).Error; err != nil { + return nil, err + } + views := make([]ExternalAccountView, 0, len(accounts)) + for _, account := range accounts { + var name, sourceType, label string + if account.AuthSourceID == 0 { + name = "default" + sourceType = "oidc" + label = "默认认证源" + } else { + source, err := GetAuthSourceByID(account.AuthSourceID) + if err != nil { + continue + } + name = source.Name + sourceType = source.Type + label = source.DisplayName + if label == "" { + label = source.Name + } + } + views = append(views, ExternalAccountView{ + ID: account.ID, + AuthSourceID: account.AuthSourceID, + AuthSourceName: name, + AuthSourceType: sourceType, + AuthSourceLabel: label, + ExternalUsername: account.ExternalUsername, + Email: account.Email, + CreatedAt: account.CreatedAt, + }) + } + return views, nil +} + +func DeleteExternalAccountForUser(id uint64, userID uint64) error { + if id == 0 || userID == 0 { + return errors.New("绑定记录 ID 不能为空") + } + return db.DB(context.Background()).Where("id = ? AND user_id = ?", id, userID).Delete(&ExternalAccount{}).Error +} diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 9fe19670..9782e557 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -33,7 +33,10 @@ import ( const ( ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔 ConfigKeySiteName = "site_name" // 站点名称 + ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录 ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册 + ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册 + ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录 ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量 ) diff --git a/internal/model/users.go b/internal/model/users.go index bce44f2d..0cf13d0d 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -43,13 +43,15 @@ const ( // OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应) type OAuthUserInfo struct { - Id uint64 `json:"id"` - Sub string `json:"sub"` - Username string `json:"username"` - Name string `json:"name"` - Active bool `json:"active"` - AvatarUrl string `json:"avatar_url"` - TrustLevel TrustLevel `json:"trust_level"` + Id uint64 `json:"id"` + Sub string `json:"sub"` + Username string `json:"username"` + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Name string `json:"name"` + Active bool `json:"active"` + AvatarUrl string `json:"avatar_url"` + TrustLevel TrustLevel `json:"trust_level"` } // GetID 获取用户 ID @@ -88,6 +90,7 @@ type LeaderboardUser struct { type User struct { ID uint64 `json:"id" gorm:"primaryKey;index:idx_users_active_bal_id,priority:3"` Username string `json:"username" gorm:"size:64;uniqueIndex"` + Password string `json:"password,omitempty" gorm:"size:255"` Nickname string `json:"nickname" gorm:"size:255"` AvatarUrl string `json:"avatar_url" gorm:"size:255"` TrustLevel TrustLevel `json:"trust_level" gorm:"index"` @@ -107,6 +110,26 @@ type User struct { UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"` } +func (u *User) SetPassword(password string) error { + if password == "" { + u.Password = "" + return nil + } + hashed, err := util.HashPassword(password) + if err != nil { + return err + } + u.Password = hashed + return nil +} + +func (u *User) CheckPassword(password string) bool { + if u.Password == "" || password == "" { + return false + } + return util.CheckPasswordHash(u.Password, password) +} + func (u *User) GetByID(tx *gorm.DB, id uint64) error { if err := tx.Where("id = ?", id).First(u).Error; err != nil { return err @@ -123,8 +146,6 @@ func GetByIDs(tx *gorm.DB, ids []uint64) ([]User, error) { return users, nil } - - func (u *User) GetUserGamificationScore(ctx context.Context) (*UserGamificationScoreResponse, error) { if u.Username == "dev_user" { var response UserGamificationScoreResponse diff --git a/internal/router/router.go b/internal/router/router.go index 443405b0..a2fc814a 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -28,11 +28,13 @@ import ( "time" "github.com/linux-do/credit/internal/apps/admin" + admin_auth_source "github.com/linux-do/credit/internal/apps/admin/auth_source" admin_task "github.com/linux-do/credit/internal/apps/admin/task" admin_user "github.com/linux-do/credit/internal/apps/admin/user" publicconfig "github.com/linux-do/credit/internal/apps/config" "github.com/linux-do/credit/internal/apps/health" "github.com/linux-do/credit/internal/apps/upload" + "github.com/linux-do/credit/internal/apps/user" "github.com/linux-do/credit/internal/util" "github.com/gin-contrib/sessions" @@ -109,15 +111,22 @@ func Serve() { apiV1Router.GET("/health", health.Health) // OAuth + apiV1Router.GET("/oauth/sources", oauth.GetLoginSources) apiV1Router.GET("/oauth/login", oauth.GetLoginURL) + apiV1Router.GET("/oauth/:source/authorize", oauth.Authorize) apiV1Router.GET("/oauth/logout", oauth.Logout) apiV1Router.POST("/oauth/callback", oauth.Callback) apiV1Router.GET("/oauth/user-info", oauth.LoginRequired(), oauth.UserInfo) + apiV1Router.GET("/oauth/external-accounts", oauth.LoginRequired(), oauth.ListExternalAccounts) + apiV1Router.POST("/oauth/external-accounts/:id/delete", oauth.LoginRequired(), oauth.DeleteExternalAccount) // User userRouter := apiV1Router.Group("/user") - userRouter.Use(oauth.LoginRequired()) { + userRouter.POST("/login", user.Login) + userRouter.POST("/register", user.Register) + userRouter.GET("/logout", user.Logout) + userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo) } // Upload @@ -155,6 +164,13 @@ func Serve() { systemConfigRouter.PUT("", system_config.UpdateSystemConfig) systemConfigRouter.DELETE("", system_config.DeleteSystemConfig) } + + // Auth Sources + adminRouter.GET("/auth-sources", admin_auth_source.ListAuthSources) + adminRouter.POST("/auth-sources", admin_auth_source.CreateAuthSource) + adminRouter.PUT("/auth-sources/:id", admin_auth_source.UpdateAuthSource) + adminRouter.PUT("/auth-sources/:id/toggle", admin_auth_source.ToggleAuthSource) + adminRouter.DELETE("/auth-sources/:id", admin_auth_source.DeleteAuthSource) } } } diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go new file mode 100644 index 00000000..3e35f364 --- /dev/null +++ b/internal/testhelper/test_helper.go @@ -0,0 +1,147 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package testhelper + +import ( + "context" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/glebarez/sqlite" + "github.com/hibiken/asynq" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/task/scheduler" + "github.com/redis/go-redis/v9" + "gorm.io/gorm" +) + +// SetupTestEnvironment initializes an in-memory SQLite DB, seeds default configurations, +// starts miniredis, and overrides the global db/Redis clients. It returns a cleanup function. +func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) { + // Initialize GORM in-memory SQLite + sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{ + DisableForeignKeyConstraintWhenMigrating: true, + }) + if err != nil { + t.Fatalf("failed to open in-memory SQLite db: %v", err) + } + + // AutoMigrate all tables + err = sqliteDB.AutoMigrate( + &model.User{}, + &model.AuthSource{}, + &model.ExternalAccount{}, + &model.SystemConfig{}, + &model.Upload{}, + ) + if err != nil { + t.Fatalf("failed to auto migrate tables: %v", err) + } + + // Set global db + db.SetDB(sqliteDB) + + // Start miniredis + mr, err := miniredis.Run() + if err != nil { + t.Fatalf("failed to start miniredis: %v", err) + } + + // Hook up Redis Client to miniredis + redisClient := redis.NewClient(&redis.Options{ + Addr: mr.Addr(), + }) + db.Redis = redisClient + + // Hook up AsynqClient to miniredis + scheduler.AsynqClient = asynq.NewClient(asynq.RedisClientOpt{ + Addr: mr.Addr(), + }) + + // Seed default configurations + seedDefaultConfigs(t, sqliteDB) + + // Cleanup function + cleanup := func() { + redisClient.Close() + mr.Close() + // Reset database and Redis references + db.SetDB(nil) + db.Redis = nil + scheduler.AsynqClient = nil + } + + return sqliteDB, mr, cleanup +} + +func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { + defaultConfigs := []model.SystemConfig{ + { + Key: model.ConfigKeyUploadAllowedExtensions, + Value: "jpg,png,webp", + Type: "system", + Description: "允许上传的图片扩展名(逗号分隔)", + }, + { + Key: model.ConfigKeySiteName, + Value: "Antigravity Project", + Type: "system", + Description: "系统平台的展示名称", + }, + { + Key: model.ConfigKeyPasswordLoginEnabled, + Value: "true", + Type: "system", + Description: "是否允许使用账号密码登录", + }, + { + Key: model.ConfigKeyRegistrationEnabled, + Value: "true", + Type: "system", + Description: "控制普通用户是否可以自主注册(true/false)", + }, + { + Key: model.ConfigKeyPasswordRegisterEnabled, + Value: "true", + Type: "system", + Description: "是否允许通过密码创建本地账号", + }, + { + Key: model.ConfigKeyOIDCLoginEnabled, + Value: "true", + Type: "system", + Description: "是否允许使用第三方 OIDC 认证源登录", + }, + { + Key: model.ConfigKeyMaxAPIKeysPerUser, + Value: "5", + Type: "business", + Description: "限制每个普通用户可以创建的 API Key 最大数量", + }, + } + + if err := tx.Create(&defaultConfigs).Error; err != nil { + t.Fatalf("failed to seed default system configs: %v", err) + } + + // Also seed these in miniredis context if required, but they are stored in postgres first. + // We'll write configs to miniredis in actual handlers. + for _, config := range defaultConfigs { + _ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, config.Key, &config) + } +} diff --git a/internal/util/http_clients.go b/internal/util/http_clients.go index 7d081cb9..bbf8f4e5 100644 --- a/internal/util/http_clients.go +++ b/internal/util/http_clients.go @@ -47,6 +47,10 @@ var httpClient = &http.Client{ }), } +func SetHTTPClient(c *http.Client) { + httpClient = c +} + func Request(ctx context.Context, method, url string, body io.Reader, headers, cookies map[string]string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { diff --git a/internal/util/password.go b/internal/util/password.go new file mode 100644 index 00000000..d66bd0a6 --- /dev/null +++ b/internal/util/password.go @@ -0,0 +1,15 @@ +package util + +import "golang.org/x/crypto/bcrypt" + +func HashPassword(password string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func CheckPasswordHash(hash, password string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/main.go b/main.go index dc513b53..235d9ef9 100644 --- a/main.go +++ b/main.go @@ -20,6 +20,15 @@ import "github.com/linux-do/credit/internal/cmd" // @title LINUX DO Credit // @version 1.0.0 +// @description LINUX DO Credit 平台后端 API,提供用户认证、商户 API Key 管理、系统配置等功能。 +// @contact.name LINUX DO Credit +// @contact.url https://linux.do +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @BasePath / +// @securityDefinitions.apikey SessionCookie +// @in cookie +// @name session func main() { cmd.Execute() }