mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
OIDC
This commit is contained in:
@@ -11,8 +11,7 @@ export default defineConfig({
|
||||
srcExclude: [
|
||||
'zh/**',
|
||||
'components/**',
|
||||
'snippets/**',
|
||||
'website-configuration-redesign.md'
|
||||
'snippets/**'
|
||||
],
|
||||
|
||||
markdown: {
|
||||
|
||||
@@ -24,10 +24,13 @@ Origin
|
||||
* GORM
|
||||
* SQLite / PostgreSQL
|
||||
* 现有登录与 Session 体系
|
||||
* 认证源与外部账号绑定
|
||||
* 托管 `openflare_server/web` 静态构建产物
|
||||
|
||||
Server 负责管理端 UI 与 API、Agent API、配置渲染、版本发布、数据存储与聚合查询。
|
||||
|
||||
认证源登录由 Server 统一处理。管理端配置 `github` 或 `oidc` 认证源后,登录页从 `/api/status` 获取已启用认证源列表;OAuth/OIDC callback 仍回到管理端前端页面,再由前端调用 Server API 完成 code 交换、账号绑定与 Session 建立。
|
||||
|
||||
## Agent
|
||||
|
||||
`openflare_agent` 是 Go 单体程序:
|
||||
@@ -51,4 +54,4 @@ Agent 负责首次注册、周期性心跳、配置同步、文件写入、`open
|
||||
|
||||
## 核心对象
|
||||
|
||||
当前有效实体包括 `proxy_routes`、`origins`、`config_versions`、`nodes`、`apply_logs`、`tls_certificates`、`managed_domains`、`node_request_reports`、`node_access_logs`、`node_metric_snapshots`、`traffic_analytics_rollups` 与 `node_health_events`。
|
||||
当前有效实体包括 `proxy_routes`、`origins`、`config_versions`、`nodes`、`auth_sources`、`external_accounts`、`apply_logs`、`tls_certificates`、`managed_domains`、`node_request_reports`、`node_access_logs`、`node_metric_snapshots`、`traffic_analytics_rollups` 与 `node_health_events`。
|
||||
|
||||
@@ -135,6 +135,8 @@ tests/
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
@@ -164,6 +166,8 @@ tests/
|
||||
* `nodes` 只保留控制面状态与低频摘要。
|
||||
* 观测数据必须按节点与时间窗口关联,快照与聚合结果采用追加式模型。
|
||||
* 原始访问明细必须有受控保留策略。
|
||||
* `auth_sources` 仅保存管理端第三方登录源配置,当前支持 `github` 与 `oidc`。
|
||||
* `external_accounts` 是第三方账号与本地用户的唯一绑定来源;旧 `users.github_id` 仅用于兼容迁移,不得作为新登录流程的业务输入。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
@@ -197,6 +201,9 @@ tests/
|
||||
* 总览与节点详情优先使用专用聚合接口。
|
||||
* 管理端变更类接口统一使用 `POST`;只读接口使用 `GET`。
|
||||
* 管理端继续复用现有登录、角色与 Session。
|
||||
* 第三方登录统一通过认证源 API 进入,认证源管理接口必须要求 Root Session。
|
||||
* `/api/status` 只能返回已启用认证源的公开字段,不得返回 Client Secret。
|
||||
* 第三方账号未绑定且注册关闭时,应提供绑定已有账号流程,不得自动创建用户。
|
||||
* Agent 正式请求统一使用节点专属 `agent_token`。
|
||||
* 首次接入可使用全局 `discovery_token`。
|
||||
* Agent 请求头统一使用 `X-Agent-Token`。
|
||||
|
||||
@@ -16,6 +16,7 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
| 基础观测 | 聚合节点请求、资源快照、健康事件和访问分析 |
|
||||
| 节点管理 | 节点状态、令牌体系、部署与更新链路 |
|
||||
| 管理端前端 | 基于 Next.js 的正式管理端 |
|
||||
| 认证源登录 | 支持以认证源形式配置 GitHub 与标准 OIDC 登录入口,并允许第三方账号绑定已有本地用户 |
|
||||
|
||||
默认工作方式:
|
||||
|
||||
@@ -31,6 +32,8 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
* `origins`
|
||||
* `config_versions`
|
||||
* `nodes`
|
||||
* `auth_sources`
|
||||
* `external_accounts`
|
||||
* `node_system_profiles`
|
||||
* `apply_logs`
|
||||
* `tls_certificates`
|
||||
@@ -80,6 +83,19 @@ OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组
|
||||
* 未绑定证书的域名不得被自动带入 HTTPS。
|
||||
* 必须将 `proxy_routes.domains` 中的全部域名一并纳入同一站点配置,避免同站点在版本快照中被拆散。
|
||||
|
||||
## 认证源约束
|
||||
|
||||
`auth_sources` 是管理端第三方登录入口的配置对象,当前仅支持 `github` 与 `oidc` 两类。启用后的认证源会显示在登录页。
|
||||
|
||||
`external_accounts` 保存认证源外部账号与本地用户的绑定关系。第三方账号首次登录时:
|
||||
|
||||
* 已绑定本地用户则直接登录。
|
||||
* 当前已有本地登录 Session 时,绑定到当前用户。
|
||||
* 未绑定且允许注册时,自动创建普通用户并绑定。
|
||||
* 未绑定且关闭注册时,只允许用户输入已有本地账号密码完成绑定。
|
||||
|
||||
旧 `users.github_id` 仅作为升级迁移来源,新的第三方账号登录与绑定关系必须以 `external_accounts` 为准。
|
||||
|
||||
## 版本与观测约束
|
||||
|
||||
* `config_versions` 必须保存完整快照、渲染结果与 `checksum`。
|
||||
|
||||
@@ -93,6 +93,34 @@ go run .
|
||||
|
||||
默认监听 `3000` 端口。
|
||||
|
||||
## 认证源登录配置
|
||||
|
||||
OpenFlare 支持通过认证源配置 GitHub OAuth 或标准 OIDC 登录入口。认证源保存在数据库中,不通过环境变量配置。
|
||||
|
||||
配置步骤:
|
||||
|
||||
1. 登录管理端,进入“设置 -> 系统设置 -> 配置认证源”。
|
||||
2. 新增认证源,选择 `GitHub` 或 `OIDC`。
|
||||
3. 填写 `Name`、展示名称、Client ID、Client Secret。OIDC 还需要填写 Discovery URL。
|
||||
4. 在第三方平台配置 Redirect URI / Callback URL。
|
||||
5. 回到 OpenFlare 启用认证源。
|
||||
|
||||
回调地址格式固定为:
|
||||
|
||||
```text
|
||||
<OpenFlare 访问地址>/oauth/<认证源 Name>
|
||||
```
|
||||
|
||||
例如 OpenFlare 访问地址为 `https://openflare.example.com`,认证源 `Name` 为 `github`,则第三方平台中应填写:
|
||||
|
||||
```text
|
||||
https://openflare.example.com/oauth/github
|
||||
```
|
||||
|
||||
`Name` 是认证源唯一标识,只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。修改 `Name` 后,第三方平台中的回调地址也必须同步修改。
|
||||
|
||||
启用后的认证源会显示在登录页。第三方账号首次登录时,如果已经绑定本地用户会直接登录;如果未绑定且允许注册,会自动创建普通用户;如果未绑定且关闭注册,需要使用已有本地账号完成绑定。
|
||||
|
||||
## Swagger
|
||||
|
||||
登录管理端后访问:
|
||||
|
||||
@@ -78,6 +78,38 @@ go run . --port 3000 --log-dir ./logs
|
||||
* `DatabaseAutoCleanupEnabled` 开启后,Server 会在每天凌晨 3 点自动清理 `node_access_logs`、`node_metric_snapshots`、`node_request_reports` 三类观测数据。
|
||||
* `DatabaseAutoCleanupRetentionDays` 为统一保留天数,必须大于等于 1。
|
||||
* 管理端支持手动清理时留空保留天数,以直接删除对应数据集的全部历史记录。
|
||||
* 第三方登录不再通过 `GitHubOAuthEnabled`、`GitHubClientId`、`GitHubClientSecret` 作为主配置入口;这些旧 Option 仅用于升级时迁移默认 GitHub 认证源。
|
||||
* 微信登录旧 Option 保留为兼容字段,但管理端不再提供微信登录配置入口。
|
||||
* Turnstile 旧 Option 与后端校验能力保留,已有配置仍会生效;本版本移除了旧 `OAuth / WeChat / Turnstile` 集成卡片。
|
||||
|
||||
## 认证源配置
|
||||
|
||||
认证源配置保存在数据库 `auth_sources` 表,由管理端“设置 -> 系统设置 -> 配置认证源”维护,不通过环境变量或启动参数配置。
|
||||
|
||||
当前支持:
|
||||
|
||||
| 类型 | 必填配置 | 默认 Scope | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `github` | Name、Client ID、Client Secret | `user:email` | 使用 GitHub OAuth 登录 |
|
||||
| `oidc` | Name、Client ID、Client Secret、OIDC Discovery URL | `openid profile email` | 适用于 Logto、authentik 等标准 OIDC Provider |
|
||||
|
||||
启用后的认证源会通过 `/api/status` 暴露公开字段并显示在登录页。`Client Secret` 不会通过 API 回显。
|
||||
|
||||
`Name` 是认证源的唯一标识,也会作为 OAuth/OIDC 回调路径的一部分。第三方平台中的 Redirect URI / Callback URL 应配置为:
|
||||
|
||||
```text
|
||||
<OpenFlare 访问地址>/oauth/<认证源 Name>
|
||||
```
|
||||
|
||||
例如 OpenFlare 访问地址为 `https://openflare.example.com`,认证源 `Name` 为 `github` 时,回调地址为:
|
||||
|
||||
```text
|
||||
https://openflare.example.com/oauth/github
|
||||
```
|
||||
|
||||
认证源 `Name` 只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。修改 `Name` 后必须同步更新第三方平台中的回调地址。
|
||||
|
||||
外部账号绑定保存在 `external_accounts` 表。第三方账号未绑定时,如果允许注册则自动创建普通用户;如果关闭注册,则只能绑定已有本地账号。
|
||||
|
||||
## OpenResty 参数
|
||||
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
# 网站配置改造需求与开发计划
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前规则模块以“一个域名对应一条规则”为中心,已经支持单域名绑定一个或多个上游,但无法表达“多个域名共享同一套站点配置”的场景。
|
||||
|
||||
现阶段已经出现以下真实需求:
|
||||
|
||||
* 多个域名指向同一站点,并共享反向代理、缓存等设置,同时允许按域名分别绑定 HTTPS 证书
|
||||
* 后续希望围绕“网站”继续叠加更多功能,而不是持续在规则列表中堆积字段
|
||||
* 现有抽屉式编辑界面已经不适合承载更复杂的配置结构
|
||||
|
||||
因此,本轮改造将 `proxy_routes` 从“单域名规则”升级为“网站配置”视角,并引入独立的配置子页面。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
本轮改造的目标如下:
|
||||
|
||||
* 支持一个网站绑定多个域名
|
||||
* 支持一个网站绑定一个或多个上游
|
||||
* 引入 `site_name` 作为网站业务唯一标识
|
||||
* 将原列表页的“编辑”操作替换为“配置”,进入独立子页面管理
|
||||
* 将网站配置拆分为更清晰的功能分区,为后续扩展预留结构
|
||||
|
||||
## 3. 本轮范围
|
||||
|
||||
本轮仅覆盖以下站点级配置能力:
|
||||
|
||||
* 域名设置
|
||||
* 流量限制
|
||||
* 反向代理
|
||||
* 缓存
|
||||
|
||||
## 4. 核心模型要求
|
||||
|
||||
### 4.1 网站标识
|
||||
|
||||
* `site_name` 为网站业务唯一标识
|
||||
* 新建网站时,若用户未输入 `site_name`,默认取域名列表第一项
|
||||
* `site_name` 在首次生成后允许独立编辑,不随域名变更自动同步,避免影响引用、跳转和审计
|
||||
* 数据库内部主键可以继续使用现有数值 `id`,但业务层必须校验 `site_name` 唯一性
|
||||
|
||||
### 4.2 域名列表
|
||||
|
||||
* 网站的域名字段改为 `domains` 列表
|
||||
* `domains` 至少包含一个有效域名
|
||||
* `domains[0]` 视为主域名,用于列表摘要、默认展示和兼容历史逻辑
|
||||
* 同一网站内域名不能重复
|
||||
* 任一域名在全局只能属于一个网站
|
||||
* 域名列表需要支持新增、删除和调整顺序
|
||||
|
||||
### 4.3 历史兼容
|
||||
|
||||
* 存量单域名数据迁移后应自动转换为:
|
||||
`site_name = domain`
|
||||
`domains = [domain]`
|
||||
* 若迁移期保留旧 `domain` 字段,该字段仅作为 `domains[0]` 的兼容镜像,不再作为主要业务输入
|
||||
* 版本渲染、差异预览、接口返回和前端展示都应逐步以 `site_name + domains` 为准
|
||||
|
||||
## 5. 功能需求
|
||||
|
||||
### 5.1 列表页改造
|
||||
|
||||
规则列表改造为“网站列表”视图,要求如下:
|
||||
|
||||
* 保留当前列表页入口,但展示对象改为网站
|
||||
* 原“编辑”按钮替换为“配置”按钮
|
||||
* 点击“配置”进入网站配置子页面
|
||||
* 列表项至少展示:
|
||||
`site_name`
|
||||
主域名
|
||||
域名数量
|
||||
上游摘要
|
||||
HTTPS/缓存/启用状态摘要
|
||||
* 删除、发布等现有高风险操作仍保留明确确认
|
||||
|
||||
### 5.2 网站配置子页面
|
||||
|
||||
网站配置采用左右布局:
|
||||
|
||||
* 左侧为菜单栏,用于切换配置分区
|
||||
* 右侧为当前分区的设置面板
|
||||
* 默认进入“域名设置”分区
|
||||
* 建议基于 App Router 子路由或稳定的 tab 路由参数实现,保证可直接访问和刷新恢复
|
||||
|
||||
建议左侧菜单项固定为:
|
||||
|
||||
1. 域名设置
|
||||
2. 流量限制
|
||||
3. 反向代理
|
||||
4. 缓存
|
||||
|
||||
为降低跨分区校验干扰,每个分区应支持独立保存与反馈;若采用统一保存,也必须提供未保存修改提示。
|
||||
|
||||
### 5.3 域名设置
|
||||
|
||||
域名设置分区负责维护网站身份与域名列表,要求如下:
|
||||
|
||||
* 可编辑 `site_name`
|
||||
* 可维护 `domains` 列表
|
||||
* 可新增、删除、排序域名
|
||||
* 明确提示第一项为主域名
|
||||
* 每个域名可单独选择一张证书,形成与 `domains` 平行的 `domain_cert_ids`
|
||||
* 若某个域名未选择证书,则该域名不启用 HTTPS
|
||||
* `HTTP -> HTTPS` 跳转逻辑与域名证书绑定放在同一分区维护
|
||||
* 保存前校验:
|
||||
`site_name` 非空且唯一
|
||||
`domains` 非空
|
||||
每个域名格式合法
|
||||
域名在当前站点内不重复
|
||||
域名在全局不与其他网站冲突
|
||||
已选择证书的域名必须被对应证书覆盖
|
||||
|
||||
### 5.4 流量限制
|
||||
|
||||
流量限制分区用于配置站点级限流,第一期要求覆盖以下字段:
|
||||
|
||||
* `limit_conn perserver`
|
||||
* `limit_conn perip`
|
||||
* `limit_rate`
|
||||
|
||||
要求如下:
|
||||
|
||||
* 采用结构化字段存储,不允许直接录入原始 Nginx 片段
|
||||
* `limit_conn perserver` 与 `limit_conn perip` 为整数;空值或 `0` 视为未启用
|
||||
* `limit_rate` 采用人类可读格式录入,例如 `512k`、`1m`
|
||||
* 保存前进行格式校验,并在页面中提供示例说明
|
||||
* 配置发布后渲染为对应的 OpenResty/Nginx 指令
|
||||
|
||||
### 5.5 反向代理
|
||||
|
||||
反向代理分区负责维护网站回源配置,要求如下:
|
||||
|
||||
* 支持一个或多个上游
|
||||
* 至少保留一个上游
|
||||
* 支持维护回源主机名 `origin_host`
|
||||
* 继续兼容当前单上游带 path/query、多上游做负载均衡的模式
|
||||
* 若复用 `origins` 目录,只作为地址候选来源,不改变网站配置为主的编辑模型
|
||||
|
||||
建议继续保留当前兼容约束:
|
||||
|
||||
* 单上游可附带 path/query
|
||||
* 多上游模式下,上游项保持 `scheme://host[:port]` 形式
|
||||
* 同一网站的多个上游在多上游模式下维持统一协议,降低渲染复杂度
|
||||
|
||||
### 5.6 缓存
|
||||
|
||||
缓存分区负责维护站点级缓存策略,要求如下:
|
||||
|
||||
* 支持开启或关闭缓存
|
||||
* 支持多种缓存策略
|
||||
* 第一阶段至少兼容当前已存在的策略:
|
||||
`url`
|
||||
`suffix`
|
||||
`path_prefix`
|
||||
`path_exact`
|
||||
* 缓存规则继续采用结构化配置,不直接暴露原始 Nginx 片段
|
||||
* 保持当前安全绕过逻辑,不因界面改造改变默认缓存边界
|
||||
|
||||
## 6. 接口与渲染要求
|
||||
|
||||
* 列表接口需要返回 `site_name`、`domains`、主域名、状态摘要等字段
|
||||
* 详情接口需要按分区所需字段返回完整站点配置
|
||||
* 更新接口需要支持按分区或按网站整体更新,但服务端必须统一做跨字段校验
|
||||
* 配置 diff 不再只关注单个域名变更,还要能识别:
|
||||
网站新增/删除
|
||||
域名列表变更
|
||||
站点级配置变更
|
||||
* 发布渲染时,同一网站的全部域名必须落入同一份站点配置上下文中
|
||||
* 同一网站内,带证书的域名需按证书分组生成 HTTPS `server`;未配置证书的域名只保留 HTTP
|
||||
|
||||
## 7. 前端实现要求
|
||||
|
||||
* 列表页负责导航与摘要,不再承载完整编辑表单
|
||||
* 网站配置子页面中的每个分区表单继续遵循 `React Hook Form + Zod`
|
||||
* API 请求统一收敛在 `lib/api/`
|
||||
* 站点级数据查询与缓存继续使用 TanStack Query
|
||||
* 左侧菜单切换时需要明确处理未保存状态,避免无提示丢失修改
|
||||
* 页面至少覆盖加载态、空态、错误态和保存成功反馈
|
||||
|
||||
## 8. 数据迁移要求
|
||||
|
||||
实施前必须准备显式数据库迁移与校验逻辑,至少包含:
|
||||
|
||||
1. 新增 `site_name`、`domains` 与 `domain_cert_ids` 存储结构
|
||||
2. 将旧数据从单域名回填到站点结构,并补齐逐域名证书映射
|
||||
3. 为 `site_name` 建立唯一约束
|
||||
4. 为域名唯一性建立可校验约束
|
||||
5. 对迁移结果做一致性校验
|
||||
|
||||
迁移失败时,启动流程必须中止,不允许带半迁移状态继续运行。
|
||||
|
||||
## 9. 开发计划
|
||||
|
||||
### 阶段一:模型与渲染改造
|
||||
|
||||
目标:
|
||||
|
||||
* 定义网站级 `proxy_routes` 数据结构
|
||||
* 完成存量数据迁移
|
||||
* 调整配置渲染与发布链路,支持多域名同站点输出
|
||||
|
||||
交付物:
|
||||
|
||||
* 数据库迁移
|
||||
* model/service 调整
|
||||
* 配置渲染兼容实现
|
||||
* 迁移与渲染测试
|
||||
|
||||
### 阶段二:接口与校验改造
|
||||
|
||||
目标:
|
||||
|
||||
* 更新列表、详情、创建、更新接口的数据结构
|
||||
* 引入 `site_name`、`domains`、流量限制等字段校验
|
||||
* 调整版本 diff 与发布预览语义
|
||||
|
||||
交付物:
|
||||
|
||||
* API 契约更新
|
||||
* 服务端参数校验与错误消息
|
||||
* diff/preview 适配
|
||||
* 接口回归测试
|
||||
|
||||
### 阶段三:前端网站列表与配置子页面
|
||||
|
||||
目标:
|
||||
|
||||
* 将规则列表切换为网站列表
|
||||
* 用“配置”按钮替代“编辑”按钮
|
||||
* 落地左右布局的网站配置子页面与五个分区
|
||||
|
||||
交付物:
|
||||
|
||||
* 列表页 UI 改造
|
||||
* 子页面路由与布局
|
||||
* 域名设置、流量限制、反向代理、缓存四个分区
|
||||
* 前端交互与表单测试
|
||||
|
||||
### 阶段四:联调、发布验证与文档收口
|
||||
|
||||
目标:
|
||||
|
||||
* 验证从创建网站到发布配置的全链路
|
||||
* 验证 Agent 拉取、应用与回滚不受影响
|
||||
* 收口文档与测试
|
||||
|
||||
交付物:
|
||||
|
||||
* 联调记录
|
||||
* 发布/回滚回归验证
|
||||
* 文档同步更新
|
||||
|
||||
## 10. 验收标准
|
||||
|
||||
满足以下条件后,本专项可视为完成:
|
||||
|
||||
* 可以创建一个网站,并绑定多个域名
|
||||
* 一个网站可以绑定单个或多个上游
|
||||
* `site_name` 唯一,且创建时默认取第一个域名
|
||||
* 原列表页已用“配置”按钮替代“编辑”按钮
|
||||
* 网站配置子页面已经采用左侧菜单、右侧设置的布局
|
||||
* 五个分区均可独立完成基本配置与保存
|
||||
* 发布后的渲染结果可正确覆盖同一网站的全部域名,并只为已绑定证书的域名生成 HTTPS 配置
|
||||
* Agent 同步、应用、回滚链路不被破坏
|
||||
* 迁移、接口、渲染与前端关键路径均有对应测试或等效回归验证
|
||||
@@ -0,0 +1,333 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const pendingExternalAccountSessionKey = "pending_external_account"
|
||||
|
||||
type authSourceTogglePayload struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
type authSourcePayload 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"`
|
||||
}
|
||||
|
||||
func (payload authSourcePayload) toModel() model.AuthSource {
|
||||
return model.AuthSource{
|
||||
Name: payload.Name,
|
||||
Type: payload.Type,
|
||||
DisplayName: payload.DisplayName,
|
||||
IsActive: payload.IsActive,
|
||||
ClientID: payload.ClientID,
|
||||
ClientSecret: payload.ClientSecret,
|
||||
OpenIDDiscoveryURL: payload.OpenIDDiscoveryURL,
|
||||
Scopes: payload.Scopes,
|
||||
IconURL: payload.IconURL,
|
||||
}
|
||||
}
|
||||
|
||||
func ListAuthSources(c *gin.Context) {
|
||||
sources, err := model.GetAuthSources()
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, sources)
|
||||
}
|
||||
|
||||
func CreateAuthSource(c *gin.Context) {
|
||||
var payload authSourcePayload
|
||||
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
|
||||
respondBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
source := payload.toModel()
|
||||
if err := model.CreateAuthSource(&source); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
source.Sanitize()
|
||||
respondSuccess(c, source)
|
||||
}
|
||||
|
||||
func UpdateAuthSource(c *gin.Context) {
|
||||
id, err := parseAuthSourceID(c)
|
||||
if err != nil {
|
||||
respondBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
var payload authSourcePayload
|
||||
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
|
||||
respondBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
source := payload.toModel()
|
||||
source.ID = id
|
||||
keepSecret := strings.TrimSpace(source.ClientSecret) == ""
|
||||
if err := model.UpdateAuthSource(&source, keepSecret); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
updated, err := model.GetAuthSourceByID(id)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
updated.Sanitize()
|
||||
respondSuccess(c, updated)
|
||||
}
|
||||
|
||||
func DeleteAuthSource(c *gin.Context) {
|
||||
id, err := parseAuthSourceID(c)
|
||||
if err != nil {
|
||||
respondBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if err := model.DeleteAuthSource(id); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func ToggleAuthSource(c *gin.Context) {
|
||||
id, err := parseAuthSourceID(c)
|
||||
if err != nil {
|
||||
respondBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
var payload authSourceTogglePayload
|
||||
if err := decodeJSONBody(c.Request.Body, &payload); err != nil {
|
||||
respondBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
if err := model.ToggleAuthSource(id, payload.IsActive); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccessMessage(c, "")
|
||||
}
|
||||
|
||||
func OAuthAuthorize(c *gin.Context) {
|
||||
source, err := getAuthSourceFromRoute(c)
|
||||
if err != nil {
|
||||
respondBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if !source.IsActive {
|
||||
respondFailure(c, "认证源未启用")
|
||||
return
|
||||
}
|
||||
if err := source.Validate(); err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
state, err := service.GenerateOAuthState()
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
session.Set(oauthStateSessionKey(source.ID), state)
|
||||
if err := session.Save(); err != nil {
|
||||
respondFailure(c, "无法保存授权状态,请重试")
|
||||
return
|
||||
}
|
||||
redirectURL := oauthFrontendCallbackURL(c, source.ID)
|
||||
authorizeURL, err := service.BuildAuthorizeURL(c.Request.Context(), source, redirectURL, state)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
respondSuccess(c, gin.H{"authorize_url": authorizeURL})
|
||||
}
|
||||
|
||||
func OAuthCallback(c *gin.Context) {
|
||||
source, err := getAuthSourceFromRoute(c)
|
||||
if err != nil {
|
||||
respondBadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if !source.IsActive {
|
||||
respondFailure(c, "认证源未启用")
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
expectedState, _ := session.Get(oauthStateSessionKey(source.ID)).(string)
|
||||
state := c.Query("state")
|
||||
if expectedState == "" || state == "" || state != expectedState {
|
||||
respondFailure(c, "授权状态无效,请重新登录")
|
||||
return
|
||||
}
|
||||
session.Delete(oauthStateSessionKey(source.ID))
|
||||
if err := session.Save(); err != nil {
|
||||
respondFailure(c, "无法更新授权状态,请重试")
|
||||
return
|
||||
}
|
||||
if oauthError := c.Query("error"); oauthError != "" {
|
||||
description := c.Query("error_description")
|
||||
if description == "" {
|
||||
description = oauthError
|
||||
}
|
||||
respondFailure(c, description)
|
||||
return
|
||||
}
|
||||
|
||||
profile, err := service.ExchangeOAuthProfile(c.Request.Context(), source, c.Query("code"), oauthFrontendCallbackURL(c, source.ID))
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
var currentUserID *int
|
||||
if value := session.Get("id"); value != nil {
|
||||
if idValue, ok := value.(int); ok {
|
||||
currentUserID = &idValue
|
||||
}
|
||||
}
|
||||
result, pending, err := service.CompleteOAuthLogin(source, profile, currentUserID)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
if pending != nil {
|
||||
raw, err := json.Marshal(pending)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
session.Set(pendingExternalAccountSessionKey, string(raw))
|
||||
if err := session.Save(); err != nil {
|
||||
respondFailure(c, "无法保存待绑定账号,请重试")
|
||||
return
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
return
|
||||
}
|
||||
if result.User != nil {
|
||||
cleanUser, err := setLoginSession(result.User, c)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法保存会话信息,请重试")
|
||||
return
|
||||
}
|
||||
result.User = cleanUser
|
||||
}
|
||||
respondSuccess(c, result)
|
||||
}
|
||||
|
||||
func LinkExistingOAuthAccount(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
raw, _ := session.Get(pendingExternalAccountSessionKey).(string)
|
||||
if raw == "" {
|
||||
respondFailure(c, "待绑定第三方账号已失效,请重新登录")
|
||||
return
|
||||
}
|
||||
var pending service.PendingExternalAccount
|
||||
if err := json.Unmarshal([]byte(raw), &pending); err != nil {
|
||||
respondFailure(c, "待绑定第三方账号无效,请重新登录")
|
||||
return
|
||||
}
|
||||
var input service.LinkExistingRequest
|
||||
if err := decodeJSONBody(c.Request.Body, &input); err != nil {
|
||||
respondBadRequest(c, "无效的参数")
|
||||
return
|
||||
}
|
||||
user, err := service.LinkPendingExternalAccount(&pending, input)
|
||||
if err != nil {
|
||||
respondFailure(c, err.Error())
|
||||
return
|
||||
}
|
||||
session.Delete(pendingExternalAccountSessionKey)
|
||||
if err := session.Save(); err != nil {
|
||||
respondFailure(c, "无法更新会话信息,请重试")
|
||||
return
|
||||
}
|
||||
cleanUser, err := setLoginSession(user, c)
|
||||
if err != nil {
|
||||
respondFailure(c, "无法保存会话信息,请重试")
|
||||
return
|
||||
}
|
||||
respondSuccess(c, service.OAuthCallbackResult{Status: "linked", User: cleanUser})
|
||||
}
|
||||
|
||||
func parseAuthSourceID(c *gin.Context) (uint, error) {
|
||||
raw := c.Param("source_id")
|
||||
if raw == "" {
|
||||
raw = c.Param("id")
|
||||
}
|
||||
parsed, err := strconv.ParseUint(raw, 10, 64)
|
||||
if err != nil || parsed == 0 {
|
||||
return 0, fmt.Errorf("认证源 ID 无效")
|
||||
}
|
||||
return uint(parsed), nil
|
||||
}
|
||||
|
||||
func getAuthSourceFromRoute(c *gin.Context) (*model.AuthSource, error) {
|
||||
raw := strings.TrimSpace(c.Param("source"))
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(c.Param("source_id"))
|
||||
}
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(c.Param("id"))
|
||||
}
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("认证源不能为空")
|
||||
}
|
||||
if parsed, err := strconv.ParseUint(raw, 10, 64); err == nil && parsed > 0 {
|
||||
source, err := model.GetAuthSourceByID(uint(parsed))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
source, err := model.GetAuthSourceByName(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return source, nil
|
||||
}
|
||||
|
||||
func oauthStateSessionKey(sourceID uint) string {
|
||||
return fmt.Sprintf("oauth_state_%d", sourceID)
|
||||
}
|
||||
|
||||
func oauthFrontendCallbackURL(c *gin.Context, sourceID uint) string {
|
||||
base := strings.TrimRight(common.ServerAddress, "/")
|
||||
if base == "" {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
host := c.Request.Host
|
||||
if forwardedHost := c.GetHeader("X-Forwarded-Host"); forwardedHost != "" {
|
||||
host = forwardedHost
|
||||
}
|
||||
base = scheme + "://" + host
|
||||
}
|
||||
source, err := model.GetAuthSourceByID(sourceID)
|
||||
sourceName := strconv.FormatUint(uint64(sourceID), 10)
|
||||
if err == nil && strings.TrimSpace(source.Name) != "" {
|
||||
sourceName = source.Name
|
||||
}
|
||||
callback, _ := url.JoinPath(base, "oauth", sourceName)
|
||||
return callback
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"openflare/service"
|
||||
"openflare/utils/mail"
|
||||
"openflare/utils/security"
|
||||
"openflare/utils/validation"
|
||||
@@ -19,6 +20,10 @@ import (
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/status [get]
|
||||
func GetStatus(c *gin.Context) {
|
||||
authSources, err := service.PublicAuthSources("/api")
|
||||
if err != nil {
|
||||
authSources = []service.PublicAuthSource{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -38,6 +43,7 @@ func GetStatus(c *gin.Context) {
|
||||
"turnstile_site_key": common.TurnstileSiteKey,
|
||||
"register_enabled": common.RegisterEnabled,
|
||||
"password_register_enabled": common.PasswordRegisterEnabled,
|
||||
"auth_sources": authSources,
|
||||
},
|
||||
})
|
||||
return
|
||||
|
||||
@@ -61,7 +61,7 @@ func Login(c *gin.Context) {
|
||||
}
|
||||
|
||||
// setup session & cookies and then return user info
|
||||
func setupLogin(user *model.User, c *gin.Context) {
|
||||
func setLoginSession(user *model.User, c *gin.Context) (*model.User, error) {
|
||||
session := sessions.Default(c)
|
||||
session.Set("id", user.Id)
|
||||
session.Set("username", user.Username)
|
||||
@@ -69,23 +69,31 @@ func setupLogin(user *model.User, c *gin.Context) {
|
||||
session.Set("status", user.Status)
|
||||
err := session.Save()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无法保存会话信息,请重试",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
cleanUser := model.User{
|
||||
cleanUser := &model.User{
|
||||
Id: user.Id,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
}
|
||||
return cleanUser, nil
|
||||
}
|
||||
|
||||
func setupLogin(user *model.User, c *gin.Context) {
|
||||
cleanUser, err := setLoginSession(user, c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "无法保存会话信息,请重试",
|
||||
"success": false,
|
||||
})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "",
|
||||
"success": true,
|
||||
"data": cleanUser,
|
||||
"data": *cleanUser,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthSourceTypeGitHub = "github"
|
||||
AuthSourceTypeOIDC = "oidc"
|
||||
)
|
||||
|
||||
var authSourceNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$`)
|
||||
|
||||
type AuthSource struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name" gorm:"uniqueIndex;size:80;not null"`
|
||||
Type string `json:"type" gorm:"index;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:"column:client_id;size:255"`
|
||||
ClientSecret string `json:"-" gorm:"column:client_secret;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 uint `json:"id"`
|
||||
AuthSourceID uint `json:"auth_source_id" gorm:"uniqueIndex:idx_external_account_source_external;index;not null"`
|
||||
UserID int `json:"user_id" gorm:"index;not null"`
|
||||
ExternalID string `json:"external_id" gorm:"uniqueIndex:idx_external_account_source_external;size:255;not null"`
|
||||
ExternalUsername string `json:"external_username" gorm:"size:255"`
|
||||
Email string `json:"email" gorm:"size:255"`
|
||||
AuthSource AuthSource `json:"-" gorm:"constraint:OnDelete:CASCADE"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (source *AuthSource) Normalize() {
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
source.Type = strings.TrimSpace(strings.ToLower(source.Type))
|
||||
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"
|
||||
}
|
||||
if source.Type == AuthSourceTypeGitHub && source.Scopes == "" {
|
||||
source.Scopes = "user:email"
|
||||
}
|
||||
}
|
||||
|
||||
func (source *AuthSource) Validate() error {
|
||||
source.Normalize()
|
||||
if source.Name == "" {
|
||||
return errors.New("认证源名称不能为空")
|
||||
}
|
||||
if !authSourceNamePattern.MatchString(source.Name) {
|
||||
return errors.New("认证源名称只能包含字母、数字、短横线或下划线,且必须以字母或数字开头")
|
||||
}
|
||||
switch source.Type {
|
||||
case AuthSourceTypeGitHub:
|
||||
case AuthSourceTypeOIDC:
|
||||
if source.OpenIDDiscoveryURL == "" {
|
||||
return errors.New("OIDC 认证源必须配置 Discovery URL")
|
||||
}
|
||||
default:
|
||||
return errors.New("认证源类型仅支持 github 或 oidc")
|
||||
}
|
||||
if source.IsActive {
|
||||
if 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
|
||||
err := DB.Order("id asc").Find(&sources).Error
|
||||
for index := range sources {
|
||||
sources[index].Sanitize()
|
||||
}
|
||||
return sources, err
|
||||
}
|
||||
|
||||
func GetActiveAuthSources() ([]AuthSource, error) {
|
||||
var sources []AuthSource
|
||||
err := DB.Where("is_active = ?", true).Order("id asc").Find(&sources).Error
|
||||
for index := range sources {
|
||||
sources[index].Sanitize()
|
||||
}
|
||||
return sources, err
|
||||
}
|
||||
|
||||
func GetAuthSourceByID(id uint) (*AuthSource, error) {
|
||||
if id == 0 {
|
||||
return nil, errors.New("认证源 ID 不能为空")
|
||||
}
|
||||
var source AuthSource
|
||||
if err := DB.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.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.Create(source).Error
|
||||
}
|
||||
|
||||
func UpdateAuthSource(source *AuthSource, keepSecret bool) error {
|
||||
if source.ID == 0 {
|
||||
return errors.New("认证源 ID 不能为空")
|
||||
}
|
||||
var current AuthSource
|
||||
if err := DB.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.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 uint, 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.Model(&AuthSource{}).Where("id = ?", id).Update("is_active", isActive).Error
|
||||
}
|
||||
|
||||
func DeleteAuthSource(id uint) error {
|
||||
if id == 0 {
|
||||
return errors.New("认证源 ID 不能为空")
|
||||
}
|
||||
return DB.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 uint, externalID string) (*ExternalAccount, error) {
|
||||
var account ExternalAccount
|
||||
err := DB.Where("auth_source_id = ? AND external_id = ?", sourceID, externalID).First(&account).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func LinkExternalAccount(account *ExternalAccount) error {
|
||||
if account.AuthSourceID == 0 || 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.Where(ExternalAccount{
|
||||
AuthSourceID: account.AuthSourceID,
|
||||
ExternalID: account.ExternalID,
|
||||
}).FirstOrCreate(account).Error
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
const (
|
||||
legacyDatabaseSchemaVersion = 1
|
||||
currentDatabaseSchemaVersion = 9
|
||||
currentDatabaseSchemaVersion = 10
|
||||
databaseSchemaVersionRowID = 1
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ func registeredModels() []any {
|
||||
return []any{
|
||||
&File{},
|
||||
&User{},
|
||||
&AuthSource{},
|
||||
&ExternalAccount{},
|
||||
&Option{},
|
||||
&Origin{},
|
||||
&ProxyRoute{},
|
||||
|
||||
@@ -1216,6 +1216,108 @@ func migrateV9(db *gorm.DB, backend string) error {
|
||||
return backfillProxyRouteDomainCertificateFields(db)
|
||||
}
|
||||
|
||||
func ensureDefaultGitHubAuthSource(db *gorm.DB) error {
|
||||
if db == nil || !db.Migrator().HasTable(&AuthSource{}) || !db.Migrator().HasTable(&ExternalAccount{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var githubUserCount int64
|
||||
if db.Migrator().HasColumn(&User{}, "github_id") {
|
||||
if err := db.Model(&User{}).Where("github_id <> ''").Count(&githubUserCount).Error; err != nil {
|
||||
return fmt.Errorf("count legacy github users failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
optionMap := map[string]string{}
|
||||
if db.Migrator().HasTable(&Option{}) {
|
||||
var options []Option
|
||||
if err := db.Find(&options).Error; err != nil {
|
||||
return fmt.Errorf("query options for github auth source migration failed: %w", err)
|
||||
}
|
||||
for _, option := range options {
|
||||
optionMap[option.Key] = option.Value
|
||||
}
|
||||
}
|
||||
|
||||
clientID := strings.TrimSpace(optionMap["GitHubClientId"])
|
||||
clientSecret := strings.TrimSpace(optionMap["GitHubClientSecret"])
|
||||
enabled := optionMap["GitHubOAuthEnabled"] == "true" && clientID != "" && clientSecret != ""
|
||||
if githubUserCount == 0 && clientID == "" && clientSecret == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
source := AuthSource{}
|
||||
err := db.Where("type = ? AND name = ?", AuthSourceTypeGitHub, "GitHub").First(&source).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
source = AuthSource{
|
||||
Name: "GitHub",
|
||||
Type: AuthSourceTypeGitHub,
|
||||
DisplayName: "GitHub",
|
||||
IsActive: enabled,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
Scopes: "user:email",
|
||||
}
|
||||
if err := db.Create(&source).Error; err != nil {
|
||||
return fmt.Errorf("create default github auth source failed: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("query default github auth source failed: %w", err)
|
||||
} else {
|
||||
updates := map[string]any{}
|
||||
if source.ClientID == "" && clientID != "" {
|
||||
updates["client_id"] = clientID
|
||||
}
|
||||
if source.ClientSecret == "" && clientSecret != "" {
|
||||
updates["client_secret"] = clientSecret
|
||||
}
|
||||
if source.Scopes == "" {
|
||||
updates["scopes"] = "user:email"
|
||||
}
|
||||
if enabled && !source.IsActive {
|
||||
updates["is_active"] = true
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
if err := db.Model(&source).Updates(updates).Error; err != nil {
|
||||
return fmt.Errorf("update default github auth source failed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if githubUserCount == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var users []User
|
||||
if err := db.Select("id", "github_id", "username", "email").Where("github_id <> ''").Find(&users).Error; err != nil {
|
||||
return fmt.Errorf("query legacy github users failed: %w", err)
|
||||
}
|
||||
for _, user := range users {
|
||||
account := ExternalAccount{
|
||||
AuthSourceID: source.ID,
|
||||
UserID: user.Id,
|
||||
ExternalID: user.GitHubId,
|
||||
ExternalUsername: user.GitHubId,
|
||||
Email: user.Email,
|
||||
}
|
||||
if err := db.Where(ExternalAccount{
|
||||
AuthSourceID: source.ID,
|
||||
ExternalID: user.GitHubId,
|
||||
}).FirstOrCreate(&account).Error; err != nil {
|
||||
return fmt.Errorf("migrate github external account for user %d failed: %w", user.Id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrateV10 adds configurable auth sources and external account bindings.
|
||||
func migrateV10(db *gorm.DB, backend string) error {
|
||||
if err := applyCurrentSchema(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return ensureDefaultGitHubAuthSource(db)
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV9(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV8(db, backend); err != nil {
|
||||
return err
|
||||
@@ -1229,6 +1331,19 @@ func validateDatabaseSchemaV9(db *gorm.DB, backend string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDatabaseSchemaV10(db *gorm.DB, backend string) error {
|
||||
if err := validateDatabaseSchemaV9(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
if !db.Migrator().HasTable(&AuthSource{}) {
|
||||
return fmt.Errorf("table auth_sources is missing")
|
||||
}
|
||||
if !db.Migrator().HasTable(&ExternalAccount{}) {
|
||||
return fmt.Errorf("table external_accounts is missing")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
return []databaseSchemaMigration{
|
||||
{fromVersion: 1, toVersion: 2, migrate: migrateV2, validate: validateDatabaseSchemaV2},
|
||||
@@ -1239,6 +1354,7 @@ func databaseSchemaMigrations() []databaseSchemaMigration {
|
||||
{fromVersion: 6, toVersion: 7, migrate: migrateV7, validate: validateDatabaseSchemaV7},
|
||||
{fromVersion: 7, toVersion: 8, migrate: migrateV8, validate: validateDatabaseSchemaV8},
|
||||
{fromVersion: 8, toVersion: 9, migrate: migrateV9, validate: validateDatabaseSchemaV9},
|
||||
{fromVersion: 9, toVersion: 10, migrate: migrateV10, validate: validateDatabaseSchemaV10},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1321,7 +1437,10 @@ func initializeFreshDatabaseSchema(db *gorm.DB, backend string) error {
|
||||
if err := backfillProxyRouteDomainCertificateFields(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDatabaseSchemaV9(db, backend); err != nil {
|
||||
if err := ensureDefaultGitHubAuthSource(db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDatabaseSchemaV10(db, backend); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveDatabaseSchemaVersion(db, currentDatabaseSchemaVersion)
|
||||
|
||||
@@ -21,6 +21,9 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.GET("/oauth/wechat", middleware.CriticalRateLimit(), controller.WeChatAuth)
|
||||
apiRouter.GET("/oauth/wechat/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.WeChatBind)
|
||||
apiRouter.GET("/oauth/email/bind", middleware.CriticalRateLimit(), middleware.UserAuth(), controller.EmailBind)
|
||||
apiRouter.GET("/oauth/:source/authorize", middleware.CriticalRateLimit(), controller.OAuthAuthorize)
|
||||
apiRouter.GET("/oauth/:source/callback", middleware.CriticalRateLimit(), controller.OAuthCallback)
|
||||
apiRouter.POST("/oauth/link-existing", middleware.CriticalRateLimit(), controller.LinkExistingOAuthAccount)
|
||||
|
||||
userRoute := apiRouter.Group("/user")
|
||||
{
|
||||
@@ -58,6 +61,15 @@ func SetApiRouter(router *gin.Engine) {
|
||||
optionRoute.POST("/geoip/lookup", controller.LookupGeoIP)
|
||||
optionRoute.POST("/database/cleanup", controller.CleanupDatabaseObservability)
|
||||
}
|
||||
authSourceRoute := apiRouter.Group("/auth-sources")
|
||||
authSourceRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
authSourceRoute.GET("/", controller.ListAuthSources)
|
||||
authSourceRoute.POST("/", controller.CreateAuthSource)
|
||||
authSourceRoute.POST("/:id/update", controller.UpdateAuthSource)
|
||||
authSourceRoute.POST("/:id/delete", controller.DeleteAuthSource)
|
||||
authSourceRoute.POST("/:id/toggle", controller.ToggleAuthSource)
|
||||
}
|
||||
updateRoute := apiRouter.Group("/update")
|
||||
updateRoute.Use(middleware.RootAuth(), middleware.NoTokenAuth())
|
||||
{
|
||||
|
||||
@@ -201,6 +201,74 @@ func TestPhase2BatchOptionUpdateValidatesMergedState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthSourceUpdateAcceptsClientSecret(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
common.RedisEnabled = false
|
||||
setupTestDB(t)
|
||||
|
||||
engine := gin.New()
|
||||
engine.Use(sessions.Sessions("session", cookie.NewStore([]byte("test-secret"))))
|
||||
router.SetApiRouter(engine)
|
||||
|
||||
loginCookie := loginAsRoot(t, engine)
|
||||
|
||||
createResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/", map[string]any{
|
||||
"name": "GitHub",
|
||||
"type": "github",
|
||||
"display_name": "GitHub",
|
||||
"is_active": false,
|
||||
"client_id": "github-client-id",
|
||||
"client_secret": "initial-secret",
|
||||
"scopes": "user:email",
|
||||
})
|
||||
|
||||
var created model.AuthSource
|
||||
decodeResponseData(t, createResp, &created)
|
||||
if created.ClientSecret != "" {
|
||||
t.Fatal("expected create response to avoid exposing client_secret")
|
||||
}
|
||||
if !created.ClientSecretConfigured {
|
||||
t.Fatal("expected create response to mark client_secret as configured")
|
||||
}
|
||||
|
||||
updateResp := performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/1/update", map[string]any{
|
||||
"name": "GitHub",
|
||||
"type": "github",
|
||||
"display_name": "GitHub",
|
||||
"is_active": true,
|
||||
"client_id": "github-client-id",
|
||||
"client_secret": "updated-secret",
|
||||
"scopes": "user:email",
|
||||
})
|
||||
|
||||
var updated model.AuthSource
|
||||
decodeResponseData(t, updateResp, &updated)
|
||||
if updated.ClientSecret != "" {
|
||||
t.Fatal("expected update response to avoid exposing client_secret")
|
||||
}
|
||||
if !updated.ClientSecretConfigured {
|
||||
t.Fatal("expected update response to mark client_secret as configured")
|
||||
}
|
||||
if !updated.IsActive {
|
||||
t.Fatal("expected auth source to be active after update")
|
||||
}
|
||||
|
||||
stored, err := model.GetAuthSourceByID(1)
|
||||
if err != nil {
|
||||
t.Fatalf("expected auth source to exist: %v", err)
|
||||
}
|
||||
if stored.ClientSecret != "updated-secret" {
|
||||
t.Fatalf("expected stored client secret to be updated, got %q", stored.ClientSecret)
|
||||
}
|
||||
|
||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/1/toggle", map[string]any{
|
||||
"is_active": false,
|
||||
})
|
||||
performSessionJSONRequest(t, engine, loginCookie, http.MethodPost, "/api/auth-sources/1/toggle", map[string]any{
|
||||
"is_active": true,
|
||||
})
|
||||
}
|
||||
|
||||
func loginAsRoot(t *testing.T, engine http.Handler) *http.Cookie {
|
||||
t.Helper()
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
|
||||
@@ -41,6 +41,9 @@ func setWebRouter(router *gin.Engine, buildFS embed.FS, indexPage []byte) {
|
||||
|
||||
func serveExportedPage(c *gin.Context, buildFS fs.FS) bool {
|
||||
requestPath := strings.Trim(c.Request.URL.Path, "/")
|
||||
if isOAuthCallbackPath(requestPath) {
|
||||
requestPath = "oauth/callback"
|
||||
}
|
||||
|
||||
candidates := []string{"index.html"}
|
||||
if requestPath != "" {
|
||||
@@ -61,6 +64,19 @@ func serveExportedPage(c *gin.Context, buildFS fs.FS) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func isOAuthCallbackPath(requestPath string) bool {
|
||||
if !strings.HasPrefix(requestPath, "oauth/") || strings.Count(requestPath, "/") != 1 {
|
||||
return false
|
||||
}
|
||||
source := strings.TrimPrefix(requestPath, "oauth/")
|
||||
switch source {
|
||||
case "", "callback", "link":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStaticExportDataNavigation() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
requestPath := c.Request.URL.Path
|
||||
|
||||
@@ -93,3 +93,20 @@ func TestCacheHeadersKeepImmutableStaticAssets(t *testing.T) {
|
||||
t.Fatalf("unexpected cache-control for static asset: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthCallbackPathMatchesSourceNames(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"oauth/github": true,
|
||||
"oauth/oidc-main": true,
|
||||
"oauth/1": true,
|
||||
"oauth/callback": false,
|
||||
"oauth/link": false,
|
||||
"oauth": false,
|
||||
}
|
||||
|
||||
for requestPath, expected := range cases {
|
||||
if got := isOAuthCallbackPath(requestPath); got != expected {
|
||||
t.Fatalf("expected %s match=%v, got %v", requestPath, expected, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PublicAuthSource struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
DisplayName string `json:"display_name"`
|
||||
AuthorizeURL string `json:"authorize_url"`
|
||||
IconURL string `json:"icon_url"`
|
||||
}
|
||||
|
||||
type OAuthProfile struct {
|
||||
ExternalID string
|
||||
ExternalUsername string
|
||||
DisplayName string
|
||||
Email string
|
||||
}
|
||||
|
||||
type OAuthCallbackResult struct {
|
||||
Status string `json:"status"`
|
||||
User *model.User `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
type LinkExistingRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type PendingExternalAccount struct {
|
||||
AuthSourceID uint `json:"auth_source_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
ExternalUsername string `json:"external_username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type oidcDiscovery struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||
JWKSURI string `json:"jwks_uri"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
type oauthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
IDToken string `json:"id_token"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
var oauthHTTPClient = &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
func GenerateOAuthState() (string, error) {
|
||||
buffer := make([]byte, 24)
|
||||
if _, err := rand.Read(buffer); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||
}
|
||||
|
||||
func PublicAuthSources(baseAPIPath string) ([]PublicAuthSource, error) {
|
||||
sources, err := model.GetActiveAuthSources()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]PublicAuthSource, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
result = append(result, PublicAuthSource{
|
||||
ID: source.ID,
|
||||
Name: source.Name,
|
||||
Type: source.Type,
|
||||
DisplayName: source.DisplayName,
|
||||
AuthorizeURL: fmt.Sprintf("%s/oauth/%s/authorize", strings.TrimRight(baseAPIPath, "/"), url.PathEscape(source.Name)),
|
||||
IconURL: source.IconURL,
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func BuildAuthorizeURL(ctx context.Context, source *model.AuthSource, redirectURL string, state string) (string, error) {
|
||||
source.Normalize()
|
||||
switch source.Type {
|
||||
case model.AuthSourceTypeGitHub:
|
||||
authorizeURL, err := url.Parse("https://github.com/login/oauth/authorize")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := authorizeURL.Query()
|
||||
values.Set("client_id", source.ClientID)
|
||||
values.Set("redirect_uri", redirectURL)
|
||||
values.Set("scope", source.Scopes)
|
||||
values.Set("state", state)
|
||||
authorizeURL.RawQuery = values.Encode()
|
||||
return authorizeURL.String(), nil
|
||||
case model.AuthSourceTypeOIDC:
|
||||
discovery, err := fetchOIDCDiscovery(ctx, source.OpenIDDiscoveryURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorizeURL, err := url.Parse(discovery.AuthorizationEndpoint)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := authorizeURL.Query()
|
||||
values.Set("client_id", source.ClientID)
|
||||
values.Set("redirect_uri", redirectURL)
|
||||
values.Set("response_type", "code")
|
||||
values.Set("scope", source.Scopes)
|
||||
values.Set("state", state)
|
||||
authorizeURL.RawQuery = values.Encode()
|
||||
return authorizeURL.String(), nil
|
||||
default:
|
||||
return "", errors.New("不支持的认证源类型")
|
||||
}
|
||||
}
|
||||
|
||||
func ExchangeOAuthProfile(ctx context.Context, source *model.AuthSource, code string, redirectURL string) (*OAuthProfile, error) {
|
||||
if strings.TrimSpace(code) == "" {
|
||||
return nil, errors.New("授权 code 不能为空")
|
||||
}
|
||||
source.Normalize()
|
||||
switch source.Type {
|
||||
case model.AuthSourceTypeGitHub:
|
||||
return exchangeGitHubProfile(ctx, source, code, redirectURL)
|
||||
case model.AuthSourceTypeOIDC:
|
||||
return exchangeOIDCProfile(ctx, source, code, redirectURL)
|
||||
default:
|
||||
return nil, errors.New("不支持的认证源类型")
|
||||
}
|
||||
}
|
||||
|
||||
func CompleteOAuthLogin(source *model.AuthSource, profile *OAuthProfile, currentUserID *int) (*OAuthCallbackResult, *PendingExternalAccount, error) {
|
||||
if source == nil || profile == nil || strings.TrimSpace(profile.ExternalID) == "" {
|
||||
return nil, nil, errors.New("第三方账号资料不完整")
|
||||
}
|
||||
|
||||
account, err := model.FindExternalAccount(source.ID, profile.ExternalID)
|
||||
if err == nil {
|
||||
user, err := model.GetUserById(account.UserID, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
return nil, nil, errors.New("用户已被封禁")
|
||||
}
|
||||
return &OAuthCallbackResult{Status: "logged_in", User: user}, nil, nil
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if currentUserID != nil && *currentUserID > 0 {
|
||||
user, err := model.GetUserById(*currentUserID, false)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
return nil, nil, errors.New("用户已被封禁")
|
||||
}
|
||||
if err := model.LinkExternalAccount(&model.ExternalAccount{
|
||||
AuthSourceID: source.ID,
|
||||
UserID: user.Id,
|
||||
ExternalID: profile.ExternalID,
|
||||
ExternalUsername: profile.ExternalUsername,
|
||||
Email: profile.Email,
|
||||
}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &OAuthCallbackResult{Status: "linked", User: user}, nil, nil
|
||||
}
|
||||
|
||||
if common.RegisterEnabled {
|
||||
user, err := createUserFromOAuthProfile(source, profile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &OAuthCallbackResult{Status: "registered", User: user}, nil, nil
|
||||
}
|
||||
|
||||
pending := &PendingExternalAccount{
|
||||
AuthSourceID: source.ID,
|
||||
ExternalID: profile.ExternalID,
|
||||
ExternalUsername: profile.ExternalUsername,
|
||||
DisplayName: profile.DisplayName,
|
||||
Email: profile.Email,
|
||||
}
|
||||
return &OAuthCallbackResult{Status: "link_required"}, pending, nil
|
||||
}
|
||||
|
||||
func LinkPendingExternalAccount(pending *PendingExternalAccount, input LinkExistingRequest) (*model.User, error) {
|
||||
if pending == nil || pending.AuthSourceID == 0 || pending.ExternalID == "" {
|
||||
return nil, errors.New("待绑定第三方账号已失效,请重新登录")
|
||||
}
|
||||
user := model.User{
|
||||
Username: strings.TrimSpace(input.Username),
|
||||
Password: input.Password,
|
||||
}
|
||||
if err := user.ValidateAndFill(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.Status != common.UserStatusEnabled {
|
||||
return nil, errors.New("用户已被封禁")
|
||||
}
|
||||
|
||||
if existing, err := model.FindExternalAccount(pending.AuthSourceID, pending.ExternalID); err == nil {
|
||||
if existing.UserID != user.Id {
|
||||
return nil, errors.New("该第三方账号已绑定其他用户")
|
||||
}
|
||||
return &user, nil
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := model.LinkExternalAccount(&model.ExternalAccount{
|
||||
AuthSourceID: pending.AuthSourceID,
|
||||
UserID: user.Id,
|
||||
ExternalID: pending.ExternalID,
|
||||
ExternalUsername: pending.ExternalUsername,
|
||||
Email: pending.Email,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func createUserFromOAuthProfile(source *model.AuthSource, profile *OAuthProfile) (*model.User, error) {
|
||||
displayName := strings.TrimSpace(profile.DisplayName)
|
||||
if displayName == "" {
|
||||
displayName = strings.TrimSpace(profile.ExternalUsername)
|
||||
}
|
||||
if displayName == "" {
|
||||
displayName = source.DisplayName + " User"
|
||||
}
|
||||
if len([]rune(displayName)) > 20 {
|
||||
displayName = string([]rune(displayName)[:20])
|
||||
}
|
||||
|
||||
prefix := source.Type
|
||||
if prefix == "" {
|
||||
prefix = "oauth"
|
||||
}
|
||||
var username string
|
||||
for index := 0; index < 20; index++ {
|
||||
username = fmt.Sprintf("%s_%d", prefix, model.GetMaxUserId()+1+index)
|
||||
if !model.IsUsernameAlreadyTaken(username) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
user := &model.User{
|
||||
Username: username,
|
||||
DisplayName: displayName,
|
||||
Email: profile.Email,
|
||||
Role: common.RoleCommonUser,
|
||||
Status: common.UserStatusEnabled,
|
||||
}
|
||||
if err := user.Insert(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := model.LinkExternalAccount(&model.ExternalAccount{
|
||||
AuthSourceID: source.ID,
|
||||
UserID: user.Id,
|
||||
ExternalID: profile.ExternalID,
|
||||
ExternalUsername: profile.ExternalUsername,
|
||||
Email: profile.Email,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func exchangeGitHubProfile(ctx context.Context, source *model.AuthSource, code string, redirectURL string) (*OAuthProfile, error) {
|
||||
values := map[string]string{
|
||||
"client_id": source.ClientID,
|
||||
"client_secret": source.ClientSecret,
|
||||
"code": code,
|
||||
"redirect_uri": redirectURL,
|
||||
}
|
||||
body, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://github.com/login/oauth/access_token", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("github oauth access token request failed", "error", err)
|
||||
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("GitHub token 接口返回异常状态: %s", resp.Status)
|
||||
}
|
||||
var token oauthTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
return nil, errors.New("GitHub 未返回 access token")
|
||||
}
|
||||
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token.AccessToken)
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
resp, err = oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("github user info request failed", "error", err)
|
||||
return nil, errors.New("无法连接至 GitHub 服务器,请稍后重试")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("GitHub 用户接口返回异常状态: %s", resp.Status)
|
||||
}
|
||||
var githubUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&githubUser); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if githubUser.ID == 0 && githubUser.Login == "" {
|
||||
return nil, errors.New("GitHub 用户资料缺少唯一标识")
|
||||
}
|
||||
return &OAuthProfile{
|
||||
ExternalID: githubUser.Login,
|
||||
ExternalUsername: githubUser.Login,
|
||||
DisplayName: firstNonEmpty(githubUser.Name, githubUser.Login),
|
||||
Email: githubUser.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func exchangeOIDCProfile(ctx context.Context, source *model.AuthSource, code string, redirectURL string) (*OAuthProfile, error) {
|
||||
discovery, err := fetchOIDCDiscovery(ctx, source.OpenIDDiscoveryURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := exchangeOIDCToken(ctx, discovery.TokenEndpoint, source, code, redirectURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
return nil, errors.New("OIDC 未返回 access token")
|
||||
}
|
||||
claims, err := fetchOIDCUserInfo(ctx, discovery.UserInfoEndpoint, token.AccessToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(claims) == 0 && token.IDToken != "" {
|
||||
claims = decodeJWTClaims(token.IDToken)
|
||||
}
|
||||
profile := profileFromClaims(claims)
|
||||
if profile.ExternalID == "" {
|
||||
return nil, errors.New("OIDC 用户资料缺少 sub")
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func fetchOIDCDiscovery(ctx context.Context, discoveryURL string) (*oidcDiscovery, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法获取 OIDC discovery 配置: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("OIDC discovery 返回异常状态: %s", resp.Status)
|
||||
}
|
||||
var discovery oidcDiscovery
|
||||
if err := json.NewDecoder(resp.Body).Decode(&discovery); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if discovery.AuthorizationEndpoint == "" || discovery.TokenEndpoint == "" {
|
||||
return nil, errors.New("OIDC discovery 缺少授权或 token 端点")
|
||||
}
|
||||
return &discovery, nil
|
||||
}
|
||||
|
||||
func exchangeOIDCToken(ctx context.Context, tokenEndpoint string, source *model.AuthSource, code string, redirectURL string) (*oauthTokenResponse, error) {
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "authorization_code")
|
||||
form.Set("client_id", source.ClientID)
|
||||
form.Set("client_secret", source.ClientSecret)
|
||||
form.Set("code", code)
|
||||
form.Set("redirect_uri", redirectURL)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OIDC token 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("OIDC token 接口返回异常状态: %s %s", resp.Status, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var token oauthTokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func fetchOIDCUserInfo(ctx context.Context, endpoint string, accessToken string) (map[string]any, error) {
|
||||
if endpoint == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := oauthHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OIDC userinfo 请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("OIDC userinfo 返回异常状态: %s %s", resp.Status, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func decodeJWTClaims(token string) map[string]any {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) < 2 {
|
||||
return map[string]any{}
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
var claims map[string]any
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return claims
|
||||
}
|
||||
|
||||
func profileFromClaims(claims map[string]any) *OAuthProfile {
|
||||
stringClaim := func(keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := claims[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return &OAuthProfile{
|
||||
ExternalID: stringClaim("sub"),
|
||||
ExternalUsername: stringClaim("preferred_username", "nickname", "name", "email"),
|
||||
DisplayName: stringClaim("name", "preferred_username", "nickname", "email"),
|
||||
Email: stringClaim("email"),
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"openflare/common"
|
||||
"openflare/model"
|
||||
)
|
||||
|
||||
func TestCompleteOAuthLoginRequiresLinkWhenRegistrationDisabled(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
previousRegisterEnabled := common.RegisterEnabled
|
||||
common.RegisterEnabled = false
|
||||
t.Cleanup(func() {
|
||||
common.RegisterEnabled = previousRegisterEnabled
|
||||
})
|
||||
|
||||
source := createTestAuthSource(t)
|
||||
result, pending, err := CompleteOAuthLogin(source, &OAuthProfile{
|
||||
ExternalID: "external-1",
|
||||
ExternalUsername: "external-user",
|
||||
DisplayName: "External User",
|
||||
Email: "external@example.com",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteOAuthLogin failed: %v", err)
|
||||
}
|
||||
if result.Status != "link_required" || pending == nil {
|
||||
t.Fatalf("expected link_required with pending account, got %#v pending=%#v", result, pending)
|
||||
}
|
||||
|
||||
user, err := LinkPendingExternalAccount(pending, LinkExistingRequest{
|
||||
Username: "root",
|
||||
Password: "123456",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LinkPendingExternalAccount failed: %v", err)
|
||||
}
|
||||
if user.Username != "root" {
|
||||
t.Fatalf("expected root user, got %s", user.Username)
|
||||
}
|
||||
account, err := model.FindExternalAccount(source.ID, "external-1")
|
||||
if err != nil {
|
||||
t.Fatalf("expected external account to be linked: %v", err)
|
||||
}
|
||||
if account.UserID != user.Id {
|
||||
t.Fatalf("expected external account user %d, got %d", user.Id, account.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOAuthLoginAutoRegistersWhenEnabled(t *testing.T) {
|
||||
setupServiceTestDB(t)
|
||||
previousRegisterEnabled := common.RegisterEnabled
|
||||
common.RegisterEnabled = true
|
||||
t.Cleanup(func() {
|
||||
common.RegisterEnabled = previousRegisterEnabled
|
||||
})
|
||||
|
||||
source := createTestAuthSource(t)
|
||||
result, pending, err := CompleteOAuthLogin(source, &OAuthProfile{
|
||||
ExternalID: "external-2",
|
||||
ExternalUsername: "oidc-user",
|
||||
DisplayName: "OIDC User",
|
||||
Email: "oidc@example.com",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteOAuthLogin failed: %v", err)
|
||||
}
|
||||
if pending != nil {
|
||||
t.Fatalf("expected no pending account when registration is enabled")
|
||||
}
|
||||
if result.Status != "registered" || result.User == nil {
|
||||
t.Fatalf("expected registered user result, got %#v", result)
|
||||
}
|
||||
account, err := model.FindExternalAccount(source.ID, "external-2")
|
||||
if err != nil {
|
||||
t.Fatalf("expected external account to be linked: %v", err)
|
||||
}
|
||||
if account.UserID != result.User.Id {
|
||||
t.Fatalf("expected external account user %d, got %d", result.User.Id, account.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestAuthSource(t *testing.T) *model.AuthSource {
|
||||
t.Helper()
|
||||
source := &model.AuthSource{
|
||||
Name: "Test OIDC",
|
||||
Type: model.AuthSourceTypeOIDC,
|
||||
DisplayName: "Test OIDC",
|
||||
ClientID: "client-id",
|
||||
ClientSecret: "client-secret",
|
||||
Scopes: "openid profile email",
|
||||
OpenIDDiscoveryURL: "https://idp.example.com/.well-known/openid-configuration",
|
||||
}
|
||||
if err := model.CreateAuthSource(source); err != nil {
|
||||
t.Fatalf("CreateAuthSource failed: %v", err)
|
||||
}
|
||||
return source
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Suspense } from 'react';
|
||||
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { OAuthCallback } from '@/features/auth/components/oauth-callback';
|
||||
|
||||
export default function OAuthSourceCallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={<LoadingState />}>
|
||||
<OAuthCallback />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OAuthLinkExistingForm } from '@/features/auth/components/oauth-link-existing-form';
|
||||
|
||||
export default function OAuthLinkPage() {
|
||||
return <OAuthLinkExistingForm />;
|
||||
}
|
||||
@@ -1,60 +1,100 @@
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
PasswordResetRequestPayload,
|
||||
RegisterPayload,
|
||||
} from '@/types/auth';
|
||||
|
||||
export function getCurrentUser() {
|
||||
return apiRequest<AuthUser>('/user/self');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return apiRequest<AuthUser>('/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/user/logout');
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload, turnstileToken?: string) {
|
||||
const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : '';
|
||||
|
||||
return apiRequest<void>(`/user/register${query}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendEmailVerification(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/verification?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function sendPasswordResetEmail(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||
return apiRequest<string>('/user/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function exchangeGitHubCode(code: string) {
|
||||
return apiRequest<AuthUser>(`/oauth/github?code=${encodeURIComponent(code)}`);
|
||||
}
|
||||
import { apiRequest } from '@/lib/api/client';
|
||||
import type {
|
||||
AuthUser,
|
||||
LoginPayload,
|
||||
PasswordResetRequestPayload,
|
||||
RegisterPayload,
|
||||
} from '@/types/auth';
|
||||
|
||||
export function getCurrentUser() {
|
||||
return apiRequest<AuthUser>('/user/self');
|
||||
}
|
||||
|
||||
export function login(payload: LoginPayload) {
|
||||
return apiRequest<AuthUser>('/user/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return apiRequest<void>('/user/logout');
|
||||
}
|
||||
|
||||
export function register(payload: RegisterPayload, turnstileToken?: string) {
|
||||
const query = turnstileToken
|
||||
? `?turnstile=${encodeURIComponent(turnstileToken)}`
|
||||
: '';
|
||||
|
||||
return apiRequest<void>(`/user/register${query}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function sendEmailVerification(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/verification?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function sendPasswordResetEmail(email: string, turnstileToken?: string) {
|
||||
const searchParams = new URLSearchParams({ email });
|
||||
if (turnstileToken) {
|
||||
searchParams.set('turnstile', turnstileToken);
|
||||
}
|
||||
|
||||
return apiRequest<void>(`/reset_password?${searchParams.toString()}`);
|
||||
}
|
||||
|
||||
export function resetPassword(payload: PasswordResetRequestPayload) {
|
||||
return apiRequest<string>('/user/reset', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function exchangeGitHubCode(code: string) {
|
||||
return apiRequest<AuthUser>(`/oauth/github?code=${encodeURIComponent(code)}`);
|
||||
}
|
||||
|
||||
export interface OAuthAuthorizeResult {
|
||||
authorize_url: string;
|
||||
}
|
||||
|
||||
export interface OAuthCallbackResult {
|
||||
status: 'logged_in' | 'registered' | 'linked' | 'link_required';
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
export interface LinkExistingOAuthPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function getOAuthAuthorizeUrl(source: number | string) {
|
||||
return apiRequest<OAuthAuthorizeResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/authorize`,
|
||||
);
|
||||
}
|
||||
|
||||
export function exchangeOAuthCode(
|
||||
source: number | string,
|
||||
code: string,
|
||||
state: string,
|
||||
) {
|
||||
const searchParams = new URLSearchParams({ code, state });
|
||||
return apiRequest<OAuthCallbackResult>(
|
||||
`/oauth/${encodeURIComponent(String(source))}/callback?${searchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function linkExistingOAuthAccount(payload: LinkExistingOAuthPayload) {
|
||||
return apiRequest<OAuthCallbackResult>('/oauth/link-existing', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { z } from 'zod';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { login } from '@/features/auth/api/auth';
|
||||
import { getOAuthAuthorizeUrl, login } from '@/features/auth/api/auth';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
AuthButton,
|
||||
@@ -24,14 +24,14 @@ import { PublicAuthGuard } from '@/features/auth/components/public-auth-guard';
|
||||
const TEXT = {
|
||||
usernameRequired: '\u8bf7\u8f93\u5165\u7528\u6237\u540d',
|
||||
passwordRequired: '\u8bf7\u8f93\u5165\u5bc6\u7801',
|
||||
loginFailed: '\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
githubUnavailable: 'GitHub \u767b\u5f55\u5f53\u524d\u4e0d\u53ef\u7528\u3002',
|
||||
loginFailed:
|
||||
'\u767b\u5f55\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u3002',
|
||||
oauthUnavailable: '第三方登录当前不可用。',
|
||||
title: '\u7528\u6237\u767b\u5f55',
|
||||
username: '\u7528\u6237\u540d',
|
||||
password: '\u5bc6\u7801',
|
||||
loginPending: '\u767b\u5f55\u4e2d...',
|
||||
login: '\u767b\u5f55',
|
||||
githubLogin: 'GitHub \u767b\u5f55',
|
||||
forgotPassword: '\u5fd8\u8bb0\u5bc6\u7801\uff1f',
|
||||
register: '\u6ce8\u518c',
|
||||
};
|
||||
@@ -78,35 +78,37 @@ export function LoginForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const oauthMutation = useMutation({
|
||||
mutationFn: getOAuthAuthorizeUrl,
|
||||
onSuccess: (result) => {
|
||||
window.location.href = result.authorize_url;
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || TEXT.oauthUnavailable);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
loginMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleGitHubLogin = () => {
|
||||
const clientId = statusQuery.data?.github_client_id;
|
||||
if (!clientId) {
|
||||
setErrorMessage(TEXT.githubUnavailable);
|
||||
return;
|
||||
}
|
||||
|
||||
const authorizeUrl = new URL('https://github.com/login/oauth/authorize');
|
||||
authorizeUrl.searchParams.set('client_id', clientId);
|
||||
authorizeUrl.searchParams.set('scope', 'user:email');
|
||||
window.location.href = authorizeUrl.toString();
|
||||
const handleOAuthLogin = (sourceName: string) => {
|
||||
setErrorMessage('');
|
||||
oauthMutation.mutate(sourceName);
|
||||
};
|
||||
|
||||
return (
|
||||
<PublicAuthGuard>
|
||||
<AppCard title={TEXT.title}>
|
||||
<form className='space-y-4' onSubmit={handleSubmit}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label={TEXT.username}>
|
||||
<AuthInput
|
||||
placeholder={TEXT.username}
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -114,41 +116,53 @@ export function LoginForm() {
|
||||
|
||||
<AuthFormField label={TEXT.password}>
|
||||
<AuthInput
|
||||
type='password'
|
||||
type="password"
|
||||
placeholder={TEXT.password}
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className='text-xs text-[var(--status-danger-foreground)]'>
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone='danger' message={errorMessage} />
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<div className='flex flex-col gap-3 sm:flex-row'>
|
||||
<AuthButton type='submit' disabled={loginMutation.isPending}>
|
||||
<div>
|
||||
<AuthButton type="submit" disabled={loginMutation.isPending}>
|
||||
{loginMutation.isPending ? TEXT.loginPending : TEXT.login}
|
||||
</AuthButton>
|
||||
{statusQuery.data?.github_oauth ? (
|
||||
<SecondaryButton
|
||||
type='button'
|
||||
onClick={handleGitHubLogin}
|
||||
className='w-full sm:w-auto'
|
||||
>
|
||||
{TEXT.githubLogin}
|
||||
</SecondaryButton>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{(statusQuery.data?.auth_sources ?? []).length > 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 pt-1">
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
第三方账号登录
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-3">
|
||||
{(statusQuery.data?.auth_sources ?? []).map((source) => (
|
||||
<SecondaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleOAuthLogin(source.name)}
|
||||
className="min-w-36"
|
||||
disabled={oauthMutation.isPending}
|
||||
>
|
||||
{source.display_name || source.name} 登录
|
||||
</SecondaryButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
<div className='mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]'>
|
||||
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href='/reset'
|
||||
className='text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
href="/reset"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.forgotPassword}
|
||||
</Link>
|
||||
@@ -156,8 +170,8 @@ export function LoginForm() {
|
||||
<>
|
||||
<span>|</span>
|
||||
<Link
|
||||
href='/register'
|
||||
className='text-[var(--brand-primary)] transition hover:opacity-80'
|
||||
href="/register"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
{TEXT.register}
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { exchangeOAuthCode } from '@/features/auth/api/auth';
|
||||
|
||||
function parseOAuthSource(pathname: string | null, sourceParam: string) {
|
||||
const normalizedParam = sourceParam.trim();
|
||||
if (normalizedParam) {
|
||||
return normalizedParam;
|
||||
}
|
||||
|
||||
const pathMatch = pathname?.match(/^\/oauth\/([^/?#]+)$/);
|
||||
if (!pathMatch) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return decodeURIComponent(pathMatch[1]);
|
||||
}
|
||||
|
||||
export function OAuthCallback({ sourceId }: { sourceId?: number }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const { setUser } = useAuth();
|
||||
const handledRef = useRef('');
|
||||
const [prompt, setPrompt] = useState('正在处理授权结果...');
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'danger' | 'success';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const code = searchParams?.get('code')?.trim() ?? '';
|
||||
const state = searchParams?.get('state')?.trim() ?? '';
|
||||
const oauthError = searchParams?.get('error')?.trim() ?? '';
|
||||
const oauthErrorDescription =
|
||||
searchParams?.get('error_description')?.trim() ?? '';
|
||||
const resolvedSource =
|
||||
sourceId ?? parseOAuthSource(pathname, searchParams?.get('source') ?? '');
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => exchangeOAuthCode(resolvedSource, code, state),
|
||||
onSuccess: (result) => {
|
||||
if (result.status === 'link_required') {
|
||||
setMessage({ tone: 'success', text: '请绑定已有账号以完成登录。' });
|
||||
router.replace('/oauth/link');
|
||||
return;
|
||||
}
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
setMessage({ tone: 'success', text: '登录成功,正在跳转...' });
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({ tone: 'danger', text: '授权结果缺少用户信息。' });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: error.message || '授权失败,请稍后重试。',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (oauthError || oauthErrorDescription) {
|
||||
setPrompt('授权处理失败');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: oauthErrorDescription || oauthError,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (String(resolvedSource).trim() === '') {
|
||||
setPrompt('缺少认证源参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到认证源参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
setPrompt('缺少授权参数');
|
||||
setMessage({
|
||||
tone: 'danger',
|
||||
text: '未收到完整授权参数,请返回登录页重试。',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${resolvedSource}:${code}:${state}`;
|
||||
if (handledRef.current === key) {
|
||||
return;
|
||||
}
|
||||
handledRef.current = key;
|
||||
mutation.mutate();
|
||||
}, [
|
||||
code,
|
||||
mutation,
|
||||
oauthError,
|
||||
oauthErrorDescription,
|
||||
resolvedSource,
|
||||
state,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppCard title="第三方登录回调" description={prompt}>
|
||||
<div className="space-y-4">
|
||||
{mutation.isPending ? <LoadingState /> : null}
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
{message?.tone === 'danger' ? (
|
||||
<div className="flex justify-center">
|
||||
<Link
|
||||
href="/login"
|
||||
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { linkExistingOAuthAccount } from '@/features/auth/api/auth';
|
||||
import {
|
||||
AuthButton,
|
||||
AuthFormField,
|
||||
AuthInput,
|
||||
} from '@/features/auth/components/auth-form-primitives';
|
||||
|
||||
const schema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function OAuthLinkExistingForm() {
|
||||
const router = useRouter();
|
||||
const { setUser } = useAuth();
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: linkExistingOAuthAccount,
|
||||
onSuccess: (result) => {
|
||||
if (result.user) {
|
||||
setUser(result.user);
|
||||
router.replace('/');
|
||||
return;
|
||||
}
|
||||
setErrorMessage('绑定成功但未返回用户信息,请重新登录。');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
setErrorMessage(error.message || '绑定失败,请稍后重试。');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
setErrorMessage('');
|
||||
mutation.mutate(values);
|
||||
});
|
||||
|
||||
return (
|
||||
<AppCard
|
||||
title="绑定已有账号"
|
||||
description="当前第三方账号尚未绑定本地用户,请使用已有账号完成关联。"
|
||||
>
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<AuthFormField label="用户名">
|
||||
<AuthInput
|
||||
placeholder="请输入用户名"
|
||||
{...form.register('username')}
|
||||
/>
|
||||
{form.formState.errors.username ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.username.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
<AuthFormField label="密码">
|
||||
<AuthInput
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
{...form.register('password')}
|
||||
/>
|
||||
{form.formState.errors.password ? (
|
||||
<span className="text-xs text-[var(--status-danger-foreground)]">
|
||||
{form.formState.errors.password.message}
|
||||
</span>
|
||||
) : null}
|
||||
</AuthFormField>
|
||||
|
||||
{errorMessage ? (
|
||||
<InlineMessage tone="danger" message={errorMessage} />
|
||||
) : null}
|
||||
|
||||
<AuthButton type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending ? '绑定中...' : '绑定并登录'}
|
||||
</AuthButton>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-sm text-[var(--foreground-secondary)]">
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-[var(--brand-primary)] transition hover:opacity-80"
|
||||
>
|
||||
返回登录
|
||||
</Link>
|
||||
</div>
|
||||
</AppCard>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { apiRequest } from '@/lib/api/client';
|
||||
|
||||
import type {
|
||||
BootstrapTokenPayload,
|
||||
AuthSource,
|
||||
AuthSourcePayload,
|
||||
DatabaseCleanupPayload,
|
||||
DatabaseCleanupResult,
|
||||
GeoIPLookupResult,
|
||||
@@ -43,6 +45,37 @@ export function cleanupDatabaseObservability(payload: DatabaseCleanupPayload) {
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuthSources() {
|
||||
return apiRequest<AuthSource[]>('/auth-sources/');
|
||||
}
|
||||
|
||||
export function createAuthSource(payload: AuthSourcePayload) {
|
||||
return apiRequest<AuthSource>('/auth-sources/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function updateAuthSource(id: number, payload: AuthSourcePayload) {
|
||||
return apiRequest<AuthSource>(`/auth-sources/${id}/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function toggleAuthSource(id: number, isActive: boolean) {
|
||||
return apiRequest<void>(`/auth-sources/${id}/toggle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ is_active: isActive }),
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAuthSource(id: number) {
|
||||
return apiRequest<void>(`/auth-sources/${id}/delete`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
export function getBootstrapToken() {
|
||||
return apiRequest<BootstrapTokenPayload>('/nodes/bootstrap-token');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { InlineMessage } from '@/components/feedback/inline-message';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import {
|
||||
createAuthSource,
|
||||
deleteAuthSource,
|
||||
toggleAuthSource,
|
||||
updateAuthSource,
|
||||
} from '@/features/settings/api/settings';
|
||||
import type {
|
||||
AuthSource,
|
||||
AuthSourcePayload,
|
||||
AuthSourceType,
|
||||
} from '@/features/settings/types';
|
||||
import {
|
||||
DangerButton,
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
ResourceSelect,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
const emptyForm: AuthSourcePayload = {
|
||||
name: '',
|
||||
type: 'github',
|
||||
display_name: '',
|
||||
is_active: false,
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
openid_discovery_url: '',
|
||||
scopes: 'user:email',
|
||||
icon_url: '',
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
function sourceToForm(source: AuthSource): AuthSourcePayload {
|
||||
return {
|
||||
id: source.id,
|
||||
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 ||
|
||||
(source.type === 'oidc' ? 'openid profile email' : 'user:email'),
|
||||
icon_url: source.icon_url,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCallbackURL(origin: string, sourceName: string) {
|
||||
const normalizedName = sourceName.trim() || '认证源名称';
|
||||
return `${origin || '当前访问地址'}/oauth/${encodeURIComponent(normalizedName)}`;
|
||||
}
|
||||
|
||||
export function AuthSourceModal({
|
||||
isOpen,
|
||||
sources,
|
||||
isLoading,
|
||||
error,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
sources: AuthSource[];
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
onClose: () => void;
|
||||
onChanged: () => Promise<void>;
|
||||
}) {
|
||||
const [mode, setMode] = useState<'list' | 'edit'>('list');
|
||||
const [editingSource, setEditingSource] = useState<AuthSource | null>(null);
|
||||
const [form, setForm] = useState<AuthSourcePayload>(emptyForm);
|
||||
const [busyKey, setBusyKey] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<{
|
||||
tone: 'success' | 'danger' | 'info';
|
||||
text: string;
|
||||
} | null>(null);
|
||||
const [browserOrigin, setBrowserOrigin] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setMode('list');
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
setBusyKey(null);
|
||||
setMessage(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
setBrowserOrigin(window.location.origin);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startCreate = () => {
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
setMessage(null);
|
||||
setMode('edit');
|
||||
};
|
||||
|
||||
const startEdit = (source: AuthSource) => {
|
||||
setEditingSource(source);
|
||||
setForm(sourceToForm(source));
|
||||
setMessage(null);
|
||||
setMode('edit');
|
||||
};
|
||||
|
||||
const updateType = (type: AuthSourceType) => {
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
type,
|
||||
scopes:
|
||||
previous.scopes === 'user:email' ||
|
||||
previous.scopes === 'openid profile email' ||
|
||||
!previous.scopes
|
||||
? type === 'oidc'
|
||||
? 'openid profile email'
|
||||
: 'user:email'
|
||||
: previous.scopes,
|
||||
}));
|
||||
};
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
setBusyKey(key);
|
||||
setMessage(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (actionError) {
|
||||
setMessage({ tone: 'danger', text: getErrorMessage(actionError) });
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const saveForm = () => {
|
||||
void runAction('save', async () => {
|
||||
const payload: AuthSourcePayload = {
|
||||
...form,
|
||||
name: form.name.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
client_id: form.client_id.trim(),
|
||||
client_secret: form.client_secret.trim(),
|
||||
openid_discovery_url: form.openid_discovery_url.trim(),
|
||||
scopes: form.scopes.trim(),
|
||||
icon_url: form.icon_url.trim(),
|
||||
};
|
||||
if (editingSource) {
|
||||
await updateAuthSource(editingSource.id, payload);
|
||||
} else {
|
||||
await createAuthSource(payload);
|
||||
}
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源已保存。' });
|
||||
setMode('list');
|
||||
setEditingSource(null);
|
||||
setForm(emptyForm);
|
||||
});
|
||||
};
|
||||
|
||||
const removeSource = (source: AuthSource) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`确定删除认证源「${source.display_name || source.name}」吗?`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void runAction(`delete-${source.id}`, async () => {
|
||||
await deleteAuthSource(source.id);
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源已删除。' });
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSource = (source: AuthSource) => {
|
||||
void runAction(`toggle-${source.id}`, async () => {
|
||||
await toggleAuthSource(source.id, !source.is_active);
|
||||
await onChanged();
|
||||
setMessage({ tone: 'success', text: '认证源状态已更新。' });
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
title="认证源"
|
||||
description="配置 GitHub 或标准 OIDC 登录入口。启用后会显示在登录页。"
|
||||
size="xl"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
mode === 'edit' ? (
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={() => setMode('list')}>
|
||||
返回列表
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={saveForm}
|
||||
disabled={busyKey === 'save'}
|
||||
>
|
||||
{busyKey === 'save' ? '保存中...' : '保存认证源'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-end">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
关闭
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
{message ? (
|
||||
<InlineMessage tone={message.tone} message={message.text} />
|
||||
) : null}
|
||||
|
||||
{mode === 'list' ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<PrimaryButton type="button" onClick={startCreate}>
|
||||
新增认证源
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
{error ? (
|
||||
<ErrorState
|
||||
title="认证源加载失败"
|
||||
description={getErrorMessage(error)}
|
||||
/>
|
||||
) : null}
|
||||
{!isLoading && !error && sources.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
|
||||
暂无认证源。
|
||||
</div>
|
||||
) : null}
|
||||
{!isLoading && !error && sources.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
|
||||
<table className="w-full min-w-[720px] text-left text-sm">
|
||||
<thead className="bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium">名称</th>
|
||||
<th className="px-4 py-3 font-medium">类型</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
<th className="px-4 py-3 font-medium">Client ID</th>
|
||||
<th className="px-4 py-3 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{sources.map((source) => (
|
||||
<tr key={source.id}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-[var(--foreground-primary)]">
|
||||
{source.display_name || source.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--foreground-secondary)]">
|
||||
{source.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 uppercase">{source.type}</td>
|
||||
<td className="px-4 py-3">
|
||||
{source.is_active ? '已启用' : '已禁用'}
|
||||
</td>
|
||||
<td className="max-w-[220px] truncate px-4 py-3">
|
||||
{source.client_id || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => toggleSource(source)}
|
||||
disabled={busyKey === `toggle-${source.id}`}
|
||||
>
|
||||
{source.is_active ? '禁用' : '启用'}
|
||||
</SecondaryButton>
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => startEdit(source)}
|
||||
>
|
||||
修改
|
||||
</SecondaryButton>
|
||||
<DangerButton
|
||||
type="button"
|
||||
onClick={() => removeSource(source)}
|
||||
disabled={busyKey === `delete-${source.id}`}
|
||||
>
|
||||
删除
|
||||
</DangerButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="认证源名称">
|
||||
<ResourceInput
|
||||
value={form.name}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="GitHub"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="展示名称">
|
||||
<ResourceInput
|
||||
value={form.display_name}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
display_name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="GitHub"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="类型">
|
||||
<ResourceSelect
|
||||
value={form.type}
|
||||
onChange={(event) =>
|
||||
updateType(event.target.value as AuthSourceType)
|
||||
}
|
||||
>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="oidc">OIDC</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="状态">
|
||||
<ResourceSelect
|
||||
value={form.is_active ? 'true' : 'false'}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
is_active: event.target.value === 'true',
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="false">禁用</option>
|
||||
<option value="true">启用</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
<ResourceField label="Client ID">
|
||||
<ResourceInput
|
||||
value={form.client_id}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
client_id: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField
|
||||
label="Client Secret"
|
||||
hint={editingSource ? '留空表示不更新现有密钥。' : undefined}
|
||||
>
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={form.client_secret}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
client_secret: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<div className="md:col-span-2 rounded-2xl border border-[var(--status-info-border)] bg-[var(--status-info-soft)] px-4 py-3 text-sm leading-6 text-[var(--status-info-foreground)]">
|
||||
第三方平台的 Redirect URI / Callback URL 请填写:
|
||||
<span className="font-medium">
|
||||
{' '}
|
||||
{buildCallbackURL(browserOrigin, form.name)}
|
||||
</span>
|
||||
。末尾路径使用上方填写的认证源名称,保存后如修改认证源名称,也需要同步更新第三方平台中的回调地址。
|
||||
</div>
|
||||
{form.type === 'oidc' ? (
|
||||
<ResourceField
|
||||
label="OIDC Discovery URL"
|
||||
className="md:col-span-2"
|
||||
>
|
||||
<ResourceInput
|
||||
value={form.openid_discovery_url}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
openid_discovery_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://auth.example.com/.well-known/openid-configuration"
|
||||
/>
|
||||
</ResourceField>
|
||||
) : null}
|
||||
<ResourceField label="Scopes">
|
||||
<ResourceInput
|
||||
value={form.scopes}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
scopes: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="图标 URL">
|
||||
<ResourceInput
|
||||
value={form.icon_url}
|
||||
onChange={(event) =>
|
||||
setForm((previous) => ({
|
||||
...previous,
|
||||
icon_url: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
@@ -13,13 +13,16 @@ import { useAuth } from '@/components/providers/auth-provider';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { AppCard } from '@/components/ui/app-card';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import { sendEmailVerification } from '@/features/auth/api/auth';
|
||||
import {
|
||||
getOAuthAuthorizeUrl,
|
||||
sendEmailVerification,
|
||||
} from '@/features/auth/api/auth';
|
||||
import { getPublicStatus } from '@/features/auth/api/public';
|
||||
import {
|
||||
bindEmail,
|
||||
bindWeChat,
|
||||
cleanupDatabaseObservability,
|
||||
generateAccessToken,
|
||||
getAuthSources,
|
||||
getBootstrapToken,
|
||||
getOptions,
|
||||
getSettingsProfile,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
updateOptions,
|
||||
updateSelf,
|
||||
} from '@/features/settings/api/settings';
|
||||
import { AuthSourceModal } from '@/features/settings/components/auth-source-modal';
|
||||
import type {
|
||||
BootstrapTokenPayload,
|
||||
DatabaseCleanupResult,
|
||||
@@ -50,6 +54,7 @@ import {
|
||||
import { formatDateTime } from '@/lib/utils/date';
|
||||
|
||||
const settingsQueryKey = ['settings', 'options'] as const;
|
||||
const authSourcesQueryKey = ['settings', 'auth-sources'] as const;
|
||||
const installerScriptUrl =
|
||||
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh';
|
||||
|
||||
@@ -152,12 +157,7 @@ type CleanupModalState = {
|
||||
label: string;
|
||||
};
|
||||
|
||||
type SettingsTab =
|
||||
| 'personal'
|
||||
| 'operation'
|
||||
| 'database'
|
||||
| 'system'
|
||||
| 'other';
|
||||
type SettingsTab = 'personal' | 'operation' | 'database' | 'system' | 'other';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
@@ -249,10 +249,10 @@ export function SettingsPage() {
|
||||
const [otherFields, setOtherFields] = useState(defaultOtherFields);
|
||||
const [databaseFields, setDatabaseFields] = useState(defaultDatabaseFields);
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [wechatCode, setWeChatCode] = useState('');
|
||||
const [emailAddress, setEmailAddress] = useState('');
|
||||
const [emailCode, setEmailCode] = useState('');
|
||||
const [emailTurnstileToken, setEmailTurnstileToken] = useState('');
|
||||
const [authSourceModalOpen, setAuthSourceModalOpen] = useState(false);
|
||||
const [geoIPTestIP, setGeoIPTestIP] = useState('8.8.8.8');
|
||||
const [cleanupModalState, setCleanupModalState] =
|
||||
useState<CleanupModalState | null>(null);
|
||||
@@ -276,6 +276,12 @@ export function SettingsPage() {
|
||||
enabled: isRoot,
|
||||
});
|
||||
|
||||
const authSourcesQuery = useQuery({
|
||||
queryKey: authSourcesQueryKey,
|
||||
queryFn: getAuthSources,
|
||||
enabled: isRoot,
|
||||
});
|
||||
|
||||
const bootstrapQuery = useQuery({
|
||||
queryKey: ['settings', 'bootstrap-token'],
|
||||
queryFn: getBootstrapToken,
|
||||
@@ -523,7 +529,7 @@ export function SettingsPage() {
|
||||
{
|
||||
key: 'system' as const,
|
||||
label: '系统设置',
|
||||
description: '登录注册、SMTP、OAuth、限流与风控开关。',
|
||||
description: '登录注册、SMTP、认证源、限流与风控开关。',
|
||||
},
|
||||
{
|
||||
key: 'database' as const,
|
||||
@@ -629,20 +635,10 @@ export function SettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleBindWeChat = () => {
|
||||
if (!wechatCode.trim()) {
|
||||
setFeedback({ tone: 'danger', message: '请输入微信验证码。' });
|
||||
return;
|
||||
}
|
||||
|
||||
void runBusyAction('wechat-bind', async () => {
|
||||
await bindWeChat(wechatCode.trim());
|
||||
setWeChatCode('');
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', 'profile'] }),
|
||||
refreshUser(),
|
||||
]);
|
||||
setFeedback({ tone: 'success', message: '微信账号已绑定。' });
|
||||
const handleBindAuthSource = (sourceName: string) => {
|
||||
void runBusyAction(`auth-source-bind-${sourceName}`, async () => {
|
||||
const result = await getOAuthAuthorizeUrl(sourceName);
|
||||
window.location.href = result.authorize_url;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -824,79 +820,36 @@ export function SettingsPage() {
|
||||
|
||||
<AppCard
|
||||
title="账号绑定"
|
||||
description="支持绑定 GitHub、微信和邮箱地址,用于统一个人身份入口。"
|
||||
description="支持绑定已启用的认证源和邮箱地址,用于统一个人身份入口。"
|
||||
>
|
||||
<div className="grid gap-6 xl:grid-cols-3">
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold text-[var(--foreground-primary)]">
|
||||
GitHub 账号
|
||||
第三方认证源
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
当前状态:
|
||||
{profile.github_id
|
||||
? `已绑定 ${profile.github_id}`
|
||||
: '未绑定'}
|
||||
登录状态下发起授权会直接绑定到当前账号。
|
||||
</p>
|
||||
</div>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://github.com/login/oauth/authorize?client_id=${publicStatus.github_client_id}&scope=user:email`,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
!publicStatus.github_oauth || !publicStatus.github_client_id
|
||||
}
|
||||
>
|
||||
{publicStatus.github_oauth
|
||||
? '绑定 GitHub'
|
||||
: '未启用 GitHub OAuth'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold text-[var(--foreground-primary)]">
|
||||
微信账号
|
||||
</p>
|
||||
<p className="text-sm leading-6 text-[var(--foreground-secondary)]">
|
||||
当前状态:
|
||||
{profile.wechat_id
|
||||
? `已绑定 ${profile.wechat_id}`
|
||||
: '未绑定'}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(publicStatus.auth_sources ?? []).length > 0 ? (
|
||||
publicStatus.auth_sources.map((source) => (
|
||||
<PrimaryButton
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => handleBindAuthSource(source.name)}
|
||||
disabled={busyKey === `auth-source-bind-${source.name}`}
|
||||
>
|
||||
绑定 {source.display_name || source.name}
|
||||
</PrimaryButton>
|
||||
))
|
||||
) : (
|
||||
<span className="text-sm text-[var(--foreground-secondary)]">
|
||||
当前未启用认证源。
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{publicStatus.wechat_login && publicStatus.wechat_qrcode ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={publicStatus.wechat_qrcode}
|
||||
alt="微信绑定二维码"
|
||||
className="h-40 w-40 rounded-2xl border border-[var(--border-default)] object-cover"
|
||||
/>
|
||||
) : null}
|
||||
<ResourceField
|
||||
label="验证码"
|
||||
hint="扫码关注后输入“验证码”获取绑定码。"
|
||||
>
|
||||
<ResourceInput
|
||||
value={wechatCode}
|
||||
onChange={(event) => setWeChatCode(event.target.value)}
|
||||
placeholder="请输入微信验证码"
|
||||
/>
|
||||
</ResourceField>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleBindWeChat}
|
||||
disabled={
|
||||
!publicStatus.wechat_login || busyKey === 'wechat-bind'
|
||||
}
|
||||
>
|
||||
{busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -1279,8 +1232,7 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
<AppCard
|
||||
title="版本与构建信息">
|
||||
<AppCard title="版本与构建信息">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
@@ -1396,7 +1348,7 @@ export function SettingsPage() {
|
||||
</ResourceField>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-3">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
触发频率
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -1404,7 +1356,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
默认执行时间
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -1412,7 +1364,7 @@ export function SettingsPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)] px-4 py-4">
|
||||
<p className="text-xs tracking-[0.2em] uppercase text-[var(--foreground-muted)]">
|
||||
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
|
||||
生效范围
|
||||
</p>
|
||||
<p className="mt-2 text-sm font-semibold text-[var(--foreground-primary)]">
|
||||
@@ -1490,8 +1442,16 @@ export function SettingsPage() {
|
||||
<AppCard
|
||||
title="登录与注册开关"
|
||||
description="切换后立即生效,无需重启服务。"
|
||||
action={
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={() => setAuthSourceModalOpen(true)}
|
||||
>
|
||||
配置认证源
|
||||
</SecondaryButton>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<ToggleField
|
||||
label="允许密码登录"
|
||||
description="关闭后将无法使用用户名密码登录。"
|
||||
@@ -1519,33 +1479,6 @@ export function SettingsPage() {
|
||||
}
|
||||
disabled={busyKey === 'toggle-EmailVerificationEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="启用 GitHub OAuth"
|
||||
description="允许用户通过 GitHub 登录与注册。"
|
||||
checked={systemFields.GitHubOAuthEnabled}
|
||||
onChange={(checked) =>
|
||||
handleToggleOption('GitHubOAuthEnabled', checked)
|
||||
}
|
||||
disabled={busyKey === 'toggle-GitHubOAuthEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="启用微信登录"
|
||||
description="允许用户通过微信入口登录与注册。"
|
||||
checked={systemFields.WeChatAuthEnabled}
|
||||
onChange={(checked) =>
|
||||
handleToggleOption('WeChatAuthEnabled', checked)
|
||||
}
|
||||
disabled={busyKey === 'toggle-WeChatAuthEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="启用 Turnstile"
|
||||
description="开启后注册、邮箱验证码等流程需要先通过人机验证。"
|
||||
checked={systemFields.TurnstileCheckEnabled}
|
||||
onChange={(checked) =>
|
||||
handleToggleOption('TurnstileCheckEnabled', checked)
|
||||
}
|
||||
disabled={busyKey === 'toggle-TurnstileCheckEnabled'}
|
||||
/>
|
||||
<ToggleField
|
||||
label="允许新用户注册"
|
||||
description="关闭后将禁止所有新用户注册入口。"
|
||||
@@ -1556,6 +1489,9 @@ export function SettingsPage() {
|
||||
disabled={busyKey === 'toggle-RegisterEnabled'}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-5 text-sm text-[var(--foreground-secondary)]">
|
||||
当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||
@@ -1694,145 +1630,6 @@ export function SettingsPage() {
|
||||
</ResourceField>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="OAuth / WeChat / Turnstile"
|
||||
description="敏感密钥不会从后端回显,留空即保持原值。"
|
||||
action={
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runBusyAction('system-integrations', async () => {
|
||||
await saveOptionEntries(
|
||||
[
|
||||
[
|
||||
'GitHubClientId',
|
||||
systemFields.GitHubClientId.trim(),
|
||||
],
|
||||
[
|
||||
'GitHubClientSecret',
|
||||
systemFields.GitHubClientSecret.trim(),
|
||||
],
|
||||
[
|
||||
'WeChatServerAddress',
|
||||
normalizeServerUrl(
|
||||
systemFields.WeChatServerAddress,
|
||||
),
|
||||
],
|
||||
[
|
||||
'WeChatServerToken',
|
||||
systemFields.WeChatServerToken.trim(),
|
||||
],
|
||||
[
|
||||
'WeChatAccountQRCodeImageURL',
|
||||
systemFields.WeChatAccountQRCodeImageURL.trim(),
|
||||
],
|
||||
[
|
||||
'TurnstileSiteKey',
|
||||
systemFields.TurnstileSiteKey.trim(),
|
||||
],
|
||||
[
|
||||
'TurnstileSecretKey',
|
||||
systemFields.TurnstileSecretKey.trim(),
|
||||
],
|
||||
],
|
||||
'第三方集成设置已保存。',
|
||||
);
|
||||
})
|
||||
}
|
||||
disabled={busyKey === 'system-integrations'}
|
||||
>
|
||||
{busyKey === 'system-integrations'
|
||||
? '保存中...'
|
||||
: '保存集成设置'}
|
||||
</PrimaryButton>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="GitHub Client ID">
|
||||
<ResourceInput
|
||||
value={systemFields.GitHubClientId}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
GitHubClientId: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="GitHub Client Secret">
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={systemFields.GitHubClientSecret}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
GitHubClientSecret: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="WeChat Server 地址">
|
||||
<ResourceInput
|
||||
value={systemFields.WeChatServerAddress}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
WeChatServerAddress: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="WeChat Server Token">
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={systemFields.WeChatServerToken}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
WeChatServerToken: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="公众号二维码链接">
|
||||
<ResourceInput
|
||||
value={systemFields.WeChatAccountQRCodeImageURL}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
WeChatAccountQRCodeImageURL: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="Turnstile Site Key">
|
||||
<ResourceInput
|
||||
value={systemFields.TurnstileSiteKey}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
TurnstileSiteKey: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="Turnstile Secret Key">
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={systemFields.TurnstileSecretKey}
|
||||
onChange={(event) =>
|
||||
setSystemFields((previous) => ({
|
||||
...previous,
|
||||
TurnstileSecretKey: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</div>
|
||||
<AppCard
|
||||
title="请求限流设置"
|
||||
@@ -2247,6 +2044,20 @@ export function SettingsPage() {
|
||||
|
||||
{renderTabContent()}
|
||||
|
||||
<AuthSourceModal
|
||||
isOpen={authSourceModalOpen}
|
||||
sources={authSourcesQuery.data ?? []}
|
||||
isLoading={authSourcesQuery.isLoading}
|
||||
error={authSourcesQuery.error}
|
||||
onClose={() => setAuthSourceModalOpen(false)}
|
||||
onChanged={async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: authSourcesQueryKey }),
|
||||
queryClient.invalidateQueries({ queryKey: ['public-status'] }),
|
||||
]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
isOpen={cleanupModalState !== null}
|
||||
title={`清理${cleanupModalState?.label ?? ''}`}
|
||||
|
||||
@@ -9,6 +9,30 @@ export interface OptionBatchPayload {
|
||||
options: OptionItem[];
|
||||
}
|
||||
|
||||
export type AuthSourceType = 'github' | 'oidc';
|
||||
|
||||
export interface AuthSource {
|
||||
id: number;
|
||||
name: string;
|
||||
type: AuthSourceType;
|
||||
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;
|
||||
}
|
||||
|
||||
export type AuthSourcePayload = Omit<
|
||||
AuthSource,
|
||||
'id' | 'client_secret_configured'
|
||||
> & {
|
||||
id?: number;
|
||||
client_secret: string;
|
||||
};
|
||||
|
||||
export interface BootstrapTokenPayload {
|
||||
discovery_token: string;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
export interface PublicStatus {
|
||||
version: string;
|
||||
start_time: number;
|
||||
email_verification: boolean;
|
||||
github_oauth: boolean;
|
||||
github_client_id: string;
|
||||
system_name: string;
|
||||
home_page_link: string;
|
||||
footer_html: string;
|
||||
wechat_qrcode: string;
|
||||
export interface PublicAuthSource {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'github' | 'oidc';
|
||||
display_name: string;
|
||||
authorize_url: string;
|
||||
icon_url?: string;
|
||||
}
|
||||
|
||||
export interface PublicStatus {
|
||||
version: string;
|
||||
start_time: number;
|
||||
email_verification: boolean;
|
||||
github_oauth: boolean;
|
||||
github_client_id: string;
|
||||
system_name: string;
|
||||
home_page_link: string;
|
||||
footer_html: string;
|
||||
wechat_qrcode: string;
|
||||
wechat_login: boolean;
|
||||
server_address: string;
|
||||
turnstile_check: boolean;
|
||||
turnstile_site_key: string;
|
||||
register_enabled: boolean;
|
||||
password_register_enabled: boolean;
|
||||
auth_sources: PublicAuthSource[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user