diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 418ff3e7..91f94005 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -11,8 +11,7 @@ export default defineConfig({ srcExclude: [ 'zh/**', 'components/**', - 'snippets/**', - 'website-configuration-redesign.md' + 'snippets/**' ], markdown: { diff --git a/docs/design/architecture.md b/docs/design/architecture.md index 768dcfcd..cedb8181 100644 --- a/docs/design/architecture.md +++ b/docs/design/architecture.md @@ -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`。 diff --git a/docs/design/development.md b/docs/design/development.md index 164ac22f..693243c5 100644 --- a/docs/design/development.md +++ b/docs/design/development.md @@ -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`。 diff --git a/docs/design/index.md b/docs/design/index.md index 125eaee3..20d68251 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -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`。 diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index a4f6cf61..8ca808d1 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -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 +/oauth/<认证源 Name> +``` + +例如 OpenFlare 访问地址为 `https://openflare.example.com`,认证源 `Name` 为 `github`,则第三方平台中应填写: + +```text +https://openflare.example.com/oauth/github +``` + +`Name` 是认证源唯一标识,只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。修改 `Name` 后,第三方平台中的回调地址也必须同步修改。 + +启用后的认证源会显示在登录页。第三方账号首次登录时,如果已经绑定本地用户会直接登录;如果未绑定且允许注册,会自动创建普通用户;如果未绑定且关闭注册,需要使用已有本地账号完成绑定。 + ## Swagger 登录管理端后访问: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 4218c77e..9e2798a3 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -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 +/oauth/<认证源 Name> +``` + +例如 OpenFlare 访问地址为 `https://openflare.example.com`,认证源 `Name` 为 `github` 时,回调地址为: + +```text +https://openflare.example.com/oauth/github +``` + +认证源 `Name` 只能包含字母、数字、短横线或下划线,并且必须以字母或数字开头。修改 `Name` 后必须同步更新第三方平台中的回调地址。 + +外部账号绑定保存在 `external_accounts` 表。第三方账号未绑定时,如果允许注册则自动创建普通用户;如果关闭注册,则只能绑定已有本地账号。 ## OpenResty 参数 diff --git a/docs/website-configuration-redesign.md b/docs/website-configuration-redesign.md deleted file mode 100644 index b0891041..00000000 --- a/docs/website-configuration-redesign.md +++ /dev/null @@ -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 同步、应用、回滚链路不被破坏 -* 迁移、接口、渲染与前端关键路径均有对应测试或等效回归验证 diff --git a/openflare_server/controller/auth_source.go b/openflare_server/controller/auth_source.go new file mode 100644 index 00000000..5a0aebb4 --- /dev/null +++ b/openflare_server/controller/auth_source.go @@ -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 +} diff --git a/openflare_server/controller/misc.go b/openflare_server/controller/misc.go index 68219c3d..40ad5c9c 100644 --- a/openflare_server/controller/misc.go +++ b/openflare_server/controller/misc.go @@ -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 diff --git a/openflare_server/controller/user.go b/openflare_server/controller/user.go index 501b4149..91d7d465 100644 --- a/openflare_server/controller/user.go +++ b/openflare_server/controller/user.go @@ -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, }) } diff --git a/openflare_server/model/auth_source.go b/openflare_server/model/auth_source.go new file mode 100644 index 00000000..18791912 --- /dev/null +++ b/openflare_server/model/auth_source.go @@ -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 +} diff --git a/openflare_server/model/database_schema_version.go b/openflare_server/model/database_schema_version.go index a8eaaafe..11cf1219 100644 --- a/openflare_server/model/database_schema_version.go +++ b/openflare_server/model/database_schema_version.go @@ -4,7 +4,7 @@ import "time" const ( legacyDatabaseSchemaVersion = 1 - currentDatabaseSchemaVersion = 9 + currentDatabaseSchemaVersion = 10 databaseSchemaVersionRowID = 1 ) diff --git a/openflare_server/model/main.go b/openflare_server/model/main.go index a4c0aaf7..0b35e857 100644 --- a/openflare_server/model/main.go +++ b/openflare_server/model/main.go @@ -26,6 +26,8 @@ func registeredModels() []any { return []any{ &File{}, &User{}, + &AuthSource{}, + &ExternalAccount{}, &Option{}, &Origin{}, &ProxyRoute{}, diff --git a/openflare_server/model/migrations.go b/openflare_server/model/migrations.go index 0bb052e4..e9892147 100644 --- a/openflare_server/model/migrations.go +++ b/openflare_server/model/migrations.go @@ -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) diff --git a/openflare_server/router/api-router.go b/openflare_server/router/api-router.go index 1fd08aef..6a401046 100644 --- a/openflare_server/router/api-router.go +++ b/openflare_server/router/api-router.go @@ -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()) { diff --git a/openflare_server/router/api_phase2_test.go b/openflare_server/router/api_phase2_test.go index 967b5ca8..2719be7a 100644 --- a/openflare_server/router/api_phase2_test.go +++ b/openflare_server/router/api_phase2_test.go @@ -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{ diff --git a/openflare_server/router/web-router.go b/openflare_server/router/web-router.go index f05a76bf..79191bcf 100644 --- a/openflare_server/router/web-router.go +++ b/openflare_server/router/web-router.go @@ -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 diff --git a/openflare_server/router/web-router_test.go b/openflare_server/router/web-router_test.go index b27019fd..e3ae570a 100644 --- a/openflare_server/router/web-router_test.go +++ b/openflare_server/router/web-router_test.go @@ -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) + } + } +} diff --git a/openflare_server/service/auth_source.go b/openflare_server/service/auth_source.go new file mode 100644 index 00000000..e59e3ebd --- /dev/null +++ b/openflare_server/service/auth_source.go @@ -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 "" +} diff --git a/openflare_server/service/auth_source_test.go b/openflare_server/service/auth_source_test.go new file mode 100644 index 00000000..786696ce --- /dev/null +++ b/openflare_server/service/auth_source_test.go @@ -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 +} diff --git a/openflare_server/web/app/(public)/oauth/callback/page.tsx b/openflare_server/web/app/(public)/oauth/callback/page.tsx new file mode 100644 index 00000000..69e9768b --- /dev/null +++ b/openflare_server/web/app/(public)/oauth/callback/page.tsx @@ -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 ( + }> + + + ); +} diff --git a/openflare_server/web/app/(public)/oauth/link/page.tsx b/openflare_server/web/app/(public)/oauth/link/page.tsx new file mode 100644 index 00000000..ce44813f --- /dev/null +++ b/openflare_server/web/app/(public)/oauth/link/page.tsx @@ -0,0 +1,5 @@ +import { OAuthLinkExistingForm } from '@/features/auth/components/oauth-link-existing-form'; + +export default function OAuthLinkPage() { + return ; +} diff --git a/openflare_server/web/features/auth/api/auth.ts b/openflare_server/web/features/auth/api/auth.ts index 6bdefd37..d8df5d50 100644 --- a/openflare_server/web/features/auth/api/auth.ts +++ b/openflare_server/web/features/auth/api/auth.ts @@ -1,60 +1,100 @@ -import { apiRequest } from '@/lib/api/client'; -import type { - AuthUser, - LoginPayload, - PasswordResetRequestPayload, - RegisterPayload, -} from '@/types/auth'; - -export function getCurrentUser() { - return apiRequest('/user/self'); -} - -export function login(payload: LoginPayload) { - return apiRequest('/user/login', { - method: 'POST', - body: JSON.stringify(payload), - }); -} - -export function logout() { - return apiRequest('/user/logout'); -} - -export function register(payload: RegisterPayload, turnstileToken?: string) { - const query = turnstileToken ? `?turnstile=${encodeURIComponent(turnstileToken)}` : ''; - - return apiRequest(`/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(`/verification?${searchParams.toString()}`); -} - -export function sendPasswordResetEmail(email: string, turnstileToken?: string) { - const searchParams = new URLSearchParams({ email }); - if (turnstileToken) { - searchParams.set('turnstile', turnstileToken); - } - - return apiRequest(`/reset_password?${searchParams.toString()}`); -} - -export function resetPassword(payload: PasswordResetRequestPayload) { - return apiRequest('/user/reset', { - method: 'POST', - body: JSON.stringify(payload), - }); -} - -export function exchangeGitHubCode(code: string) { - return apiRequest(`/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('/user/self'); +} + +export function login(payload: LoginPayload) { + return apiRequest('/user/login', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function logout() { + return apiRequest('/user/logout'); +} + +export function register(payload: RegisterPayload, turnstileToken?: string) { + const query = turnstileToken + ? `?turnstile=${encodeURIComponent(turnstileToken)}` + : ''; + + return apiRequest(`/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(`/verification?${searchParams.toString()}`); +} + +export function sendPasswordResetEmail(email: string, turnstileToken?: string) { + const searchParams = new URLSearchParams({ email }); + if (turnstileToken) { + searchParams.set('turnstile', turnstileToken); + } + + return apiRequest(`/reset_password?${searchParams.toString()}`); +} + +export function resetPassword(payload: PasswordResetRequestPayload) { + return apiRequest('/user/reset', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function exchangeGitHubCode(code: string) { + return apiRequest(`/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( + `/oauth/${encodeURIComponent(String(source))}/authorize`, + ); +} + +export function exchangeOAuthCode( + source: number | string, + code: string, + state: string, +) { + const searchParams = new URLSearchParams({ code, state }); + return apiRequest( + `/oauth/${encodeURIComponent(String(source))}/callback?${searchParams.toString()}`, + ); +} + +export function linkExistingOAuthAccount(payload: LinkExistingOAuthPayload) { + return apiRequest('/oauth/link-existing', { + method: 'POST', + body: JSON.stringify(payload), + }); +} diff --git a/openflare_server/web/features/auth/components/login-form.tsx b/openflare_server/web/features/auth/components/login-form.tsx index fbc83442..b71ea9ae 100644 --- a/openflare_server/web/features/auth/components/login-form.tsx +++ b/openflare_server/web/features/auth/components/login-form.tsx @@ -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 ( -
+ {form.formState.errors.username ? ( - + {form.formState.errors.username.message} ) : null} @@ -114,41 +116,53 @@ export function LoginForm() { {form.formState.errors.password ? ( - + {form.formState.errors.password.message} ) : null} {errorMessage ? ( - + ) : null} -
- +
+ {loginMutation.isPending ? TEXT.loginPending : TEXT.login} - {statusQuery.data?.github_oauth ? ( - - {TEXT.githubLogin} - - ) : null}
+ + {(statusQuery.data?.auth_sources ?? []).length > 0 ? ( +
+
+ 第三方账号登录 +
+
+ {(statusQuery.data?.auth_sources ?? []).map((source) => ( + handleOAuthLogin(source.name)} + className="min-w-36" + disabled={oauthMutation.isPending} + > + {source.display_name || source.name} 登录 + + ))} +
+
+ ) : null} -
+
{TEXT.forgotPassword} @@ -156,8 +170,8 @@ export function LoginForm() { <> | {TEXT.register} diff --git a/openflare_server/web/features/auth/components/oauth-callback.tsx b/openflare_server/web/features/auth/components/oauth-callback.tsx new file mode 100644 index 00000000..ec00f15c --- /dev/null +++ b/openflare_server/web/features/auth/components/oauth-callback.tsx @@ -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 ( + +
+ {mutation.isPending ? : null} + {message ? ( + + ) : null} + {message?.tone === 'danger' ? ( +
+ + 返回登录 + +
+ ) : null} +
+
+ ); +} diff --git a/openflare_server/web/features/auth/components/oauth-link-existing-form.tsx b/openflare_server/web/features/auth/components/oauth-link-existing-form.tsx new file mode 100644 index 00000000..80b6f108 --- /dev/null +++ b/openflare_server/web/features/auth/components/oauth-link-existing-form.tsx @@ -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; + +export function OAuthLinkExistingForm() { + const router = useRouter(); + const { setUser } = useAuth(); + const [errorMessage, setErrorMessage] = useState(''); + const form = useForm({ + 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 ( + +
+ + + {form.formState.errors.username ? ( + + {form.formState.errors.username.message} + + ) : null} + + + + + {form.formState.errors.password ? ( + + {form.formState.errors.password.message} + + ) : null} + + + {errorMessage ? ( + + ) : null} + + + {mutation.isPending ? '绑定中...' : '绑定并登录'} + + + +
+ + 返回登录 + +
+
+ ); +} diff --git a/openflare_server/web/features/settings/api/settings.ts b/openflare_server/web/features/settings/api/settings.ts index 5ee35b12..a48575d5 100644 --- a/openflare_server/web/features/settings/api/settings.ts +++ b/openflare_server/web/features/settings/api/settings.ts @@ -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('/auth-sources/'); +} + +export function createAuthSource(payload: AuthSourcePayload) { + return apiRequest('/auth-sources/', { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function updateAuthSource(id: number, payload: AuthSourcePayload) { + return apiRequest(`/auth-sources/${id}/update`, { + method: 'POST', + body: JSON.stringify(payload), + }); +} + +export function toggleAuthSource(id: number, isActive: boolean) { + return apiRequest(`/auth-sources/${id}/toggle`, { + method: 'POST', + body: JSON.stringify({ is_active: isActive }), + }); +} + +export function deleteAuthSource(id: number) { + return apiRequest(`/auth-sources/${id}/delete`, { + method: 'POST', + }); +} + export function getBootstrapToken() { return apiRequest('/nodes/bootstrap-token'); } diff --git a/openflare_server/web/features/settings/components/auth-source-modal.tsx b/openflare_server/web/features/settings/components/auth-source-modal.tsx new file mode 100644 index 00000000..417c1bbe --- /dev/null +++ b/openflare_server/web/features/settings/components/auth-source-modal.tsx @@ -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; +}) { + const [mode, setMode] = useState<'list' | 'edit'>('list'); + const [editingSource, setEditingSource] = useState(null); + const [form, setForm] = useState(emptyForm); + const [busyKey, setBusyKey] = useState(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) => { + 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 ( + + setMode('list')}> + 返回列表 + + + {busyKey === 'save' ? '保存中...' : '保存认证源'} + +
+ ) : ( +
+ + 关闭 + +
+ ) + } + > +
+ {message ? ( + + ) : null} + + {mode === 'list' ? ( +
+
+ + 新增认证源 + +
+ {isLoading ? : null} + {error ? ( + + ) : null} + {!isLoading && !error && sources.length === 0 ? ( +
+ 暂无认证源。 +
+ ) : null} + {!isLoading && !error && sources.length > 0 ? ( +
+ + + + + + + + + + + + {sources.map((source) => ( + + + + + + + + ))} + +
名称类型状态Client ID操作
+
+ {source.display_name || source.name} +
+
+ {source.name} +
+
{source.type} + {source.is_active ? '已启用' : '已禁用'} + + {source.client_id || '-'} + +
+ toggleSource(source)} + disabled={busyKey === `toggle-${source.id}`} + > + {source.is_active ? '禁用' : '启用'} + + startEdit(source)} + > + 修改 + + removeSource(source)} + disabled={busyKey === `delete-${source.id}`} + > + 删除 + +
+
+
+ ) : null} +
+ ) : ( +
+ + + setForm((previous) => ({ + ...previous, + name: event.target.value, + })) + } + placeholder="GitHub" + /> + + + + setForm((previous) => ({ + ...previous, + display_name: event.target.value, + })) + } + placeholder="GitHub" + /> + + + + updateType(event.target.value as AuthSourceType) + } + > + + + + + + + setForm((previous) => ({ + ...previous, + is_active: event.target.value === 'true', + })) + } + > + + + + + + + setForm((previous) => ({ + ...previous, + client_id: event.target.value, + })) + } + /> + + + + setForm((previous) => ({ + ...previous, + client_secret: event.target.value, + })) + } + /> + +
+ 第三方平台的 Redirect URI / Callback URL 请填写: + + {' '} + {buildCallbackURL(browserOrigin, form.name)} + + 。末尾路径使用上方填写的认证源名称,保存后如修改认证源名称,也需要同步更新第三方平台中的回调地址。 +
+ {form.type === 'oidc' ? ( + + + setForm((previous) => ({ + ...previous, + openid_discovery_url: event.target.value, + })) + } + placeholder="https://auth.example.com/.well-known/openid-configuration" + /> + + ) : null} + + + setForm((previous) => ({ + ...previous, + scopes: event.target.value, + })) + } + /> + + + + setForm((previous) => ({ + ...previous, + icon_url: event.target.value, + })) + } + /> + +
+ )} +
+ + ); +} diff --git a/openflare_server/web/features/settings/components/settings-page.tsx b/openflare_server/web/features/settings/components/settings-page.tsx index b86eb2c1..56e7b6d4 100644 --- a/openflare_server/web/features/settings/components/settings-page.tsx +++ b/openflare_server/web/features/settings/components/settings-page.tsx @@ -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(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() { -
+

- GitHub 账号 + 第三方认证源

- 当前状态: - {profile.github_id - ? `已绑定 ${profile.github_id}` - : '未绑定'} + 登录状态下发起授权会直接绑定到当前账号。

- - 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'} - -
- -
-
-

- 微信账号 -

-

- 当前状态: - {profile.wechat_id - ? `已绑定 ${profile.wechat_id}` - : '未绑定'} -

+
+ {(publicStatus.auth_sources ?? []).length > 0 ? ( + publicStatus.auth_sources.map((source) => ( + handleBindAuthSource(source.name)} + disabled={busyKey === `auth-source-bind-${source.name}`} + > + 绑定 {source.display_name || source.name} + + )) + ) : ( + + 当前未启用认证源。 + + )}
- {publicStatus.wechat_login && publicStatus.wechat_qrcode ? ( - // eslint-disable-next-line @next/next/no-img-element - 微信绑定二维码 - ) : null} - - setWeChatCode(event.target.value)} - placeholder="请输入微信验证码" - /> - - - {busyKey === 'wechat-bind' ? '绑定中...' : '绑定微信'} -
@@ -1279,8 +1232,7 @@ export function SettingsPage() {
)} - +

@@ -1396,7 +1348,7 @@ export function SettingsPage() {

-

+

触发频率

@@ -1404,7 +1356,7 @@ export function SettingsPage() {

-

+

默认执行时间

@@ -1412,7 +1364,7 @@ export function SettingsPage() {

-

+

生效范围

@@ -1490,8 +1442,16 @@ export function SettingsPage() { setAuthSourceModalOpen(true)} + > + 配置认证源 + + } > -

+
- - handleToggleOption('GitHubOAuthEnabled', checked) - } - disabled={busyKey === 'toggle-GitHubOAuthEnabled'} - /> - - handleToggleOption('WeChatAuthEnabled', checked) - } - disabled={busyKey === 'toggle-WeChatAuthEnabled'} - /> - - handleToggleOption('TurnstileCheckEnabled', checked) - } - disabled={busyKey === 'toggle-TurnstileCheckEnabled'} - />
+
+ 当前已配置 {authSourcesQuery.data?.length ?? 0} 个认证源。 +
@@ -1694,145 +1630,6 @@ export function SettingsPage() {
- - - 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' - ? '保存中...' - : '保存集成设置'} - - } - > -
-
- - - setSystemFields((previous) => ({ - ...previous, - GitHubClientId: event.target.value, - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - GitHubClientSecret: event.target.value, - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - WeChatServerAddress: event.target.value, - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - WeChatServerToken: event.target.value, - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - WeChatAccountQRCodeImageURL: event.target.value, - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - TurnstileSiteKey: event.target.value, - })) - } - /> - - - - setSystemFields((previous) => ({ - ...previous, - TurnstileSecretKey: event.target.value, - })) - } - /> - -
-
-
setAuthSourceModalOpen(false)} + onChanged={async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: authSourcesQueryKey }), + queryClient.invalidateQueries({ queryKey: ['public-status'] }), + ]); + }} + /> + & { + id?: number; + client_secret: string; +}; + export interface BootstrapTokenPayload { discovery_token: string; } diff --git a/openflare_server/web/types/public-status.ts b/openflare_server/web/types/public-status.ts index 260d31ec..1d395615 100644 --- a/openflare_server/web/types/public-status.ts +++ b/openflare_server/web/types/public-status.ts @@ -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[]; }