diff --git a/api-docs/sproutgate-api.json b/api-docs/sproutgate-api.json new file mode 100644 index 0000000..72032c8 --- /dev/null +++ b/api-docs/sproutgate-api.json @@ -0,0 +1,564 @@ +{ + "info": { + "_postman_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "name": "sproutgate-api", + "description": "萌芽账户认证中心:统一登录注册、令牌与资料、每日签到、公开用户目录与主页点赞、管理端用户与注册策略等 JSON HTTP 接口。集合变量 `baseUrl` 已指向生产环境,调试前请填写 `jwtToken`(用户 JWT)或 `adminToken`(管理端令牌)。", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "https://auth.api.shumengya.top", + "type": "string" + }, + { + "key": "jwtToken", + "value": "", + "type": "string" + }, + { + "key": "adminToken", + "value": "", + "type": "string" + }, + { + "key": "account", + "value": "demo", + "type": "string" + }, + { + "key": "inviteCode", + "value": "", + "type": "string" + } + ], + "item": [ + { + "name": "服务说明", + "item": [ + { + "name": "API 根信息", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/", + "description": "返回 API 名称、版本与路由前缀说明。" + }, + "response": [] + }, + { + "name": "API 根信息 /api", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api", + "description": "与 GET / 相同。" + }, + "response": [] + }, + { + "name": "健康检查", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/health", + "description": "服务存活与运行环境标识。" + }, + "response": [] + } + ] + }, + { + "name": "认证 /api/auth", + "item": [ + { + "name": "登录", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"\",\n \"password\": \"\",\n \"clientId\": \"\",\n \"clientName\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/login", + "description": "账号密码登录,返回 JWT 与用户信息。可选 clientId/clientName 用于记录接入应用(亦可通过请求头 X-Auth-Client / X-Auth-Client-Name)。" + }, + "response": [] + }, + { + "name": "注册", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"\",\n \"password\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"inviteCode\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/register", + "description": "新用户注册(策略由服务端配置决定,可能要求邀请码)。" + }, + "response": [] + }, + { + "name": "邮箱验证", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"\",\n \"code\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/verify-email", + "description": "提交邮箱验证码完成验证。" + }, + "response": [] + }, + { + "name": "忘记密码", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"\",\n \"email\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/forgot-password", + "description": "请求向绑定邮箱发送重置流程(具体行为以后端为准)。" + }, + "response": [] + }, + { + "name": "重置密码", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"\",\n \"code\": \"\",\n \"newPassword\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/reset-password", + "description": "使用验证码设置新密码。" + }, + "response": [] + }, + { + "name": "辅助邮箱 - 请求验证码", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwtToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/secondary-email/request", + "description": "已登录用户为辅助邮箱请求验证码。" + }, + "response": [] + }, + { + "name": "辅助邮箱 - 验证", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwtToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"\",\n \"code\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/auth/secondary-email/verify", + "description": "提交验证码完成辅助邮箱绑定。" + }, + "response": [] + }, + { + "name": "校验 JWT", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"token\": \"{{jwtToken}}\"\n}" + }, + "url": "{{baseUrl}}/api/auth/verify", + "description": "校验 token 是否有效并返回用户公开信息。可选请求头 X-Auth-Client、X-Auth-Client-Name 记录接入方。" + }, + "response": [] + }, + { + "name": "当前用户(含签到摘要)", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwtToken}}" + }, + { + "key": "X-Visit-Ip", + "value": "", + "description": "可选,覆盖访问 IP;留空则使用服务端看到的客户端 IP。", + "disabled": true + }, + { + "key": "X-Visit-Location", + "value": "", + "description": "可选,若为空且配置了地理位置服务则可能根据 IP 解析。", + "disabled": true + }, + { + "key": "X-Auth-Client", + "value": "", + "disabled": true + }, + { + "key": "X-Auth-Client-Name", + "value": "", + "disabled": true + } + ], + "url": "{{baseUrl}}/api/auth/me", + "description": "需 Authorization: Bearer。返回用户资料与今日签到相关字段,并记录访问信息。" + }, + "response": [] + }, + { + "name": "每日签到", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwtToken}}" + } + ], + "url": "{{baseUrl}}/api/auth/check-in", + "description": "当日签到领取萌芽币等奖励。" + }, + "response": [] + }, + { + "name": "更新个人资料", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "Authorization", + "value": "Bearer {{jwtToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"password\": null,\n \"username\": null,\n \"phone\": null,\n \"avatarUrl\": null,\n \"websiteUrl\": null,\n \"bio\": null\n}" + }, + "url": "{{baseUrl}}/api/auth/profile", + "description": "部分字段更新;未修改的字段可省略或置为 null。" + }, + "response": [] + } + ] + }, + { + "name": "公开 /api/public", + "item": [ + { + "name": "公开用户目录", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/public/users", + "description": "列出未封禁用户的公开目录项(按注册时间排序)。" + }, + "response": [] + }, + { + "name": "公开用户主页", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwtToken}}", + "description": "可选;带上时返回访客点赞状态等额外字段。", + "disabled": true + } + ], + "url": "{{baseUrl}}/api/public/users/{{account}}", + "description": "按账号获取公开资料与点赞数;可选 JWT 用于访客视角。" + }, + "response": [] + }, + { + "name": "主页点赞", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{jwtToken}}" + } + ], + "url": "{{baseUrl}}/api/public/users/{{account}}/like", + "description": "对已登录用户给指定主页点赞(有每日次数等限制)。" + }, + "response": [] + }, + { + "name": "注册策略(公开)", + "request": { + "method": "GET", + "header": [], + "url": "{{baseUrl}}/api/public/registration-policy", + "description": "前端展示用:是否需要邀请码等公开策略。" + }, + "response": [] + } + ] + }, + { + "name": "管理 /api/admin", + "item": [ + { + "name": "用户列表", + "request": { + "method": "GET", + "header": [ + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "url": "{{baseUrl}}/api/admin/users", + "description": "管理端:列出全部用户(需 X-Admin-Token,或 Query token=、或 Authorization Bearer)。本请求使用集合变量 adminToken。" + }, + "response": [] + }, + { + "name": "创建用户", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"\",\n \"password\": \"\",\n \"username\": \"\",\n \"email\": \"\",\n \"level\": 0,\n \"sproutCoins\": 0,\n \"secondaryEmails\": [],\n \"phone\": \"\",\n \"avatarUrl\": \"\",\n \"websiteUrl\": \"\",\n \"bio\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/admin/users", + "description": "管理端创建用户。" + }, + "response": [] + }, + { + "name": "更新用户", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"password\": null,\n \"username\": null,\n \"email\": null,\n \"level\": null,\n \"sproutCoins\": null,\n \"secondaryEmails\": null,\n \"phone\": null,\n \"avatarUrl\": null,\n \"websiteUrl\": null,\n \"bio\": null,\n \"banned\": null,\n \"banReason\": null\n}" + }, + "url": "{{baseUrl}}/api/admin/users/{{account}}", + "description": "管理端更新用户字段;封禁等会触发 token 失效策略。" + }, + "response": [] + }, + { + "name": "删除用户", + "request": { + "method": "DELETE", + "header": [ + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "url": "{{baseUrl}}/api/admin/users/{{account}}", + "description": "管理端删除用户。" + }, + "response": [] + }, + { + "name": "签到配置 - 获取", + "request": { + "method": "GET", + "header": [ + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "url": "{{baseUrl}}/api/admin/check-in/config", + "description": "获取每日签到奖励萌芽币配置。" + }, + "response": [] + }, + { + "name": "签到配置 - 更新", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"rewardCoins\": 1\n}" + }, + "url": "{{baseUrl}}/api/admin/check-in/config", + "description": "设置每次签到奖励(必须大于 0)。" + }, + "response": [] + }, + { + "name": "注册策略 - 获取(含邀请码列表)", + "request": { + "method": "GET", + "header": [ + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "url": "{{baseUrl}}/api/admin/registration", + "description": "管理端查看注册策略与邀请码条目。" + }, + "response": [] + }, + { + "name": "注册策略 - 更新", + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"requireInviteCode\": false,\n \"forbiddenAccounts\": \"\",\n \"inviteRegisterRewardCoins\": null\n}" + }, + "url": "{{baseUrl}}/api/admin/registration", + "description": "更新是否必填邀请码、禁用账号名单、邀请注册奖励等。" + }, + "response": [] + }, + { + "name": "创建邀请码", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"note\": \"\",\n \"maxUses\": 1,\n \"expiresAt\": \"\"\n}" + }, + "url": "{{baseUrl}}/api/admin/registration/invites", + "description": "新增邀请码;expiresAt 格式以后端校验为准(通常 ISO 时间字符串)。" + }, + "response": [] + }, + { + "name": "删除邀请码", + "request": { + "method": "DELETE", + "header": [ + { + "key": "X-Admin-Token", + "value": "{{adminToken}}" + } + ], + "url": "{{baseUrl}}/api/admin/registration/invites/{{inviteCode}}", + "description": "按邀请码删除。" + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/docs/cloudflare-turnstile.md b/docs/cloudflare-turnstile.md new file mode 100644 index 0000000..e09fb9b --- /dev/null +++ b/docs/cloudflare-turnstile.md @@ -0,0 +1,185 @@ +# Cloudflare Turnstile 网站接入指南 + +本文说明如何在任意网站中集成 [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) 人机验证:从控制台拿密钥、前端展示控件、把一次性 token 交给后端、由服务端向 Cloudflare 校验。内容适用于自建站点与 API,不限定具体技术栈,文末可对照本仓库中的实现。 + +--- + +## 1. 概念与流程 + + +| 项 | 说明 | +| ------------------- | -------------------------------------------------------------------------------------- | +| **Site Key** | 公钥,可出现在前端代码或 HTML 中。 | +| **Secret Key** | 私钥,**仅**能用于你的服务端调用校验接口。 | +| **Token(response)** | 用户通过验证后,Turnstile 在浏览器中生成的一串临时字符串,需在你自己的业务请求里随表单/JSON 提交到**你的后端**。 | +| **二次校验** | 前端 token **不能**单独作为“已通过人机验证”的依据;必须由服务器使用 Secret Key 调用 Cloudflare 的 `siteverify` 接口确认。 | + + +典型流程: + +1. 页面加载 Cloudflare 提供的 JS,在指定 DOM 中渲染小组件。 +2. 用户通过挑战后,前端拿到 `token`,写入状态或随登录/注册请求提交。 +3. 你的后端在执行业务逻辑**之前**用 Secret Key + `token`(及可选的客户端 IP)请求 `siteverify`;`success: true` 才继续。 +4. 每个 token 仅应用一次、且有时效;提交失败后应**重置**控件以获取新 token(见第 3 节)。 + +--- + +## 2. 在 Cloudflare 控制台创建站点 + +1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/),进入 **Turnstile**。 +2. 选择 **Add widget** / 添加站点。 +3. 填写网站域名、选择 **Managed**(或按需选择可见性/交互方式)。 +4. 创建后得到 **Site Key** 与 **Secret Key**;为生产环境可单独为生产域名建 widget,勿把 Secret Key 写进仓库或静态资源。 + +**本地开发**:在 Turnstile 站点里把 `localhost` 及测试域名加入允许的主机名(与 Cloudflare 当前 UI 中「主机名」/域名列表一致),否则可能无法加载或校验失败。 + +--- + +## 3. 前端:加载脚本与渲染控件 + +### 3.1 官方脚本 + +推荐按文档使用官方脚本,例如**显式渲染**(`render=explicit`)便于在单页应用里把控件挂在指定容器上: + +- 脚本地址: +`https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit` + +在页面中插入一次即可(注意避免重复注入同一脚本多份,除非你有意为之)。 + +```html + +``` + +全局会暴露 `window.turnstile`,主要方法包括: + +- `turnstile.render(container, options)`:在 `container`(DOM 元素或选择器)内渲染,返回 `widgetId`。 +- `turnstile.reset(widgetId)`:同一会话内换发新 token(失败重试、重复提交时很有用)。 +- `turnstile.remove(widgetId)`:销毁实例。 + +### 3.2 常用 `render` 参数 + + +| 参数 | 含义 | +| ------------------ | ------------------------------- | +| `sitekey` | 必填,即 Site Key。 | +| `callback` | 验证通过时回调,参数为 `token` 字符串。 | +| `expired-callback` | token 过期时。 | +| `error-callback` | 发生错误时。 | +| `theme` | 可选 `light` / `dark` / `auto` 等。 | + + +建议在 `expired-callback` 与 `error-callback` 里把本地产出的 token 清空,避免用失效 token 提交。 + +### 3.3 把 token 交给你的后端 + +验证通过后,在 `callback` 中保存 `token`(如 React 的 state、Vue 的 ref)。用户点击「登录/提交」时,将 **同一请求** 中的其它字段与 `turnstileToken`(或你命名的字段名)一起发给**你自己的** API。字段名仅前后端一致即可,例如 JSON: + +```json +{ + "username": "user", + "password": "…", + "turnstileToken": "0.xxx…" +} +``` + +**注意**: + +- 未启用 Turnstile 的页面或测试环境,前后端要约定好是否“可不传 token”,避免生产误关校验。 +- 提交后若失败,应调用 `reset` 并清空本地 token,避免复用。 + +### 3.4 与 Content Security Policy (CSP) 的兼容 + +若站点启用了严格 CSP,需允许 Cloudflare 相关源,例如(按实际策略合并): + +- `script-src`:`https://challenges.cloudflare.com` +- `frame-src` 或 `child-src`:同上(部分实现会以 iframe 呈现挑战) +- 有时还需 `https://challenges.cloudflare.com/cdn-cgi/...` 等,以浏览器控制台与官方文档为准。 + +--- + +## 4. 后端:siteverify 校验 + +在**执行业务逻辑之前**用服务器端向 Cloudflare 验证 token。不应信任客户端声称的“已验证”。 + +### 4.1 端点 + +``` +POST https://challenges.cloudflare.com/turnstile/v0/siteverify +``` + +使用 `application/x-www-form-urlencoded` 或等价的表单编码提交。 + +### 4.2 参数 + + +| 参数 | 必填 | 说明 | +| ---------- | --- | ------------------------------------- | +| `secret` | 是 | Secret Key。 | +| `response` | 是 | 前端传来的 token。 | +| `remoteip` | 否 | 发起请求的用户 IP,可与 Cloudflare 侧风控一致,建议有则传。 | + + +### 4.3 响应 + +JSON 中至少包含 `success`(布尔值)。`success` 为 `true` 才应继续登录、注册、发帖等业务。 + +失败时 JSON 中常带有 `error-codes` 数组,便于排障,例如 `invalid-input-response`、`timeout-or-duplicate` 等(以[官方说明](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)为准)。 + +**实践建议**: + +- 为 HTTP 客户端设置合理超时(如 8~15 秒),失败时对用户显示通用错误,**不要**在错误信息中泄露 Secret Key 或内部堆栈。 +- `siteverify` 应仅在服务端调用;不要从浏览器直接带 Secret 请求。 + +### 4.4 伪代码示例 + +```text +if secret_key is empty: return 500 # 未配置 +if user_token is empty: return 400 # 请完成人机验证 +POST form: secret, response, (optional) remoteip +if JSON.success != true: return 400 # 验证失败 +# 再执行登录/注册等逻辑 +``` + +语言无关:Go 可用 `http.PostForm`,Node 可用 `fetch` + `URLSearchParams`,Python 可用 `httpx`/`requests` 的 `data=` 等。 + +--- + +## 5. 安全与产品注意点 + +- **Site Key 可公开,Secret Key 绝不可进前端、公开仓库、日志。** 使用环境变量或密钥管理。 +- **每次敏感操作**若需要防护,可要求新的 token,而不是长缓存同一条 token。 +- 与**速率限制、账户锁定、异常 IP 检测**等组合,而不是只依赖 Turnstile。 +- 若使用**反向代理**,`remoteip` 应取真实客户端 IP(与 Turnstile 可见一致),与信任代理头配置统一。 +- 多环境(dev/staging/prod)使用不同 widget 与密钥,避免误用。 + +--- + +## 6. 排障简表 + + +| 现象 | 可能原因 | +| ------------------- | ------------------------------------------------ | +| 前端不显示控件 | 域名未在 Turnstile 站点中、脚本被 CSP/广告拦截、脚本未加载完就 `render` | +| 总是 `error-callback` | Site Key 错误、域名校验失败、网络问题 | +| `siteverify` 失败 | token 已用过/过期、Secret Key 错误、时间偏差过大、请求不是 POST 表单 | +| 生产正常、本地失败 | 未把 localhost 加入允许主机名、混用不同环境的 Key | + + +更完整的错误码与行为以 Cloudflare 官方文档为准: +[https://developers.cloudflare.com/turnstile/](https://developers.cloudflare.com/turnstile/) + +--- + +## 7. 与本项目(SproutGate)的对应关系(参考) + +本仓库中可作为对照的实现位置(不保证与上文逐字相同,以代码为准): + +- 前端组件:`sproutgate-frontend/src/components/oauth/TurnstileWidget.jsx`(显式 `render`、token 回调、`reset`)。 +- 前端在登录/注册中附带 `turnstileToken`:`UserPortal.jsx`、`UserPortalAuthSection.jsx`。 +- 后端校验:`sproutgate-backend/internal/handlers/turnstile.go` 中 `verifyTurnstileToken` 及 `turnstileVerifyURL`。 +- 管理端配置 Site/Secret 与开关:`/api/admin/turnstile`、存储层 `TurnstileConfig`。 + +第三方站点只需遵守本文第 1~5 节的通用步骤即可;具体路由与配置项以各自系统为准。 \ No newline at end of file diff --git a/docs/oauth-github-gitea-testing.md b/docs/oauth-github-gitea-testing.md new file mode 100644 index 0000000..3f69b64 --- /dev/null +++ b/docs/oauth-github-gitea-testing.md @@ -0,0 +1,230 @@ +# GitHub / Gitea / Google / Microsoft / LINUX DO 登录:开发与生产环境测试指南 + +本文说明如何在**本地开发**与**生产环境**中配置、测试并验证萌芽统一账户的第三方 OAuth 登录(GitHub、自建 Gitea、[Google 账号](https://console.cloud.google.com/)、[Microsoft / Entra ID](https://entra.microsoft.com/)、以及 [LINUX DO Connect](https://connect.linux.do))。 + +--- + +## 一、共同概念 + +### 1.1 两次「地址」要分清 + + +| 角色 | 说明 | 典型开发值 | 典型生产值 | +| -------------------- | ----------------------------------------------------------------- | --------------------------------- | ------------------------------ | +| **API 根地址** | 浏览器与 SproutGate 后端通信的基址;OAuth **回调**由后端路径承担 | `http://localhost:8080` | `https://auth.api.example.com` | +| **前端回跳 `return_to`** | 用户授权完成后,后端把浏览器重定向到该地址,并在 **URL fragment(# 后)** 写入 `oauth_token` 等 | `http://localhost:5173/`(Vite 默认) | `https://你的前端站/` | +| **允许的回跳前缀** | 管理后台「第三方登录」里配置的 **白名单**;`return_to` 必须以其中某一项为前缀,否则拒绝 | 需包含开发用前端根 | 需包含生产前端根 | + + +### 1.2 在各 IdP 里要填的「回调 / Redirect URI」 + +OAuth 应用里登记的 **Redirect URI** 必须与后端实际对外地址一致(协议、主机、端口、路径一字不差): + +- GitHub: +`{API根}/api/auth/oauth/github/callback` +- Gitea: +`{API根}/api/auth/oauth/gitea/callback` +- Google(Google Cloud「OAuth 2.0 客户端 ID」,类型为 **Web 应用** 时的「已获授权的重定向 URI」): +`{API根}/api/auth/oauth/google/callback` +- Microsoft / Azure / Entra(**应用注册** → 身份验证 → 添加平台 **Web** 的「重定向 URI」): +`{API根}/api/auth/oauth/microsoft/callback` + 本服务使用 **v2.0 授权终结点**;租户在管理后台为 **Microsoft 租户** 字段,默认 `common`(多租户 + 个人 Microsoft 账号)。若你只需单一组织目录,可改为**具体租户 ID**。 +- LINUX DO(Connect 应用接入): +`{API根}/api/auth/oauth/linuxdo/callback` +与你在 [应用接入](https://connect.linux.do) 中填写的回调一致即可(开发时可为 `http://localhost:8080/...`)。 + +开发时 `{API根}` 多为 `http://localhost:8080`;生产时为 `https://你的 API 域名`(无尾部斜杠)。 + +### 1.3 后端环境变量(与部署相关) + + +| 变量 | 作用 | +| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PUBLIC_API_BASE` | **建议生产必设**。值为 **纯 API 根**(无路径),如 `https://auth.api.example.com`。用于拼 OAuth `redirect_uri`。本地若访问 API 用 `http://localhost:8080`,可设 `PUBLIC_API_BASE=http://localhost:8080`,与浏览器访问后端的地址一致。 | +| `GITHUB_CLIENT_SECRET` / `GITEA_CLIENT_SECRET` / `GOOGLE_CLIENT_SECRET` / `MICROSOFT_CLIENT_SECRET` / `LINUXDO_CLIENT_SECRET` | 可选;若设置,会覆盖数据库里保存的对应 Secret,便于不把 Secret 写进库。 | +| `PORT` | 后端监听端口,默认 `8080`。 | + + +--- + +## 二、开发环境(本机) + +### 2.1 默认假设 + +- 后端:`http://localhost:8080`(`go run .` 或 `sproutgate.bat dev`) +- 前端:`http://localhost:5173`(`npm run dev`) +- 前端已配置 `VITE_API_BASE=http://localhost:8080`(或留空走开发默认) + +### 2.2 在 GitHub 创建 OAuth App + +1. 打开 GitHub → **Settings** → **Developer settings** → **OAuth Apps** → **New OAuth App**(或 Organization 下创建)。 +2. 填写示例: + - **Application name**:任意,如 `SproutGate Dev` + - **Homepage URL**:`http://localhost:5173` + - **Authorization callback URL**: + `http://localhost:8080/api/auth/oauth/github/callback` +3. 创建后记录 **Client ID**,生成 **Client secrets** 并复制保存(只显示一次)。 + +GitHub 允许使用 `http://localhost` 作为回调,无需公网。 + +### 2.3 在 LINUX DO Connect 创建应用 + +1. 打开 [https://connect.linux.do](https://connect.linux.do)(需登录),侧栏 **应用接入** → 新建接入。 +2. **回调地址** 填 `http://localhost:8080/api/auth/oauth/linuxdo/callback`(生产改为 `https://{API域名}/api/auth/oauth/linuxdo/callback`)。 +3. 保存后记录 **Client ID**、**Client Secret**;管理后台「LINUX DO Connect 根地址」通常填 `https://connect.linux.do`(与官方端点一致即可)。 + +### 2.4 在 Gitea 创建 OAuth2 应用 + +1. 登录你的 Gitea(如 `https://git.shumengya.top`)→ **站点管理** 或 **用户设置** 中的 **Applications** / **OAuth2**,创建新应用(不同版本菜单位置略有差异)。 +2. **Redirect URI**(重定向 URI): + `http://localhost:8080/api/auth/oauth/gitea/callback` +3. 记录 **Client ID**、**Client Secret**,勾选所需权限(至少能读用户资料,一般 `read:user` 或界面勾「读取用户信息」类选项)。 + +### 2.4a 在 Google Cloud 创建 OAuth 2.0 客户端(可选) + +1. 打开 [Google Cloud Console](https://console.cloud.google.com/),选择或创建项目 → **API 和服务** → **OAuth 同意屏幕**(先配置为测试/内部/公开视需求)。 +2. **凭据** → **创建凭据** → **OAuth 客户端 ID** → 应用类型选 **Web 应用**。 +3. **已获授权的重定向 URI** 填 `http://localhost:8080/api/auth/oauth/google/callback`(生产改为 `https://{API域名}/api/auth/oauth/google/callback`)。 +4. 记录 **客户端 ID**、**客户端密钥**;在 SproutGate 管理后台「第三方登录」中勾选 **Google** 并填入。 + +### 2.4b 在 Microsoft Entra(原 Azure AD)应用注册(可选) + +1. 打开 [Microsoft Entra 管理中心](https://entra.microsoft.com/) → **应用注册** → **新注册**。 +2. 支持的帐户类型中,若与后台 **Microsoft 租户** 填 `common` 一致,通常选「**任何组织目录中的帐户和个人 Microsoft 帐户**」。 +3. **身份验证** → 添加平台 **Web** → **重定向 URI** 填 `http://localhost:8080/api/auth/oauth/microsoft/callback`(生产用 HTTPS 与真实 API 域)。 +4. 在 **证书和密码** 中创建 **客户端密码**,并记录**应用程序(客户端) ID** 与 secret;管理后台 **Microsoft 租户** 可填 `common` 或你的目录租户 ID(单租户时便于限制登录范围)。 + +### 2.5 在 SproutGate 管理后台配置 + +1. 打开管理页,填写 **管理员 Token**,进入 **第三方登录**。 +2. 按需勾选 **GitHub** / **Gitea** / **Google** / **Microsoft** / **LINUX DO 登录**。 +3. 分别填入各平台的 **Client ID** 与 **Client Secret**;Microsoft 另填 **Microsoft 租户**(默认 `common`);LINUX DO 另需确认 **Connect 根地址**(默认 `https://connect.linux.do`)。 +4. **Gitea 根地址**:如 `https://git.shumengya.top`(无尾斜杠也可,保存时会规范化)。 +5. **允许的回跳地址**:至少包含(每行一个,**建议带尾斜杠**): + - `http://localhost:5173/` + - 若用 `127.0.0.1` 打开前端,再加一行: + `http://127.0.0.1:5173/` +6. **需邀请时仍允许 OAuth 新用户**:若你开启了「强制邀请码」自助注册,又想用 OAuth **直接注册**新账号,需勾选此项;否则邀请制下 OAuth 只能登录**已绑定**的账号。 +7. 保存。 + +### 2.6 本地设置 `PUBLIC_API_BASE`(推荐) + +使后端生成的 OAuth `redirect_uri` 与你在各平台应用里填的完全一致: + +**Windows PowerShell(当前终端)** + +```powershell +$env:PUBLIC_API_BASE = "http://localhost:8080" +``` + +**或在启动脚本里** 写死上述一行再启动后端。 + +然后重启后端,再测登录。 + +### 2.7 验证步骤(开发) + +1. 打开 `http://localhost:5173`,**退出**当前登录(如有)。 +2. 在登录界面应看到已启用的第三方按钮(**GitHub**、**Gitea**、**谷歌**、**微软**、**LINUX DO** 等,仅当对应「启用」已打开)。 +3. 点击 **GitHub**:应跳转到 GitHub 授权页,授权后回到 `http://localhost:5173/#oauth_token=...`(地址栏 hash 中有 token),页面应显示已登录。 +4. 退出后测 **Gitea** / **Google** / **Microsoft** / **LINUX DO**,流程同上(LINUX DO 会先到 `connect.linux.do` 授权页)。 +5. **绑定**:先普通登录,进入用户中心 → **第三方账号** → **绑定**,应跳转到对应平台,返回后应显示「已绑定」。 +6. **解绑**:点「解绑」,确认后状态应更新。 + +若返回后 URL 带 `#oauth_error=1`,查看 `oauth_error_description` 片段内容(多为白名单、`redirect_uri` 不匹配、或策略禁止注册等)。 + +--- + +## 三、生产环境 + +### 3.1 前置条件 + +- API 对外为 **HTTPS**(GitHub 生产回调通常要求 HTTPS;本地 localhost 除外)。 +- 用户浏览器能访问:**前端域名**、**API 域名**、**Gitea 域名**(若用 Gitea 登录)。 + +### 3.2 在 GitHub 生产 OAuth App + +1. 新建或使用独立 **Production** OAuth App(勿与开发混用同一回调,易错)。 +2. **Authorization callback URL** 填: + `https://{你的API域名}/api/auth/oauth/github/callback` + 例如:`https://auth.api.shumengya.top/api/auth/oauth/github/callback` +3. 记录 Client ID / Secret。 + +### 3.3 在 Gitea 生产 OAuth 应用 + +1. **Redirect URI**: + `https://{你的API域名}/api/auth/oauth/gitea/callback` +2. 记录 Client ID / Secret。 + +### 3.4 服务器环境变量 + +示例(按你的实际域名修改): + +```bash +export PUBLIC_API_BASE="https://auth.api.shumengya.top" +export GITHUB_CLIENT_SECRET="..." # 可选 +export GITEA_CLIENT_SECRET="..." # 可选 +export GOOGLE_CLIENT_SECRET="..." # 可选 +export MICROSOFT_CLIENT_SECRET="..." # 可选 +``` + +确保反向代理(Nginx/Caddy 等)转发的 **Host** / **X-Forwarded-Proto** 正确,否则未设 `PUBLIC_API_BASE` 时,程序可能错误拼出 `http` 或错误主机,导致与 GitHub/Gitea 登记的回调不一致。 + +### 3.5 前端 + +- 生产构建时设置 `VITE_API_BASE=https://{你的API域名}`(与浏览器实际请求后端的地址一致,无多余斜杠)。 + +### 3.6 管理后台 + +在 **允许的回跳地址** 中加入生产前端的根地址,例如: + +```text +https://user.shumengya.top/ +``` + +(具体以你实际用户访问的「用户中心 / 登录页」域名为准,**须带尾斜杠**,与代码里 `getOAuthReturnTo()` 生成的前缀匹配。) + +保存各平台的 Client ID;Secret 可用环境变量注入,界面里可留空以保留原值。 + +### 3.7 验证步骤(生产) + +与 **2.6** 相同,只是把 `http://localhost:5173` 换成线上前端地址,并确认: + +- 授权完成后地址栏出现 `oauth_token` 或绑定成功后的 `oauth_bound`; +- 新用户(未注册过)在允许策略下可完成首次 OAuth 注册(视「邀请码」与「需邀请时仍允许 OAuth 新用户」组合而定)。 + +--- + +## 四、常见问题 + + +| 现象 | 可能原因 | 处理 | +| -------------------------------------------- | --------------------------------------------- | --------------------------------------------------------- | +| GitHub 提示 `redirect_uri` 不匹配 | 回调 URL 与 OAuth 应用配置不一致,或 `PUBLIC_API_BASE` 错误 | 对齐三处:GitHub 后台、`PUBLIC_API_BASE`、实际访问 API 的协议与主机 | +| 回到前端后 `#oauth_error`,描述含 `invalid return_to` | 前端 `return_to` 不在白名单 | 在管理后台增加该前端的 **根地址前缀**(含 `https://` 与尾 `/`) | +| 邀请制下无法 OAuth 新用户 | 策略限制 | 开启「需邀请时仍允许 OAuth 新用户」,或关闭「强制邀请码」,或先用邮箱+邀请码注册再绑定 | +| Gitea 授权后失败 | Gitea 实例不能访问、或 Token 请求失败 | 检查服务器能否访问 Gitea;Client ID/Secret;Gitea OAuth 应用回调是否填对 | +| LINUX DO 提示 scope 或 `userinfo_failed` | 应用权限、或 `/api/user` 返回非 200 | 确认 Connect 应用已同意;本服务使用 scope `read:user`;可查看后端日志中 HTTP 状态 | +| 本地能行、生产不行 | 多为 HTTPS、反代头、`PUBLIC_API_BASE`、白名单未含生产前端 | 逐项对照第二节、第三节 | + + +--- + +## 五、快速检查清单 + +**开发** + +- `http://localhost:8080/api/health` 可访问 +- 各平台回调填 `http://localhost:8080/api/auth/oauth/{github|gitea|google|microsoft|linuxdo}/callback` +- 管理后台白名单含 `http://localhost:5173/`(及实际使用的前端 origin) +- 已设 `PUBLIC_API_BASE=http://localhost:8080`(推荐) +- `VITE_API_BASE` 指向本机 API + +**生产** + +- API 与前端均为 HTTPS(或符合你安全策略) +- OAuth 应用回调为 `https://{API}/api/auth/oauth/.../callback` +- `PUBLIC_API_BASE` 与对外 API 一致 +- 白名单含生产前端根 URL(尾斜杠) +- 前端 `VITE_API_BASE` 为生产 API + +按上述配置后,即可在对应环境完成 GitHub、Gitea、Google、Microsoft、LINUX DO 登录及绑定验证。 \ No newline at end of file diff --git a/docs/oauth-providers/README.md b/docs/oauth-providers/README.md new file mode 100644 index 0000000..1d2e7f1 --- /dev/null +++ b/docs/oauth-providers/README.md @@ -0,0 +1,19 @@ +# OAuth 2.0 第三方登录:分提供商教程(Golang) + +本目录为**通用**接入说明,适用于任意自建后端或 BFF:在对应**身份提供商(IdP)**注册应用,使用 **OAuth 2.0 授权码(Authorization Code)** 换 `access_token`,再调用各平台用户 API。语言示例统一为 **Go**(`golang.org/x/oauth2`)。 + +| 文档 | 说明 | +|------|------| +| [github.md](github.md) | GitHub OAuth App,标准 `github.Endpoint` | +| [gitea.md](gitea.md) | 自建/托管 Gitea 的 OAuth2,端点随实例 URL 变化 | +| [linuxdo.md](linuxdo.md) | LINUX DO Connect(官方 Connect 端点) | +| [google.md](google.md) | Google 账号,官方 `google.Endpoint` + userinfo | +| [microsoft.md](microsoft.md) | Microsoft / Entra ID,v2.0 终结点 + Microsoft Graph | + +**共同前提** + +- 浏览器中完成授权;`redirect_uri` 必须在 IdP 控制台**逐字登记**(含 `http`/`https`、主机、端口、路径)。 +- 换 token 与调用户 API 一般由**你的服务端**发起;请保证服务器出网可达对应域名(企业网络/地区网络可能需代理)。 +- 生产环境使用 **HTTPS**;`state` 参数防 CSRF,勿省略。 + +以下各篇相互独立,可按需只读其一。 diff --git a/docs/oauth-providers/gitea.md b/docs/oauth-providers/gitea.md new file mode 100644 index 0000000..0e8daa8 --- /dev/null +++ b/docs/oauth-providers/gitea.md @@ -0,0 +1,123 @@ +# 使用 Gitea 登录:OAuth 2.0 接入指南(Golang) + +**Gitea** 实例提供与平台绑定的 OAuth2 端点:授权地址、令牌地址都带**你的 Gitea 根 URL** 前缀。本文说明通用注册方式与 Go 中的 `oauth2.Endpoint` 拼法,与具体业务项目无关。 + +## 1. 协议与端点 + +对 Gitea 根地址 `BASE`(无尾斜杠,如 `https://git.example.com`): + +| 步骤 | URL | +|------|-----| +| 授权 | `{BASE}/login/oauth/authorize` | +| 换 token | `{BASE}/login/oauth/access_token` | +| 当前用户 API | `{BASE}/api/v1/user`(Header:`Authorization: token {access_token}`) | + +Scope 常用:`read:user`(以你 Gitea 版本界面为准)。 + +官方概念说明见 Gitea 文档中的 OAuth2 / Applications 章节(不同版本路径可能为站点管理或用户 **Applications**)。 + +## 2. 在 Gitea 中注册应用 + +1. 在目标 Gitea 上创建 **OAuth2 应用** / **应用(OAuth2)**,填写 **Redirect URI**(你自有服务的回调,须 HTTPS 生产或本地开发约定)。 +2. 记录 **Client ID**、**Client Secret**。 + +**注意**:`redirect_uri` 在授权与换 token 两阶段须一致,且与 Gitea 中登记项一致。 + +## 3. Golang:`oauth2.Endpoint` 与示例 + +`golang.org/x/oauth2` 的 `Config.Endpoint` 需手动设为: + +```go +endpoint := oauth2.Endpoint{ + AuthURL: base + "/login/oauth/authorize", + TokenURL: base + "/login/oauth/access_token", +} +``` + +调用 Gitea `GET /api/v1/user` 时,使用 **token** 头:`Authorization: token ` + `accessToken`(Gitea 传统写法;若你的版本支持 Bearer 以实际文档为准)。 + +## 4. 最小示例片段 + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "golang.org/x/oauth2" +) + +func giteaEndpoint(base string) oauth2.Endpoint { + base = trimRightSlash(base) + return oauth2.Endpoint{ + AuthURL: base + "/login/oauth/authorize", + TokenURL: base + "/login/oauth/access_token", + } +} + +func trimRightSlash(s string) string { + for len(s) > 0 && s[len(s)-1] == '/' { + s = s[:len(s)-1] + } + return s +} + +// 示例:构造 Config(BASE、ClientID、Secret、RedirectURL 从环境或配置读取) +func newGiteaConfig() *oauth2.Config { + base := os.Getenv("GITEA_BASE_URL") // 如 https://git.example.com + return &oauth2.Config{ + ClientID: os.Getenv("GITEA_CLIENT_ID"), + ClientSecret: os.Getenv("GITEA_CLIENT_SECRET"), + RedirectURL: os.Getenv("GITEA_REDIRECT_URL"), + Scopes: []string{"read:user"}, + Endpoint: giteaEndpoint(base), + } +} + +type giteaUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + FullName string `json:"full_name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` +} + +func fetchGiteaUser(ctx context.Context, base, access string) (*giteaUser, error) { + base = trimRightSlash(base) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/user", nil) + req.Header.Set("Authorization", "token "+access) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + b, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gitea: %d %s", res.StatusCode, b) + } + var u giteaUser + if err := json.Unmarshal(b, &u); err != nil { + return nil, err + } + return &u, nil +} + +``` + +在 `main` 中挂载 HTTP 路由:首页生成 `state` 并重定向到 `cfg.AuthCodeURL`;在 `/oauth/gitea/callback` 中 `cfg.Exchange` 后调用 `fetchGiteaUser` 即可。整体与 [github.md](github.md) 中示例同构,仅将 `Config.Endpoint` 换为 `giteaEndpoint`、将 GitHub `Bearer` 换为 Gitea 的 `token` 头。 + +## 5. 常见错误 + +- **401**:token 头格式或 API 版本与实例不一致。 +- **redirect_uri 不匹配**:授权与回调的 host/scheme/端口与 Gitea 中登记的不一致。 +- 多 Gitea 实例时:每个 **Client** 与 **base URL** 一一对应,勿混用。 + +## 6. 安全建议 + +- Secret 只放服务端。 +- 以 Gitea 返回的**数字 user id** 作为绑定主键更稳妥。 diff --git a/docs/oauth-providers/github.md b/docs/oauth-providers/github.md new file mode 100644 index 0000000..1953c17 --- /dev/null +++ b/docs/oauth-providers/github.md @@ -0,0 +1,196 @@ +# 使用 GitHub 登录:OAuth 2.0 接入指南(Golang) + +本文介绍在自有应用中接入 **GitHub 账号** 的通用做法:在 GitHub 注册 OAuth App,使用 **授权码流程** 换取 `access_token`,并调用 GitHub REST API 获取用户信息。与具体业务系统无关。 + +## 1. 协议与端点 + +GitHub 支持标准 OAuth 2.0。Go 中可直接使用: + +```text +import "golang.org/x/oauth2/github" + +Endpoint: github.Endpoint // AuthURL + TokenURL 已内置 +``` + +| 步骤 | URL | +|------|-----| +| 引导用户授权 | `https://github.com/login/oauth/authorize`(由 `oauth2.Config` 生成) | +| 用 `code` 换 token | `https://github.com/login/oauth/access_token`(`Exchange` 自动 POST) | +| 读当前用户(示例) | `GET https://api.github.com/user`(`Authorization: Bearer {token}`) | +| 主邮箱(若 user 里无 email) | `GET https://api.github.com/user/emails` | + +常用 **Scope**:`read:user`、`user:email`(需要邮箱时建议带 `user:email`)。 + +官方文档:[Authorizing OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) + +## 2. 在 GitHub 注册应用 + +1. 个人:`Settings` → `Developer settings` → `OAuth Apps` → `New OAuth App`。 +2. **Authorization callback URL** 填你**真实**的回调地址,例如: + `http://localhost:8080/oauth/github/callback`(开发) + `https://你的域名/oauth/github/callback`(生产) +3. 保存 **Client ID** 与 **Client secrets**(机密只显示一次,可轮换)。 + +## 3. `redirect_uri` 与换 token + +- 授权请求中的 `redirect_uri` 与在 GitHub 登记的 **必须完全一致**。 +- 用 `code` 换 token 时,客户端库会带上**同一** `redirect_uri`,否则换 token 失败。 + +## 4. Golang 最小示例(授权码 + 拉取用户) + +依赖:`go get golang.org/x/oauth2`,并安装子包引用 `github.Endpoint`(同一 module)。 + +```go +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" +) + +func main() { + redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/github/callback") + cfg := &oauth2.Config{ + ClientID: os.Getenv("GITHUB_CLIENT_ID"), + ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + RedirectURL: redirectURL, + Scopes: []string{"read:user", "user:email"}, + Endpoint: github.Endpoint, + } + if cfg.ClientID == "" || cfg.ClientSecret == "" { + log.Fatal("set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET") + } + + states := newStateStore() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, 16) + _, _ = rand.Read(b) + state := base64.RawURLEncoding.EncodeToString(b) + states.Put(state, time.Now().Add(10*time.Minute)) + url := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline) + http.Redirect(w, r, url, http.StatusFound) + }) + + http.HandleFunc("/oauth/github/callback", func(w http.ResponseWriter, r *http.Request) { + if err := r.URL.Query().Get("error"); err != "" { + http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest) + return + } + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + if !states.Consume(state) { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + ctx := r.Context() + tok, err := cfg.Exchange(ctx, code) + if err != nil { + http.Error(w, "token exchange: "+err.Error(), http.StatusBadRequest) + return + } + u, err := fetchGitHubUser(ctx, tok.AccessToken) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(u) + }) + + log.Println("open http://127.0.0.1:8080/ (callback must match GitHub app:", redirectURL+")") + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +type ghUser struct { + ID int64 `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` +} + +func fetchGitHubUser(ctx context.Context, access string) (*ghUser, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) + req.Header.Set("Authorization", "Bearer "+access) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "oauth-demo") + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + b, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github user: %d %s", res.StatusCode, b) + } + var u ghUser + if err := json.Unmarshal(b, &u); err != nil { + return nil, err + } + return &u, nil +} + +type stateStore struct { + mu sync.Mutex + m map[string]time.Time +} + +func newStateStore() *stateStore { + return &stateStore{m: make(map[string]time.Time)} +} + +func (s *stateStore) Put(key string, exp time.Time) { + s.mu.Lock() + s.m[key] = exp + s.mu.Unlock() +} + +func (s *stateStore) Consume(key string) bool { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.m[key] + if !ok || time.Now().After(t) { + return false + } + delete(s.m, key) + return true +} + +func getenv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} +``` + +**运行前**: + +- 在 GitHub OAuth App 的回调里填写与 `OAUTH_REDIRECT_URL` **完全一致**的 URL(未设置时默认 `http://127.0.0.1:8080/oauth/github/callback`)。 +- `export GITHUB_CLIENT_ID=...` 与 `GITHUB_CLIENT_SECRET=...` + +## 5. 常见错误 + +| 现象 | 原因 | +|------|------| +| `redirect_uri` 与登记不符 | 修改了端口、`localhost` vs `127.0.0.1` 或 `http`/`https` 不一致 | +| 换 token 失败 | 同上;或 `Client Secret` 错误 | +| 用户 email 为空 | 邮箱可能未公开,需调 `user/emails` 并带 `user:email` scope | + +## 6. 安全建议 + +- `state` 应随机且**一次性**;生产可用加签或服务端 Session。 +- `Client Secret` 仅放服务端;勿写入前端。 +- 对 GitHub 用户 ID(数字 id)做账号绑定主键,勿仅用 login(可改名)。 diff --git a/docs/oauth-providers/google.md b/docs/oauth-providers/google.md new file mode 100644 index 0000000..4525091 --- /dev/null +++ b/docs/oauth-providers/google.md @@ -0,0 +1,185 @@ +# 使用 Google 账号登录:OAuth 2.0 接入指南(Golang) + +本文介绍通过 **Google OAuth 2.0** 获取用户身份:在 Google Cloud 创建 **Web 应用** 凭据,使用授权码换 `access_token`,再调用 **OAuth2 userinfo** 或 **OpenID Connect**。与具体站点/产品无关。 + +## 1. 协议与端点 + +Go 可使用官方端点常量: + +```go +import "golang.org/x/oauth2/google" + +oauth2.Config{ + Endpoint: google.Endpoint, + // ... +} +``` + +| 步骤 | URL | +|------|-----| +| 授权 | `https://accounts.google.com/o/oauth2/auth`(由库生成,实际以库为准) | +| 换 token | `https://oauth2.googleapis.com/token` | +| 用户信息(常用) | `GET https://www.googleapis.com/oauth2/v2/userinfo`,`Authorization: Bearer {token}` | + +Scope 常用:`openid`、`email`、`profile`(userinfo v2 常见组合)。 + +官方文档:[Using OAuth 2.0 to Access Google APIs](https://developers.google.com/identity/protocols/oauth2) + +## 2. 在 Google Cloud 注册 + +1. [Google Cloud Console](https://console.cloud.google.com/) → 选择项目 → **API 和服务** → **OAuth 同意屏幕**(先配置完毕)。 +2. **凭据** → **创建凭据** → **OAuth 客户端 ID** → 类型 **Web 应用**。 +3. **已获授权的重定向 URI** 必须包含你后端的**真实回调**,例如: + `http://127.0.0.1:8080/oauth/google/callback`(开发) + `https://你的域名/oauth/google/callback`(生产) + 注意:**不是**前端开发机上的随便一条路径,除非你的换码服务就挂在那台机且路径一致。 +4. 记录 **客户端 ID**、**客户端密钥**。 + +## 3. 服务端出网 + +换 token 请求发往 `oauth2.googleapis.com`;若服务器在受限网络,需配置代理或出站策略,否则会出现 `token_exchange` 类错误(浏览器能打开 Google 不代表服务器能连 token 端点)。 + +## 4. Golang 最小示例 + +```go +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sync" + "time" + + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" +) + +func main() { + redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/google/callback") + cfg := &oauth2.Config{ + ClientID: os.Getenv("GOOGLE_CLIENT_ID"), + ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + RedirectURL: redirectURL, + Scopes: []string{"openid", "email", "profile"}, + Endpoint: google.Endpoint, + } + if cfg.ClientID == "" || cfg.ClientSecret == "" { + log.Fatal("set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET") + } + + states := newStateStore() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, 16) + _, _ = rand.Read(b) + state := base64.RawURLEncoding.EncodeToString(b) + states.Put(state, time.Now().Add(10*time.Minute)) + url := cfg.AuthCodeURL(state) + http.Redirect(w, r, url, http.StatusFound) + }) + + http.HandleFunc("/oauth/google/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("error") != "" { + http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest) + return + } + if !states.Consume(r.URL.Query().Get("state")) { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + ctx := r.Context() + tok, err := cfg.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "exchange: "+err.Error(), http.StatusBadRequest) + return + } + u, err := fetchGoogleUserinfo(ctx, tok.AccessToken) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(u) + }) + + log.Println("Google console redirect URI must be:", redirectURL) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +type googleUserinfo struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func fetchGoogleUserinfo(ctx context.Context, access string) (*googleUserinfo, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + "https://www.googleapis.com/oauth2/v2/userinfo", nil) + req.Header.Set("Authorization", "Bearer "+access) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + b, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("userinfo: %d %s", res.StatusCode, b) + } + var u googleUserinfo + if err := json.Unmarshal(b, &u); err != nil { + return nil, err + } + return &u, nil +} + +type stateStore struct { + mu sync.Mutex + m map[string]time.Time +} + +func newStateStore() *stateStore { + return &stateStore{m: make(map[string]time.Time)} +} +func (s *stateStore) Put(k string, exp time.Time) { + s.mu.Lock() + s.m[k] = exp + s.mu.Unlock() +} +func (s *stateStore) Consume(k string) bool { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.m[k] + if !ok || time.Now().After(t) { + return false + } + delete(s.m, k) + return true +} +func getenv(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} +``` + +## 5. 常见错误 + +| 错误 | 说明 | +|------|------| +| `redirect_uri_mismatch` | Google 控制台未登记与程序**完全一致**的回调(含协议、主机、端口、路径) | +| 换 token 失败 | `Client Secret` 错误;或授权与换码时 `redirect_uri` 不一致 | +| `access blocked` | OAuth 同意屏幕为「测试」且当前用户不在测试用户列表等,见 Google 控制台提示 | + +## 6. 安全建议 + +- `state` 必校验;Secret 仅服务端。 +- 用 `sub`/`id` 作为跨登录稳定主键;邮箱可能变化,慎单独依赖。 diff --git a/docs/oauth-providers/linuxdo.md b/docs/oauth-providers/linuxdo.md new file mode 100644 index 0000000..8566a30 --- /dev/null +++ b/docs/oauth-providers/linuxdo.md @@ -0,0 +1,75 @@ +# 使用 LINUX DO Connect 登录:OAuth 2.0 接入指南(Golang) + +**LINUX DO Connect** 提供标准 OAuth2 式授权与用户接口,官方入口为 [connect.linux.do](https://connect.linux.do)。本文说明其端点形态、在 Connect 中登记回调及 Go 侧接入方式,**与任意自建应用兼容**(不限定具体产品名)。 + +> 以 Connect 当前文档与控制台为准;若端点有变更,请以官网「应用接入」说明为准。 + +## 1. 协议与端点 + +对 **Connect 根地址** `BASE`(无尾斜杠,常见为 `https://connect.linux.do`): + + +| 步骤 | 典型 URL | +| ------- | ---------------------------------------------------------------------- | +| 授权 | `{BASE}/oauth2/authorize` | +| 换 token | `{BASE}/oauth2/token` | +| 当前用户 | `{BASE}/api/user`(`Authorization: Bearer {access_token}`,Accept: JSON) | + + +Scope 以 Connect 应用接入要求为准,常见为读取用户信息类 scope(例如文档中的 `read:user` 等,以实际申请为准)。 + +## 2. 在 Connect 注册应用 + +1. 登录 [connect.linux.do](https://connect.linux.do),在 **应用接入** 中创建应用。 +2. **回调地址** 填写你服务的真实 `redirect_uri`(与下文 Go 中 `RedirectURL` 一致)。 +3. 记录 **Client ID**、**Client Secret**。 + +## 3. Golang:`Endpoint` 配置 + +```go +import "golang.org/x/oauth2" + +func linuxDoEndpoint(base string) oauth2.Endpoint { + // 去掉 base 尾斜杠 + for len(base) > 0 && base[len(base)-1] == '/' { + base = base[:len(base)-1] + } + return oauth2.Endpoint{ + AuthURL: base + "/oauth2/authorize", + TokenURL: base + "/oauth2/token", + } +} +``` + +`oauth2.Config` 示例: + +- `RedirectURL`:与 Connect 中登记的回调**完全一致** +- `Scopes`:与创建应用时一致 +- 回调里:`cfg.Exchange(ctx, code)` 换 `access_token` + +## 4. 拉取用户资料 + +成功换 token 后,请求: + +- `GET {BASE}/api/user` +- Header:`Authorization: Bearer {access_token}` + +响应为 JSON。常见字段含用户 id、用户名、邮箱、头像等;**建议用返回的稳定 id 作为账号主键**(不要假设字段名永久不变,对接时以你抓包/文档为准)。 + +## 5. 网络与排障 + +- 换 token、访问 `/api/user` 由**你的服务器**发出;若机器无法访问 `connect.linux.do`(代理、墙、内网限制),会表现为超时或 `token exchange` 失败。 +- 浏览器能打开 Connect 授权页,不代表**后端**能连上 `token` URL;请在部署环境上测试出站 HTTPS。 + +## 6. 与 GitHub 示例同构的整合方式 + +实现步骤与 [github.md](github.md) 相同:根路径生成 `state` 并重定向到 `AuthCodeURL`;`oauth2/authorize` 的回调路径上取 `code`,`Exchange` 后带 Bearer 调 `/api/user`。仅替换: + +- `oauth2.Config.Endpoint` 为 `linuxDoEndpoint(os.Getenv("LINUXDO_BASE"))` +- 用户 JSON 的解析按 Connect 实际返回字段编写 `struct` 或 `map`。 + +## 7. 安全建议 + +- 校验 `state`;`Client Secret` 驻留服务端。 +- 对 Connect 用户 id 与本地用户做一对一绑定,并处理“该 id 已绑其他用户”的冲突。 + diff --git a/docs/oauth-providers/microsoft.md b/docs/oauth-providers/microsoft.md new file mode 100644 index 0000000..b9402dc --- /dev/null +++ b/docs/oauth-providers/microsoft.md @@ -0,0 +1,199 @@ +# 使用 Microsoft / Entra ID 登录:OAuth 2.0 接入指南(Golang) + +本文介绍通过 **Microsoft 身份平台(v2.0)** 使用个人或工作/学校账号登录:在 **Microsoft Entra(原 Azure AD)** 注册应用,走授权码流程,再用 **Microsoft Graph** 读取用户资料。与具体业务系统无关。 + +## 1. 协议与端点 + +v2.0 形式(`{tenant}` 可为 `common`、组织租户 ID、`consumers` 等,依你的「支持的帐户类型」选择): + +| 步骤 | URL | +|------|-----| +| 授权 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` | +| 换 token | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` | +| 当前用户 | `GET https://graph.microsoft.com/v1.0/me`,`Authorization: Bearer {token}` | + +Go 中手动拼 `oauth2.Endpoint`: + +```go +func microsoftV2Endpoint(tenant string) oauth2.Endpoint { + if tenant == "" { + tenant = "common" + } + base := "https://login.microsoftonline.com/" + tenant + return oauth2.Endpoint{ + AuthURL: base + "/oauth2/v2.0/authorize", + TokenURL: base + "/oauth2/v2.0/token", + } +} +``` + +Scope 至少包含:`openid`、`profile`、`email` 与 `https://graph.microsoft.com/User.Read`(读 `/me`)。 + +官方文档:[Microsoft identity platform and OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow) + +## 2. 在 Entra 注册应用 + +1. [Entra 管理中心](https://entra.microsoft.com/) → **应用注册** → **新注册**。 +2. **重定向 URI** 选 **Web**,填你的回调,例如: + `https://你的服务/oauth/microsoft/callback` +3. **证书和密码** → 新建客户端机密;记录 **应用程序(客户端) ID** 与机密。 +4. **API 权限**:确保可登录并调用 Graph(`User.Read` 等,与 scope 一致)。 + +## 3. Golang 最小示例 + +```go +package main + +import ( + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sync" + "time" + + "golang.org/x/oauth2" +) + +func main() { + tenant := getenv("MS_TENANT", "common") + redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/microsoft/callback") + + cfg := &oauth2.Config{ + ClientID: os.Getenv("MS_CLIENT_ID"), + ClientSecret: os.Getenv("MS_CLIENT_SECRET"), + RedirectURL: redirectURL, + Scopes: []string{ + "openid", "profile", "email", + "https://graph.microsoft.com/User.Read", + }, + Endpoint: microsoftV2Endpoint(tenant), + } + if cfg.ClientID == "" || cfg.ClientSecret == "" { + log.Fatal("set MS_CLIENT_ID and MS_CLIENT_SECRET") + } + + states := newStateStore() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + b := make([]byte, 16) + _, _ = rand.Read(b) + state := base64.RawURLEncoding.EncodeToString(b) + states.Put(state, time.Now().Add(10*time.Minute)) + http.Redirect(w, r, cfg.AuthCodeURL(state), http.StatusFound) + }) + + http.HandleFunc("/oauth/microsoft/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("error") != "" { + http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest) + return + } + if !states.Consume(r.URL.Query().Get("state")) { + http.Error(w, "invalid state", http.StatusBadRequest) + return + } + ctx := r.Context() + tok, err := cfg.Exchange(ctx, r.URL.Query().Get("code")) + if err != nil { + http.Error(w, "exchange: "+err.Error(), http.StatusBadRequest) + return + } + u, err := fetchGraphMe(ctx, tok.AccessToken) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(u) + }) + + log.Println("Entra redirect URI must match:", redirectURL) + log.Fatal(http.ListenAndServe(":8080", nil)) +} + +type graphMe struct { + ID string `json:"id"` + Mail string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` + DisplayName string `json:"displayName"` +} + +func fetchGraphMe(ctx context.Context, access string) (*graphMe, error) { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, + "https://graph.microsoft.com/v1.0/me", nil) + req.Header.Set("Authorization", "Bearer "+access) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + b, _ := io.ReadAll(res.Body) + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("graph: %d %s", res.StatusCode, b) + } + var u graphMe + if err := json.Unmarshal(b, &u); err != nil { + return nil, err + } + return &u, nil +} + +func microsoftV2Endpoint(tenant string) oauth2.Endpoint { + if tenant == "" { + tenant = "common" + } + base := "https://login.microsoftonline.com/" + tenant + return oauth2.Endpoint{ + AuthURL: base + "/oauth2/v2.0/authorize", + TokenURL: base + "/oauth2/v2.0/token", + } +} + +type stateStore struct { + mu sync.Mutex + m map[string]time.Time +} + +func newStateStore() *stateStore { return &stateStore{m: make(map[string]time.Time)} } +func (s *stateStore) Put(k string, exp time.Time) { + s.mu.Lock() + s.m[k] = exp + s.mu.Unlock() +} +func (s *stateStore) Consume(k string) bool { + s.mu.Lock() + defer s.mu.Unlock() + t, ok := s.m[k] + if !ok || time.Now().After(t) { + return false + } + delete(s.m, k) + return true +} +func getenv(k, d string) string { + if v := os.Getenv(k); v != "" { + return v + } + return d +} +``` + +## 4. 租户 `common` 与单租户 + +- **`common`**:适合「多组织 + 个人账户」类应用;与 Entra 注册时「支持的帐户类型」需一致。 +- **单一组织**:将 `MS_TENANT` 设为**目录租户 ID**,并在应用注册中选择仅本组织,减少误登范围。 + +## 5. 常见错误 + +- **换 token 失败**:`redirect_uri` 与注册应用时不一致;或 Secret 错误。 +- **Graph 403**:未授予 `User.Read` 或 token 中无对应 scope。 + +## 6. 安全建议 + +- 使用返回的 **用户 `id`(GUID)** 作为外部账号主键。 +- `state` 防重放;Secret 仅存服务端。 diff --git a/sproutgate-backend/.dockerignore b/sproutgate-backend/.dockerignore index c792fb2..de56bb0 100644 --- a/sproutgate-backend/.dockerignore +++ b/sproutgate-backend/.dockerignore @@ -5,6 +5,6 @@ data node_modules -dist +# 勿忽略 dist/:默认 Dockerfile 从 dist/sproutgate-backend 复制(先 build-linux-amd64.bat) *.log diff --git a/sproutgate-backend/Dockerfile b/sproutgate-backend/Dockerfile index 4098e0b..e75632b 100644 --- a/sproutgate-backend/Dockerfile +++ b/sproutgate-backend/Dockerfile @@ -1,11 +1,9 @@ -FROM golang:1.20-alpine AS builder - -WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/sproutgate-backend . +# 默认:只复制本机/CI 已构建的 Linux amd64 二进制,不在镜像里执行 go build。 +# 1) 在 sproutgate-backend 目录执行: build-linux-amd64.bat +# 或: set CGO_ENABLED=0&& set GOOS=linux&& set GOARCH=amd64&& go build -trimpath -ldflags="-s -w" -o dist\sproutgate-backend . +# 2) 再: docker compose build +# +# 若必须在 Docker 里从源码编译,请用: docker build -f Dockerfile.build -t ... . FROM alpine:3.19 RUN apk add --no-cache ca-certificates tzdata @@ -14,8 +12,7 @@ WORKDIR /app ENV PORT=8080 ENV DATA_DIR=/data -COPY --from=builder /out/sproutgate-backend /usr/local/bin/sproutgate-backend -COPY API_DOCS.md /app/API_DOCS.md +COPY dist/sproutgate-backend /usr/local/bin/sproutgate-backend EXPOSE 8080 VOLUME ["/data"] diff --git a/sproutgate-backend/build-Linux-amd.bat b/sproutgate-backend/build-Linux-amd.bat new file mode 100644 index 0000000..492af48 --- /dev/null +++ b/sproutgate-backend/build-Linux-amd.bat @@ -0,0 +1,5 @@ +@echo off +setlocal +cd /d "%~dp0" +call "%~dp0build-linux-amd64.bat" +exit /b %errorlevel% diff --git a/sproutgate-backend/build-linux-amd64.bat b/sproutgate-backend/build-linux-amd64.bat new file mode 100644 index 0000000..95c5a91 --- /dev/null +++ b/sproutgate-backend/build-linux-amd64.bat @@ -0,0 +1,58 @@ +@echo off +setlocal + +set "SCRIPT_DIR=%~dp0" +cd /d "%SCRIPT_DIR%" +if errorlevel 1 goto fail_cd + +set "APP_NAME=sproutgate-backend" +set "DIST_DIR=%SCRIPT_DIR%dist" +set "OUTPUT_FILE=%DIST_DIR%\%APP_NAME%" +set "GOCACHE_DIR=%TEMP%\%APP_NAME%-gocache" + +where go >nul 2>nul +if errorlevel 1 goto fail_go + +if not exist "%DIST_DIR%" mkdir "%DIST_DIR%" +if errorlevel 1 goto fail_dist + +if not exist "%GOCACHE_DIR%" mkdir "%GOCACHE_DIR%" + +echo [INFO] Working directory: %CD% +echo [INFO] Building %APP_NAME% for linux amd64... +echo [INFO] Output: %OUTPUT_FILE% + +set "CGO_ENABLED=0" +set "GOOS=linux" +set "GOARCH=amd64" +set "GOCACHE=%GOCACHE_DIR%" + +echo [INFO] Regenerating Swagger docs (swag)... +go run github.com/swaggo/swag/cmd/swag@latest init -g main.go -o docs --parseInternal +if errorlevel 1 goto fail_build + +go build -trimpath -ldflags "-s -w" -o "%OUTPUT_FILE%" . +if errorlevel 1 goto fail_build + +echo [OK] Build completed: %OUTPUT_FILE% +exit /b 0 + +:fail_cd +echo [ERROR] Cannot change to script directory: %SCRIPT_DIR% +goto pause_and_exit + +:fail_go +echo [ERROR] Go was not found in PATH. +goto pause_and_exit + +:fail_dist +echo [ERROR] Cannot create output directory: %DIST_DIR% +goto pause_and_exit + +:fail_build +echo [ERROR] Build failed. Check the Go output above. +goto pause_and_exit + +:pause_and_exit +pause +exit /b 1 diff --git a/sproutgate-backend/docs/docs.go b/sproutgate-backend/docs/docs.go new file mode 100644 index 0000000..95c4404 --- /dev/null +++ b/sproutgate-backend/docs/docs.go @@ -0,0 +1,2190 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/": { + "get": { + "description": "与 GET /api 相同,返回名称、版本与路由前缀说明。", + "produces": [ + "application/json" + ], + "tags": [ + "meta" + ], + "summary": "API 简介 JSON", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api": { + "get": { + "description": "与 GET / 相同。", + "produces": [ + "application/json" + ], + "tags": [ + "meta" + ], + "summary": "API 简介 JSON", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/check-in/config": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "签到奖励配置(管理端也可用)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新签到奖励", + "parameters": [ + { + "description": "rewardCoins", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateCheckInConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/oauth": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "OAuth 配置(脱敏)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新 OAuth 配置", + "parameters": [ + { + "description": "配置", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.PutAdminOAuthRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/registration": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "注册策略与邀请码列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新注册策略", + "parameters": [ + { + "description": "策略", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateRegistrationPolicyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/registration/invites": { + "post": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建邀请码", + "parameters": [ + { + "description": "邀请配置", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateInviteRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/registration/invites/{code}": { + "delete": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除邀请码", + "parameters": [ + { + "type": "string", + "description": "邀请码", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/turnstile": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Turnstile 配置(脱敏)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新 Turnstile 配置", + "parameters": [ + { + "description": "siteKey、secretKey", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.PutAdminTurnstileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/users": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "用户列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建用户", + "parameters": [ + { + "description": "用户", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/users/{account}": { + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新用户", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "account", + "in": "path", + "required": true + }, + { + "description": "可部分更新", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除用户", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "account", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/check-in": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "每日签到", + "responses": { + "200": { + "description": "checkedIn、user、checkIn 等", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/forgot-password": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "忘记密码(发重置邮件)", + "parameters": [ + { + "description": "账号与邮箱", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ForgotPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/login": { + "post": { + "description": "可选 Turnstile:开启时 body 需带 turnstileToken。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "账号密码登录", + "parameters": [ + { + "description": "登录请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "token、expiresAt、user", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "需要 Authorization: Bearer。可选头:X-Visit-Ip、X-Visit-Location、X-Auth-Client、X-Auth-Client-Name。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "当前用户(含签到摘要)", + "parameters": [ + { + "type": "string", + "description": "访客 IP", + "name": "X-Visit-Ip", + "in": "header" + }, + { + "type": "string", + "description": "访客地区展示文案", + "name": "X-Visit-Location", + "in": "header" + }, + { + "type": "string", + "description": "接入应用 ID", + "name": "X-Auth-Client", + "in": "header" + }, + { + "type": "string", + "description": "接入应用名称", + "name": "X-Auth-Client-Name", + "in": "header" + } + ], + "responses": { + "200": { + "description": "user、checkIn", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解绑 OAuth 提供商", + "parameters": [ + { + "type": "string", + "description": "github|gitea|google|microsoft|linuxdo", + "name": "provider", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}/bind": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "已登录用户绑定 OAuth(返回授权 URL)", + "parameters": [ + { + "type": "string", + "description": "github|gitea|google|microsoft|linuxdo", + "name": "provider", + "in": "path", + "required": true + }, + { + "description": "returnTo", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.OAuthBindURLRequest" + } + } + ], + "responses": { + "200": { + "description": "url", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}/callback": { + "get": { + "description": "成功时通常 302 回 return_to 并带 token;失败时 JSON 或重定向错误页。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 回调(IdP 重定向)", + "parameters": [ + { + "type": "string", + "description": "提供商", + "name": "provider", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权码", + "name": "code", + "in": "query" + }, + { + "type": "string", + "description": "state", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "IdP 错误码", + "name": "error", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "302": { + "description": "重定向至客户端" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}/start": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 登录起点(重定向 IdP)", + "parameters": [ + { + "type": "string", + "description": "github|gitea|google|microsoft|linuxdo", + "name": "provider", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "登录完成回跳 URL(须符合后台允许前缀)", + "name": "return_to", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Turnstile 开启时必填", + "name": "turnstile_token", + "in": "query" + } + ], + "responses": { + "302": { + "description": "重定向至 IdP" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/profile": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "更新个人资料(可选改密码)", + "parameters": [ + { + "description": "资料字段", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "自助注册(发验证邮件)", + "parameters": [ + { + "description": "注册请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterRequest" + } + } + ], + "responses": { + "200": { + "description": "sent、expiresAt", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/reset-password": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "重置密码", + "parameters": [ + { + "description": "账号、验证码、新密码", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/secondary-email/request": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "请求验证辅助邮箱(发邮件)", + "parameters": [ + { + "description": "邮箱", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SecondaryEmailRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/secondary-email/verify": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "验证并绑定辅助邮箱", + "parameters": [ + { + "description": "邮箱与验证码", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.VerifySecondaryEmailRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/verify": { + "post": { + "description": "校验 token 是否有效;可在请求头带 X-Auth-Client / X-Auth-Client-Name 记录接入应用。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "校验 JWT", + "parameters": [ + { + "description": "token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.VerifyRequest" + } + } + ], + "responses": { + "200": { + "description": "valid、user", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/verify-email": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "邮箱验证并完成注册", + "parameters": [ + { + "description": "账号与验证码", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.VerifyEmailRequest" + } + } + ], + "responses": { + "201": { + "description": "created、user", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "meta" + ], + "summary": "健康检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/registration-policy": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "公开注册策略与 OAuth/Turnstile 开关", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "公开用户目录", + "responses": { + "200": { + "description": "total、users", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/users/{account}": { + "get": { + "description": "可选 Authorization:登录用户可看到点赞状态等扩展字段。", + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "公开用户主页", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "account", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Bearer(可选)", + "name": "Authorization", + "in": "header" + } + ], + "responses": { + "200": { + "description": "user、profileLikeCount 等", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/users/{account}/like": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "主页点赞", + "parameters": [ + { + "type": "string", + "description": "被点赞用户账号", + "name": "account", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "definitions": { + "handlers.CreateInviteRequest": { + "type": "object", + "properties": { + "expiresAt": { + "type": "string" + }, + "maxUses": { + "type": "integer" + }, + "note": { + "type": "string" + } + } + }, + "handlers.CreateUserRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "secondaryEmails": { + "type": "array", + "items": { + "type": "string" + } + }, + "sproutCoins": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "websiteUrl": { + "type": "string" + } + } + }, + "handlers.ForgotPasswordRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "handlers.LoginRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientName": { + "type": "string" + }, + "password": { + "type": "string" + }, + "turnstileToken": { + "type": "string" + } + } + }, + "handlers.OAuthBindURLRequest": { + "type": "object", + "properties": { + "returnTo": { + "type": "string" + } + } + }, + "handlers.PutAdminOAuthRequest": { + "type": "object", + "properties": { + "allowOAuthSignUpWhenInviteRequired": { + "type": "boolean" + }, + "allowedReturnPrefixes": { + "type": "array", + "items": { + "type": "string" + } + }, + "giteaBaseUrl": { + "type": "string" + }, + "giteaClientId": { + "type": "string" + }, + "giteaClientSecret": { + "type": "string" + }, + "giteaEnabled": { + "type": "boolean" + }, + "giteaLogoUrl": { + "type": "string" + }, + "githubClientId": { + "type": "string" + }, + "githubClientSecret": { + "type": "string" + }, + "githubEnabled": { + "type": "boolean" + }, + "githubLogoUrl": { + "type": "string" + }, + "googleClientId": { + "type": "string" + }, + "googleClientSecret": { + "type": "string" + }, + "googleEnabled": { + "type": "boolean" + }, + "googleLogoUrl": { + "type": "string" + }, + "linuxdoClientId": { + "type": "string" + }, + "linuxdoClientSecret": { + "type": "string" + }, + "linuxdoConnectBaseUrl": { + "type": "string" + }, + "linuxdoEnabled": { + "type": "boolean" + }, + "linuxdoLogoUrl": { + "type": "string" + }, + "microsoftClientId": { + "type": "string" + }, + "microsoftClientSecret": { + "type": "string" + }, + "microsoftEnabled": { + "type": "boolean" + }, + "microsoftLogoUrl": { + "type": "string" + }, + "microsoftTenant": { + "type": "string" + } + } + }, + "handlers.PutAdminTurnstileRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "secretKey": { + "type": "string" + }, + "siteKey": { + "type": "string" + } + } + }, + "handlers.RegisterRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "email": { + "type": "string" + }, + "inviteCode": { + "type": "string" + }, + "password": { + "type": "string" + }, + "turnstileToken": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.ResetPasswordRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "code": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "handlers.SecondaryEmailRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "handlers.UpdateCheckInConfigRequest": { + "type": "object", + "properties": { + "rewardCoins": { + "type": "integer" + } + } + }, + "handlers.UpdateProfileRequest": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "username": { + "type": "string" + }, + "websiteUrl": { + "type": "string" + } + } + }, + "handlers.UpdateRegistrationPolicyRequest": { + "type": "object", + "properties": { + "forbiddenAccounts": { + "type": "string" + }, + "inviteRegisterRewardCoins": { + "description": "nil 表示不修改此项", + "type": "integer" + }, + "requireInviteCode": { + "type": "boolean" + } + } + }, + "handlers.UpdateUserRequest": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "banReason": { + "type": "string" + }, + "banned": { + "type": "boolean" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "secondaryEmails": { + "type": "array", + "items": { + "type": "string" + } + }, + "sproutCoins": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "websiteUrl": { + "type": "string" + } + } + }, + "handlers.VerifyEmailRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "handlers.VerifyRequest": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "handlers.VerifySecondaryEmailRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "AdminToken": { + "description": "管理端令牌;也可使用 Query ` + "`" + `token` + "`" + ` 或与部分客户端相同的 ` + "`" + `Authorization` + "`" + ` 头(见实现)。", + "type": "apiKey", + "name": "X-Admin-Token", + "in": "header" + }, + "BearerAuth": { + "description": "JWT:Authorization 头填写「Bearer 」+ token(示例:Bearer eyJ...)。", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "0.1.0", + Host: "localhost:8080", + BasePath: "/", + Schemes: []string{"http", "https"}, + Title: "萌芽账户认证中心 API", + Description: "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/sproutgate-backend/docs/swagger.json b/sproutgate-backend/docs/swagger.json new file mode 100644 index 0000000..9d8b2bc --- /dev/null +++ b/sproutgate-backend/docs/swagger.json @@ -0,0 +1,2170 @@ +{ + "schemes": [ + "http", + "https" + ], + "swagger": "2.0", + "info": { + "description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。", + "title": "萌芽账户认证中心 API", + "contact": {}, + "version": "0.1.0" + }, + "host": "localhost:8080", + "basePath": "/", + "paths": { + "/": { + "get": { + "description": "与 GET /api 相同,返回名称、版本与路由前缀说明。", + "produces": [ + "application/json" + ], + "tags": [ + "meta" + ], + "summary": "API 简介 JSON", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api": { + "get": { + "description": "与 GET / 相同。", + "produces": [ + "application/json" + ], + "tags": [ + "meta" + ], + "summary": "API 简介 JSON", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/check-in/config": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "签到奖励配置(管理端也可用)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新签到奖励", + "parameters": [ + { + "description": "rewardCoins", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateCheckInConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/oauth": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "OAuth 配置(脱敏)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新 OAuth 配置", + "parameters": [ + { + "description": "配置", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.PutAdminOAuthRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/registration": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "注册策略与邀请码列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新注册策略", + "parameters": [ + { + "description": "策略", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateRegistrationPolicyRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/registration/invites": { + "post": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建邀请码", + "parameters": [ + { + "description": "邀请配置", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateInviteRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/registration/invites/{code}": { + "delete": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除邀请码", + "parameters": [ + { + "type": "string", + "description": "邀请码", + "name": "code", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/turnstile": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Turnstile 配置(脱敏)", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新 Turnstile 配置", + "parameters": [ + { + "description": "siteKey、secretKey", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.PutAdminTurnstileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/users": { + "get": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "用户列表", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "post": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "创建用户", + "parameters": [ + { + "description": "用户", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.CreateUserRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/admin/users/{account}": { + "put": { + "security": [ + { + "AdminToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "更新用户", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "account", + "in": "path", + "required": true + }, + { + "description": "可部分更新", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateUserRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + }, + "delete": { + "security": [ + { + "AdminToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "删除用户", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "account", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/check-in": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "每日签到", + "responses": { + "200": { + "description": "checkedIn、user、checkIn 等", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/forgot-password": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "忘记密码(发重置邮件)", + "parameters": [ + { + "description": "账号与邮箱", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ForgotPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/login": { + "post": { + "description": "可选 Turnstile:开启时 body 需带 turnstileToken。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "账号密码登录", + "parameters": [ + { + "description": "登录请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "token、expiresAt、user", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "需要 Authorization: Bearer。可选头:X-Visit-Ip、X-Visit-Location、X-Auth-Client、X-Auth-Client-Name。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "当前用户(含签到摘要)", + "parameters": [ + { + "type": "string", + "description": "访客 IP", + "name": "X-Visit-Ip", + "in": "header" + }, + { + "type": "string", + "description": "访客地区展示文案", + "name": "X-Visit-Location", + "in": "header" + }, + { + "type": "string", + "description": "接入应用 ID", + "name": "X-Auth-Client", + "in": "header" + }, + { + "type": "string", + "description": "接入应用名称", + "name": "X-Auth-Client-Name", + "in": "header" + } + ], + "responses": { + "200": { + "description": "user、checkIn", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "解绑 OAuth 提供商", + "parameters": [ + { + "type": "string", + "description": "github|gitea|google|microsoft|linuxdo", + "name": "provider", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}/bind": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "已登录用户绑定 OAuth(返回授权 URL)", + "parameters": [ + { + "type": "string", + "description": "github|gitea|google|microsoft|linuxdo", + "name": "provider", + "in": "path", + "required": true + }, + { + "description": "returnTo", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.OAuthBindURLRequest" + } + } + ], + "responses": { + "200": { + "description": "url", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}/callback": { + "get": { + "description": "成功时通常 302 回 return_to 并带 token;失败时 JSON 或重定向错误页。", + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 回调(IdP 重定向)", + "parameters": [ + { + "type": "string", + "description": "提供商", + "name": "provider", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "授权码", + "name": "code", + "in": "query" + }, + { + "type": "string", + "description": "state", + "name": "state", + "in": "query" + }, + { + "type": "string", + "description": "IdP 错误码", + "name": "error", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "302": { + "description": "重定向至客户端" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/oauth/{provider}/start": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "oauth" + ], + "summary": "OAuth 登录起点(重定向 IdP)", + "parameters": [ + { + "type": "string", + "description": "github|gitea|google|microsoft|linuxdo", + "name": "provider", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "登录完成回跳 URL(须符合后台允许前缀)", + "name": "return_to", + "in": "query", + "required": true + }, + { + "type": "string", + "description": "Turnstile 开启时必填", + "name": "turnstile_token", + "in": "query" + } + ], + "responses": { + "302": { + "description": "重定向至 IdP" + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/profile": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "更新个人资料(可选改密码)", + "parameters": [ + { + "description": "资料字段", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/register": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "自助注册(发验证邮件)", + "parameters": [ + { + "description": "注册请求", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.RegisterRequest" + } + } + ], + "responses": { + "200": { + "description": "sent、expiresAt", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/reset-password": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "重置密码", + "parameters": [ + { + "description": "账号、验证码、新密码", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.ResetPasswordRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/secondary-email/request": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "请求验证辅助邮箱(发邮件)", + "parameters": [ + { + "description": "邮箱", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.SecondaryEmailRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/secondary-email/verify": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "验证并绑定辅助邮箱", + "parameters": [ + { + "description": "邮箱与验证码", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.VerifySecondaryEmailRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/verify": { + "post": { + "description": "校验 token 是否有效;可在请求头带 X-Auth-Client / X-Auth-Client-Name 记录接入应用。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "校验 JWT", + "parameters": [ + { + "description": "token", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.VerifyRequest" + } + } + ], + "responses": { + "200": { + "description": "valid、user", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/auth/verify-email": { + "post": { + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "邮箱验证并完成注册", + "parameters": [ + { + "description": "账号与验证码", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/handlers.VerifyEmailRequest" + } + } + ], + "responses": { + "201": { + "description": "created、user", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/health": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "meta" + ], + "summary": "健康检查", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/registration-policy": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "公开注册策略与 OAuth/Turnstile 开关", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/users": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "公开用户目录", + "responses": { + "200": { + "description": "total、users", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/users/{account}": { + "get": { + "description": "可选 Authorization:登录用户可看到点赞状态等扩展字段。", + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "公开用户主页", + "parameters": [ + { + "type": "string", + "description": "账号", + "name": "account", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Bearer(可选)", + "name": "Authorization", + "in": "header" + } + ], + "responses": { + "200": { + "description": "user、profileLikeCount 等", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + }, + "/api/public/users/{account}/like": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "主页点赞", + "parameters": [ + { + "type": "string", + "description": "被点赞用户账号", + "name": "account", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": true + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": true + } + } + } + } + } + }, + "definitions": { + "handlers.CreateInviteRequest": { + "type": "object", + "properties": { + "expiresAt": { + "type": "string" + }, + "maxUses": { + "type": "integer" + }, + "note": { + "type": "string" + } + } + }, + "handlers.CreateUserRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "avatarUrl": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "secondaryEmails": { + "type": "array", + "items": { + "type": "string" + } + }, + "sproutCoins": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "websiteUrl": { + "type": "string" + } + } + }, + "handlers.ForgotPasswordRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "email": { + "type": "string" + } + } + }, + "handlers.LoginRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientName": { + "type": "string" + }, + "password": { + "type": "string" + }, + "turnstileToken": { + "type": "string" + } + } + }, + "handlers.OAuthBindURLRequest": { + "type": "object", + "properties": { + "returnTo": { + "type": "string" + } + } + }, + "handlers.PutAdminOAuthRequest": { + "type": "object", + "properties": { + "allowOAuthSignUpWhenInviteRequired": { + "type": "boolean" + }, + "allowedReturnPrefixes": { + "type": "array", + "items": { + "type": "string" + } + }, + "giteaBaseUrl": { + "type": "string" + }, + "giteaClientId": { + "type": "string" + }, + "giteaClientSecret": { + "type": "string" + }, + "giteaEnabled": { + "type": "boolean" + }, + "giteaLogoUrl": { + "type": "string" + }, + "githubClientId": { + "type": "string" + }, + "githubClientSecret": { + "type": "string" + }, + "githubEnabled": { + "type": "boolean" + }, + "githubLogoUrl": { + "type": "string" + }, + "googleClientId": { + "type": "string" + }, + "googleClientSecret": { + "type": "string" + }, + "googleEnabled": { + "type": "boolean" + }, + "googleLogoUrl": { + "type": "string" + }, + "linuxdoClientId": { + "type": "string" + }, + "linuxdoClientSecret": { + "type": "string" + }, + "linuxdoConnectBaseUrl": { + "type": "string" + }, + "linuxdoEnabled": { + "type": "boolean" + }, + "linuxdoLogoUrl": { + "type": "string" + }, + "microsoftClientId": { + "type": "string" + }, + "microsoftClientSecret": { + "type": "string" + }, + "microsoftEnabled": { + "type": "boolean" + }, + "microsoftLogoUrl": { + "type": "string" + }, + "microsoftTenant": { + "type": "string" + } + } + }, + "handlers.PutAdminTurnstileRequest": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "secretKey": { + "type": "string" + }, + "siteKey": { + "type": "string" + } + } + }, + "handlers.RegisterRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "email": { + "type": "string" + }, + "inviteCode": { + "type": "string" + }, + "password": { + "type": "string" + }, + "turnstileToken": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "handlers.ResetPasswordRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "code": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + } + }, + "handlers.SecondaryEmailRequest": { + "type": "object", + "properties": { + "email": { + "type": "string" + } + } + }, + "handlers.UpdateCheckInConfigRequest": { + "type": "object", + "properties": { + "rewardCoins": { + "type": "integer" + } + } + }, + "handlers.UpdateProfileRequest": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "username": { + "type": "string" + }, + "websiteUrl": { + "type": "string" + } + } + }, + "handlers.UpdateRegistrationPolicyRequest": { + "type": "object", + "properties": { + "forbiddenAccounts": { + "type": "string" + }, + "inviteRegisterRewardCoins": { + "description": "nil 表示不修改此项", + "type": "integer" + }, + "requireInviteCode": { + "type": "boolean" + } + } + }, + "handlers.UpdateUserRequest": { + "type": "object", + "properties": { + "avatarUrl": { + "type": "string" + }, + "banReason": { + "type": "string" + }, + "banned": { + "type": "boolean" + }, + "bio": { + "type": "string" + }, + "email": { + "type": "string" + }, + "level": { + "type": "integer" + }, + "password": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "secondaryEmails": { + "type": "array", + "items": { + "type": "string" + } + }, + "sproutCoins": { + "type": "integer" + }, + "username": { + "type": "string" + }, + "websiteUrl": { + "type": "string" + } + } + }, + "handlers.VerifyEmailRequest": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "code": { + "type": "string" + } + } + }, + "handlers.VerifyRequest": { + "type": "object", + "properties": { + "token": { + "type": "string" + } + } + }, + "handlers.VerifySecondaryEmailRequest": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "email": { + "type": "string" + } + } + } + }, + "securityDefinitions": { + "AdminToken": { + "description": "管理端令牌;也可使用 Query `token` 或与部分客户端相同的 `Authorization` 头(见实现)。", + "type": "apiKey", + "name": "X-Admin-Token", + "in": "header" + }, + "BearerAuth": { + "description": "JWT:Authorization 头填写「Bearer 」+ token(示例:Bearer eyJ...)。", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + } +} \ No newline at end of file diff --git a/sproutgate-backend/docs/swagger.yaml b/sproutgate-backend/docs/swagger.yaml new file mode 100644 index 0000000..28c8e02 --- /dev/null +++ b/sproutgate-backend/docs/swagger.yaml @@ -0,0 +1,1428 @@ +basePath: / +definitions: + handlers.CreateInviteRequest: + properties: + expiresAt: + type: string + maxUses: + type: integer + note: + type: string + type: object + handlers.CreateUserRequest: + properties: + account: + type: string + avatarUrl: + type: string + bio: + type: string + email: + type: string + level: + type: integer + password: + type: string + phone: + type: string + secondaryEmails: + items: + type: string + type: array + sproutCoins: + type: integer + username: + type: string + websiteUrl: + type: string + type: object + handlers.ForgotPasswordRequest: + properties: + account: + type: string + email: + type: string + type: object + handlers.LoginRequest: + properties: + account: + type: string + clientId: + type: string + clientName: + type: string + password: + type: string + turnstileToken: + type: string + type: object + handlers.OAuthBindURLRequest: + properties: + returnTo: + type: string + type: object + handlers.PutAdminOAuthRequest: + properties: + allowOAuthSignUpWhenInviteRequired: + type: boolean + allowedReturnPrefixes: + items: + type: string + type: array + giteaBaseUrl: + type: string + giteaClientId: + type: string + giteaClientSecret: + type: string + giteaEnabled: + type: boolean + giteaLogoUrl: + type: string + githubClientId: + type: string + githubClientSecret: + type: string + githubEnabled: + type: boolean + githubLogoUrl: + type: string + googleClientId: + type: string + googleClientSecret: + type: string + googleEnabled: + type: boolean + googleLogoUrl: + type: string + linuxdoClientId: + type: string + linuxdoClientSecret: + type: string + linuxdoConnectBaseUrl: + type: string + linuxdoEnabled: + type: boolean + linuxdoLogoUrl: + type: string + microsoftClientId: + type: string + microsoftClientSecret: + type: string + microsoftEnabled: + type: boolean + microsoftLogoUrl: + type: string + microsoftTenant: + type: string + type: object + handlers.PutAdminTurnstileRequest: + properties: + enabled: + type: boolean + secretKey: + type: string + siteKey: + type: string + type: object + handlers.RegisterRequest: + properties: + account: + type: string + email: + type: string + inviteCode: + type: string + password: + type: string + turnstileToken: + type: string + username: + type: string + type: object + handlers.ResetPasswordRequest: + properties: + account: + type: string + code: + type: string + newPassword: + type: string + type: object + handlers.SecondaryEmailRequest: + properties: + email: + type: string + type: object + handlers.UpdateCheckInConfigRequest: + properties: + rewardCoins: + type: integer + type: object + handlers.UpdateProfileRequest: + properties: + avatarUrl: + type: string + bio: + type: string + password: + type: string + phone: + type: string + username: + type: string + websiteUrl: + type: string + type: object + handlers.UpdateRegistrationPolicyRequest: + properties: + forbiddenAccounts: + type: string + inviteRegisterRewardCoins: + description: nil 表示不修改此项 + type: integer + requireInviteCode: + type: boolean + type: object + handlers.UpdateUserRequest: + properties: + avatarUrl: + type: string + banReason: + type: string + banned: + type: boolean + bio: + type: string + email: + type: string + level: + type: integer + password: + type: string + phone: + type: string + secondaryEmails: + items: + type: string + type: array + sproutCoins: + type: integer + username: + type: string + websiteUrl: + type: string + type: object + handlers.VerifyEmailRequest: + properties: + account: + type: string + code: + type: string + type: object + handlers.VerifyRequest: + properties: + token: + type: string + type: object + handlers.VerifySecondaryEmailRequest: + properties: + code: + type: string + email: + type: string + type: object +host: localhost:8080 +info: + contact: {} + description: 统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。 + title: 萌芽账户认证中心 API + version: 0.1.0 +paths: + /: + get: + description: 与 GET /api 相同,返回名称、版本与路由前缀说明。 + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: API 简介 JSON + tags: + - meta + /api: + get: + description: 与 GET / 相同。 + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: API 简介 JSON + tags: + - meta + /api/admin/check-in/config: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 签到奖励配置(管理端也可用) + tags: + - admin + put: + consumes: + - application/json + parameters: + - description: rewardCoins + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.UpdateCheckInConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 更新签到奖励 + tags: + - admin + /api/admin/oauth: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: OAuth 配置(脱敏) + tags: + - admin + put: + consumes: + - application/json + parameters: + - description: 配置 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.PutAdminOAuthRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 更新 OAuth 配置 + tags: + - admin + /api/admin/registration: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 注册策略与邀请码列表 + tags: + - admin + put: + consumes: + - application/json + parameters: + - description: 策略 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.UpdateRegistrationPolicyRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 更新注册策略 + tags: + - admin + /api/admin/registration/invites: + post: + consumes: + - application/json + parameters: + - description: 邀请配置 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.CreateInviteRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 创建邀请码 + tags: + - admin + /api/admin/registration/invites/{code}: + delete: + parameters: + - description: 邀请码 + in: path + name: code + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 删除邀请码 + tags: + - admin + /api/admin/turnstile: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: Turnstile 配置(脱敏) + tags: + - admin + put: + consumes: + - application/json + parameters: + - description: siteKey、secretKey + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.PutAdminTurnstileRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 更新 Turnstile 配置 + tags: + - admin + /api/admin/users: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 用户列表 + tags: + - admin + post: + consumes: + - application/json + parameters: + - description: 用户 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.CreateUserRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 创建用户 + tags: + - admin + /api/admin/users/{account}: + delete: + parameters: + - description: 账号 + in: path + name: account + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 删除用户 + tags: + - admin + put: + consumes: + - application/json + parameters: + - description: 账号 + in: path + name: account + required: true + type: string + - description: 可部分更新 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.UpdateUserRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - AdminToken: [] + summary: 更新用户 + tags: + - admin + /api/auth/check-in: + post: + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: checkedIn、user、checkIn 等 + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 每日签到 + tags: + - auth + /api/auth/forgot-password: + post: + consumes: + - application/json + parameters: + - description: 账号与邮箱 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ForgotPasswordRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 忘记密码(发重置邮件) + tags: + - auth + /api/auth/login: + post: + consumes: + - application/json + description: 可选 Turnstile:开启时 body 需带 turnstileToken。 + parameters: + - description: 登录请求 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.LoginRequest' + produces: + - application/json + responses: + "200": + description: token、expiresAt、user + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 账号密码登录 + tags: + - auth + /api/auth/me: + get: + consumes: + - application/json + description: '需要 Authorization: Bearer。可选头:X-Visit-Ip、X-Visit-Location、X-Auth-Client、X-Auth-Client-Name。' + parameters: + - description: 访客 IP + in: header + name: X-Visit-Ip + type: string + - description: 访客地区展示文案 + in: header + name: X-Visit-Location + type: string + - description: 接入应用 ID + in: header + name: X-Auth-Client + type: string + - description: 接入应用名称 + in: header + name: X-Auth-Client-Name + type: string + produces: + - application/json + responses: + "200": + description: user、checkIn + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 当前用户(含签到摘要) + tags: + - auth + /api/auth/oauth/{provider}: + delete: + consumes: + - application/json + parameters: + - description: github|gitea|google|microsoft|linuxdo + in: path + name: provider + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 解绑 OAuth 提供商 + tags: + - oauth + /api/auth/oauth/{provider}/bind: + post: + consumes: + - application/json + parameters: + - description: github|gitea|google|microsoft|linuxdo + in: path + name: provider + required: true + type: string + - description: returnTo + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.OAuthBindURLRequest' + produces: + - application/json + responses: + "200": + description: url + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 已登录用户绑定 OAuth(返回授权 URL) + tags: + - oauth + /api/auth/oauth/{provider}/callback: + get: + description: 成功时通常 302 回 return_to 并带 token;失败时 JSON 或重定向错误页。 + parameters: + - description: 提供商 + in: path + name: provider + required: true + type: string + - description: 授权码 + in: query + name: code + type: string + - description: state + in: query + name: state + type: string + - description: IdP 错误码 + in: query + name: error + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "302": + description: 重定向至客户端 + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + summary: OAuth 回调(IdP 重定向) + tags: + - oauth + /api/auth/oauth/{provider}/start: + get: + parameters: + - description: github|gitea|google|microsoft|linuxdo + in: path + name: provider + required: true + type: string + - description: 登录完成回跳 URL(须符合后台允许前缀) + in: query + name: return_to + required: true + type: string + - description: Turnstile 开启时必填 + in: query + name: turnstile_token + type: string + produces: + - application/json + responses: + "302": + description: 重定向至 IdP + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: true + type: object + summary: OAuth 登录起点(重定向 IdP) + tags: + - oauth + /api/auth/profile: + put: + consumes: + - application/json + parameters: + - description: 资料字段 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.UpdateProfileRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 更新个人资料(可选改密码) + tags: + - auth + /api/auth/register: + post: + consumes: + - application/json + parameters: + - description: 注册请求 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.RegisterRequest' + produces: + - application/json + responses: + "200": + description: sent、expiresAt + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 自助注册(发验证邮件) + tags: + - auth + /api/auth/reset-password: + post: + consumes: + - application/json + parameters: + - description: 账号、验证码、新密码 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.ResetPasswordRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 重置密码 + tags: + - auth + /api/auth/secondary-email/request: + post: + consumes: + - application/json + parameters: + - description: 邮箱 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.SecondaryEmailRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 请求验证辅助邮箱(发邮件) + tags: + - auth + /api/auth/secondary-email/verify: + post: + consumes: + - application/json + parameters: + - description: 邮箱与验证码 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.VerifySecondaryEmailRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 验证并绑定辅助邮箱 + tags: + - auth + /api/auth/verify: + post: + consumes: + - application/json + description: 校验 token 是否有效;可在请求头带 X-Auth-Client / X-Auth-Client-Name 记录接入应用。 + parameters: + - description: token + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.VerifyRequest' + produces: + - application/json + responses: + "200": + description: valid、user + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 校验 JWT + tags: + - auth + /api/auth/verify-email: + post: + consumes: + - application/json + parameters: + - description: 账号与验证码 + in: body + name: body + required: true + schema: + $ref: '#/definitions/handlers.VerifyEmailRequest' + produces: + - application/json + responses: + "201": + description: created、user + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 邮箱验证并完成注册 + tags: + - auth + /api/health: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: 健康检查 + tags: + - meta + /api/public/registration-policy: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + summary: 公开注册策略与 OAuth/Turnstile 开关 + tags: + - public + /api/public/users: + get: + produces: + - application/json + responses: + "200": + description: total、users + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 公开用户目录 + tags: + - public + /api/public/users/{account}: + get: + description: 可选 Authorization:登录用户可看到点赞状态等扩展字段。 + parameters: + - description: 账号 + in: path + name: account + required: true + type: string + - description: Bearer(可选) + in: header + name: Authorization + type: string + produces: + - application/json + responses: + "200": + description: user、profileLikeCount 等 + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + summary: 公开用户主页 + tags: + - public + /api/public/users/{account}/like: + post: + consumes: + - application/json + parameters: + - description: 被点赞用户账号 + in: path + name: account + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: true + type: object + "400": + description: Bad Request + schema: + additionalProperties: true + type: object + "401": + description: Unauthorized + schema: + additionalProperties: true + type: object + "404": + description: Not Found + schema: + additionalProperties: true + type: object + "500": + description: Internal Server Error + schema: + additionalProperties: true + type: object + security: + - BearerAuth: [] + summary: 主页点赞 + tags: + - public +schemes: +- http +- https +securityDefinitions: + AdminToken: + description: 管理端令牌;也可使用 Query `token` 或与部分客户端相同的 `Authorization` 头(见实现)。 + in: header + name: X-Admin-Token + type: apiKey + BearerAuth: + description: JWT:Authorization 头填写「Bearer 」+ token(示例:Bearer eyJ...)。 + in: header + name: Authorization + type: apiKey +swagger: "2.0" diff --git a/sproutgate-backend/go.mod b/sproutgate-backend/go.mod index 3fd70bb..a39d259 100644 --- a/sproutgate-backend/go.mod +++ b/sproutgate-backend/go.mod @@ -1,22 +1,35 @@ module sproutgate-backend -go 1.20 +go 1.25.0 require ( github.com/gin-contrib/cors v1.5.0 github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.2.1 - golang.org/x/crypto v0.23.0 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + golang.org/x/crypto v0.36.0 + golang.org/x/oauth2 v0.36.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.31.1 ) require ( + cloud.google.com/go/compute/metadata v0.3.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/PuerkitoBio/purell v1.1.1 // indirect + github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-openapi/jsonpointer v0.19.5 // indirect + github.com/go-openapi/jsonreference v0.19.6 // indirect + github.com/go-openapi/spec v0.20.4 // indirect + github.com/go-openapi/swag v0.19.15 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect @@ -24,22 +37,25 @@ require ( github.com/goccy/go-json v0.10.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect - github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/swaggo/swag v1.16.6 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.20.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.38.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/text v0.23.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - gorm.io/driver/mysql v1.6.0 // indirect - gorm.io/gorm v1.31.1 // indirect ) diff --git a/sproutgate-backend/go.sum b/sproutgate-backend/go.sum index 8e16aea..9073e30 100644 --- a/sproutgate-backend/go.sum +++ b/sproutgate-backend/go.sum @@ -1,5 +1,13 @@ +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= +cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -16,11 +24,24 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/cors v1.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk= github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI= +github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4= +github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -33,23 +54,34 @@ github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -57,16 +89,19 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -74,31 +109,78 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= +golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= +golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= diff --git a/sproutgate-backend/internal/handlers/admin.go b/sproutgate-backend/internal/handlers/admin.go index 2b39ea5..3f55e4b 100644 --- a/sproutgate-backend/internal/handlers/admin.go +++ b/sproutgate-backend/internal/handlers/admin.go @@ -10,6 +10,15 @@ import ( "sproutgate-backend/internal/models" ) +// ListUsers +// @Summary 用户列表 +// @Tags admin +// @Produce json +// @Security AdminToken +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/users [get] func (h *Handler) ListUsers(c *gin.Context) { users, err := h.store.ListUsers() if err != nil { @@ -23,8 +32,20 @@ func (h *Handler) ListUsers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"total": len(publicUsers), "users": publicUsers}) } +// CreateUser +// @Summary 创建用户 +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminToken +// @Param body body CreateUserRequest true "用户" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/users [post] func (h *Handler) CreateUser(c *gin.Context) { - var req createUserRequest + var req CreateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return @@ -66,13 +87,27 @@ func (h *Handler) CreateUser(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"user": record.OwnerPublic()}) } +// UpdateUser +// @Summary 更新用户 +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminToken +// @Param account path string true "账号" +// @Param body body UpdateUserRequest true "可部分更新" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/users/{account} [put] func (h *Handler) UpdateUser(c *gin.Context) { account := strings.TrimSpace(c.Param("account")) if account == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account is required"}) return } - var req updateUserRequest + var req UpdateUserRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return @@ -158,6 +193,17 @@ func (h *Handler) UpdateUser(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()}) } +// DeleteUser +// @Summary 删除用户 +// @Tags admin +// @Produce json +// @Security AdminToken +// @Param account path string true "账号" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/users/{account} [delete] func (h *Handler) DeleteUser(c *gin.Context) { account := strings.TrimSpace(c.Param("account")) if account == "" { diff --git a/sproutgate-backend/internal/handlers/auth_login.go b/sproutgate-backend/internal/handlers/auth_login.go index 0f259d6..57421a4 100644 --- a/sproutgate-backend/internal/handlers/auth_login.go +++ b/sproutgate-backend/internal/handlers/auth_login.go @@ -15,17 +15,36 @@ import ( "sproutgate-backend/internal/models" ) +// Login +// @Summary 账号密码登录 +// @Description 可选 Turnstile:开启时 body 需带 turnstileToken。 +// @Tags auth +// @Accept json +// @Produce json +// @Param body body LoginRequest true "登录请求" +// @Success 200 {object} map[string]interface{} "token、expiresAt、user" +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/login [post] func (h *Handler) Login(c *gin.Context) { - var req loginRequest + var req LoginRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } + h.store.MaybeSyncRuntimeConfigFromDB() req.Account = strings.TrimSpace(req.Account) if req.Account == "" || req.Password == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "account and password are required"}) return } + if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled { + if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, req.TurnstileToken, c.ClientIP()); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } user, found, err := h.store.GetUser(req.Account) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) @@ -61,8 +80,20 @@ func (h *Handler) Login(c *gin.Context) { }) } +// Verify +// @Summary 校验 JWT +// @Description 校验 token 是否有效;可在请求头带 X-Auth-Client / X-Auth-Client-Name 记录接入应用。 +// @Tags auth +// @Accept json +// @Produce json +// @Param body body VerifyRequest true "token" +// @Success 200 {object} map[string]interface{} "valid、user" +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/verify [post] func (h *Handler) Verify(c *gin.Context) { - var req verifyRequest + var req VerifyRequest if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Token) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) return @@ -94,6 +125,21 @@ func (h *Handler) Verify(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"valid": true, "user": user.Public()}) } +// Me +// @Summary 当前用户(含签到摘要) +// @Description 需要 Authorization: Bearer。可选头:X-Visit-Ip、X-Visit-Location、X-Auth-Client、X-Auth-Client-Name。 +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param X-Visit-Ip header string false "访客 IP" +// @Param X-Visit-Location header string false "访客地区展示文案" +// @Param X-Auth-Client header string false "接入应用 ID" +// @Param X-Auth-Client-Name header string false "接入应用名称" +// @Success 200 {object} map[string]interface{} "user、checkIn" +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/me [get] func (h *Handler) Me(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { diff --git a/sproutgate-backend/internal/handlers/auth_password.go b/sproutgate-backend/internal/handlers/auth_password.go index bc84be3..bbe4434 100644 --- a/sproutgate-backend/internal/handlers/auth_password.go +++ b/sproutgate-backend/internal/handlers/auth_password.go @@ -14,12 +14,23 @@ import ( "sproutgate-backend/internal/storage" ) +// Register +// @Summary 自助注册(发验证邮件) +// @Tags auth +// @Accept json +// @Produce json +// @Param body body RegisterRequest true "注册请求" +// @Success 200 {object} map[string]interface{} "sent、expiresAt" +// @Failure 400 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/register [post] func (h *Handler) Register(c *gin.Context) { - var req registerRequest + var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return } + h.store.MaybeSyncRuntimeConfigFromDB() req.Account = storage.NormalizeSelfServiceAccount(req.Account) req.Email = strings.TrimSpace(req.Email) inviteTrim := strings.TrimSpace(req.InviteCode) @@ -31,6 +42,12 @@ func (h *Handler) Register(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled { + if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, req.TurnstileToken, c.ClientIP()); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } requireInv := h.store.RegistrationRequireInvite() if requireInv && inviteTrim == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "invite code is required"}) @@ -88,8 +105,18 @@ func (h *Handler) Register(c *gin.Context) { }) } +// VerifyEmail +// @Summary 邮箱验证并完成注册 +// @Tags auth +// @Accept json +// @Produce json +// @Param body body VerifyEmailRequest true "账号与验证码" +// @Success 201 {object} map[string]interface{} "created、user" +// @Failure 400 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/verify-email [post] func (h *Handler) VerifyEmail(c *gin.Context) { - var req verifyEmailRequest + var req VerifyEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return @@ -153,8 +180,18 @@ func (h *Handler) VerifyEmail(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"created": true, "user": record.OwnerPublic()}) } +// ForgotPassword +// @Summary 忘记密码(发重置邮件) +// @Tags auth +// @Accept json +// @Produce json +// @Param body body ForgotPasswordRequest true "账号与邮箱" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/forgot-password [post] func (h *Handler) ForgotPassword(c *gin.Context) { - var req forgotPasswordRequest + var req ForgotPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return @@ -206,8 +243,18 @@ func (h *Handler) ForgotPassword(c *gin.Context) { }) } +// ResetPassword +// @Summary 重置密码 +// @Tags auth +// @Accept json +// @Produce json +// @Param body body ResetPasswordRequest true "账号、验证码、新密码" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/reset-password [post] func (h *Handler) ResetPassword(c *gin.Context) { - var req resetPasswordRequest + var req ResetPasswordRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return diff --git a/sproutgate-backend/internal/handlers/checkin.go b/sproutgate-backend/internal/handlers/checkin.go index 0cdbad7..150fa16 100644 --- a/sproutgate-backend/internal/handlers/checkin.go +++ b/sproutgate-backend/internal/handlers/checkin.go @@ -12,6 +12,16 @@ import ( "sproutgate-backend/internal/storage" ) +// CheckIn +// @Summary 每日签到 +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} map[string]interface{} "checkedIn、user、checkIn 等" +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/check-in [post] func (h *Handler) CheckIn(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { @@ -71,13 +81,33 @@ func (h *Handler) CheckIn(c *gin.Context) { }) } +// GetCheckInConfig +// @Summary 签到奖励配置(管理端也可用) +// @Tags admin +// @Produce json +// @Security AdminToken +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Router /api/admin/check-in/config [get] func (h *Handler) GetCheckInConfig(c *gin.Context) { cfg := h.store.CheckInConfig() c.JSON(http.StatusOK, gin.H{"rewardCoins": cfg.RewardCoins}) } +// UpdateCheckInConfig +// @Summary 更新签到奖励 +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminToken +// @Param body body UpdateCheckInConfigRequest true "rewardCoins" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/check-in/config [put] func (h *Handler) UpdateCheckInConfig(c *gin.Context) { - var req updateCheckInConfigRequest + var req UpdateCheckInConfigRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return diff --git a/sproutgate-backend/internal/handlers/oauth.go b/sproutgate-backend/internal/handlers/oauth.go new file mode 100644 index 0000000..396a056 --- /dev/null +++ b/sproutgate-backend/internal/handlers/oauth.go @@ -0,0 +1,961 @@ +package handlers + +import ( + "context" + "crypto/hmac" + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + "golang.org/x/oauth2" + "golang.org/x/oauth2/github" + "golang.org/x/oauth2/google" + + "sproutgate-backend/internal/auth" + "sproutgate-backend/internal/models" +) + +const oauthStateTTL = 20 * time.Minute + +func microsoftV2Endpoint(tenant string) oauth2.Endpoint { + t := strings.TrimSpace(tenant) + if t == "" { + t = "common" + } + base := "https://login.microsoftonline.com/" + t + return oauth2.Endpoint{ + AuthURL: base + "/oauth2/v2.0/authorize", + TokenURL: base + "/oauth2/v2.0/token", + } +} + +func isOAuthProvider(p string) bool { + switch strings.ToLower(strings.TrimSpace(p)) { + case "github", "gitea", "google", "microsoft", "linuxdo": + return true + default: + return false + } +} + +type oauthStatePayload struct { + Exp int64 `json:"exp"` + Nonce string `json:"n"` + Ret string `json:"r"` + Mode string `json:"m"` // login | bind + Acc string `json:"a"` + Prov string `json:"p"` +} + +func publicAPIBase(c *gin.Context) string { + if b := os.Getenv("PUBLIC_API_BASE"); strings.TrimSpace(b) != "" { + return strings.TrimRight(strings.TrimSpace(b), "/") + } + host := c.Request.Host + if host == "" { + return "" + } + scheme := "http" + if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") { + scheme = "https" + } + return scheme + "://" + host +} + +func (h *Handler) signOAuthState(p oauthStatePayload) (string, error) { + if p.Exp == 0 { + p.Exp = time.Now().Add(oauthStateTTL).Unix() + } + raw, err := json.Marshal(p) + if err != nil { + return "", err + } + mac := hmac.New(sha256.New, h.store.JWTSecret()) + _, _ = mac.Write(raw) + sig := mac.Sum(nil) + return base64.RawURLEncoding.EncodeToString(raw) + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +func (h *Handler) parseOAuthState(s string) (oauthStatePayload, error) { + parts := strings.Split(s, ".") + if len(parts) != 2 { + return oauthStatePayload{}, errors.New("invalid state") + } + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return oauthStatePayload{}, err + } + wantSig, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return oauthStatePayload{}, err + } + mac := hmac.New(sha256.New, h.store.JWTSecret()) + _, _ = mac.Write(raw) + if !hmac.Equal(mac.Sum(nil), wantSig) { + return oauthStatePayload{}, errors.New("bad signature") + } + var p oauthStatePayload + if err := json.Unmarshal(raw, &p); err != nil { + return oauthStatePayload{}, err + } + if time.Now().Unix() > p.Exp { + return oauthStatePayload{}, errors.New("state expired") + } + return p, nil +} + +func randomNonce() (string, error) { + b := make([]byte, 16) + if _, err := cryptorand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func (h *Handler) oAuth2Config(c *gin.Context, provider string) (oauth2.Config, error) { + cfg := h.store.GetOAuthConfig() + redirect := publicAPIBase(c) + if redirect == "" { + return oauth2.Config{}, errors.New("cannot resolve public API base (set PUBLIC_API_BASE)") + } + var oc oauth2.Config + switch provider { + case "github": + if !cfg.GitHubEnabled { + return oc, errors.New("github oauth disabled") + } + secret := strings.TrimSpace(os.Getenv("GITHUB_CLIENT_SECRET")) + if secret == "" { + secret = cfg.GitHubClientSecret + } + if strings.TrimSpace(cfg.GitHubClientID) == "" || secret == "" { + return oc, errors.New("github oauth not configured") + } + oc = oauth2.Config{ + ClientID: strings.TrimSpace(cfg.GitHubClientID), + ClientSecret: secret, + RedirectURL: redirect + "/api/auth/oauth/github/callback", + Scopes: []string{"read:user", "user:email"}, + Endpoint: github.Endpoint, + } + return oc, nil + case "gitea": + if !cfg.GiteaEnabled { + return oc, errors.New("gitea oauth disabled") + } + secret := strings.TrimSpace(os.Getenv("GITEA_CLIENT_SECRET")) + if secret == "" { + secret = cfg.GiteaClientSecret + } + base := strings.TrimRight(cfg.GiteaBaseURL, "/") + if base == "" { + base = "https://git.shumengya.top" + } + if strings.TrimSpace(cfg.GiteaClientID) == "" || secret == "" { + return oc, errors.New("gitea oauth not configured") + } + oc = oauth2.Config{ + ClientID: strings.TrimSpace(cfg.GiteaClientID), + ClientSecret: secret, + RedirectURL: redirect + "/api/auth/oauth/gitea/callback", + Scopes: []string{"read:user"}, + Endpoint: oauth2.Endpoint{ + AuthURL: base + "/login/oauth/authorize", + TokenURL: base + "/login/oauth/access_token", + }, + } + return oc, nil + case "google": + if !cfg.GoogleEnabled { + return oc, errors.New("google oauth disabled") + } + secret := strings.TrimSpace(os.Getenv("GOOGLE_CLIENT_SECRET")) + if secret == "" { + secret = cfg.GoogleClientSecret + } + if strings.TrimSpace(cfg.GoogleClientID) == "" || secret == "" { + return oc, errors.New("google oauth not configured") + } + oc = oauth2.Config{ + ClientID: strings.TrimSpace(cfg.GoogleClientID), + ClientSecret: secret, + RedirectURL: redirect + "/api/auth/oauth/google/callback", + Scopes: []string{"openid", "email", "profile"}, + Endpoint: google.Endpoint, + } + return oc, nil + case "microsoft": + if !cfg.MicrosoftEnabled { + return oc, errors.New("microsoft oauth disabled") + } + secret := strings.TrimSpace(os.Getenv("MICROSOFT_CLIENT_SECRET")) + if secret == "" { + secret = cfg.MicrosoftClientSecret + } + tenant := strings.TrimSpace(cfg.MicrosoftTenant) + if tenant == "" { + tenant = "common" + } + if strings.TrimSpace(cfg.MicrosoftClientID) == "" || secret == "" { + return oc, errors.New("microsoft oauth not configured") + } + oc = oauth2.Config{ + ClientID: strings.TrimSpace(cfg.MicrosoftClientID), + ClientSecret: secret, + RedirectURL: redirect + "/api/auth/oauth/microsoft/callback", + Scopes: []string{"openid", "profile", "email", "https://graph.microsoft.com/User.Read"}, + Endpoint: microsoftV2Endpoint(tenant), + } + return oc, nil + case "linuxdo": + if !cfg.LinuxdoEnabled { + return oc, errors.New("linuxdo oauth disabled") + } + secret := strings.TrimSpace(os.Getenv("LINUXDO_CLIENT_SECRET")) + if secret == "" { + secret = cfg.LinuxdoClientSecret + } + base := strings.TrimRight(cfg.LinuxdoConnectBaseURL, "/") + if base == "" { + base = "https://connect.linux.do" + } + if strings.TrimSpace(cfg.LinuxdoClientID) == "" || secret == "" { + return oc, errors.New("linuxdo oauth not configured") + } + oc = oauth2.Config{ + ClientID: strings.TrimSpace(cfg.LinuxdoClientID), + ClientSecret: secret, + RedirectURL: redirect + "/api/auth/oauth/linuxdo/callback", + Scopes: []string{"read:user"}, + Endpoint: oauth2.Endpoint{ + AuthURL: base + "/oauth2/authorize", + TokenURL: base + "/oauth2/token", + }, + } + return oc, nil + default: + return oc, errors.New("unknown provider") + } +} + +// OAuthStart +// @Summary OAuth 登录起点(重定向 IdP) +// @Tags oauth +// @Produce json +// @Param provider path string true "github|gitea|google|microsoft|linuxdo" +// @Param return_to query string true "登录完成回跳 URL(须符合后台允许前缀)" +// @Param turnstile_token query string false "Turnstile 开启时必填" +// @Success 302 "重定向至 IdP" +// @Failure 400 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Failure 503 {object} map[string]interface{} +// @Router /api/auth/oauth/{provider}/start [get] +func (h *Handler) OAuthStart(c *gin.Context) { + provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) + if !isOAuthProvider(provider) { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) + return + } + h.store.MaybeSyncRuntimeConfigFromDB() + ret := strings.TrimSpace(c.Query("return_to")) + cfg := h.store.GetOAuthConfig() + if !cfg.IsReturnURLAllowed(ret) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid return_to (not allowed for OAuth redirect)"}) + return + } + if tcfg := h.store.GetTurnstileConfig(); tcfg.Enabled { + tok := strings.TrimSpace(c.Query("turnstile_token")) + if err := verifyTurnstileToken(c.Request.Context(), tcfg.SecretKey, tok, c.ClientIP()); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + nonce, err := randomNonce() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start oauth"}) + return + } + st, err := h.signOAuthState(oauthStatePayload{ + Exp: time.Now().Add(oauthStateTTL).Unix(), + Nonce: nonce, + Ret: ret, + Mode: "login", + Prov: provider, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start oauth"}) + return + } + oac, err := h.oAuth2Config(c, provider) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + url := oac.AuthCodeURL(st, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + c.Redirect(http.StatusFound, url) +} + +// OAuthBindURL +// @Summary 已登录用户绑定 OAuth(返回授权 URL) +// @Tags oauth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param provider path string true "github|gitea|google|microsoft|linuxdo" +// @Param body body OAuthBindURLRequest true "returnTo" +// @Success 200 {object} map[string]interface{} "url" +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Failure 503 {object} map[string]interface{} +// @Router /api/auth/oauth/{provider}/bind [post] +func (h *Handler) OAuthBindURL(c *gin.Context) { + provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) + if !isOAuthProvider(provider) { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) + return + } + tok := bearerToken(c.GetHeader("Authorization")) + if tok == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), tok) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + bindUser, bfound, berr := h.store.GetUser(claims.Account) + if berr == nil && bfound && bindUser.Banned { + writeBanJSON(c, bindUser.BanReason) + return + } + var req OAuthBindURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + ret := strings.TrimSpace(req.ReturnTo) + if ret == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "returnTo is required"}) + return + } + if !h.store.GetOAuthConfig().IsReturnURLAllowed(ret) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid returnTo"}) + return + } + nonce, err := randomNonce() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start bind"}) + return + } + st, err := h.signOAuthState(oauthStatePayload{ + Exp: time.Now().Add(oauthStateTTL).Unix(), + Nonce: nonce, + Ret: ret, + Mode: "bind", + Acc: claims.Account, + Prov: provider, + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start bind"}) + return + } + oac, err := h.oAuth2Config(c, provider) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + authu := oac.AuthCodeURL(st, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + c.JSON(http.StatusOK, gin.H{"url": authu}) +} + +// OAuthUnlink +// @Summary 解绑 OAuth 提供商 +// @Tags oauth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param provider path string true "github|gitea|google|microsoft|linuxdo" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/oauth/{provider} [delete] +func (h *Handler) OAuthUnlink(c *gin.Context) { + provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) + if !isOAuthProvider(provider) { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) + return + } + tok := bearerToken(c.GetHeader("Authorization")) + if tok == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing token"}) + return + } + claims, err := auth.ParseToken(h.store.JWTSecret(), h.store.JWTIssuer(), tok) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + acc := claims.Account + var err2 error + switch provider { + case "github": + err2 = h.store.ClearUserGitHubID(acc) + case "gitea": + err2 = h.store.ClearUserGiteaID(acc) + case "google": + err2 = h.store.ClearUserGoogleID(acc) + case "microsoft": + err2 = h.store.ClearUserMicrosoftID(acc) + case "linuxdo": + err2 = h.store.ClearUserLinuxdoID(acc) + } + if err2 != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err2.Error()}) + return + } + user, found, e := h.store.GetUser(acc) + if e != nil || !found { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"}) + return + } + if abortIfUserBanned(c, user) { + return + } + if abortIfTokenEpochStale(c, claims, user) { + return + } + c.JSON(http.StatusOK, gin.H{"user": user.OwnerPublic()}) +} + +// OAuthCallback +// @Summary OAuth 回调(IdP 重定向) +// @Description 成功时通常 302 回 return_to 并带 token;失败时 JSON 或重定向错误页。 +// @Tags oauth +// @Produce json +// @Param provider path string true "提供商" +// @Param code query string false "授权码" +// @Param state query string false "state" +// @Param error query string false "IdP 错误码" +// @Success 302 "重定向至客户端" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Router /api/auth/oauth/{provider}/callback [get] +func (h *Handler) OAuthCallback(c *gin.Context) { + provider := strings.ToLower(strings.TrimSpace(c.Param("provider"))) + if !isOAuthProvider(provider) { + c.JSON(http.StatusNotFound, gin.H{"error": "unknown provider"}) + return + } + h.store.MaybeSyncRuntimeConfigFromDB() + if errParam := strings.TrimSpace(c.Query("error")); errParam != "" { + if st := strings.TrimSpace(c.Query("state")); st != "" { + if p, perr := h.parseOAuthState(st); perr == nil { + h.redirectOAuthError(c, p.Ret, c.Query("error_description")) + return + } + } + c.String(http.StatusBadRequest, "oauth error: %s", errParam) + return + } + code := strings.TrimSpace(c.Query("code")) + st := strings.TrimSpace(c.Query("state")) + if code == "" || st == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "missing code or state"}) + return + } + p, err := h.parseOAuthState(st) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid state"}) + return + } + if p.Prov != provider { + c.JSON(http.StatusBadRequest, gin.H{"error": "provider mismatch"}) + return + } + if !h.store.GetOAuthConfig().IsReturnURLAllowed(p.Ret) { + c.JSON(http.StatusBadRequest, gin.H{"error": "stale return url"}) + return + } + oac, err := h.oAuth2Config(c, provider) + if err != nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) + return + } + ctx := c.Request.Context() + t, err := oac.Exchange(ctx, code) + if err != nil { + h.redirectOAuthError(c, p.Ret, "token_exchange_failed") + return + } + var extID, email, username, avatar string + oCfg := h.store.GetOAuthConfig() + switch provider { + case "github": + extID, email, username, avatar, err = h.fetchGitHubUser(ctx, t.AccessToken) + case "gitea": + extID, email, username, avatar, err = h.fetchGiteaUser(ctx, oCfg.GiteaBaseURL, t.AccessToken) + case "google": + extID, email, username, avatar, err = h.fetchGoogleUser(ctx, t.AccessToken) + case "microsoft": + extID, email, username, avatar, err = h.fetchMicrosoftUser(ctx, t.AccessToken) + case "linuxdo": + extID, email, username, avatar, err = h.fetchLinuxdoUser(ctx, oCfg.LinuxdoConnectBaseURL, t.AccessToken) + } + if err != nil || extID == "" { + h.redirectOAuthError(c, p.Ret, "userinfo_failed") + return + } + if p.Mode == "bind" { + acc := strings.TrimSpace(p.Acc) + if acc == "" { + h.redirectOAuthError(c, p.Ret, "bind_no_account") + return + } + switch provider { + case "github": + err = h.store.SetUserGitHubID(acc, extID) + case "gitea": + err = h.store.SetUserGiteaID(acc, extID) + case "google": + err = h.store.SetUserGoogleID(acc, extID) + case "microsoft": + err = h.store.SetUserMicrosoftID(acc, extID) + case "linuxdo": + err = h.store.SetUserLinuxdoID(acc, extID) + } + if err != nil { + h.redirectOAuthError(c, p.Ret, err.Error()) + return + } + h.redirectOAuthBindDone(c, p.Ret) + return + } + // login + var linkedUser models.UserRecord + var linkedOK bool + var dberr error + switch provider { + case "github": + linkedUser, linkedOK, dberr = h.store.GetUserByGitHubID(extID) + case "gitea": + linkedUser, linkedOK, dberr = h.store.GetUserByGiteaID(extID) + case "google": + linkedUser, linkedOK, dberr = h.store.GetUserByGoogleID(extID) + case "microsoft": + linkedUser, linkedOK, dberr = h.store.GetUserByMicrosoftID(extID) + case "linuxdo": + linkedUser, linkedOK, dberr = h.store.GetUserByLinuxdoID(extID) + } + if dberr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + if linkedOK { + if linkedUser.Banned { + c.JSON(http.StatusForbidden, gin.H{"error": "banned", "banReason": linkedUser.BanReason}) + return + } + h.issueTokenAndRedirect(c, p.Ret, linkedUser) + return + } + if !h.store.OAuthSignUpAllowed() { + h.redirectOAuthError(c, p.Ret, "oauth_signup_disabled") + return + } + email = strings.TrimSpace(email) + if email != "" { + _, efound, eerr := h.store.GetUserByEmail(email) + if eerr != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + return + } + if efound { + h.redirectOAuthError(c, p.Ret, "email_already_registered") + return + } + } + acc, err := h.store.ProposeUniqueOAuthAccount() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "account allocation failed"}) + return + } + if email == "" { + email = acc + "@oauth.local.sproutgate" + } + rp := make([]byte, 40) + _, _ = cryptorand.Read(rp) + randPass := base64.RawURLEncoding.EncodeToString(rp) + hash, err := bcrypt.GenerateFromPassword([]byte(randPass), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"}) + return + } + rec := models.UserRecord{ + Account: acc, + PasswordHash: string(hash), + Username: strings.TrimSpace(username), + Email: email, + Level: 0, + SproutCoins: 0, + AvatarURL: strings.TrimSpace(avatar), + CreatedAt: models.NowISO(), + UpdatedAt: models.NowISO(), + } + switch provider { + case "github": + rec.GitHubUserID = extID + case "gitea": + rec.GiteaUserID = extID + case "google": + rec.GoogleUserID = extID + case "microsoft": + rec.MicrosoftUserID = extID + case "linuxdo": + rec.LinuxdoUserID = extID + } + if err := h.store.CreateUser(rec); err != nil { + h.redirectOAuthError(c, p.Ret, "create_user_failed") + return + } + if u, found, e := h.store.GetUser(rec.Account); e == nil && found { + h.issueTokenAndRedirect(c, p.Ret, u) + } +} + +func (h *Handler) issueTokenAndRedirect(c *gin.Context, returnTo string, u models.UserRecord) { + if u.Banned { + c.JSON(http.StatusForbidden, gin.H{"error": "banned", "banReason": u.BanReason}) + return + } + token, expiresAt, err := auth.GenerateToken(h.store.JWTSecret(), h.store.JWTIssuer(), u.Account, u.TokenEpoch, 7*24*time.Hour) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "token failed"}) + return + } + h.redirectOAuthToken(c, returnTo, token, expiresAt.Format(time.RFC3339)) +} + +func (h *Handler) redirectOAuthToken(c *gin.Context, returnTo, token, expiresAt string) { + frag := url.Values{} + frag.Set("oauth_token", token) + frag.Set("oauth_expires_at", expiresAt) + u := returnTo + if i := strings.IndexByte(u, '#'); i >= 0 { + u = u[:i] + } + c.Redirect(http.StatusFound, u+"#"+frag.Encode()) +} + +func (h *Handler) redirectOAuthBindDone(c *gin.Context, returnTo string) { + frag := url.Values{} + frag.Set("oauth_bound", "1") + u := returnTo + if i := strings.IndexByte(u, '#'); i >= 0 { + u = u[:i] + } + c.Redirect(http.StatusFound, u+"#"+frag.Encode()) +} + +func (h *Handler) redirectOAuthError(c *gin.Context, returnTo, desc string) { + u := returnTo + if i := strings.IndexByte(u, '#'); i >= 0 { + u = u[:i] + } + frag := url.Values{} + frag.Set("oauth_error", "1") + if desc != "" { + frag.Set("oauth_error_description", desc) + } + if u == "" { + c.String(http.StatusBadRequest, "oauth error: %s", desc) + return + } + c.Redirect(http.StatusFound, u+"#"+frag.Encode()) +} + +func (h *Handler) fetchGitHubUser(ctx context.Context, access string) (id, email, username, avatar string, err error) { + type gh struct { + ID int64 `json:"id"` + Login string `json:"login"` + Email string `json:"email"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil) + if err != nil { + return "", "", "", "", err + } + req.Header.Set("Authorization", "Bearer "+access) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", "sproutgate-oauth") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", "", "", err + } + defer res.Body.Close() + b, rerr := readResponseBody(res) + if rerr != nil { + return "", "", "", "", rerr + } + if res.StatusCode != http.StatusOK { + return "", "", "", "", errors.New("github user api: " + string(b)) + } + var u gh + if err := json.Unmarshal(b, &u); err != nil { + return "", "", "", "", err + } + email = strings.TrimSpace(u.Email) + username = strings.TrimSpace(u.Name) + if username == "" { + username = u.Login + } + if email == "" { + email, err = h.fetchGitHubPrimaryEmail(ctx, access) + if err != nil { + email = "" + } + } + avatar = strings.TrimSpace(u.AvatarURL) + return strconv.FormatInt(u.ID, 10), strings.TrimSpace(email), username, avatar, nil +} + +func (h *Handler) fetchGitHubPrimaryEmail(ctx context.Context, access string) (string, error) { + type em struct { + Email string `json:"email"` + Primary bool `json:"primary"` + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user/emails", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+access) + req.Header.Set("User-Agent", "sproutgate-oauth") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + b, rerr := readResponseBody(res) + if rerr != nil { + return "", rerr + } + if res.StatusCode != http.StatusOK { + return "", errors.New("github emails") + } + var list []em + if err := json.Unmarshal(b, &list); err != nil { + return "", err + } + for _, e := range list { + if e.Primary { + return strings.TrimSpace(e.Email), nil + } + } + if len(list) > 0 { + return strings.TrimSpace(list[0].Email), nil + } + return "", nil +} + +func (h *Handler) fetchGiteaUser(ctx context.Context, giteaBase, access string) (id, email, username, avatar string, err error) { + base := strings.TrimRight(strings.TrimSpace(giteaBase), "/") + if base == "" { + base = "https://git.shumengya.top" + } + type gu struct { + ID int64 `json:"id"` + Login string `json:"login"` + Email string `json:"email"` + FullName string `json:"full_name"` + AvatarURL string `json:"avatar_url"` + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/user", nil) + if err != nil { + return "", "", "", "", err + } + req.Header.Set("Authorization", "token "+access) + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", "", "", err + } + defer res.Body.Close() + b, rerr := readResponseBody(res) + if rerr != nil { + return "", "", "", "", rerr + } + if res.StatusCode != http.StatusOK { + return "", "", "", "", errors.New("gitea user api") + } + var u gu + if err := json.Unmarshal(b, &u); err != nil { + return "", "", "", "", err + } + username = strings.TrimSpace(u.FullName) + if username == "" { + username = u.Login + } + avatar = strings.TrimSpace(u.AvatarURL) + return strconv.FormatInt(u.ID, 10), strings.TrimSpace(u.Email), username, avatar, nil +} + +func (h *Handler) fetchGoogleUser(ctx context.Context, access string) (id, email, username, avatar string, err error) { + type gu struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return "", "", "", "", err + } + req.Header.Set("Authorization", "Bearer "+access) + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", "", "", err + } + defer res.Body.Close() + b, rerr := readResponseBody(res) + if rerr != nil { + return "", "", "", "", rerr + } + if res.StatusCode != http.StatusOK { + return "", "", "", "", errors.New("google userinfo: " + string(b)) + } + var u gu + if err := json.Unmarshal(b, &u); err != nil { + return "", "", "", "", err + } + id = strings.TrimSpace(u.ID) + if id == "" { + return "", "", "", "", errors.New("google userinfo: missing id") + } + email = strings.TrimSpace(u.Email) + username = strings.TrimSpace(u.Name) + avatar = strings.TrimSpace(u.Picture) + return id, email, username, avatar, nil +} + +func (h *Handler) fetchMicrosoftUser(ctx context.Context, access string) (id, email, username, avatar string, err error) { + type mu struct { + ID string `json:"id"` + Mail string `json:"mail"` + UserPrincipalName string `json:"userPrincipalName"` + DisplayName string `json:"displayName"` + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://graph.microsoft.com/v1.0/me", nil) + if err != nil { + return "", "", "", "", err + } + req.Header.Set("Authorization", "Bearer "+access) + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", "", "", err + } + defer res.Body.Close() + b, rerr := readResponseBody(res) + if rerr != nil { + return "", "", "", "", rerr + } + if res.StatusCode != http.StatusOK { + return "", "", "", "", errors.New("microsoft graph me: " + string(b)) + } + var u mu + if err := json.Unmarshal(b, &u); err != nil { + return "", "", "", "", err + } + id = strings.TrimSpace(u.ID) + if id == "" { + return "", "", "", "", errors.New("microsoft graph: missing id") + } + email = strings.TrimSpace(u.Mail) + if email == "" { + upn := strings.TrimSpace(u.UserPrincipalName) + if strings.Contains(upn, "@") { + email = upn + } + } + username = strings.TrimSpace(u.DisplayName) + return id, email, username, "", nil +} + +// fetchLinuxdoUser 调用 LINUX DO Connect 用户接口(/api/user)。 +// 文档: https://connect.linux.do 应用接入中给出的 User Endpoint。 +func (h *Handler) fetchLinuxdoUser(ctx context.Context, connectBase, access string) (id, email, username, avatar string, err error) { + base := strings.TrimRight(strings.TrimSpace(connectBase), "/") + if base == "" { + base = "https://connect.linux.do" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/user", nil) + if err != nil { + return "", "", "", "", err + } + req.Header.Set("Authorization", "Bearer "+access) + req.Header.Set("Accept", "application/json") + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", "", "", "", err + } + defer res.Body.Close() + b, rerr := readResponseBody(res) + if rerr != nil { + return "", "", "", "", rerr + } + if res.StatusCode != http.StatusOK { + return "", "", "", "", fmt.Errorf("linuxdo user api: status %d", res.StatusCode) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + return "", "", "", "", err + } + if v, ok := m["id"]; ok && v != nil { + id = fmt.Sprint(v) + } + if strings.TrimSpace(id) == "" { + return "", "", "", "", errors.New("linuxdo user: missing id") + } + if s, ok := m["username"].(string); ok { + username = strings.TrimSpace(s) + } + if s, ok := m["name"].(string); ok && username == "" { + username = strings.TrimSpace(s) + } + if s, ok := m["email"].(string); ok { + email = strings.TrimSpace(s) + } + if s, ok := m["avatar_url"].(string); ok { + avatar = strings.TrimSpace(s) + } + if avatar == "" { + if tpl, ok := m["avatar_template"].(string); ok && strings.TrimSpace(tpl) != "" { + tpl = strings.TrimSpace(tpl) + if strings.HasPrefix(tpl, "http://") || strings.HasPrefix(tpl, "https://") { + avatar = tpl + } else { + avatar = strings.TrimRight(base, "/") + tpl + } + } + } + return id, email, username, avatar, nil +} + +func readResponseBody(res *http.Response) ([]byte, error) { + return io.ReadAll(io.LimitReader(res.Body, 1<<20)) +} diff --git a/sproutgate-backend/internal/handlers/oauth_admin.go b/sproutgate-backend/internal/handlers/oauth_admin.go new file mode 100644 index 0000000..8c64476 --- /dev/null +++ b/sproutgate-backend/internal/handlers/oauth_admin.go @@ -0,0 +1,123 @@ +package handlers + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + + "sproutgate-backend/internal/storage" +) + +// GetAdminOAuth +// @Summary OAuth 配置(脱敏) +// @Tags admin +// @Produce json +// @Security AdminToken +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Router /api/admin/oauth [get] +func (h *Handler) GetAdminOAuth(c *gin.Context) { + cfg := h.store.GetOAuthConfig() + c.JSON(http.StatusOK, gin.H{ + "githubEnabled": cfg.GitHubEnabled, + "giteaEnabled": cfg.GiteaEnabled, + "googleEnabled": cfg.GoogleEnabled, + "microsoftEnabled": cfg.MicrosoftEnabled, + "linuxdoEnabled": cfg.LinuxdoEnabled, + "githubClientId": strings.TrimSpace(cfg.GitHubClientID), + "githubClientSecretSet": strings.TrimSpace(cfg.GitHubClientSecret) != "", + "giteaBaseUrl": cfg.GiteaBaseURL, + "giteaClientId": strings.TrimSpace(cfg.GiteaClientID), + "giteaClientSecretSet": strings.TrimSpace(cfg.GiteaClientSecret) != "", + "googleClientId": strings.TrimSpace(cfg.GoogleClientID), + "googleClientSecretSet": strings.TrimSpace(cfg.GoogleClientSecret) != "", + "microsoftClientId": strings.TrimSpace(cfg.MicrosoftClientID), + "microsoftClientSecretSet": strings.TrimSpace(cfg.MicrosoftClientSecret) != "", + "microsoftTenant": strings.TrimSpace(cfg.MicrosoftTenant), + "linuxdoConnectBaseUrl": cfg.LinuxdoConnectBaseURL, + "linuxdoClientId": strings.TrimSpace(cfg.LinuxdoClientID), + "linuxdoClientSecretSet": strings.TrimSpace(cfg.LinuxdoClientSecret) != "", + "githubLogoUrl": strings.TrimSpace(cfg.GitHubLogoURL), + "giteaLogoUrl": strings.TrimSpace(cfg.GiteaLogoURL), + "googleLogoUrl": strings.TrimSpace(cfg.GoogleLogoURL), + "microsoftLogoUrl": strings.TrimSpace(cfg.MicrosoftLogoURL), + "linuxdoLogoUrl": strings.TrimSpace(cfg.LinuxdoLogoURL), + "allowedReturnPrefixes": cfg.AllowedReturnPrefixes, + "allowOAuthSignUpWhenInviteRequired": cfg.AllowOAuthSignUpWhenInviteRequired, + }) +} + +// PutAdminOAuth +// @Summary 更新 OAuth 配置 +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminToken +// @Param body body PutAdminOAuthRequest true "配置" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/oauth [put] +func (h *Handler) PutAdminOAuth(c *gin.Context) { + var req PutAdminOAuthRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + prefixes := make([]string, 0, len(req.AllowedReturnPrefixes)) + for _, p := range req.AllowedReturnPrefixes { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if !strings.HasSuffix(p, "/") { + p += "/" + } + prefixes = append(prefixes, p) + } + if len(prefixes) == 0 { + prefixes = append([]string(nil), h.store.GetOAuthConfig().AllowedReturnPrefixes...) + } + giteaBase := strings.TrimRight(strings.TrimSpace(req.GiteaBaseURL), "/") + if giteaBase == "" { + giteaBase = "https://git.shumengya.top" + } + ldBase := strings.TrimRight(strings.TrimSpace(req.LinuxdoConnectBaseURL), "/") + if ldBase == "" { + ldBase = "https://connect.linux.do" + } + out := storage.OAuthConfig{ + GitHubEnabled: req.GitHubEnabled, + GiteaEnabled: req.GiteaEnabled, + GoogleEnabled: req.GoogleEnabled, + MicrosoftEnabled: req.MicrosoftEnabled, + LinuxdoEnabled: req.LinuxdoEnabled, + GitHubClientID: strings.TrimSpace(req.GitHubClientID), + GitHubClientSecret: strings.TrimSpace(req.GitHubClientSecret), + GiteaBaseURL: giteaBase, + GiteaClientID: strings.TrimSpace(req.GiteaClientID), + GiteaClientSecret: strings.TrimSpace(req.GiteaClientSecret), + GoogleClientID: strings.TrimSpace(req.GoogleClientID), + GoogleClientSecret: strings.TrimSpace(req.GoogleClientSecret), + MicrosoftClientID: strings.TrimSpace(req.MicrosoftClientID), + MicrosoftClientSecret: strings.TrimSpace(req.MicrosoftClientSecret), + MicrosoftTenant: strings.TrimSpace(req.MicrosoftTenant), + LinuxdoConnectBaseURL: ldBase, + LinuxdoClientID: strings.TrimSpace(req.LinuxdoClientID), + LinuxdoClientSecret: strings.TrimSpace(req.LinuxdoClientSecret), + GitHubLogoURL: strings.TrimSpace(req.GitHubLogoURL), + GiteaLogoURL: strings.TrimSpace(req.GiteaLogoURL), + GoogleLogoURL: strings.TrimSpace(req.GoogleLogoURL), + MicrosoftLogoURL: strings.TrimSpace(req.MicrosoftLogoURL), + LinuxdoLogoURL: strings.TrimSpace(req.LinuxdoLogoURL), + AllowedReturnPrefixes: prefixes, + AllowOAuthSignUpWhenInviteRequired: req.AllowOAuthSignUpWhenInviteRequired, + } + if err := h.store.MergeUpdateOAuthConfig(out); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save oauth config"}) + return + } + h.GetAdminOAuth(c) +} diff --git a/sproutgate-backend/internal/handlers/profile.go b/sproutgate-backend/internal/handlers/profile.go index d2775ad..28c743a 100644 --- a/sproutgate-backend/internal/handlers/profile.go +++ b/sproutgate-backend/internal/handlers/profile.go @@ -10,6 +10,18 @@ import ( "sproutgate-backend/internal/auth" ) +// UpdateProfile +// @Summary 更新个人资料(可选改密码) +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body UpdateProfileRequest true "资料字段" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/profile [put] func (h *Handler) UpdateProfile(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { @@ -21,7 +33,7 @@ func (h *Handler) UpdateProfile(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - var req updateProfileRequest + var req UpdateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return diff --git a/sproutgate-backend/internal/handlers/public_profile.go b/sproutgate-backend/internal/handlers/public_profile.go index a84c314..7825831 100644 --- a/sproutgate-backend/internal/handlers/public_profile.go +++ b/sproutgate-backend/internal/handlers/public_profile.go @@ -13,7 +13,13 @@ import ( "sproutgate-backend/internal/storage" ) -// ListPublicUsers 公开用户目录(未封禁用户,默认按注册时间从早到晚)。 +// ListPublicUsers +// @Summary 公开用户目录 +// @Tags public +// @Produce json +// @Success 200 {object} map[string]interface{} "total、users" +// @Failure 500 {object} map[string]interface{} +// @Router /api/public/users [get] func (h *Handler) ListPublicUsers(c *gin.Context) { users, err := h.store.ListUsers() if err != nil { @@ -33,6 +39,18 @@ func (h *Handler) ListPublicUsers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"total": len(out), "users": out}) } +// GetPublicUser +// @Summary 公开用户主页 +// @Description 可选 Authorization:登录用户可看到点赞状态等扩展字段。 +// @Tags public +// @Produce json +// @Param account path string true "账号" +// @Param Authorization header string false "Bearer(可选)" +// @Success 200 {object} map[string]interface{} "user、profileLikeCount 等" +// @Failure 400 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/public/users/{account} [get] func (h *Handler) GetPublicUser(c *gin.Context) { account := strings.TrimSpace(c.Param("account")) if account == "" { @@ -83,6 +101,19 @@ func (h *Handler) GetPublicUser(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) } +// PostPublicProfileLike +// @Summary 主页点赞 +// @Tags public +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param account path string true "被点赞用户账号" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/public/users/{account}/like [post] func (h *Handler) PostPublicProfileLike(c *gin.Context) { likedParam := strings.TrimSpace(c.Param("account")) if likedParam == "" { @@ -130,9 +161,9 @@ func (h *Handler) PostPublicProfileLike(c *gin.Context) { case errors.Is(err, storage.ErrDailyLikeLimitReached): rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account) c.JSON(http.StatusBadRequest, gin.H{ - "error": err.Error(), + "error": err.Error(), "viewerLikesRemainingToday": rem, - "profileLikeDailyMax": storage.MaxProfileLikesPerDay, + "profileLikeDailyMax": storage.MaxProfileLikesPerDay, }) return case errors.Is(err, storage.ErrProfileLikeLiker): @@ -146,9 +177,9 @@ func (h *Handler) PostPublicProfileLike(c *gin.Context) { rem, _ := h.store.ProfileLikeRemainingToday(likerUser.Account) c.JSON(http.StatusOK, gin.H{ - "profileLikeCount": newCount, - "viewerHasLikedToday": true, + "profileLikeCount": newCount, + "viewerHasLikedToday": true, "viewerLikesRemainingToday": rem, - "profileLikeDailyMax": storage.MaxProfileLikesPerDay, + "profileLikeDailyMax": storage.MaxProfileLikesPerDay, }) } diff --git a/sproutgate-backend/internal/handlers/public_registration.go b/sproutgate-backend/internal/handlers/public_registration.go index 893833e..780786e 100644 --- a/sproutgate-backend/internal/handlers/public_registration.go +++ b/sproutgate-backend/internal/handlers/public_registration.go @@ -2,19 +2,41 @@ package handlers import ( "net/http" + "strings" "github.com/gin-gonic/gin" "sproutgate-backend/internal/storage" ) -// GetPublicRegistrationPolicy 公开:是否必须邀请码、自助注册账号规则说明(不含具体邀请码)。 +// GetPublicRegistrationPolicy +// @Summary 公开注册策略与 OAuth/Turnstile 开关 +// @Tags public +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /api/public/registration-policy [get] func (h *Handler) GetPublicRegistrationPolicy(c *gin.Context) { + h.store.MaybeSyncRuntimeConfigFromDB() + o := h.store.PublicOAuthFlags() + oc := h.store.GetOAuthConfig() + tc := h.store.GetTurnstileConfig() c.JSON(http.StatusOK, gin.H{ - "requireInviteCode": h.store.RegistrationRequireInvite(), + "requireInviteCode": h.store.RegistrationRequireInvite(), "inviteRegisterRewardCoins": h.store.RegistrationInviteRegisterRewardCoins(), "selfServiceAccountMin": storage.MinSelfServiceAccountLen, "selfServiceAccountMax": storage.MaxSelfServiceAccountLen, "selfServiceAccountHint": "lowercase letters and digits only", + "githubOAuthEnabled": o.GitHubEnabled, + "giteaOAuthEnabled": o.GiteaEnabled, + "googleOAuthEnabled": o.GoogleEnabled, + "microsoftOAuthEnabled": o.MicrosoftEnabled, + "linuxdoOAuthEnabled": o.LinuxdoEnabled, + "githubOAuthLogoUrl": strings.TrimSpace(oc.GitHubLogoURL), + "giteaOAuthLogoUrl": strings.TrimSpace(oc.GiteaLogoURL), + "googleOAuthLogoUrl": strings.TrimSpace(oc.GoogleLogoURL), + "microsoftOAuthLogoUrl": strings.TrimSpace(oc.MicrosoftLogoURL), + "linuxdoOAuthLogoUrl": strings.TrimSpace(oc.LinuxdoLogoURL), + "turnstileEnabled": tc.Enabled, + "turnstileSiteKey": strings.TrimSpace(tc.SiteKey), }) } diff --git a/sproutgate-backend/internal/handlers/registration_admin.go b/sproutgate-backend/internal/handlers/registration_admin.go index d5b2ba9..ebe7483 100644 --- a/sproutgate-backend/internal/handlers/registration_admin.go +++ b/sproutgate-backend/internal/handlers/registration_admin.go @@ -7,18 +7,38 @@ import ( "github.com/gin-gonic/gin" ) +// GetAdminRegistration +// @Summary 注册策略与邀请码列表 +// @Tags admin +// @Produce json +// @Security AdminToken +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Router /api/admin/registration [get] func (h *Handler) GetAdminRegistration(c *gin.Context) { cfg := h.store.GetRegistrationConfig() c.JSON(http.StatusOK, gin.H{ - "requireInviteCode": cfg.RequireInviteCode, - "forbiddenAccounts": cfg.ForbiddenAccounts, + "requireInviteCode": cfg.RequireInviteCode, + "forbiddenAccounts": cfg.ForbiddenAccounts, "inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins, - "invites": cfg.Invites, + "invites": cfg.Invites, }) } +// PutAdminRegistrationPolicy +// @Summary 更新注册策略 +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminToken +// @Param body body UpdateRegistrationPolicyRequest true "策略" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/registration [put] func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) { - var req updateRegistrationPolicyRequest + var req UpdateRegistrationPolicyRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return @@ -29,14 +49,25 @@ func (h *Handler) PutAdminRegistrationPolicy(c *gin.Context) { } cfg := h.store.GetRegistrationConfig() c.JSON(http.StatusOK, gin.H{ - "requireInviteCode": cfg.RequireInviteCode, - "forbiddenAccounts": cfg.ForbiddenAccounts, + "requireInviteCode": cfg.RequireInviteCode, + "forbiddenAccounts": cfg.ForbiddenAccounts, "inviteRegisterRewardCoins": cfg.InviteRegisterRewardCoins, }) } +// PostAdminInvite +// @Summary 创建邀请码 +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminToken +// @Param body body CreateInviteRequest true "邀请配置" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Router /api/admin/registration/invites [post] func (h *Handler) PostAdminInvite(c *gin.Context) { - var req createInviteRequest + var req CreateInviteRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return @@ -49,6 +80,17 @@ func (h *Handler) PostAdminInvite(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"invite": entry}) } +// DeleteAdminInvite +// @Summary 删除邀请码 +// @Tags admin +// @Produce json +// @Security AdminToken +// @Param code path string true "邀请码" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 404 {object} map[string]interface{} +// @Router /api/admin/registration/invites/{code} [delete] func (h *Handler) DeleteAdminInvite(c *gin.Context) { code := strings.TrimSpace(c.Param("code")) if err := h.store.DeleteInviteEntry(code); err != nil { diff --git a/sproutgate-backend/internal/handlers/requests.go b/sproutgate-backend/internal/handlers/requests.go index 27fe6ff..3119daf 100644 --- a/sproutgate-backend/internal/handlers/requests.go +++ b/sproutgate-backend/internal/handlers/requests.go @@ -6,31 +6,38 @@ import ( "strings" ) -type loginRequest struct { - Account string `json:"account"` - Password string `json:"password"` - ClientID string `json:"clientId"` - ClientName string `json:"clientName"` +// LoginRequest POST /api/auth/login JSON body +type LoginRequest struct { + Account string `json:"account"` + Password string `json:"password"` + ClientID string `json:"clientId"` + ClientName string `json:"clientName"` + TurnstileToken string `json:"turnstileToken"` } -type verifyRequest struct { +// VerifyRequest POST /api/auth/verify +type VerifyRequest struct { Token string `json:"token"` } -type registerRequest struct { - Account string `json:"account"` - Password string `json:"password"` - Username string `json:"username"` - Email string `json:"email"` - InviteCode string `json:"inviteCode"` +// RegisterRequest POST /api/auth/register +type RegisterRequest struct { + Account string `json:"account"` + Password string `json:"password"` + Username string `json:"username"` + Email string `json:"email"` + InviteCode string `json:"inviteCode"` + TurnstileToken string `json:"turnstileToken"` } -type verifyEmailRequest struct { +// VerifyEmailRequest POST /api/auth/verify-email +type VerifyEmailRequest struct { Account string `json:"account"` Code string `json:"code"` } -type updateProfileRequest struct { +// UpdateProfileRequest PUT /api/auth/profile +type UpdateProfileRequest struct { Password *string `json:"password"` Username *string `json:"username"` Phone *string `json:"phone"` @@ -39,43 +46,51 @@ type updateProfileRequest struct { Bio *string `json:"bio"` } -type forgotPasswordRequest struct { +// ForgotPasswordRequest POST /api/auth/forgot-password +type ForgotPasswordRequest struct { Account string `json:"account"` Email string `json:"email"` } -type resetPasswordRequest struct { +// ResetPasswordRequest POST /api/auth/reset-password +type ResetPasswordRequest struct { Account string `json:"account"` Code string `json:"code"` NewPassword string `json:"newPassword"` } -type secondaryEmailRequest struct { +// SecondaryEmailRequest POST /api/auth/secondary-email/request +type SecondaryEmailRequest struct { Email string `json:"email"` } -type verifySecondaryEmailRequest struct { +// VerifySecondaryEmailRequest POST /api/auth/secondary-email/verify +type VerifySecondaryEmailRequest struct { Email string `json:"email"` Code string `json:"code"` } -type updateCheckInConfigRequest struct { +// UpdateCheckInConfigRequest PUT /api/admin/check-in/config +type UpdateCheckInConfigRequest struct { RewardCoins int `json:"rewardCoins"` } -type updateRegistrationPolicyRequest struct { - RequireInviteCode bool `json:"requireInviteCode"` - ForbiddenAccounts string `json:"forbiddenAccounts"` - InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins"` // nil 表示不修改此项 +// UpdateRegistrationPolicyRequest PUT /api/admin/registration +type UpdateRegistrationPolicyRequest struct { + RequireInviteCode bool `json:"requireInviteCode"` + ForbiddenAccounts string `json:"forbiddenAccounts"` + InviteRegisterRewardCoins *int `json:"inviteRegisterRewardCoins"` // nil 表示不修改此项 } -type createInviteRequest struct { +// CreateInviteRequest POST /api/admin/registration/invites +type CreateInviteRequest struct { Note string `json:"note"` MaxUses int `json:"maxUses"` ExpiresAt string `json:"expiresAt"` } -type createUserRequest struct { +// CreateUserRequest POST /api/admin/users +type CreateUserRequest struct { Account string `json:"account"` Password string `json:"password"` Username string `json:"username"` @@ -91,7 +106,8 @@ type createUserRequest struct { const maxBanReasonLen = 500 -type updateUserRequest struct { +// UpdateUserRequest PUT /api/admin/users/:account +type UpdateUserRequest struct { Password *string `json:"password"` Username *string `json:"username"` Email *string `json:"email"` @@ -106,6 +122,47 @@ type updateUserRequest struct { BanReason *string `json:"banReason"` } +// OAuthBindURLRequest POST /api/auth/oauth/:provider/bind +type OAuthBindURLRequest struct { + ReturnTo string `json:"returnTo"` +} + +// PutAdminOAuthRequest PUT /api/admin/oauth +type PutAdminOAuthRequest struct { + GitHubEnabled bool `json:"githubEnabled"` + GiteaEnabled bool `json:"giteaEnabled"` + GoogleEnabled bool `json:"googleEnabled"` + MicrosoftEnabled bool `json:"microsoftEnabled"` + LinuxdoEnabled bool `json:"linuxdoEnabled"` + GitHubClientID string `json:"githubClientId"` + GitHubClientSecret string `json:"githubClientSecret"` + GiteaBaseURL string `json:"giteaBaseUrl"` + GiteaClientID string `json:"giteaClientId"` + GiteaClientSecret string `json:"giteaClientSecret"` + GoogleClientID string `json:"googleClientId"` + GoogleClientSecret string `json:"googleClientSecret"` + MicrosoftClientID string `json:"microsoftClientId"` + MicrosoftClientSecret string `json:"microsoftClientSecret"` + MicrosoftTenant string `json:"microsoftTenant"` + LinuxdoConnectBaseURL string `json:"linuxdoConnectBaseUrl"` + LinuxdoClientID string `json:"linuxdoClientId"` + LinuxdoClientSecret string `json:"linuxdoClientSecret"` + GitHubLogoURL string `json:"githubLogoUrl"` + GiteaLogoURL string `json:"giteaLogoUrl"` + GoogleLogoURL string `json:"googleLogoUrl"` + MicrosoftLogoURL string `json:"microsoftLogoUrl"` + LinuxdoLogoURL string `json:"linuxdoLogoUrl"` + AllowedReturnPrefixes []string `json:"allowedReturnPrefixes"` + AllowOAuthSignUpWhenInviteRequired bool `json:"allowOAuthSignUpWhenInviteRequired"` +} + +// PutAdminTurnstileRequest PUT /api/admin/turnstile +type PutAdminTurnstileRequest struct { + Enabled bool `json:"enabled"` + SiteKey string `json:"siteKey"` + SecretKey string `json:"secretKey"` +} + const maxWebsiteURLLen = 2048 func normalizePublicWebsiteURL(raw string) (string, error) { diff --git a/sproutgate-backend/internal/handlers/secondary_email.go b/sproutgate-backend/internal/handlers/secondary_email.go index fa134f0..266d300 100644 --- a/sproutgate-backend/internal/handlers/secondary_email.go +++ b/sproutgate-backend/internal/handlers/secondary_email.go @@ -13,6 +13,18 @@ import ( "sproutgate-backend/internal/models" ) +// RequestSecondaryEmail +// @Summary 请求验证辅助邮箱(发邮件) +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body SecondaryEmailRequest true "邮箱" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/secondary-email/request [post] func (h *Handler) RequestSecondaryEmail(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { @@ -24,7 +36,7 @@ func (h *Handler) RequestSecondaryEmail(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - var req secondaryEmailRequest + var req SecondaryEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return @@ -87,6 +99,18 @@ func (h *Handler) RequestSecondaryEmail(c *gin.Context) { }) } +// VerifySecondaryEmail +// @Summary 验证并绑定辅助邮箱 +// @Tags auth +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param body body VerifySecondaryEmailRequest true "邮箱与验证码" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/auth/secondary-email/verify [post] func (h *Handler) VerifySecondaryEmail(c *gin.Context) { token := bearerToken(c.GetHeader("Authorization")) if token == "" { @@ -98,7 +122,7 @@ func (h *Handler) VerifySecondaryEmail(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) return } - var req verifySecondaryEmailRequest + var req VerifySecondaryEmailRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) return diff --git a/sproutgate-backend/internal/handlers/turnstile.go b/sproutgate-backend/internal/handlers/turnstile.go new file mode 100644 index 0000000..99d253b --- /dev/null +++ b/sproutgate-backend/internal/handlers/turnstile.go @@ -0,0 +1,95 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "sproutgate-backend/internal/storage" +) + +const turnstileVerifyURL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" + +type turnstileVerifyResp struct { + Success bool `json:"success"` +} + +// verifyTurnstileToken 向 Cloudflare 验证 Turnstile token,失败返回用户可见错误。 +func verifyTurnstileToken(ctx context.Context, secretKey, token, remoteIP string) error { + if strings.TrimSpace(token) == "" { + return fmt.Errorf("请完成人机验证") + } + form := url.Values{} + form.Set("secret", secretKey) + form.Set("response", token) + if remoteIP != "" { + form.Set("remoteip", remoteIP) + } + client := &http.Client{Timeout: 8 * time.Second} + resp, err := client.PostForm(turnstileVerifyURL, form) + if err != nil { + return fmt.Errorf("人机验证服务暂时不可用,请稍后重试") + } + defer resp.Body.Close() + var result turnstileVerifyResp + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return fmt.Errorf("人机验证响应解析失败") + } + if !result.Success { + return fmt.Errorf("人机验证未通过,请重试") + } + return nil +} + +// GetAdminTurnstile +// @Summary Turnstile 配置(脱敏) +// @Tags admin +// @Produce json +// @Security AdminToken +// @Success 200 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Router /api/admin/turnstile [get] +func (h *Handler) GetAdminTurnstile(c *gin.Context) { + cfg := h.store.GetTurnstileConfig() + c.JSON(http.StatusOK, gin.H{ + "enabled": cfg.Enabled, + "siteKey": strings.TrimSpace(cfg.SiteKey), + "secretKeySet": strings.TrimSpace(cfg.SecretKey) != "", + }) +} + +// PutAdminTurnstile +// @Summary 更新 Turnstile 配置 +// @Tags admin +// @Accept json +// @Produce json +// @Security AdminToken +// @Param body body PutAdminTurnstileRequest true "siteKey、secretKey" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {object} map[string]interface{} +// @Failure 401 {object} map[string]interface{} +// @Failure 500 {object} map[string]interface{} +// @Router /api/admin/turnstile [put] +func (h *Handler) PutAdminTurnstile(c *gin.Context) { + var req PutAdminTurnstileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + return + } + in := storage.TurnstileConfig{ + Enabled: req.Enabled, + SiteKey: strings.TrimSpace(req.SiteKey), + SecretKey: strings.TrimSpace(req.SecretKey), + } + if err := h.store.UpdateTurnstileConfig(in); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save turnstile config"}) + return + } + h.GetAdminTurnstile(c) +} diff --git a/sproutgate-backend/internal/models/user.go b/sproutgate-backend/internal/models/user.go index 221eded..a2d188c 100644 --- a/sproutgate-backend/internal/models/user.go +++ b/sproutgate-backend/internal/models/user.go @@ -32,6 +32,11 @@ type UserRecord struct { BannedAt string `json:"bannedAt,omitempty"` TokenEpoch int64 `json:"-"` // 递增使用户此前签发的 JWT 全局失效;不对外 JSON 序列化 AuthClients []AuthClientEntry `json:"authClients,omitempty"` + GitHubUserID string `json:"-"` // OAuth 子;不直接序列化到通用 JSON + GiteaUserID string `json:"-"` + LinuxdoUserID string `json:"-"` + GoogleUserID string `json:"-"` + MicrosoftUserID string `json:"-"` } type UserPublic struct { @@ -62,6 +67,11 @@ type UserPublic struct { BanReason string `json:"banReason,omitempty"` BannedAt string `json:"bannedAt,omitempty"` AuthClients []AuthClientEntry `json:"authClients,omitempty"` + GitHubLinked bool `json:"githubLinked,omitempty"` + GiteaLinked bool `json:"giteaLinked,omitempty"` + LinuxdoLinked bool `json:"linuxdoLinked,omitempty"` + GoogleLinked bool `json:"googleLinked,omitempty"` + MicrosoftLinked bool `json:"microsoftLinked,omitempty"` } func (u UserRecord) Public() UserPublic { @@ -117,6 +127,11 @@ func (u UserRecord) OwnerPublic() UserPublic { if len(u.AuthClients) > 0 { p.AuthClients = append([]AuthClientEntry(nil), u.AuthClients...) } + p.GitHubLinked = strings.TrimSpace(u.GitHubUserID) != "" + p.GiteaLinked = strings.TrimSpace(u.GiteaUserID) != "" + p.LinuxdoLinked = strings.TrimSpace(u.LinuxdoUserID) != "" + p.GoogleLinked = strings.TrimSpace(u.GoogleUserID) != "" + p.MicrosoftLinked = strings.TrimSpace(u.MicrosoftUserID) != "" return p } diff --git a/sproutgate-backend/internal/storage/config_hot_reload.go b/sproutgate-backend/internal/storage/config_hot_reload.go new file mode 100644 index 0000000..d07e9ba --- /dev/null +++ b/sproutgate-backend/internal/storage/config_hot_reload.go @@ -0,0 +1,72 @@ +package storage + +import ( + "strings" + "time" +) + +const configHotReloadMinInterval = 2 * time.Second + +// MaybeSyncRuntimeConfigFromDB 在「管理后台在其它节点保存了配置」时,用 MySQL 中的最新行覆盖本进程内存缓存,避免多副本部署必须重启才生效。 +// 带最短间隔节流,减少读库频率。 +func (s *Store) MaybeSyncRuntimeConfigFromDB() { + s.hotReloadMu.Lock() + elapsed := time.Since(s.lastHotReload) + should := s.lastHotReload.IsZero() || elapsed >= configHotReloadMinInterval + if should { + s.lastHotReload = time.Now() + } + s.hotReloadMu.Unlock() + if !should { + return + } + _ = s.syncRuntimeConfigFromDB() +} + +func (s *Store) syncRuntimeConfigFromDB() error { + if err := s.reloadOAuthConfigFromDB(); err != nil { + return err + } + if err := s.reloadTurnstileConfigFromDB(); err != nil { + return err + } + // 含邀请码列表等 + return s.loadOrCreateRegistrationConfig() +} + +func (s *Store) reloadOAuthConfigFromDB() error { + var cfg OAuthConfig + found, err := s.getConfig("oauth", &cfg) + if err != nil || !found { + return err + } + if strings.TrimSpace(cfg.GiteaBaseURL) == "" { + cfg.GiteaBaseURL = defaultGiteaBaseURL + } + cfg.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GiteaBaseURL), "/") + if strings.TrimSpace(cfg.LinuxdoConnectBaseURL) == "" { + cfg.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL + } + cfg.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(cfg.LinuxdoConnectBaseURL), "/") + if strings.TrimSpace(cfg.MicrosoftTenant) == "" { + cfg.MicrosoftTenant = defaultMicrosoftTenant + } else { + cfg.MicrosoftTenant = strings.TrimSpace(cfg.MicrosoftTenant) + } + s.mu.Lock() + s.oauthConfig = cfg + s.mu.Unlock() + return nil +} + +func (s *Store) reloadTurnstileConfigFromDB() error { + var cfg TurnstileConfig + _, err := s.getConfig("turnstile", &cfg) + if err != nil { + return err + } + s.mu.Lock() + s.turnstileConfig = cfg + s.mu.Unlock() + return nil +} diff --git a/sproutgate-backend/internal/storage/dbmodels.go b/sproutgate-backend/internal/storage/dbmodels.go index 65e8530..3e6b5dc 100644 --- a/sproutgate-backend/internal/storage/dbmodels.go +++ b/sproutgate-backend/internal/storage/dbmodels.go @@ -4,6 +4,7 @@ import ( "database/sql/driver" "encoding/json" "fmt" + "strings" "sproutgate-backend/internal/models" ) @@ -104,10 +105,30 @@ type DBUser struct { BannedAt string `gorm:"column:banned_at;size:50"` TokenEpoch int64 `gorm:"column:token_epoch;default:0"` AuthClients AuthClientSlice `gorm:"column:auth_clients;type:mediumtext"` + GitHubUserID *string `gorm:"column:github_user_id;size:64;uniqueIndex:idx_users_github_id"` + GiteaUserID *string `gorm:"column:gitea_user_id;size:64;uniqueIndex:idx_users_gitea_id"` + LinuxdoUserID *string `gorm:"column:linuxdo_user_id;size:64;uniqueIndex:idx_users_linuxdo_id"` + GoogleUserID *string `gorm:"column:google_user_id;size:128;uniqueIndex:idx_users_google_id"` + MicrosoftUserID *string `gorm:"column:microsoft_user_id;size:128;uniqueIndex:idx_users_microsoft_id"` } func (DBUser) TableName() string { return "users" } +func strPtrOrNil(s string) *string { + t := strings.TrimSpace(s) + if t == "" { + return nil + } + return &t +} + +func strDeref(p *string) string { + if p == nil { + return "" + } + return *p +} + // DBUserFromRecord 将领域模型转换为 GORM 模型(导出供 migrate 工具使用)。 func DBUserFromRecord(r models.UserRecord) DBUser { return dbUserFromRecord(r) @@ -141,6 +162,11 @@ func dbUserFromRecord(r models.UserRecord) DBUser { BannedAt: r.BannedAt, TokenEpoch: r.TokenEpoch, AuthClients: AuthClientSlice(r.AuthClients), + GitHubUserID: strPtrOrNil(r.GitHubUserID), + GiteaUserID: strPtrOrNil(r.GiteaUserID), + LinuxdoUserID: strPtrOrNil(r.LinuxdoUserID), + GoogleUserID: strPtrOrNil(r.GoogleUserID), + MicrosoftUserID: strPtrOrNil(r.MicrosoftUserID), } } @@ -172,6 +198,11 @@ func (d DBUser) toRecord() models.UserRecord { BannedAt: d.BannedAt, TokenEpoch: d.TokenEpoch, AuthClients: []models.AuthClientEntry(d.AuthClients), + GitHubUserID: strDeref(d.GitHubUserID), + GiteaUserID: strDeref(d.GiteaUserID), + LinuxdoUserID: strDeref(d.LinuxdoUserID), + GoogleUserID: strDeref(d.GoogleUserID), + MicrosoftUserID: strDeref(d.MicrosoftUserID), } } diff --git a/sproutgate-backend/internal/storage/oauth.go b/sproutgate-backend/internal/storage/oauth.go new file mode 100644 index 0000000..dce3dad --- /dev/null +++ b/sproutgate-backend/internal/storage/oauth.go @@ -0,0 +1,509 @@ +package storage + +import ( + "errors" + "net/url" + "strings" + + "gorm.io/gorm" + + "sproutgate-backend/internal/models" +) + +const defaultGiteaBaseURL = "https://git.shumengya.top" +const defaultLinuxdoConnectBaseURL = "https://connect.linux.do" +const defaultMicrosoftTenant = "common" + +func (s *Store) loadOrCreateOAuthConfig() error { + var cfg OAuthConfig + found, err := s.getConfig("oauth", &cfg) + if err != nil { + return err + } + changed := !found + if strings.TrimSpace(cfg.GiteaBaseURL) == "" { + cfg.GiteaBaseURL = defaultGiteaBaseURL + changed = true + } + cfg.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GiteaBaseURL), "/") + if strings.TrimSpace(cfg.LinuxdoConnectBaseURL) == "" { + cfg.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL + changed = true + } + cfg.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(cfg.LinuxdoConnectBaseURL), "/") + if strings.TrimSpace(cfg.MicrosoftTenant) == "" { + cfg.MicrosoftTenant = defaultMicrosoftTenant + changed = true + } else { + cfg.MicrosoftTenant = strings.TrimSpace(cfg.MicrosoftTenant) + } + if len(cfg.AllowedReturnPrefixes) == 0 { + cfg.AllowedReturnPrefixes = []string{ + "http://localhost:5173/", + "http://127.0.0.1:5173/", + } + changed = true + } + if changed { + if err := s.setConfig("oauth", cfg); err != nil { + return err + } + } + s.mu.Lock() + s.oauthConfig = cfg + s.mu.Unlock() + return nil +} + +// GetOAuthConfig 返回当前 OAuth 设置副本(含密钥;仅服务端使用)。 +func (s *Store) GetOAuthConfig() OAuthConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.oauthConfig +} + +// MergeUpdateOAuthConfig 更新 OAuth 设置;空字符串的 ClientSecret 表示保留原值。 +func (s *Store) MergeUpdateOAuthConfig(in OAuthConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + prev := s.oauthConfig + if strings.TrimSpace(in.GitHubClientSecret) == "" { + in.GitHubClientSecret = prev.GitHubClientSecret + } + if strings.TrimSpace(in.GiteaClientSecret) == "" { + in.GiteaClientSecret = prev.GiteaClientSecret + } + if strings.TrimSpace(in.LinuxdoClientSecret) == "" { + in.LinuxdoClientSecret = prev.LinuxdoClientSecret + } + if strings.TrimSpace(in.GoogleClientSecret) == "" { + in.GoogleClientSecret = prev.GoogleClientSecret + } + if strings.TrimSpace(in.MicrosoftClientSecret) == "" { + in.MicrosoftClientSecret = prev.MicrosoftClientSecret + } + if strings.TrimSpace(in.GiteaBaseURL) == "" { + in.GiteaBaseURL = defaultGiteaBaseURL + } + in.GiteaBaseURL = strings.TrimRight(strings.TrimSpace(in.GiteaBaseURL), "/") + if strings.TrimSpace(in.LinuxdoConnectBaseURL) == "" { + in.LinuxdoConnectBaseURL = defaultLinuxdoConnectBaseURL + } + in.LinuxdoConnectBaseURL = strings.TrimRight(strings.TrimSpace(in.LinuxdoConnectBaseURL), "/") + if in.AllowedReturnPrefixes == nil || len(in.AllowedReturnPrefixes) == 0 { + in.AllowedReturnPrefixes = prev.AllowedReturnPrefixes + } + if strings.TrimSpace(in.MicrosoftTenant) == "" { + if strings.TrimSpace(prev.MicrosoftTenant) != "" { + in.MicrosoftTenant = prev.MicrosoftTenant + } else { + in.MicrosoftTenant = defaultMicrosoftTenant + } + } else { + in.MicrosoftTenant = strings.TrimSpace(in.MicrosoftTenant) + } + if err := s.setConfig("oauth", in); err != nil { + return err + } + s.oauthConfig = in + return nil +} + +// PublicOAuthFlags 可对外暴露的开关(无密钥)。 +type PublicOAuthFlags struct { + GitHubEnabled bool `json:"githubEnabled"` + GiteaEnabled bool `json:"giteaEnabled"` + GoogleEnabled bool `json:"googleEnabled"` + MicrosoftEnabled bool `json:"microsoftEnabled"` + LinuxdoEnabled bool `json:"linuxdoEnabled"` +} + +// PublicOAuthFlags 返回是否启用各提供商。 +func (s *Store) PublicOAuthFlags() PublicOAuthFlags { + s.mu.RLock() + defer s.mu.RUnlock() + return PublicOAuthFlags{ + GitHubEnabled: s.oauthConfig.GitHubEnabled, + GiteaEnabled: s.oauthConfig.GiteaEnabled, + GoogleEnabled: s.oauthConfig.GoogleEnabled, + MicrosoftEnabled: s.oauthConfig.MicrosoftEnabled, + LinuxdoEnabled: s.oauthConfig.LinuxdoEnabled, + } +} + +// OAuthSignUpAllowed 是否允许用 OAuth 创建新用户(结合注册策略与后台开关)。 +func (s *Store) OAuthSignUpAllowed() bool { + s.mu.RLock() + defer s.mu.RUnlock() + if s.oauthConfig.AllowOAuthSignUpWhenInviteRequired { + return true + } + if s.registrationConfig.RequireInviteCode { + return false + } + return true +} + +// GetUserByEmail 主邮箱精确匹配(大小写不敏感,存库一般为小写)。 +func (s *Store) GetUserByEmail(email string) (models.UserRecord, bool, error) { + email = strings.TrimSpace(email) + if email == "" { + return models.UserRecord{}, false, nil + } + var row DBUser + result := s.db.First(&row, "LOWER(email) = LOWER(?)", email) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return models.UserRecord{}, false, nil + } + if result.Error != nil { + return models.UserRecord{}, false, result.Error + } + return row.toRecord(), true, nil +} + +// GetUserByGitHubID 非空时查询。 +func (s *Store) GetUserByGitHubID(id string) (models.UserRecord, bool, error) { + id = strings.TrimSpace(id) + if id == "" { + return models.UserRecord{}, false, nil + } + var row DBUser + result := s.db.First(&row, "github_user_id = ?", id) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return models.UserRecord{}, false, nil + } + if result.Error != nil { + return models.UserRecord{}, false, result.Error + } + return row.toRecord(), true, nil +} + +// GetUserByGiteaID 非空时查询。 +func (s *Store) GetUserByGiteaID(id string) (models.UserRecord, bool, error) { + id = strings.TrimSpace(id) + if id == "" { + return models.UserRecord{}, false, nil + } + var row DBUser + result := s.db.First(&row, "gitea_user_id = ?", id) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return models.UserRecord{}, false, nil + } + if result.Error != nil { + return models.UserRecord{}, false, result.Error + } + return row.toRecord(), true, nil +} + +// SetUserGitHubID 将 GitHub 用户 ID 绑定到账户;id 被他人占用时返回错误。 +func (s *Store) SetUserGitHubID(account, githubID string) error { + account = strings.TrimSpace(account) + githubID = strings.TrimSpace(githubID) + if account == "" || githubID == "" { + return errors.New("account and id required") + } + other, found, err := s.GetUserByGitHubID(githubID) + if err != nil { + return err + } + if found && !strings.EqualFold(other.Account, account) { + return errors.New("github account already linked to another user") + } + u, found, err := s.GetUser(account) + if err != nil || !found { + return errors.New("user not found") + } + if strings.TrimSpace(u.GitHubUserID) != "" && u.GitHubUserID != githubID { + return errors.New("user already has a different GitHub link") + } + u.GitHubUserID = githubID + return s.SaveUser(u) +} + +// SetUserGiteaID 将 Gitea 用户 ID 绑定到账户。 +func (s *Store) SetUserGiteaID(account, giteaID string) error { + account = strings.TrimSpace(account) + giteaID = strings.TrimSpace(giteaID) + if account == "" || giteaID == "" { + return errors.New("account and id required") + } + other, found, err := s.GetUserByGiteaID(giteaID) + if err != nil { + return err + } + if found && !strings.EqualFold(other.Account, account) { + return errors.New("gitea account already linked to another user") + } + u, found, err := s.GetUser(account) + if err != nil || !found { + return errors.New("user not found") + } + if strings.TrimSpace(u.GiteaUserID) != "" && u.GiteaUserID != giteaID { + return errors.New("user already has a different Gitea link") + } + u.GiteaUserID = giteaID + return s.SaveUser(u) +} + +// ClearUserGitHubID 解绑 GitHub。 +func (s *Store) ClearUserGitHubID(account string) error { + u, found, err := s.GetUser(strings.TrimSpace(account)) + if err != nil { + return err + } + if !found { + return errors.New("user not found") + } + u.GitHubUserID = "" + return s.SaveUser(u) +} + +// ClearUserGiteaID 解绑 Gitea。 +func (s *Store) ClearUserGiteaID(account string) error { + u, found, err := s.GetUser(strings.TrimSpace(account)) + if err != nil { + return err + } + if !found { + return errors.New("user not found") + } + u.GiteaUserID = "" + return s.SaveUser(u) +} + +// GetUserByLinuxdoID 非空时查询(LINUX DO Connect 用户主键)。 +func (s *Store) GetUserByLinuxdoID(id string) (models.UserRecord, bool, error) { + id = strings.TrimSpace(id) + if id == "" { + return models.UserRecord{}, false, nil + } + var row DBUser + result := s.db.First(&row, "linuxdo_user_id = ?", id) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return models.UserRecord{}, false, nil + } + if result.Error != nil { + return models.UserRecord{}, false, result.Error + } + return row.toRecord(), true, nil +} + +// SetUserLinuxdoID 绑定 LINUX DO 用户 ID。 +func (s *Store) SetUserLinuxdoID(account, linuxdoID string) error { + account = strings.TrimSpace(account) + linuxdoID = strings.TrimSpace(linuxdoID) + if account == "" || linuxdoID == "" { + return errors.New("account and id required") + } + other, found, err := s.GetUserByLinuxdoID(linuxdoID) + if err != nil { + return err + } + if found && !strings.EqualFold(other.Account, account) { + return errors.New("linux.do account already linked to another user") + } + u, found, err := s.GetUser(account) + if err != nil || !found { + return errors.New("user not found") + } + if strings.TrimSpace(u.LinuxdoUserID) != "" && u.LinuxdoUserID != linuxdoID { + return errors.New("user already has a different LINUX DO link") + } + u.LinuxdoUserID = linuxdoID + return s.SaveUser(u) +} + +// ClearUserLinuxdoID 解绑 LINUX DO。 +func (s *Store) ClearUserLinuxdoID(account string) error { + u, found, err := s.GetUser(strings.TrimSpace(account)) + if err != nil { + return err + } + if !found { + return errors.New("user not found") + } + u.LinuxdoUserID = "" + return s.SaveUser(u) +} + +// GetUserByGoogleID 非空时查询。 +func (s *Store) GetUserByGoogleID(id string) (models.UserRecord, bool, error) { + id = strings.TrimSpace(id) + if id == "" { + return models.UserRecord{}, false, nil + } + var row DBUser + result := s.db.First(&row, "google_user_id = ?", id) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return models.UserRecord{}, false, nil + } + if result.Error != nil { + return models.UserRecord{}, false, result.Error + } + return row.toRecord(), true, nil +} + +// SetUserGoogleID 将 Google 用户 ID 绑定到账户。 +func (s *Store) SetUserGoogleID(account, googleID string) error { + account = strings.TrimSpace(account) + googleID = strings.TrimSpace(googleID) + if account == "" || googleID == "" { + return errors.New("account and id required") + } + other, found, err := s.GetUserByGoogleID(googleID) + if err != nil { + return err + } + if found && !strings.EqualFold(other.Account, account) { + return errors.New("google account already linked to another user") + } + u, found, err := s.GetUser(account) + if err != nil || !found { + return errors.New("user not found") + } + if strings.TrimSpace(u.GoogleUserID) != "" && u.GoogleUserID != googleID { + return errors.New("user already has a different Google link") + } + u.GoogleUserID = googleID + return s.SaveUser(u) +} + +// ClearUserGoogleID 解绑 Google。 +func (s *Store) ClearUserGoogleID(account string) error { + u, found, err := s.GetUser(strings.TrimSpace(account)) + if err != nil { + return err + } + if !found { + return errors.New("user not found") + } + u.GoogleUserID = "" + return s.SaveUser(u) +} + +// GetUserByMicrosoftID 非空时查询。 +func (s *Store) GetUserByMicrosoftID(id string) (models.UserRecord, bool, error) { + id = strings.TrimSpace(id) + if id == "" { + return models.UserRecord{}, false, nil + } + var row DBUser + result := s.db.First(&row, "microsoft_user_id = ?", id) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return models.UserRecord{}, false, nil + } + if result.Error != nil { + return models.UserRecord{}, false, result.Error + } + return row.toRecord(), true, nil +} + +// SetUserMicrosoftID 将 Microsoft 用户 ID 绑定到账户。 +func (s *Store) SetUserMicrosoftID(account, msID string) error { + account = strings.TrimSpace(account) + msID = strings.TrimSpace(msID) + if account == "" || msID == "" { + return errors.New("account and id required") + } + other, found, err := s.GetUserByMicrosoftID(msID) + if err != nil { + return err + } + if found && !strings.EqualFold(other.Account, account) { + return errors.New("microsoft account already linked to another user") + } + u, found, err := s.GetUser(account) + if err != nil || !found { + return errors.New("user not found") + } + if strings.TrimSpace(u.MicrosoftUserID) != "" && u.MicrosoftUserID != msID { + return errors.New("user already has a different Microsoft link") + } + u.MicrosoftUserID = msID + return s.SaveUser(u) +} + +// ClearUserMicrosoftID 解绑 Microsoft。 +func (s *Store) ClearUserMicrosoftID(account string) error { + u, found, err := s.GetUser(strings.TrimSpace(account)) + if err != nil { + return err + } + if !found { + return errors.New("user not found") + } + u.MicrosoftUserID = "" + return s.SaveUser(u) +} + +// ProposeUniqueOAuthAccount 生成未占用的随机自助格式账户名,用于 OAuth 注册。 +func (s *Store) ProposeUniqueOAuthAccount() (string, error) { + for i := 0; i < 48; i++ { + raw, err := randomOAuthLocalPart(12) + if err != nil { + return "", err + } + acc := "oauth" + raw + if err := s.ValidateSelfServiceAccount(acc); err != nil { + continue + } + _, found, err := s.GetUser(acc) + if err != nil { + return "", err + } + if !found { + return acc, nil + } + } + return "", errors.New("failed to allocate unique account") +} + +// randomOAuthLocalPart 小写字母与数字,长度 n。 +func randomOAuthLocalPart(n int) (string, error) { + if n < 1 { + n = 8 + } + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789" + buf := make([]byte, n) + randBytes, err := generateSecret() + if err != nil { + return "", err + } + for i := 0; i < n; i++ { + buf[i] = alphabet[int(randBytes[i%len(randBytes)])%len(alphabet)] + } + return string(buf), nil +} + +// IsReturnURLAllowed 校验回跳地址是否以白名单中某一前缀开头(需为 http(s) 绝对地址)。 +func (c OAuthConfig) IsReturnURLAllowed(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Scheme == "" || u.Host == "" { + return false + } + if u.Scheme != "https" && u.Scheme != "http" { + return false + } + norm := u.String() + for _, pfx := range c.AllowedReturnPrefixes { + pfx = strings.TrimSpace(pfx) + if pfx == "" { + continue + } + if !strings.HasSuffix(pfx, "/") { + pfx += "/" + } + if strings.HasPrefix(norm, pfx) { + return true + } + base := strings.TrimRight(pfx, "/") + if norm == base || strings.HasPrefix(norm, base+"/") { + return true + } + } + return false +} diff --git a/sproutgate-backend/internal/storage/storage.go b/sproutgate-backend/internal/storage/storage.go index a7365bc..2f18d2b 100644 --- a/sproutgate-backend/internal/storage/storage.go +++ b/sproutgate-backend/internal/storage/storage.go @@ -8,6 +8,7 @@ import ( "os" "strings" "sync" + "time" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -40,6 +41,42 @@ type CheckInConfig struct { RewardCoins int `json:"rewardCoins"` } +// OAuthConfig 第三方登录(GitHub / Gitea / Google / Microsoft / Linux.do),密钥也可由环境变量覆盖。 +type OAuthConfig struct { + GitHubEnabled bool `json:"githubEnabled"` + GiteaEnabled bool `json:"giteaEnabled"` + GoogleEnabled bool `json:"googleEnabled"` + MicrosoftEnabled bool `json:"microsoftEnabled"` + LinuxdoEnabled bool `json:"linuxdoEnabled"` + GitHubClientID string `json:"githubClientId"` + GitHubClientSecret string `json:"githubClientSecret"` + GiteaBaseURL string `json:"giteaBaseUrl"` + GiteaClientID string `json:"giteaClientId"` + GiteaClientSecret string `json:"giteaClientSecret"` + GoogleClientID string `json:"googleClientId"` + GoogleClientSecret string `json:"googleClientSecret"` + MicrosoftClientID string `json:"microsoftClientId"` + MicrosoftClientSecret string `json:"microsoftClientSecret"` + MicrosoftTenant string `json:"microsoftTenant"` + LinuxdoConnectBaseURL string `json:"linuxdoConnectBaseUrl"` + LinuxdoClientID string `json:"linuxdoClientId"` + LinuxdoClientSecret string `json:"linuxdoClientSecret"` + GitHubLogoURL string `json:"githubLogoUrl"` + GiteaLogoURL string `json:"giteaLogoUrl"` + GoogleLogoURL string `json:"googleLogoUrl"` + MicrosoftLogoURL string `json:"microsoftLogoUrl"` + LinuxdoLogoURL string `json:"linuxdoLogoUrl"` + AllowedReturnPrefixes []string `json:"allowedReturnPrefixes"` + AllowOAuthSignUpWhenInviteRequired bool `json:"allowOAuthSignUpWhenInviteRequired"` +} + +// TurnstileConfig Cloudflare Turnstile 人机验证配置。 +type TurnstileConfig struct { + Enabled bool `json:"enabled"` + SiteKey string `json:"siteKey"` + SecretKey string `json:"secretKey"` +} + // ─── Store ───────────────────────────────────────────────────────────────────── type Store struct { @@ -50,7 +87,11 @@ type Store struct { emailConfig EmailConfig checkInConfig CheckInConfig registrationConfig RegistrationConfig + oauthConfig OAuthConfig + turnstileConfig TurnstileConfig mu sync.RWMutex + hotReloadMu sync.Mutex + lastHotReload time.Time } // NewStore 接收已建立连接的 *gorm.DB,自动迁移表结构并加载配置。 @@ -85,6 +126,12 @@ func NewStore(db *gorm.DB) (*Store, error) { if err := store.loadOrCreateRegistrationConfig(); err != nil { return nil, err } + if err := store.loadOrCreateOAuthConfig(); err != nil { + return nil, err + } + if err := store.loadOrCreateTurnstileConfig(); err != nil { + return nil, err + } return store, nil } diff --git a/sproutgate-backend/internal/storage/turnstile.go b/sproutgate-backend/internal/storage/turnstile.go new file mode 100644 index 0000000..27f4cb4 --- /dev/null +++ b/sproutgate-backend/internal/storage/turnstile.go @@ -0,0 +1,33 @@ +package storage + +import "strings" + +func (s *Store) GetTurnstileConfig() TurnstileConfig { + s.mu.RLock() + defer s.mu.RUnlock() + return s.turnstileConfig +} + +func (s *Store) UpdateTurnstileConfig(in TurnstileConfig) error { + s.mu.Lock() + defer s.mu.Unlock() + if strings.TrimSpace(in.SecretKey) == "" { + in.SecretKey = s.turnstileConfig.SecretKey + } + in.SiteKey = strings.TrimSpace(in.SiteKey) + if err := s.setConfig("turnstile", in); err != nil { + return err + } + s.turnstileConfig = in + return nil +} + +func (s *Store) loadOrCreateTurnstileConfig() error { + var cfg TurnstileConfig + _, err := s.getConfig("turnstile", &cfg) + if err != nil { + return err + } + s.turnstileConfig = cfg + return nil +} diff --git a/sproutgate-backend/main.go b/sproutgate-backend/main.go index 83fc8aa..7be2677 100644 --- a/sproutgate-backend/main.go +++ b/sproutgate-backend/main.go @@ -1,3 +1,22 @@ +//go:generate swag init -g main.go -o docs --parseInternal + +// @title 萌芽账户认证中心 API +// @version 0.1.0 +// @description 统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。 +// @host localhost:8080 +// @BasePath / + +// @securityDefinitions.apikey BearerAuth +// @in header +// @name Authorization +// @description JWT:Authorization 头填写「Bearer 」+ token(示例:Bearer eyJ...)。 + +// @securityDefinitions.apikey AdminToken +// @in header +// @name X-Admin-Token +// @description 管理端令牌;也可使用 Query `token` 或与部分客户端相同的 `Authorization` 头(见实现)。 + +// @schemes http https package main import ( @@ -8,12 +27,72 @@ import ( "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + _ "sproutgate-backend/docs" "sproutgate-backend/internal/database" "sproutgate-backend/internal/handlers" "sproutgate-backend/internal/storage" ) +func apiIntroPayload() gin.H { + return gin.H{ + "name": "SproutGate API", + "title": "萌芽账户认证中心", + "description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。", + "version": "0.1.0", + "links": gin.H{ + "health": "GET /api/health", + "docs": "GET /swagger/index.html", + }, + "routePrefixes": []string{ + "/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入", + "/api/public — 公开用户目录与资料、主页点赞、注册策略", + "/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)", + }, + } +} + +// GetRoot +// @Summary API 简介 JSON +// @Description 与 GET /api 相同,返回名称、版本与路由前缀说明。 +// @Tags meta +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router / [get] +func GetRoot(c *gin.Context) { + c.JSON(http.StatusOK, apiIntroPayload()) +} + +// GetAPI +// @Summary API 简介 JSON +// @Description 与 GET / 相同。 +// @Tags meta +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /api [get] +func GetAPI(c *gin.Context) { + c.JSON(http.StatusOK, apiIntroPayload()) +} + +// GetHealth +// @Summary 健康检查 +// @Tags meta +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /api/health [get] +func GetHealth(c *gin.Context) { + env := os.Getenv("APP_ENV") + if env == "" { + env = "development" + } + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "env": env, + }) +} + func main() { db, err := database.Open() if err != nil { @@ -35,37 +114,15 @@ func main() { handler := handlers.NewHandler(store) - apiIntro := gin.H{ - "name": "SproutGate API", - "title": "萌芽账户认证中心", - "description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。", - "version": "0.1.0", - "links": gin.H{ - "health": "GET /api/health", - }, - "routePrefixes": []string{ - "/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入", - "/api/public — 公开用户目录与资料、主页点赞、注册策略", - "/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)", - }, - } - router.GET("/", func(c *gin.Context) { - c.JSON(http.StatusOK, apiIntro) - }) - router.GET("/api", func(c *gin.Context) { - c.JSON(http.StatusOK, apiIntro) - }) + router.GET("/", GetRoot) + router.GET("/api", GetAPI) - router.GET("/api/health", func(c *gin.Context) { - env := os.Getenv("APP_ENV") - if env == "" { - env = "development" - } - c.JSON(http.StatusOK, gin.H{ - "status": "ok", - "env": env, - }) + router.GET("/api/health", GetHealth) + + router.GET("/api/docs", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/swagger/index.html") }) + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) router.POST("/api/auth/login", handler.Login) router.POST("/api/auth/register", handler.Register) @@ -78,6 +135,10 @@ func main() { router.GET("/api/auth/me", handler.Me) router.POST("/api/auth/check-in", handler.CheckIn) router.PUT("/api/auth/profile", handler.UpdateProfile) + router.GET("/api/auth/oauth/:provider/start", handler.OAuthStart) + router.GET("/api/auth/oauth/:provider/callback", handler.OAuthCallback) + router.POST("/api/auth/oauth/:provider/bind", handler.OAuthBindURL) + router.DELETE("/api/auth/oauth/:provider", handler.OAuthUnlink) router.GET("/api/public/users", handler.ListPublicUsers) router.GET("/api/public/users/:account", handler.GetPublicUser) router.POST("/api/public/users/:account/like", handler.PostPublicProfileLike) @@ -95,6 +156,10 @@ func main() { admin.PUT("/registration", handler.PutAdminRegistrationPolicy) admin.POST("/registration/invites", handler.PostAdminInvite) admin.DELETE("/registration/invites/:code", handler.DeleteAdminInvite) + admin.GET("/oauth", handler.GetAdminOAuth) + admin.PUT("/oauth", handler.PutAdminOAuth) + admin.GET("/turnstile", handler.GetAdminTurnstile) + admin.PUT("/turnstile", handler.PutAdminTurnstile) port := os.Getenv("PORT") if port == "" { diff --git a/sproutgate-frontend/src/App.jsx b/sproutgate-frontend/src/App.jsx index 4d2822d..eab8e17 100644 --- a/sproutgate-frontend/src/App.jsx +++ b/sproutgate-frontend/src/App.jsx @@ -35,6 +35,19 @@ function App() { const authFlow = useMemo(() => getAuthFlowFromSearch(search), [search]); const publicAccount = useMemo(() => getPublicAccountFromPathname(pathname), [pathname]); + const isHomeUserCenter = !isAdmin && !isPublicUserList && !isPublicUser && !isAuthorizeRoute; + const usesUserPortal = isAuthorizeRoute || isHomeUserCenter; + + const [guestAuthLayout, setGuestAuthLayout] = useState(false); + const shellAuthGuest = guestAuthLayout && usesUserPortal; + /** 与未登录用户中心同套全屏 + 底图 + 主区磨砂 */ + const shellPublicImmersive = isPublicUserList || isPublicUser; + const shellImmersive = Boolean(shellAuthGuest || shellPublicImmersive); + + useLayoutEffect(() => { + if (!usesUserPortal) setGuestAuthLayout(false); + }, [usesUserPortal]); + const authDenyHref = useMemo(() => { const r = (authFlow?.redirectUri || "").trim(); if (!r) return ""; @@ -174,15 +187,15 @@ function App() {
-
-

萌芽账户认证中心

-
); @@ -212,12 +225,12 @@ function App() { ); return ( -
+
- {booting && } -
+ {booting && } +
{isPublicUserList ? ( -
+
@@ -230,7 +243,7 @@ function App() {
) : isPublicUser ? ( -
+
@@ -256,7 +269,11 @@ function App() {
) : isAuthorizeRoute ? ( -
+
@@ -268,13 +285,16 @@ function App() { onReady={markReady} authFlow={authFlow} onPreviewImage={openImagePreview} + onGuestAuthLayoutChange={setGuestAuthLayout} oauthStandalone />
) : ( -
+
@@ -282,7 +302,12 @@ function App() { {headerNav}
- +
@@ -296,7 +321,10 @@ function App() {

管理员入口

-

请输入与后端配置一致的管理员 Token。

+

请输入与后端 X-Admin-Token 一致的管理员 Token。

+

+ 未修改过本机/内网 data 时,默认示例常为 shumengya520,以你部署的后端 config 为准。 +

diff --git a/sproutgate-frontend/src/components/AdminPanel.jsx b/sproutgate-frontend/src/components/AdminPanel.jsx index c5c4553..e118ac9 100644 --- a/sproutgate-frontend/src/components/AdminPanel.jsx +++ b/sproutgate-frontend/src/components/AdminPanel.jsx @@ -38,12 +38,58 @@ export default function AdminPanel({ onReady }) { const [newInvMax, setNewInvMax] = useState(0); const [newInvExp, setNewInvExp] = useState(""); + const [oauthLoading, setOauthLoading] = useState(false); + const [oauthSaving, setOauthSaving] = useState(false); + const [oauthError, setOauthError] = useState(""); + const [oauthMessage, setOauthMessage] = useState(""); + const [oauthGithubEnabled, setOauthGithubEnabled] = useState(false); + const [oauthGiteaEnabled, setOauthGiteaEnabled] = useState(false); + const [oauthGoogleEnabled, setOauthGoogleEnabled] = useState(false); + const [oauthMicrosoftEnabled, setOauthMicrosoftEnabled] = useState(false); + const [oauthGithubClientId, setOauthGithubClientId] = useState(""); + const [oauthGithubClientSecret, setOauthGithubClientSecret] = useState(""); + const [oauthGiteaBase, setOauthGiteaBase] = useState("https://git.shumengya.top"); + const [oauthGiteaClientId, setOauthGiteaClientId] = useState(""); + const [oauthGiteaClientSecret, setOauthGiteaClientSecret] = useState(""); + const [oauthGoogleClientId, setOauthGoogleClientId] = useState(""); + const [oauthGoogleClientSecret, setOauthGoogleClientSecret] = useState(""); + const [oauthMicrosoftClientId, setOauthMicrosoftClientId] = useState(""); + const [oauthMicrosoftClientSecret, setOauthMicrosoftClientSecret] = useState(""); + const [oauthMicrosoftTenant, setOauthMicrosoftTenant] = useState("common"); + const [oauthLinuxdoEnabled, setOauthLinuxdoEnabled] = useState(false); + const [oauthLinuxdoConnectBase, setOauthLinuxdoConnectBase] = useState("https://connect.linux.do"); + const [oauthLinuxdoClientId, setOauthLinuxdoClientId] = useState(""); + const [oauthLinuxdoClientSecret, setOauthLinuxdoClientSecret] = useState(""); + const [oauthReturnPrefixes, setOauthReturnPrefixes] = useState("http://localhost:5173/\nhttp://127.0.0.1:5173/"); + const [oauthSignUpWithInvite, setOauthSignUpWithInvite] = useState(false); + const [oauthGithubSecretSet, setOauthGithubSecretSet] = useState(false); + const [oauthGiteaSecretSet, setOauthGiteaSecretSet] = useState(false); + const [oauthGoogleSecretSet, setOauthGoogleSecretSet] = useState(false); + const [oauthMicrosoftSecretSet, setOauthMicrosoftSecretSet] = useState(false); + const [oauthLinuxdoSecretSet, setOauthLinuxdoSecretSet] = useState(false); + const [oauthGithubLogoUrl, setOauthGithubLogoUrl] = useState(""); + const [oauthGiteaLogoUrl, setOauthGiteaLogoUrl] = useState(""); + const [oauthGoogleLogoUrl, setOauthGoogleLogoUrl] = useState(""); + const [oauthMicrosoftLogoUrl, setOauthMicrosoftLogoUrl] = useState(""); + const [oauthLinuxdoLogoUrl, setOauthLinuxdoLogoUrl] = useState(""); + + const [tsLoading, setTsLoading] = useState(false); + const [tsSaving, setTsSaving] = useState(false); + const [tsError, setTsError] = useState(""); + const [tsMessage, setTsMessage] = useState(""); + const [tsEnabled, setTsEnabled] = useState(false); + const [tsSiteKey, setTsSiteKey] = useState(""); + const [tsSecretKey, setTsSecretKey] = useState(""); + const [tsSecretKeySet, setTsSecretKeySet] = useState(false); + useEffect(() => { if (token) { localStorage.setItem("sproutgate_admin_token", token); loadUsers(); loadCheckInConfig(); loadRegistration(); + loadOAuth(); + loadTurnstile(); } else if (onReady && !readySent) { onReady(); setReadySent(true); @@ -90,6 +136,165 @@ export default function AdminPanel({ onReady }) { } }; + const loadOAuth = async () => { + if (!token) return; + setOauthLoading(true); + setOauthError(""); + try { + const res = await fetch(`${API_BASE}/api/admin/oauth`, { + headers: { "X-Admin-Token": token } + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "加载 OAuth 配置失败"); + setOauthGithubEnabled(Boolean(data.githubEnabled)); + setOauthGiteaEnabled(Boolean(data.giteaEnabled)); + setOauthGithubClientId((data.githubClientId || "").trim()); + setOauthGithubClientSecret(""); + setOauthGithubSecretSet(Boolean(data.githubClientSecretSet)); + setOauthGiteaBase((data.giteaBaseUrl || "https://git.shumengya.top").trim()); + setOauthGiteaClientId((data.giteaClientId || "").trim()); + setOauthGiteaClientSecret(""); + setOauthGiteaSecretSet(Boolean(data.giteaClientSecretSet)); + setOauthGoogleEnabled(Boolean(data.googleEnabled)); + setOauthMicrosoftEnabled(Boolean(data.microsoftEnabled)); + setOauthGoogleClientId((data.googleClientId || "").trim()); + setOauthGoogleClientSecret(""); + setOauthGoogleSecretSet(Boolean(data.googleClientSecretSet)); + setOauthMicrosoftClientId((data.microsoftClientId || "").trim()); + setOauthMicrosoftClientSecret(""); + setOauthMicrosoftSecretSet(Boolean(data.microsoftClientSecretSet)); + setOauthMicrosoftTenant((data.microsoftTenant || "common").trim() || "common"); + setOauthLinuxdoEnabled(Boolean(data.linuxdoEnabled)); + setOauthLinuxdoConnectBase((data.linuxdoConnectBaseUrl || "https://connect.linux.do").trim()); + setOauthLinuxdoClientId((data.linuxdoClientId || "").trim()); + setOauthLinuxdoClientSecret(""); + setOauthLinuxdoSecretSet(Boolean(data.linuxdoClientSecretSet)); + setOauthGithubLogoUrl((data.githubLogoUrl || "").trim()); + setOauthGiteaLogoUrl((data.giteaLogoUrl || "").trim()); + setOauthGoogleLogoUrl((data.googleLogoUrl || "").trim()); + setOauthMicrosoftLogoUrl((data.microsoftLogoUrl || "").trim()); + setOauthLinuxdoLogoUrl((data.linuxdoLogoUrl || "").trim()); + const pfx = data.allowedReturnPrefixes; + setOauthReturnPrefixes( + Array.isArray(pfx) && pfx.length ? pfx.join("\n") : "http://localhost:5173/\nhttp://127.0.0.1:5173/" + ); + setOauthSignUpWithInvite(Boolean(data.allowOAuthSignUpWhenInviteRequired)); + } catch (err) { + setOauthError(err.message); + } finally { + setOauthLoading(false); + } + }; + + const saveOAuth = async () => { + if (!token) return; + setOauthMessage(""); + setOauthError(""); + setOauthSaving(true); + const prefixes = oauthReturnPrefixes + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean); + try { + const res = await fetch(`${API_BASE}/api/admin/oauth`, { + method: "PUT", + headers: { "Content-Type": "application/json", "X-Admin-Token": token }, + body: JSON.stringify({ + githubEnabled: oauthGithubEnabled, + giteaEnabled: oauthGiteaEnabled, + googleEnabled: oauthGoogleEnabled, + microsoftEnabled: oauthMicrosoftEnabled, + linuxdoEnabled: oauthLinuxdoEnabled, + githubClientId: oauthGithubClientId.trim(), + githubClientSecret: oauthGithubClientSecret, + giteaBaseUrl: oauthGiteaBase.trim() || "https://git.shumengya.top", + giteaClientId: oauthGiteaClientId.trim(), + giteaClientSecret: oauthGiteaClientSecret, + googleClientId: oauthGoogleClientId.trim(), + googleClientSecret: oauthGoogleClientSecret, + microsoftClientId: oauthMicrosoftClientId.trim(), + microsoftClientSecret: oauthMicrosoftClientSecret, + microsoftTenant: oauthMicrosoftTenant.trim() || "common", + linuxdoConnectBaseUrl: oauthLinuxdoConnectBase.trim() || "https://connect.linux.do", + linuxdoClientId: oauthLinuxdoClientId.trim(), + linuxdoClientSecret: oauthLinuxdoClientSecret, + githubLogoUrl: oauthGithubLogoUrl.trim(), + giteaLogoUrl: oauthGiteaLogoUrl.trim(), + googleLogoUrl: oauthGoogleLogoUrl.trim(), + microsoftLogoUrl: oauthMicrosoftLogoUrl.trim(), + linuxdoLogoUrl: oauthLinuxdoLogoUrl.trim(), + allowedReturnPrefixes: prefixes, + allowOAuthSignUpWhenInviteRequired: oauthSignUpWithInvite + }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "保存失败"); + setOauthMessage("第三方登录配置已保存"); + setOauthGithubClientSecret(""); + setOauthGiteaClientSecret(""); + setOauthGoogleClientSecret(""); + setOauthMicrosoftClientSecret(""); + setOauthLinuxdoClientSecret(""); + setOauthGithubSecretSet(Boolean(data.githubClientSecretSet)); + setOauthGiteaSecretSet(Boolean(data.giteaClientSecretSet)); + setOauthGoogleSecretSet(Boolean(data.googleClientSecretSet)); + setOauthMicrosoftSecretSet(Boolean(data.microsoftClientSecretSet)); + setOauthLinuxdoSecretSet(Boolean(data.linuxdoClientSecretSet)); + } catch (err) { + setOauthError(err.message); + } finally { + setOauthSaving(false); + } + }; + + const loadTurnstile = async () => { + if (!token) return; + setTsLoading(true); + setTsError(""); + try { + const res = await fetch(`${API_BASE}/api/admin/turnstile`, { + headers: { "X-Admin-Token": token } + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "加载 Turnstile 配置失败"); + setTsEnabled(Boolean(data.enabled)); + setTsSiteKey((data.siteKey || "").trim()); + setTsSecretKey(""); + setTsSecretKeySet(Boolean(data.secretKeySet)); + } catch (err) { + setTsError(err.message); + } finally { + setTsLoading(false); + } + }; + + const saveTurnstile = async () => { + if (!token) return; + setTsMessage(""); + setTsError(""); + setTsSaving(true); + try { + const res = await fetch(`${API_BASE}/api/admin/turnstile`, { + method: "PUT", + headers: { "Content-Type": "application/json", "X-Admin-Token": token }, + body: JSON.stringify({ + enabled: tsEnabled, + siteKey: tsSiteKey.trim(), + secretKey: tsSecretKey + }) + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "保存失败"); + setTsMessage("Turnstile 配置已保存"); + setTsSecretKey(""); + setTsSecretKeySet(Boolean(data.secretKeySet)); + } catch (err) { + setTsError(err.message); + } finally { + setTsSaving(false); + } + }; + const loadRegistration = async () => { if (!token) return; setRegLoading(true); @@ -366,22 +571,71 @@ export default function AdminPanel({ onReady }) { const editingUser = users.find((u) => u.account === selectedAccount); const editingAuthClients = editingUser?.authClients || []; - return ( -
-
-
管理员控制台
+ const validAdminNav = useMemo( + () => new Set(["general", "users", "checkin", "oauth", "turnstile", "registration"]), + [] + ); + const [adminNav, setAdminNav] = useState(() => { + if (typeof window === "undefined") return "general"; + const h = (window.location.hash || "").replace(/^#/, ""); + return validAdminNav.has(h) ? h : "general"; + }); + useEffect(() => { + const onHash = () => { + const h = (window.location.hash || "").replace(/^#/, ""); + if (validAdminNav.has(h)) setAdminNav(h); + }; + window.addEventListener("hashchange", onHash); + return () => window.removeEventListener("hashchange", onHash); + }, [validAdminNav]); + const goAdminNav = (id) => { + if (!validAdminNav.has(id)) return; + setAdminNav(id); + if ((window.location.hash || "").replace(/^#/, "") !== id) { + window.location.hash = id; + } + }; + return ( +
+
+ +
+

管理员控制台

+ + {adminNav === "general" && (

管理员 Token

- +

在左侧切换模块。填写 Token 后可使用「用户管理」等需鉴权的操作。

+ )} + {adminNav === "checkin" && (

签到设置

+ )} + {adminNav === "oauth" && ( +
+

第三方登录(GitHub / Gitea / Google / Microsoft / LINUX DO)

+

+ 在对应云控制台注册应用。回调地址在 API 公网根后加 + /api/auth/oauth/github/callback、 + /api/auth/oauth/gitea/callback、 + /api/auth/oauth/google/callback、 + /api/auth/oauth/microsoft/callback、 + /api/auth/oauth/linuxdo/callback。 + 可设置 + PUBLIC_API_BASEGOOGLE_CLIENT_SECRETMICROSOFT_CLIENT_SECRET 等环境变量;见{" "} + docs/oauth-github-gitea-testing.md。下方需配置「允许的回跳地址」。 +

+ + + + + + + + + + + + + + + + + + + + + + + +