Update SproutGate

This commit is contained in:
2026-05-13 12:19:36 +08:00
parent f7db8aa053
commit a37b92e144
51 changed files with 12034 additions and 254 deletions

View File

@@ -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": []
}
]
}
]
}

View File

@@ -0,0 +1,185 @@
# Cloudflare Turnstile 网站接入指南
本文说明如何在任意网站中集成 [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) 人机验证:从控制台拿密钥、前端展示控件、把一次性 token 交给后端、由服务端向 Cloudflare 校验。内容适用于自建站点与 API不限定具体技术栈文末可对照本仓库中的实现。
---
## 1. 概念与流程
| 项 | 说明 |
| ------------------- | -------------------------------------------------------------------------------------- |
| **Site Key** | 公钥,可出现在前端代码或 HTML 中。 |
| **Secret Key** | 私钥,**仅**能用于你的服务端调用校验接口。 |
| **Tokenresponse** | 用户通过验证后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
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
defer
></script>
```
全局会暴露 `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 客户端设置合理超时(如 815 秒),失败时对用户显示通用错误,**不要**在错误信息中泄露 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`
第三方站点只需遵守本文第 15 节的通用步骤即可;具体路由与配置项以各自系统为准。

View File

@@ -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`
- GoogleGoogle 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 DOConnect 应用接入):
`{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 IDSecret 可用环境变量注入,界面里可留空以保留原值。
### 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 请求失败 | 检查服务器能否访问 GiteaClient ID/SecretGitea 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 登录及绑定验证。

View File

@@ -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 IDv2.0 终结点 + Microsoft Graph |
**共同前提**
- 浏览器中完成授权;`redirect_uri` 必须在 IdP 控制台**逐字登记**(含 `http`/`https`、主机、端口、路径)。
- 换 token 与调用户 API 一般由**你的服务端**发起;请保证服务器出网可达对应域名(企业网络/地区网络可能需代理)。
- 生产环境使用 **HTTPS**`state` 参数防 CSRF勿省略。
以下各篇相互独立,可按需只读其一。

View File

@@ -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
}
// 示例:构造 ConfigBASE、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** 作为绑定主键更稳妥。

View File

@@ -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可改名

View File

@@ -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` 作为跨登录稳定主键;邮箱可能变化,慎单独依赖。

View File

@@ -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 已绑其他用户”的冲突。

View File

@@ -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 仅存服务端。

View File

@@ -5,6 +5,6 @@
data
node_modules
dist
# 勿忽略 dist/:默认 Dockerfile 从 dist/sproutgate-backend 复制(先 build-linux-amd64.bat
*.log

View File

@@ -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"]

View File

@@ -0,0 +1,5 @@
@echo off
setlocal
cd /d "%~dp0"
call "%~dp0build-linux-amd64.bat"
exit /b %errorlevel%

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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 == "" {

View File

@@ -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 == "" {

View File

@@ -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

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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,
})
}

View File

@@ -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),
})
}

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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)
}

View File

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

View File

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

View File

@@ -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),
}
}

View File

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

View File

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

View File

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

View File

@@ -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 JWTAuthorization 头填写「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 == "" {

View File

@@ -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() {
<div className="app-header-brand">
<button
type="button"
className="app-header-logo-btn"
className="app-header-brand-btn"
onClick={handleLogoTap}
aria-label="SproutGate 标志"
aria-label="站点标志(在图标或标题上连续点击五次可打开管理员验证)"
>
<img className="app-header-logo" src={LOGO_192_SRC} alt="" width={40} height={40} decoding="async" />
<div className="app-header-brand-text">
<h1>萌芽账户认证中心</h1>
</div>
</button>
<div>
<h1>萌芽账户认证中心</h1>
</div>
</div>
);
@@ -212,12 +225,12 @@ function App() {
);
return (
<div className="app-shell">
<div className={`app-shell${shellImmersive ? " app-shell--auth-guest" : ""}`}>
<RandomSiteBackground />
{booting && <SplashScreen />}
<div className="app">
{booting && <SplashScreen onLogoTap={handleLogoTap} />}
<div className={`app${shellImmersive ? " app--auth-guest" : ""}`}>
{isPublicUserList ? (
<div className="unified-user-shell">
<div className="unified-user-shell unified-user-shell--auth-guest">
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header">
@@ -230,7 +243,7 @@ function App() {
</div>
</div>
) : isPublicUser ? (
<div className="unified-user-shell">
<div className="unified-user-shell unified-user-shell--auth-guest">
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header">
@@ -256,7 +269,11 @@ function App() {
</div>
</div>
) : isAuthorizeRoute ? (
<div className="unified-user-shell oauth-authorize-shell">
<div
className={`unified-user-shell oauth-authorize-shell${
shellAuthGuest ? " unified-user-shell--auth-guest" : ""
}`}
>
<div className="unified-user-card oauth-authorize-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header oauth-authorize-header">
@@ -268,13 +285,16 @@ function App() {
onReady={markReady}
authFlow={authFlow}
onPreviewImage={openImagePreview}
onGuestAuthLayoutChange={setGuestAuthLayout}
oauthStandalone
/>
</div>
</div>
</div>
) : (
<div className="unified-user-shell">
<div
className={`unified-user-shell${shellAuthGuest ? " unified-user-shell--auth-guest" : ""}`}
>
<div className="unified-user-card">
<div className="unified-user-card-accent" aria-hidden />
<header className="unified-user-header">
@@ -282,7 +302,12 @@ function App() {
{headerNav}
</header>
<div className="unified-user-main">
<UserPortal onReady={markReady} authFlow={authFlow} onPreviewImage={openImagePreview} />
<UserPortal
onReady={markReady}
authFlow={authFlow}
onPreviewImage={openImagePreview}
onGuestAuthLayoutChange={setGuestAuthLayout}
/>
</div>
</div>
</div>
@@ -296,7 +321,10 @@ function App() {
<div className="modal-header">
<div>
<h2>管理员入口</h2>
<p>请输入与后端配置一致的管理员 Token</p>
<p>请输入与后端 <code className="inline-code">X-Admin-Token</code> 一致的管理员 Token</p>
<p className="hint admin-gate-token-hint">
未修改过本机/内网 data 默认示例常为 <code className="inline-code">shumengya520</code>以你部署的后端 <code className="inline-code">config</code> 为准
</p>
</div>
<button type="button" className="ghost modal-close" onClick={closeAdminGate}>关闭</button>
</div>

View File

@@ -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 (
<section className="panel">
<div className="admin-console form">
<div className="panel-title">管理员控制台</div>
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 (
<section className="panel admin-shell">
<div className="admin-layout">
<aside className="admin-sidebar" aria-label="管理模块">
<div className="admin-sidebar-title">管理后台</div>
<nav className="admin-sidebar-nav">
{[
{ id: "general", label: "概览与身份" },
{ id: "users", label: "用户管理" },
{ id: "checkin", label: "签到设置" },
{ id: "oauth", label: "第三方登录" },
{ id: "turnstile", label: "人机验证" },
{ id: "registration", label: "注册与邀请" }
].map((item) => (
<button
key={item.id}
type="button"
className={adminNav === item.id ? "admin-sidebar-link is-active" : "admin-sidebar-link"}
onClick={() => goAdminNav(item.id)}
>
{item.label}
</button>
))}
</nav>
</aside>
<div className="admin-main form admin-console">
<h1 className="admin-main-title">管理员控制台</h1>
{adminNav === "general" && (
<div className="admin-section">
<h2 className="admin-section-heading">管理员 Token</h2>
<label>
<IconLabel icon={icons.token} text="Token" />
<input value={token} onChange={(e) => setToken(e.target.value.trim())} placeholder="请输入管理员 Token" />
</label>
<button className="ghost" onClick={loadUsers} disabled={!token || loading}>
{loading ? "加载中..." : "刷新用户列表"}
</button>
<p className="hint">在左侧切换模块填写 Token 后可使用用户管理等需鉴权的操作</p>
</div>
)}
{adminNav === "checkin" && (
<div className="admin-section">
<h2 className="admin-section-heading">签到设置</h2>
<label>
@@ -403,7 +657,310 @@ export default function AdminPanel({ onReady }) {
</button>
</div>
</div>
)}
{adminNav === "oauth" && (
<div className="admin-section">
<h2 className="admin-section-heading">第三方登录GitHub / Gitea / Google / Microsoft / LINUX DO</h2>
<p className="hint">
在对应云控制台注册应用回调地址在 API 公网根后加
<code className="inline-code">/api/auth/oauth/github/callback</code>
<code className="inline-code">/api/auth/oauth/gitea/callback</code>
<code className="inline-code">/api/auth/oauth/google/callback</code>
<code className="inline-code">/api/auth/oauth/microsoft/callback</code>
<code className="inline-code">/api/auth/oauth/linuxdo/callback</code>
可设置
<code className="inline-code">PUBLIC_API_BASE</code> <code className="inline-code">GOOGLE_CLIENT_SECRET</code><code className="inline-code">MICROSOFT_CLIENT_SECRET</code> 等环境变量{" "}
<code className="inline-code">docs/oauth-github-gitea-testing.md</code>下方需配置允许的回跳地址
</p>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 GitHub 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthGithubEnabled}
onChange={(e) => setOauthGithubEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="GitHub Client ID" />
<input value={oauthGithubClientId} onChange={(e) => setOauthGithubClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="GitHub Client Secret" hint={oauthGithubSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthGithubClientSecret}
onChange={(e) => setOauthGithubClientSecret(e.target.value)}
placeholder={oauthGithubSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="GitHub 登录按钮 Logo" hint="选填https 图片地址,用户中心胶囊按钮左侧)" />
<input
value={oauthGithubLogoUrl}
onChange={(e) => setOauthGithubLogoUrl(e.target.value)}
placeholder="https://example.com/github-mark.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 Gitea 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthGiteaEnabled}
onChange={(e) => setOauthGiteaEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.link} text="Gitea 根地址" />
<input value={oauthGiteaBase} onChange={(e) => setOauthGiteaBase(e.target.value)} placeholder="https://git.shumengya.top" disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.username} text="Gitea Client ID" />
<input value={oauthGiteaClientId} onChange={(e) => setOauthGiteaClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="Gitea Client Secret" hint={oauthGiteaSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthGiteaClientSecret}
onChange={(e) => setOauthGiteaClientSecret(e.target.value)}
placeholder={oauthGiteaSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="Gitea 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthGiteaLogoUrl}
onChange={(e) => setOauthGiteaLogoUrl(e.target.value)}
placeholder="https://example.com/gitea.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 Google 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthGoogleEnabled}
onChange={(e) => setOauthGoogleEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="Google Client ID" />
<input value={oauthGoogleClientId} onChange={(e) => setOauthGoogleClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="Google Client Secret" hint={oauthGoogleSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthGoogleClientSecret}
onChange={(e) => setOauthGoogleClientSecret(e.target.value)}
placeholder={oauthGoogleSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="Google 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthGoogleLogoUrl}
onChange={(e) => setOauthGoogleLogoUrl(e.target.value)}
placeholder="https://example.com/google-g.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 Microsoft 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthMicrosoftEnabled}
onChange={(e) => setOauthMicrosoftEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="Microsoft Client ID" />
<input value={oauthMicrosoftClientId} onChange={(e) => setOauthMicrosoftClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel
icon={icons.link}
text="Microsoft 租户"
hint="Entra 租户 ID 或 common多租户+个人账户常用 common"
/>
<input
value={oauthMicrosoftTenant}
onChange={(e) => setOauthMicrosoftTenant(e.target.value)}
placeholder="common"
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.token} text="Microsoft Client Secret" hint={oauthMicrosoftSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthMicrosoftClientSecret}
onChange={(e) => setOauthMicrosoftClientSecret(e.target.value)}
placeholder={oauthMicrosoftSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="Microsoft 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthMicrosoftLogoUrl}
onChange={(e) => setOauthMicrosoftLogoUrl(e.target.value)}
placeholder="https://example.com/ms.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.link} text="启用 LINUX DO 登录" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthLinuxdoEnabled}
onChange={(e) => setOauthLinuxdoEnabled(e.target.checked)}
disabled={!token || oauthLoading}
/>
</span>
</label>
<label>
<IconLabel icon={icons.link} text="LINUX DO Connect 根地址" />
<input
value={oauthLinuxdoConnectBase}
onChange={(e) => setOauthLinuxdoConnectBase(e.target.value)}
placeholder="https://connect.linux.do"
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.username} text="LINUX DO Client ID" />
<input value={oauthLinuxdoClientId} onChange={(e) => setOauthLinuxdoClientId(e.target.value)} disabled={!token || oauthLoading} />
</label>
<label>
<IconLabel icon={icons.token} text="LINUX DO Client Secret" hint={oauthLinuxdoSecretSet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={oauthLinuxdoClientSecret}
onChange={(e) => setOauthLinuxdoClientSecret(e.target.value)}
placeholder={oauthLinuxdoSecretSet ? "留空保留" : ""}
disabled={!token || oauthLoading}
/>
</label>
<label>
<IconLabel icon={icons.link} text="LINUX DO 登录按钮 Logo" hint="选填https 图片地址)" />
<input
value={oauthLinuxdoLogoUrl}
onChange={(e) => setOauthLinuxdoLogoUrl(e.target.value)}
placeholder="https://example.com/linuxdo.png"
disabled={!token || oauthLoading}
/>
</label>
<label className="full-span">
<IconLabel icon={icons.link} text="允许的回跳地址前缀" hint="(每行一个,需含协议与尾斜杠,如 https://auth.shumengya.top/" />
<textarea
value={oauthReturnPrefixes}
onChange={(e) => setOauthReturnPrefixes(e.target.value)}
rows={4}
disabled={!token || oauthLoading}
/>
</label>
<label className="admin-ban-row">
<IconLabel icon={icons.token} text="需邀请时仍允许 OAuth 新用户" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={oauthSignUpWithInvite}
onChange={(e) => setOauthSignUpWithInvite(e.target.checked)}
disabled={!token || oauthLoading}
/>
<span>开启后即使注册策略要求邀请码用户仍可用第三方登录直接注册新账户</span>
</span>
</label>
{oauthError && <div className="error">{oauthError}</div>}
{oauthMessage && <div className="success">{oauthMessage}</div>}
<div className="actions compact">
<button type="button" className="primary" onClick={saveOAuth} disabled={!token || oauthSaving || oauthLoading}>
{oauthSaving ? "保存中…" : "保存第三方登录"}
</button>
<button type="button" className="ghost" onClick={loadOAuth} disabled={!token || oauthLoading}>
{oauthLoading ? "加载中…" : "重新加载"}
</button>
</div>
</div>
)}
{adminNav === "turnstile" && (
<div className="admin-section">
<h2 className="admin-section-heading">Cloudflare Turnstile 人机验证</h2>
<p className="hint">
<a href="https://dash.cloudflare.com/" target="_blank" rel="noopener noreferrer">Cloudflare Dashboard</a> Turnstile 创建站点Site KeySecret Key填入下方
启用后登录与注册接口在提交前均需完成人机验证
</p>
<label className="admin-ban-row">
<IconLabel icon={icons.token} text="启用 Turnstile 人机验证" />
<span className="admin-ban-toggle">
<input
type="checkbox"
checked={tsEnabled}
onChange={(e) => setTsEnabled(e.target.checked)}
disabled={!token || tsLoading}
/>
<span>开启后登录与注册均需通过 Cloudflare Turnstile 验证</span>
</span>
</label>
<label>
<IconLabel icon={icons.username} text="Site Key前端展示用" />
<input
value={tsSiteKey}
onChange={(e) => setTsSiteKey(e.target.value)}
placeholder="0x4AAAAAAAxxx..."
disabled={!token || tsLoading}
/>
</label>
<label>
<IconLabel icon={icons.token} text="Secret Key后端验证用" hint={tsSecretKeySet ? "(已配置,留空则保留原值)" : ""} />
<input
type="password"
autoComplete="new-password"
value={tsSecretKey}
onChange={(e) => setTsSecretKey(e.target.value)}
placeholder={tsSecretKeySet ? "留空保留" : "0x4AAAAAAAxxx..."}
disabled={!token || tsLoading}
/>
</label>
{tsError && <div className="error">{tsError}</div>}
{tsMessage && <div className="success">{tsMessage}</div>}
<div className="actions compact">
<button type="button" className="primary" onClick={saveTurnstile} disabled={!token || tsSaving || tsLoading}>
{tsSaving ? "保存中…" : "保存 Turnstile 配置"}
</button>
<button type="button" className="ghost" onClick={loadTurnstile} disabled={!token || tsLoading}>
{tsLoading ? "加载中…" : "重新加载"}
</button>
</div>
</div>
)}
{adminNav === "registration" && (
<div className="admin-section">
<h2 className="admin-section-heading">注册与邀请码</h2>
<label className="admin-ban-row">
@@ -504,33 +1061,38 @@ export default function AdminPanel({ onReady }) {
</div>
)}
</div>
)}
{adminNav === "users" && (
<div className="admin-section">
<div className="list-header">
<h2 className="admin-section-heading admin-section-heading-inline">用户列表</h2>
<div className="actions compact">
<button className="primary" onClick={openCreateUser}>添加用户</button>
<button className="ghost" onClick={loadUsers} disabled={!token || loading}>
{loading ? "加载中…" : "刷新列表"}
</button>
</div>
</div>
{message && <div className="success">{message}</div>}
{users.length === 0 && <div className="hint">暂无用户</div>}
<div className="table admin-table">
<div className="table-row header">
<TableCell icon={icons.account}>账户</TableCell>
<TableCell icon={icons.username}>用户名</TableCell>
<TableCell icon={icons.email}>邮箱</TableCell>
<TableCell icon={icons.level}>等级</TableCell>
<TableCell icon={icons.coins}>萌芽币</TableCell>
<TableCell icon={icons.ban}>状态</TableCell>
<TableCell icon={icons.visitIp}>最近 IP</TableCell>
<TableCell icon={icons.visitGeo}>最近位置</TableCell>
<TableCell icon={icons.account} dataLabel="账户">账户</TableCell>
<TableCell icon={icons.username} dataLabel="用户名">用户名</TableCell>
<TableCell icon={icons.email} dataLabel="邮箱">邮箱</TableCell>
<TableCell icon={icons.level} dataLabel="等级">等级</TableCell>
<TableCell icon={icons.coins} dataLabel="萌芽币">萌芽币</TableCell>
<TableCell icon={icons.ban} dataLabel="状态">状态</TableCell>
<TableCell icon={icons.visitIp} dataLabel="IP">最近 IP</TableCell>
<TableCell icon={icons.visitGeo} dataLabel="位置">最近位置</TableCell>
<span>操作</span>
</div>
{users.map((u) => (
<div className="table-row" key={u.account}>
<TableCell icon={icons.account} onClick={() => selectUser(u)}>{u.account}</TableCell>
<TableCell icon={icons.username}>{u.username || "-"}</TableCell>
<TableCell icon={icons.email}>
<TableCell dataLabel="账户" icon={icons.account} onClick={() => selectUser(u)}>{u.account}</TableCell>
<TableCell dataLabel="用户名" icon={icons.username}>{u.username || "-"}</TableCell>
<TableCell dataLabel="邮箱" icon={icons.email}>
<span className="admin-email-cell">
{u.email ? (
<MailtoEmail address={u.email} className="profile-external-link">{u.email}</MailtoEmail>
@@ -550,18 +1112,18 @@ export default function AdminPanel({ onReady }) {
)}
</span>
</TableCell>
<TableCell icon={icons.level}>{u.level ?? 0} </TableCell>
<TableCell icon={icons.coins}>{u.sproutCoins}</TableCell>
<TableCell icon={icons.ban}>
<TableCell dataLabel="等级" icon={icons.level}>{u.level ?? 0} </TableCell>
<TableCell dataLabel="萌芽币" icon={icons.coins}>{u.sproutCoins}</TableCell>
<TableCell dataLabel="状态" icon={icons.ban}>
{u.banned ? (
<span className="admin-user-banned" title={u.banReason || "已封禁"}>封禁</span>
) : (
"正常"
)}
</TableCell>
<TableCell icon={icons.visitIp}><span className="mono">{u.lastVisitIp || "-"}</span></TableCell>
<TableCell icon={icons.visitGeo}>{u.lastVisitDisplayLocation || "-"}</TableCell>
<span className="row-actions">
<TableCell dataLabel="最近 IP" icon={icons.visitIp}><span className="mono">{u.lastVisitIp || "-"}</span></TableCell>
<TableCell dataLabel="位置" icon={icons.visitGeo}>{u.lastVisitDisplayLocation || "-"}</TableCell>
<span className="row-actions" data-label="操作">
<button className="ghost" onClick={() => selectUser(u)}>编辑</button>
<button className="danger" onClick={() => handleDelete(u.account)}>删除</button>
</span>
@@ -569,6 +1131,8 @@ export default function AdminPanel({ onReady }) {
))}
</div>
</div>
)}
</div>
</div>
{editorOpen && (

View File

@@ -63,7 +63,7 @@ export default function PublicUserListPage({ onReady, onPreviewImage }) {
<a className="public-user-list-row" href={`/user/${encodeURIComponent(u.account)}`}>
<AvatarImg
user={profileUser}
placeholderSize={56}
placeholderSize={44}
alt={name}
className="public-user-list-avatar previewable-image"
previewable

View File

@@ -1,11 +1,26 @@
import React from "react";
import { LOGO_192_SRC } from "../config";
export default function SplashScreen() {
/**
* 与顶栏小 Logo 相同:在图标区域连点 5 次会触发 App 中管理员入口onLogoTap
*/
export default function SplashScreen({ onLogoTap }) {
return (
<div className="splash">
<div className="splash-glow" aria-hidden="true" />
<div className="splash-content">
{typeof onLogoTap === "function" ? (
<button type="button" className="splash-logo-hit" onClick={onLogoTap} aria-label="SproutGate 标志,连点五次可打开管理员验证">
<div className="splash-logo-wrap">
<div className="splash-rings" aria-hidden="true">
<span />
<span />
<span />
</div>
<img className="splash-logo" src={LOGO_192_SRC} alt="" width={120} height={120} decoding="async" />
</div>
</button>
) : (
<div className="splash-logo-wrap">
<div className="splash-rings" aria-hidden="true">
<span />
@@ -14,6 +29,7 @@ export default function SplashScreen() {
</div>
<img className="splash-logo" src={LOGO_192_SRC} alt="SproutGate" width={120} height={120} decoding="async" />
</div>
)}
<div className="splash-title">萌芽账户认证中心</div>
<div className="splash-subtitle">加载中</div>
<div className="splash-dots" aria-label="加载中">

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import {
API_BASE,
authClientFetchHeaders,
@@ -6,6 +6,7 @@ import {
clearAuthClientContext,
fetchClientVisitMeta,
formatAuthBanMessage,
getOAuthReturnTo,
normalizeSelfServiceAccountInput,
persistAuthClientFromFlow,
randomSelfServiceAccount
@@ -14,7 +15,13 @@ import UserPortalAuthSection from "./userPortal/UserPortalAuthSection";
import UserPortalProfileSection from "./userPortal/UserPortalProfileSection";
import OAuthConsentScreen from "./OAuthConsentScreen";
export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthStandalone = false }) {
export default function UserPortal({
onReady,
authFlow,
onPreviewImage,
oauthStandalone = false,
onGuestAuthLayoutChange
}) {
const [account, setAccount] = useState("");
const [password, setPassword] = useState("");
const [user, setUser] = useState(null);
@@ -67,6 +74,22 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
const [checkInError, setCheckInError] = useState("");
const [checkInMessage, setCheckInMessage] = useState("");
const [githubOAuthEnabled, setGithubOAuthEnabled] = useState(false);
const [giteaOAuthEnabled, setGiteaOAuthEnabled] = useState(false);
const [googleOAuthEnabled, setGoogleOAuthEnabled] = useState(false);
const [microsoftOAuthEnabled, setMicrosoftOAuthEnabled] = useState(false);
const [linuxdoOAuthEnabled, setLinuxdoOAuthEnabled] = useState(false);
const [oauthLogos, setOauthLogos] = useState({
github: "", gitea: "", google: "", microsoft: "", linuxdo: ""
});
const [turnstileEnabled, setTurnstileEnabled] = useState(false);
const [turnstileSiteKey, setTurnstileSiteKey] = useState("");
const [loginTurnstileToken, setLoginTurnstileToken] = useState("");
const [registerTurnstileToken, setRegisterTurnstileToken] = useState("");
const loginTurnstileRef = useRef(null);
const registerTurnstileRef = useRef(null);
const isAuthFlow = Boolean(authFlow?.redirectUri);
const redirectToAuthCallback = (tokenValue, userData, expiresAt = "") => {
@@ -148,6 +171,39 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
useEffect(() => {
let cancelled = false;
const done = () => { if (!cancelled && onReady) onReady(); };
const hash = (window.location.hash || "").replace(/^#/, "");
if (hash) {
const p = new URLSearchParams(hash);
const ot = p.get("oauth_token");
if (ot) {
const ex = p.get("oauth_expires_at") || "";
localStorage.setItem("sproutgate_token", ot);
localStorage.setItem("sproutgate_token_expires_at", ex);
window.history.replaceState(null, "", window.location.pathname + window.location.search);
syncCurrentUser(ot).catch(() => {}).finally(() => done());
return () => { cancelled = true; };
}
if (p.get("oauth_error")) {
let desc = p.get("oauth_error_description") || "OAuth 登录失败";
try {
desc = decodeURIComponent(desc.replace(/\+/g, " "));
} catch { /* use raw */ }
setError(desc);
window.history.replaceState(null, "", window.location.pathname + window.location.search);
done();
return () => { cancelled = true; };
}
if (p.get("oauth_bound")) {
window.history.replaceState(null, "", window.location.pathname + window.location.search);
const t = localStorage.getItem("sproutgate_token");
if (t) {
syncCurrentUser(t).catch(() => {}).finally(() => done());
} else {
done();
}
return () => { cancelled = true; };
}
}
const token = localStorage.getItem("sproutgate_token");
if (token) {
syncCurrentUser(token).catch(() => {}).finally(() => done());
@@ -161,6 +217,14 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
persistAuthClientFromFlow(authFlow);
}, [authFlow]);
useLayoutEffect(() => {
if (!onGuestAuthLayoutChange) return;
const invalidOAuthStandalone = Boolean(
oauthStandalone && !(authFlow?.redirectUri || "").trim()
);
onGuestAuthLayoutChange(Boolean(!user && !invalidOAuthStandalone));
}, [user, oauthStandalone, authFlow, onGuestAuthLayoutChange]);
useEffect(() => {
let cancelled = false;
(async () => {
@@ -175,6 +239,20 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
const ami = Number(data.selfServiceAccountMin);
if (Number.isFinite(am) && am >= 3 && am <= 32) setRegistrationAccountMax(am);
if (Number.isFinite(ami) && ami >= 3 && ami <= 32) setRegistrationAccountMin(ami);
setGithubOAuthEnabled(Boolean(data.githubOAuthEnabled));
setGiteaOAuthEnabled(Boolean(data.giteaOAuthEnabled));
setGoogleOAuthEnabled(Boolean(data.googleOAuthEnabled));
setMicrosoftOAuthEnabled(Boolean(data.microsoftOAuthEnabled));
setLinuxdoOAuthEnabled(Boolean(data.linuxdoOAuthEnabled));
setOauthLogos({
github: String(data.githubOAuthLogoUrl || "").trim(),
gitea: String(data.giteaOAuthLogoUrl || "").trim(),
google: String(data.googleOAuthLogoUrl || "").trim(),
microsoft: String(data.microsoftOAuthLogoUrl || "").trim(),
linuxdo: String(data.linuxdoOAuthLogoUrl || "").trim()
});
setTurnstileEnabled(Boolean(data.turnstileEnabled));
setTurnstileSiteKey(String(data.turnstileSiteKey || "").trim());
}
} catch {
/* 忽略,默认不要求邀请码 */
@@ -202,6 +280,7 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
setError("");
try {
const loginPayload = { account, password };
if (turnstileEnabled) loginPayload.turnstileToken = loginTurnstileToken;
const cid = (authFlow?.clientId || "").trim();
const cname = (authFlow?.clientName || "").trim();
if (cid) {
@@ -237,6 +316,8 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
}
} catch (err) {
setError(err.message);
setLoginTurnstileToken("");
loginTurnstileRef.current?.reset();
} finally {
setLoading(false);
}
@@ -321,6 +402,7 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
username: registerForm.username,
email: registerForm.email
};
if (turnstileEnabled) payload.turnstileToken = registerTurnstileToken;
const inv = String(registerForm.inviteCode || "").trim();
if (inv) payload.inviteCode = inv;
const res = await fetch(`${API_BASE}/api/auth/register`, {
@@ -335,6 +417,8 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
setRegisterMessage("验证码已发送,请检查邮箱");
} catch (err) {
setRegisterError(err.message);
setRegisterTurnstileToken("");
registerTurnstileRef.current?.reset();
} finally {
setRegisterLoading(false);
}
@@ -656,6 +740,71 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
}
};
const openOAuthLogin = (provider) => {
const ret = getOAuthReturnTo();
if (!ret) return;
if (turnstileEnabled) {
const tok = mode === "register" ? registerTurnstileToken : loginTurnstileToken;
if (!tok) {
if (mode === "register") {
setRegisterError("请先完成人机验证后再使用第三方登录");
} else {
setError("请先完成人机验证后再使用第三方登录");
}
return;
}
const u = new URL(`${API_BASE}/api/auth/oauth/${encodeURIComponent(provider)}/start`);
u.searchParams.set("return_to", ret);
u.searchParams.set("turnstile_token", tok);
window.location.href = u.toString();
return;
}
window.location.href = `${API_BASE}/api/auth/oauth/${provider}/start?return_to=${encodeURIComponent(ret)}`;
};
const bindOAuth = async (provider) => {
const t = localStorage.getItem("sproutgate_token");
if (!t) return;
setProfileError("");
setProfileMessage("");
try {
const res = await fetch(`${API_BASE}/api/auth/oauth/${provider}/bind`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${t}`,
...authClientFetchHeaders()
},
body: JSON.stringify({ returnTo: getOAuthReturnTo() })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "无法开始绑定");
if (data.url) window.location.href = data.url;
} catch (e) {
setProfileError(e.message || "绑定失败");
}
};
const unlinkOAuth = async (provider) => {
if (!window.confirm("确定要解绑该第三方账号吗?")) return;
const t = localStorage.getItem("sproutgate_token");
if (!t) return;
setProfileError("");
setProfileMessage("");
try {
const res = await fetch(`${API_BASE}/api/auth/oauth/${provider}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${t}`, ...authClientFetchHeaders() }
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "解绑失败");
if (data.user) setUser(data.user);
setProfileMessage("已更新第三方账号绑定");
} catch (e) {
setProfileError(e.message || "解绑失败");
}
};
if (oauthStandalone && !authFlow?.redirectUri?.trim()) {
return (
<section className="panel">
@@ -713,6 +862,21 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
handleResetPassword={handleResetPassword}
setResetError={setResetError}
setResetMessage={setResetMessage}
githubOAuthEnabled={githubOAuthEnabled}
giteaOAuthEnabled={giteaOAuthEnabled}
googleOAuthEnabled={googleOAuthEnabled}
microsoftOAuthEnabled={microsoftOAuthEnabled}
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
oauthLogos={oauthLogos}
onOAuthLogin={openOAuthLogin}
turnstileEnabled={turnstileEnabled}
turnstileSiteKey={turnstileSiteKey}
loginTurnstileToken={loginTurnstileToken}
onLoginTurnstileToken={setLoginTurnstileToken}
loginTurnstileRef={loginTurnstileRef}
registerTurnstileToken={registerTurnstileToken}
onRegisterTurnstileToken={setRegisterTurnstileToken}
registerTurnstileRef={registerTurnstileRef}
/>
{showOAuthConsent && (
<OAuthConsentScreen
@@ -756,6 +920,14 @@ export default function UserPortal({ onReady, authFlow, onPreviewImage, oauthSta
checkInError={checkInError}
checkInMessage={checkInMessage}
handleCheckIn={handleCheckIn}
githubOAuthEnabled={githubOAuthEnabled}
giteaOAuthEnabled={giteaOAuthEnabled}
googleOAuthEnabled={googleOAuthEnabled}
microsoftOAuthEnabled={microsoftOAuthEnabled}
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
oauthLogos={oauthLogos}
onBindOAuth={bindOAuth}
onUnlinkOAuth={unlinkOAuth}
/>
)}
</section>

View File

@@ -20,11 +20,15 @@ export function InfoLabel({ icon, text }) {
);
}
export function TableCell({ icon, children, onClick }) {
export function TableCell({ icon, children, onClick, dataLabel }) {
return (
<span className="table-cell" onClick={onClick}>
<span
className="table-cell"
onClick={onClick}
data-label={dataLabel || undefined}
>
<span className="icon">{icon}</span>
<span>{children}</span>
<span className="table-cell-value">{children}</span>
</span>
);
}

View File

@@ -0,0 +1,77 @@
import React from "react";
const PROVIDERS = [
{ id: "github", label: "使用 GitHub 继续" },
{ id: "gitea", label: "使用 Gitea 继续" },
{ id: "google", label: "使用 Google 继续" },
{ id: "microsoft", label: "使用 Microsoft 继续" },
{ id: "linuxdo", label: "使用 Linux DO 继续" }
];
/**
* @param {Record<string, string>} [oauthLogos] 各 provider id → 管理后台填写的 Logo 图片 URLhttps
*/
export default function OAuthPillButtons({
githubOAuthEnabled = false,
giteaOAuthEnabled = false,
googleOAuthEnabled = false,
microsoftOAuthEnabled = false,
linuxdoOAuthEnabled = false,
oauthLogos = {},
onOAuthLogin,
disabled = false,
variant = "default"
}) {
if (!onOAuthLogin) return null;
const enabled = (p) => {
switch (p) {
case "github": return githubOAuthEnabled;
case "gitea": return giteaOAuthEnabled;
case "google": return googleOAuthEnabled;
case "microsoft": return microsoftOAuthEnabled;
case "linuxdo": return linuxdoOAuthEnabled;
default: return false;
}
};
const list = PROVIDERS.filter((p) => enabled(p.id));
if (list.length === 0) return null;
return (
<div className={`auth-oauth-stack${variant === "underLead" ? " auth-oauth-stack--under-lead" : ""}`}>
<span className="auth-oauth-label">第三方登录</span>
{list.map((p) => {
const logo = (typeof oauthLogos?.[p.id] === "string" ? oauthLogos[p.id] : "").trim();
return (
<button
key={p.id}
type="button"
className={`auth-oauth-pill${logo ? "" : " auth-oauth-pill--no-logo"}`}
onClick={() => onOAuthLogin(p.id)}
disabled={disabled}
>
<span className="auth-oauth-pill-inner">
{logo ? (
<span className="auth-oauth-pill-icon" aria-hidden>
<img
className="auth-oauth-pill-logo"
src={logo}
alt=""
loading="lazy"
decoding="async"
onError={(e) => {
const btn = e.currentTarget.closest("button");
e.currentTarget.closest(".auth-oauth-pill-icon")?.remove();
if (btn) btn.classList.add("auth-oauth-pill--no-logo");
}}
/>
</span>
) : null}
<span className="auth-oauth-pill-text">{p.label}</span>
</span>
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useCallback, useEffect, useImperativeHandle, useRef, forwardRef } from "react";
const SCRIPT_ID = "cf-turnstile-script";
const SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
function ensureScript(onReady) {
if (typeof window === "undefined") return;
const existing = document.getElementById(SCRIPT_ID);
if (existing) {
if (window.turnstile) {
onReady();
} else {
existing.addEventListener("load", onReady, { once: true });
}
return;
}
const s = document.createElement("script");
s.id = SCRIPT_ID;
s.src = SCRIPT_SRC;
s.defer = true;
s.addEventListener("load", onReady, { once: true });
document.head.appendChild(s);
}
/**
* Cloudflare Turnstile 人机验证组件。
* 通过 ref.reset() 可在提交后重置 widget 以获取新 token。
*
* @param {string} siteKey - Cloudflare Turnstile Site Key
* @param {Function} onToken - token 回调token 失效/错误时传入空串)
* @param {"light"|"dark"|"auto"} [theme]
*/
const TurnstileWidget = forwardRef(function TurnstileWidget(
{ siteKey, onToken, theme = "light" },
ref
) {
const containerRef = useRef(null);
const widgetIdRef = useRef(null);
const onTokenRef = useRef(onToken);
useEffect(() => { onTokenRef.current = onToken; }, [onToken]);
const removeWidget = useCallback(() => {
if (widgetIdRef.current != null && window.turnstile) {
try { window.turnstile.remove(widgetIdRef.current); } catch {}
widgetIdRef.current = null;
}
if (containerRef.current) containerRef.current.innerHTML = "";
}, []);
const renderWidget = useCallback(() => {
if (!containerRef.current || !window.turnstile) return;
removeWidget();
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
theme,
callback: (token) => onTokenRef.current?.(token),
"expired-callback": () => onTokenRef.current?.(""),
"error-callback": () => onTokenRef.current?.(""),
});
}, [siteKey, theme, removeWidget]);
useImperativeHandle(ref, () => ({
reset() {
if (widgetIdRef.current != null && window.turnstile) {
try { window.turnstile.reset(widgetIdRef.current); } catch {}
}
},
}));
useEffect(() => {
ensureScript(renderWidget);
return removeWidget;
}, [renderWidget, removeWidget]);
return <div ref={containerRef} className="turnstile-widget" />;
});
export default TurnstileWidget;

View File

@@ -1,6 +1,8 @@
import React from "react";
import icons from "../../icons";
import { IconLabel } from "../common";
import OAuthPillButtons from "../oauth/OAuthPillButtons";
import TurnstileWidget from "../oauth/TurnstileWidget";
export default function UserPortalAuthSection({
isAuthFlow,
@@ -42,7 +44,20 @@ export default function UserPortalAuthSection({
handleSendReset,
handleResetPassword,
setResetError,
setResetMessage
setResetMessage,
githubOAuthEnabled = false,
giteaOAuthEnabled = false,
googleOAuthEnabled = false,
microsoftOAuthEnabled = false,
linuxdoOAuthEnabled = false,
oauthLogos = {},
onOAuthLogin,
turnstileEnabled = false,
turnstileSiteKey = "",
onLoginTurnstileToken,
loginTurnstileRef,
onRegisterTurnstileToken,
registerTurnstileRef
}) {
return (
<>
@@ -82,11 +97,30 @@ export default function UserPortalAuthSection({
</label>
{error && <div className="error auth-form-alert" role="alert">{error}</div>}
</div>
{turnstileEnabled && turnstileSiteKey && (
<div className="auth-turnstile-wrap">
<TurnstileWidget
ref={loginTurnstileRef}
siteKey={turnstileSiteKey}
onToken={onLoginTurnstileToken}
/>
</div>
)}
<div className="auth-form-actions">
<button type="submit" className="primary auth-submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
</div>
<OAuthPillButtons
githubOAuthEnabled={githubOAuthEnabled}
giteaOAuthEnabled={giteaOAuthEnabled}
googleOAuthEnabled={googleOAuthEnabled}
microsoftOAuthEnabled={microsoftOAuthEnabled}
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
oauthLogos={oauthLogos}
onOAuthLogin={onOAuthLogin}
disabled={loading}
/>
<div className="auth-form-footer">
<button
type="button"
@@ -109,6 +143,28 @@ export default function UserPortalAuthSection({
<h2>注册账号</h2>
<p className="auth-form-lead">创建萌芽统一账户验证码将发送至你的邮箱</p>
</div>
{!registerSent && turnstileEnabled && turnstileSiteKey && (
<div className="auth-turnstile-wrap auth-turnstile-wrap--register-oauth">
<TurnstileWidget
ref={registerTurnstileRef}
siteKey={turnstileSiteKey}
onToken={onRegisterTurnstileToken}
/>
</div>
)}
{!registerSent && (
<OAuthPillButtons
variant="underLead"
githubOAuthEnabled={githubOAuthEnabled}
giteaOAuthEnabled={giteaOAuthEnabled}
googleOAuthEnabled={googleOAuthEnabled}
microsoftOAuthEnabled={microsoftOAuthEnabled}
linuxdoOAuthEnabled={linuxdoOAuthEnabled}
oauthLogos={oauthLogos}
onOAuthLogin={onOAuthLogin}
disabled={registerLoading}
/>
)}
<div className="auth-form-body">
<label className="auth-field">
<IconLabel

View File

@@ -1,10 +1,30 @@
import React from "react";
import React, { useState } from "react";
import { marked, formatWebsiteLabel, formatUserRegisteredAt, formatIsoDateTimeReadable } from "../../config";
import { avatarStatusLabel } from "../../avatar";
import icons from "../../icons";
import { IconLabel, MailtoEmail, StatItem, InfoRow } from "../common";
import AvatarImg from "../AvatarImg";
/** 与登录页 OAuth 按钮相同:管理后台配置的 logo URL失败时回退为链环图标 */
function ProfileOAuthProviderIcon({ providerId, oauthLogos = {} }) {
const raw = oauthLogos?.[providerId];
const url = typeof raw === "string" ? raw.trim() : "";
const [failed, setFailed] = useState(false);
if (!url || failed) {
return icons.link;
}
return (
<img
className="profile-oauth-logo"
src={url}
alt=""
loading="lazy"
decoding="async"
onError={() => setFailed(true)}
/>
);
}
export default function UserPortalProfileSection({
user,
onPreviewImage,
@@ -36,7 +56,15 @@ export default function UserPortalProfileSection({
checkInLoading,
checkInError,
checkInMessage,
handleCheckIn
handleCheckIn,
githubOAuthEnabled = false,
giteaOAuthEnabled = false,
googleOAuthEnabled = false,
microsoftOAuthEnabled = false,
linuxdoOAuthEnabled = false,
oauthLogos = {},
onBindOAuth,
onUnlinkOAuth
}) {
return (
<div className="card profile">
@@ -116,6 +144,59 @@ export default function UserPortalProfileSection({
<InfoRow icon={icons.bio} label="简介" value={user.bio ? "已填写" : "暂无简介"} actionLabel="修改" onAction={() => openProfileEditor("bio")} />
</div>
{(githubOAuthEnabled || giteaOAuthEnabled || googleOAuthEnabled || microsoftOAuthEnabled || linuxdoOAuthEnabled) && onBindOAuth && onUnlinkOAuth && (
<>
<div className="profile-section-title">第三方账号</div>
<div className="profile-info-rows">
{githubOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="github" oauthLogos={oauthLogos} />}
label="GitHub"
value={user.githubLinked ? "已绑定" : "未绑定"}
actionLabel={user.githubLinked ? "解绑" : "绑定"}
onAction={() => (user.githubLinked ? onUnlinkOAuth("github") : onBindOAuth("github"))}
/>
)}
{giteaOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="gitea" oauthLogos={oauthLogos} />}
label="Gitea"
value={user.giteaLinked ? "已绑定" : "未绑定"}
actionLabel={user.giteaLinked ? "解绑" : "绑定"}
onAction={() => (user.giteaLinked ? onUnlinkOAuth("gitea") : onBindOAuth("gitea"))}
/>
)}
{googleOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="google" oauthLogos={oauthLogos} />}
label="Google"
value={user.googleLinked ? "已绑定" : "未绑定"}
actionLabel={user.googleLinked ? "解绑" : "绑定"}
onAction={() => (user.googleLinked ? onUnlinkOAuth("google") : onBindOAuth("google"))}
/>
)}
{microsoftOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="microsoft" oauthLogos={oauthLogos} />}
label="Microsoft"
value={user.microsoftLinked ? "已绑定" : "未绑定"}
actionLabel={user.microsoftLinked ? "解绑" : "绑定"}
onAction={() => (user.microsoftLinked ? onUnlinkOAuth("microsoft") : onBindOAuth("microsoft"))}
/>
)}
{linuxdoOAuthEnabled && (
<InfoRow
icon={<ProfileOAuthProviderIcon providerId="linuxdo" oauthLogos={oauthLogos} />}
label="LINUX DO"
value={user.linuxdoLinked ? "已绑定" : "未绑定"}
actionLabel={user.linuxdoLinked ? "解绑" : "绑定"}
onAction={() => (user.linuxdoLinked ? onUnlinkOAuth("linuxdo") : onBindOAuth("linuxdo"))}
/>
)}
</div>
</>
)}
<div className="profile-section-title">统计信息</div>
<div className="profile-stats-flow">
<StatItem icon={icons.level} label="等级" value={`${user.level ?? 0}`} />

View File

@@ -9,6 +9,12 @@ export const API_BASE = (() => {
return (configuredBase || fallbackBase).replace(/\/+$/, "");
})();
/** OAuth 成功回跳:当前页(不含 # fragment与后端 `allowedReturnPrefixes` 白名单配合 */
export function getOAuthReturnTo() {
if (typeof window === "undefined") return "";
return `${window.location.origin}${window.location.pathname}${window.location.search}`;
}
/** `public/logo192.png`,含 Vite `base` 前缀,避免子路径部署时顶栏/开屏裂图 */
export const LOGO_192_SRC = `${import.meta.env.BASE_URL}logo192.png`;

View File

@@ -119,6 +119,24 @@ a {
text-align: center;
}
button.splash-logo-hit {
display: block;
border: none;
padding: 0;
margin: 0;
background: none;
cursor: pointer;
font: inherit;
line-height: 0;
border-radius: 20px;
-webkit-tap-highlight-color: transparent;
}
button.splash-logo-hit:focus-visible {
outline: 2px solid #404040;
outline-offset: 4px;
}
.splash-logo-wrap {
position: relative;
width: 200px;
@@ -246,22 +264,34 @@ a {
min-width: 0;
}
.app-header-logo-btn {
/* 整段品牌区(图 + 标题)连点 5 次,与开屏大 Logo 共用 App.handleLogoTap */
.app-header-brand-btn {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
border: none;
padding: 0;
margin: 0;
background: none;
cursor: pointer;
border-radius: 8px;
line-height: 0;
flex-shrink: 0;
border-radius: 10px;
text-align: left;
font: inherit;
line-height: 1.2;
flex: 1;
-webkit-tap-highlight-color: transparent;
}
.app-header-logo-btn:focus-visible {
.app-header-brand-btn:focus-visible {
outline: 2px solid #404040;
outline-offset: 2px;
}
.app-header-brand-text {
min-width: 0;
}
.app-header-logo {
display: block;
width: 40px;
@@ -276,7 +306,8 @@ a {
}
.app-header h1,
.unified-user-header h1 {
.unified-user-header h1,
.app-header-brand-btn h1 {
margin: 0;
font-size: 21px;
font-weight: 800;
@@ -352,6 +383,105 @@ nav a:hover {
gap: 10px;
}
/* 仅未登录的登录/注册/重置:全视口磨砂主壳 + 轻遮罩;已登录/公开页/管理/无效 OAuth 等保持原样 */
.app-shell--auth-guest {
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
}
.app-shell--auth-guest .app-rand-bg__veil {
background: rgba(244, 244, 245, 0.58);
}
.app--auth-guest {
width: 100%;
max-width: none;
margin: 0;
min-height: 0;
padding: 0;
display: flex;
flex-direction: column;
flex: 1;
box-sizing: border-box;
}
.app-shell--auth-guest > .app--auth-guest {
flex: 1;
min-height: 0;
}
.unified-user-shell--auth-guest {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
width: 100%;
}
.unified-user-shell--auth-guest .unified-user-card {
--unified-glass: rgba(255, 255, 255, 0.4);
flex: 1;
display: flex;
flex-direction: column;
min-height: 100vh;
min-height: 100dvh;
background: var(--unified-glass);
border-radius: 0;
box-shadow: none;
border: none;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
backdrop-filter: blur(20px) saturate(1.12);
-webkit-backdrop-filter: blur(20px) saturate(1.12);
}
.unified-user-shell--auth-guest .unified-user-card-accent {
flex-shrink: 0;
}
.unified-user-shell--auth-guest .unified-user-header {
background: rgba(255, 255, 255, 0.28);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
flex-shrink: 0;
}
.unified-user-shell--auth-guest .unified-user-header .app-header-logo {
background: rgba(255, 255, 255, 0.72);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.06);
}
.unified-user-shell--auth-guest .unified-user-header nav a {
background: rgba(255, 255, 255, 0.55);
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.05);
}
.unified-user-shell--auth-guest .unified-user-main {
flex: 1;
min-height: 0;
background: transparent;
}
/* 访客登录/注册/重置:表单区单独一层半透明磨砂矩形容器 */
.unified-user-shell--auth-guest .unified-user-main .panel > form.auth-card {
max-width: 480px;
width: 100%;
background: rgba(255, 255, 255, 0.34);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.55);
padding: 26px 24px 22px;
margin-left: auto;
margin-right: auto;
box-sizing: border-box;
}
.unified-page-loading {
padding: 20px 4px 8px;
text-align: center;
@@ -373,12 +503,12 @@ nav a:hover {
/* === 公开用户列表 /users === */
.public-user-list-page {
max-width: 640px;
max-width: 820px;
margin: 0 auto;
}
.public-user-list-head {
margin-bottom: 14px;
margin-bottom: 10px;
}
.public-user-list-title {
@@ -405,17 +535,17 @@ nav a:hover {
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.public-user-list-row {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 14px;
gap: 12px;
padding: 7px 12px;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 12px;
border-radius: 10px;
text-decoration: none;
color: inherit;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
@@ -435,13 +565,13 @@ nav a:hover {
.public-user-list-row .public-user-list-avatar.avatar-shell {
flex-shrink: 0;
width: 56px;
height: 56px;
width: 44px;
height: 44px;
}
.public-user-list-row .public-user-list-avatar.avatar-shell img {
width: 56px;
height: 56px;
width: 44px;
height: 44px;
}
.public-user-list-meta {
@@ -449,28 +579,31 @@ nav a:hover {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
gap: 1px;
}
.public-user-list-name {
font-size: 16px;
font-size: 15px;
font-weight: 700;
color: #111827;
line-height: 1.25;
}
.public-user-list-account {
font-size: 13px;
font-size: 12px;
color: #737373;
line-height: 1.25;
}
.public-user-list-sub {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
font-size: 13px;
gap: 4px 8px;
font-size: 12px;
color: #6b7280;
margin-top: 4px;
margin-top: 2px;
line-height: 1.3;
}
.public-user-list-sub .icon {
@@ -487,23 +620,90 @@ nav a:hover {
.public-user-list-chevron {
flex-shrink: 0;
font-size: 22px;
font-size: 18px;
font-weight: 300;
color: #a3a3a3;
line-height: 1;
}
/* 与登录/注册同套全屏毛玻璃:公开用户列表 */
.unified-user-shell--auth-guest .public-user-list-page {
max-width: 900px;
width: 100%;
padding: 18px 16px 16px;
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
box-sizing: border-box;
}
.unified-user-shell--auth-guest .public-user-list-row {
background: rgba(255, 255, 255, 0.22);
border: 1px solid rgba(255, 255, 255, 0.42);
box-shadow: 0 2px 12px rgba(15, 23, 42, 0.06);
}
.unified-user-shell--auth-guest .public-user-list-row:hover {
background: rgba(255, 255, 255, 0.38);
border-color: rgba(255, 255, 255, 0.55);
box-shadow: 0 6px 22px rgba(15, 23, 42, 0.1);
}
.unified-user-shell--auth-guest .public-user-list-sub-sep {
background: rgba(0, 0, 0, 0.12);
}
/* 公开用户个人主页:单张磨砂内容卡 */
.unified-user-shell--auth-guest .unified-user-main .card.profile {
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
padding: 22px 20px 24px;
}
.unified-user-shell--auth-guest .unified-user-main .unified-page-miss {
max-width: 420px;
margin: 0 auto;
padding: 28px 24px 26px;
text-align: center;
background: rgba(255, 255, 255, 0.32);
backdrop-filter: blur(14px) saturate(1.12);
-webkit-backdrop-filter: blur(14px) saturate(1.12);
border: 1px solid rgba(255, 255, 255, 0.48);
border-radius: 16px;
box-shadow:
0 8px 36px rgba(15, 23, 42, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
}
/* 外层已是卡片,内层登录表单不再叠第二张卡片 */
.unified-user-main .auth-card {
background: transparent;
box-shadow: none;
border-radius: 0;
padding: 4px 0 0;
max-width: 420px;
max-width: 480px;
margin-left: auto;
margin-right: auto;
}
@media (min-width: 768px) {
.unified-user-main .panel > form.auth-card,
.unified-user-shell--auth-guest .unified-user-main .panel > form.auth-card {
max-width: 560px;
}
}
.unified-user-main .auth-card::before {
display: none;
}
@@ -517,6 +717,131 @@ nav a:hover {
border-radius: 0;
}
/* === 管理后台:侧边栏 + 主区 === */
.admin-shell.panel {
max-width: 1200px;
}
.admin-layout {
display: flex;
align-items: stretch;
gap: 0;
min-height: 0;
margin: 0;
}
.admin-sidebar {
flex: 0 0 200px;
max-width: 200px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px 0 0 10px;
padding: 16px 10px 20px;
box-sizing: border-box;
}
.admin-sidebar-title {
font-size: 12px;
font-weight: 800;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 10px 8px;
}
.admin-sidebar-nav {
display: flex;
flex-direction: column;
gap: 2px;
}
button.admin-sidebar-link {
display: block;
width: 100%;
text-align: left;
padding: 10px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: #334155;
font-size: 14px;
font-weight: 600;
font-family: inherit;
line-height: 1.3;
cursor: pointer;
box-sizing: border-box;
transition: background 0.12s ease, color 0.12s ease;
}
button.admin-sidebar-link:hover {
background: #e2e8f0;
color: #0f172a;
}
button.admin-sidebar-link.is-active {
background: #111827;
color: #fff;
}
.admin-main {
flex: 1;
min-width: 0;
padding: 12px 16px 24px 20px;
max-height: min(86vh, 880px);
overflow-x: auto;
overflow-y: auto;
border: 1px solid #e2e8f0;
border-left: none;
border-radius: 0 10px 10px 0;
background: #fff;
box-sizing: border-box;
}
.admin-main-title {
font-size: 18px;
font-weight: 800;
margin: 0 0 14px;
color: #111827;
letter-spacing: -0.3px;
}
.admin-main .admin-section:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: none;
}
@media (max-width: 800px) {
.admin-layout {
flex-direction: column;
}
.admin-sidebar {
flex: none;
max-width: none;
border-radius: 10px 10px 0 0;
border-bottom: none;
}
.admin-sidebar-nav {
flex-direction: row;
flex-wrap: wrap;
gap: 6px;
}
button.admin-sidebar-link {
width: auto;
flex: 1 1 auto;
min-width: calc(50% - 4px);
text-align: center;
font-size: 13px;
padding: 8px 10px;
}
.admin-main {
max-height: none;
border-radius: 0 0 10px 10px;
border-top: 1px solid #e2e8f0;
border-left: 1px solid #e2e8f0;
}
}
/* === 管理后台:单卡片内分区(无嵌套 card=== */
.unified-user-main .admin-console {
padding: 0;
@@ -623,6 +948,111 @@ nav a:hover {
border-bottom: 1px solid #e5e5e5;
}
/* 管理后台 · 用户表:窄屏改为卡片行,避免 9 列挤碎文字 */
@media (max-width: 900px) {
.unified-user-main .admin-table {
display: block;
}
.unified-user-main .admin-table .table-row.header {
display: none !important;
}
.unified-user-main .admin-table .table-row:not(.header) {
display: flex !important;
flex-direction: column;
align-items: stretch;
gap: 10px;
padding: 14px 12px;
margin-bottom: 12px;
background: #fafbfc;
border: 1px solid #e2e8f0 !important;
border-radius: 10px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
grid-template-columns: unset !important;
}
.unified-user-main .admin-table .table-row:not(.header):hover {
background: #f1f5f9;
}
.unified-user-main .admin-table .table-row:not(.header) > .table-cell {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-start;
column-gap: 10px;
row-gap: 4px;
min-width: 0;
font-size: 14px;
}
.unified-user-main .admin-table .table-row:not(.header) > .table-cell::before {
content: attr(data-label) "";
flex: 0 0 4.5em;
color: #64748b;
font-size: 12px;
font-weight: 700;
line-height: 1.45;
padding-top: 1px;
}
.unified-user-main .admin-table .table-row:not(.header) > .table-cell .table-cell-value {
flex: 1 1 0;
min-width: 0;
word-break: normal;
overflow-wrap: anywhere;
}
.unified-user-main .admin-table .admin-email-cell,
.unified-user-main .admin-table .table-row span {
word-break: normal;
overflow-wrap: anywhere;
}
.unified-user-main .admin-table .table-row .row-actions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 8px;
width: 100%;
margin-top: 2px;
padding-top: 10px;
border-top: 1px solid #e2e8f0;
}
.unified-user-main .admin-table .table-row .row-actions::before {
content: attr(data-label) "";
flex: 0 0 100%;
color: #64748b;
font-size: 12px;
font-weight: 700;
margin: 0 0 2px;
}
}
/* 管理后台主区:小屏不裁切内容、侧栏不竖排挤占过高 */
@media (max-width: 800px) {
.admin-main {
padding: 10px 12px 20px 12px;
}
.admin-main-title {
font-size: 16px;
}
}
@media (max-width: 800px) {
.admin-layout {
min-width: 0;
}
.admin-sidebar {
padding: 12px 8px 14px;
}
}
.admin-user-banned {
color: #b91c1c;
font-weight: 700;
@@ -703,6 +1133,15 @@ nav a:hover {
font-size: 15px;
}
/* 管理后台:开关与标题同一行 */
.form label.admin-ban-row {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px 16px;
}
.label-text,
.info-label {
display: inline-flex;
@@ -1067,6 +1506,20 @@ button:disabled {
flex-shrink: 0;
}
/* 个人资料「第三方账号」:与登录页相同的管理后台配置 Logo */
.info-row-label .icon:has(> .profile-oauth-logo) {
width: 22px;
height: 22px;
}
.info-row-label .profile-oauth-logo {
display: block;
width: 22px;
height: 22px;
object-fit: contain;
object-position: center;
}
.info-row-value {
flex: 1;
min-width: 0;
@@ -1642,6 +2095,13 @@ a.tag.tag-mailto:hover {
font-size: 14px;
}
.modal-header .admin-gate-token-hint {
margin-top: 8px;
margin-bottom: 0;
color: #64748b;
font-size: 13px;
}
.modal-close {
flex-shrink: 0;
}
@@ -1747,7 +2207,7 @@ a.tag.tag-mailto:hover {
/* === Auth 登录 / 注册 / 重置(与顶栏风格统一)=== */
.panel > form.auth-card {
max-width: 420px;
max-width: 480px;
align-self: center;
width: 100%;
padding: 28px 26px 24px;
@@ -1823,6 +2283,127 @@ a.tag.tag-mailto:hover {
margin-top: 22px;
}
/* 第三方登录:全宽胶囊(登录 / 注册) */
.auth-oauth-stack {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 4px;
padding-top: 16px;
border-top: 1px solid #e2e8f0;
}
.auth-oauth-stack--under-lead {
margin-top: 0;
margin-bottom: 8px;
padding-top: 0;
border-top: none;
}
.auth-oauth-label {
width: 100%;
font-size: 12px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.auth-oauth-pill {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 44px;
padding: 8px 16px;
border: 1px solid #e5e7eb;
border-radius: 9999px;
background: #fff;
color: #1e293b;
font-size: 14px;
font-weight: 500;
line-height: 1.25;
cursor: pointer;
box-sizing: border-box;
transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.auth-oauth-pill:hover:not(:disabled) {
background: #f8fafc;
border-color: #cbd5e1;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
}
.auth-oauth-pill:focus-visible {
outline: 2px solid #94a3b8;
outline-offset: 2px;
}
.auth-oauth-pill:disabled {
opacity: 0.55;
cursor: not-allowed;
}
/* inner 仅负责让文案居中;图标用绝对定位钉在按钮左侧固定位置,
这样所有按钮宽度相同,图标左缘永远在同一条垂线上,文案各自独立居中 */
.auth-oauth-pill-inner {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
pointer-events: none;
}
.auth-oauth-pill-icon {
position: absolute;
left: 18%;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
line-height: 0;
pointer-events: none;
}
.auth-oauth-pill-logo {
display: block;
width: 28px;
height: 28px;
object-fit: contain;
object-position: center;
}
.auth-oauth-pill-text {
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.25;
}
.auth-oauth-pill--no-logo .auth-oauth-pill-text {
text-align: center;
}
.auth-turnstile-wrap {
display: flex;
justify-content: center;
padding: 4px 0 2px;
}
.auth-turnstile-wrap--register-oauth {
padding-bottom: 10px;
margin-bottom: 2px;
}
.turnstile-widget {
display: flex;
justify-content: center;
}
.auth-submit {
width: 100%;
min-height: 48px;
@@ -1946,10 +2527,14 @@ select {
/* --- Tablet (<= 900px) --- */
@media (max-width: 900px) {
.app {
.app:not(.app--auth-guest) {
padding: 12px 12px 28px;
}
.app--auth-guest {
padding: 0;
}
.app-header {
padding: 10px 12px;
border-radius: 8px;
@@ -1957,7 +2542,8 @@ select {
}
.app-header h1,
.unified-user-header h1 {
.unified-user-header h1,
.app-header-brand-btn h1 {
font-size: 19px;
}
@@ -2013,10 +2599,14 @@ select {
/* --- Mobile (<= 640px) --- */
@media (max-width: 640px) {
.app {
.app:not(.app--auth-guest) {
padding: 12px 12px 32px;
}
.app--auth-guest {
padding: 0;
}
.app-header {
padding: 10px 14px;
border-radius: 10px;
@@ -2025,7 +2615,8 @@ select {
}
.app-header h1,
.unified-user-header h1 {
.unified-user-header h1,
.app-header-brand-btn h1 {
font-size: 17px;
}
@@ -2069,6 +2660,10 @@ select {
max-width: none;
}
.unified-user-shell--auth-guest .unified-user-main .panel > form.auth-card {
padding: 20px 16px 18px;
}
.panel > form.auth-card .auth-form-head h2 {
font-size: 20px;
}

View File

@@ -1,58 +1,66 @@
# 萌芽账户认证中心 API 文档
访问 **`GET /`** 或 **`GET /api`**(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`links.health``routePrefixes` 等)。**运行时未挂载**返回 Markdown 的 **`GET /api/docs`**;完整 HTTP 清单以 **`sproutgate-backend/main.go`** 与本文件、**`sproutgate-backend/后端文档.md`** 为准。
访问 `**GET /**``**GET /api**`(无鉴权)可得到 JSON 格式的简要说明(服务名、版本、`links.health``routePrefixes` 等)。**运行时未挂载**返回 Markdown 的 `**GET /api/docs`**;完整 HTTP 清单以 `**sproutgate-backend/main.go`** 与本文件、`**sproutgate-backend/后端文档.md**` 为准。
接入地址:
- 统一登录前端:`https://auth.shumengya.top`
- 后端 API`https://auth.api.shumengya.top`
- 本地开发 API`http://<host>:8080`
对外接入建议:
1. 第三方「登录」按钮跳转到统一登录前端的 **授权入口**(推荐 `.../authorize?...`,见下节)。
2. 用户在统一登录站点完成登录与授权确认后,回跳到业务站点。
3. 业务站点使用回跳 URL **`#`** 哈希中的 `token`,由**自有后端**调用 `POST /api/auth/verify``GET /api/auth/me` 校验并建立会话。
3. 业务站点使用回跳 URL `**#`** 哈希中的 `token`,由**自有后端**调用 `POST /api/auth/verify``GET /api/auth/me` 校验并建立会话。
**推荐入口 URL生产** 将下列主机换为你的统一登录部署域名;子路径部署时在前端 `base` 后追加 `authorize`
```html
<a href="https://auth.shumengya.top/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&amp;state=abc123&amp;client_id=my-app&amp;client_name=%E6%88%91%E7%9A%84%E5%BA%94%E7%94%A8">
<a href="https://auth.shumengya.top/authorize?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&state=abc123&client_id=my-app&client_name=%E6%88%91%E7%9A%84%E5%BA%94%E7%94%A8">
使用萌芽统一账户认证登录
</a>
```
说明:
- 若用户打开的是站点根路径且仅携带 `redirect_uri` / `return_url` 等查询参数,前端会自动 **重定向****`/authorize`**(保留同一套 query以使用独立授权确认页。
- 若用户打开的是站点根路径且仅携带 `redirect_uri` / `return_url` 等查询参数,前端会自动 **重定向**`**/authorize`**(保留同一套 query以使用独立授权确认页。
- `client_id` / `client_name` 会由浏览器写入 session并在用户登录时提交给后端用于累计 **应用接入记录**(详见下文「应用接入记录」)。
回跳说明:
-**`/authorize`** 流程下,已登录用户会看到明确的授权页(应用信息、权限说明、允许/拒绝);拒绝时回跳地址的 hash 中会携带 `error=access_denied` 等。
- 登录并「允许」后,浏览器回跳到 `redirect_uri`(或 `return_url`),并在 URL **`#fragment`**(哈希)中带上令牌与用户信息(见下表)
- 第三方拿到 `token` 后,建议服务端调用 **`POST /api/auth/verify`**(不更新「最后访问」等侧写,适合网关鉴权)或 **`GET /api/auth/me`**(会更新访问/签到相关侧写,适合拉取个人中心级资料)校验并解析用户身份
- `**/authorize`** 流程下,已登录用户会看到明确的授权页(应用信息、权限说明、允许/拒绝);拒绝时回跳地址的 hash 中会携带 `error=access_denied`
- 登录并「允许」后,浏览器回跳到 `redirect_uri`(或 `return_url`),并在 URL `**#fragment`**(哈希)中带上令牌与用户信息(见下表)
- 第三方拿到 `token` 后,建议服务端调用 `**POST /api/auth/verify**`(不更新「最后访问」等侧写,适合网关鉴权)或 `**GET /api/auth/me**`(会更新访问/签到相关侧写,适合拉取个人中心级资料)校验并解析用户身份。
### 统一登录前端:查询参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |
| `return_url` | 同上 |`redirect_uri` 同义,二者都传时优先 `redirect_uri` |
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
| `client_id` | 否 | 第三方应用稳定标识;格式须符合后端校验(见下文「应用接入记录」)。写入 session 后随登录请求提交,用于 **应用接入记录** |
| `client_name` | 否 | 展示用名称(最长 128`client_id` 配对;可选。 |
| 参数 | 必填 | 说明 |
| -------------- | ------------------- | ------------------------------------------------------------------- |
| `redirect_uri` | 与 `return_url` 至少其一 | 登录成功后的回跳地址,须进行 URL 编码;可为绝对 URL 或相对路径(相对路径相对统一登录站点解析)。 |
| `return_url` | 同上 | 与 `redirect_uri` 同义,二者都传时优先 `redirect_uri` |
| `state` | 否 | OAuth 风格透传字符串;回跳时原样写入哈希参数,供业务防 CSRF 或关联会话。 |
| `prompt` | 否 | 预留;前端可读,当前可用于将来扩展交互策略。 |
| `client_id` | 否 | 第三方应用稳定标识;格式须符合后端校验(见下文「应用接入记录」)。写入 session 后随登录请求提交,用于 **应用接入记录**。 |
| `client_name` | 否 | 展示用名称(最长 128`client_id` 配对;可选。 |
### 回跳 URL`#` 哈希参数
成功授权后,前端将使用 [`URLSearchParams`](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams) 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`
成功授权后,前端将使用 `[URLSearchParams](https://developer.mozilla.org/zh-CN/docs/Web/API/URLSearchParams)` 写入哈希,例如:`https://app.example.com/auth/callback#token=...&expiresAt=...&account=...&username=...&state=...`
| 参数 | 说明 |
|------|------|
| `token` | JWT调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |
| `expiresAt` | 过期时间RFC3339与签发侧一致当前默认为登录时起算 **7 天**)。 |
| `account` | 账户名(与 JWT `sub` 一致)。 |
| `username` | 展示用昵称,可能为空。 |
| `state` | 若登录请求携带了 `state`,则原样返回。 |
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 **`POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
| 参数 | 说明 |
| ----------- | -------------------------------------------------- |
| `token` | JWT调用受保护接口时放在请求头 `Authorization: Bearer <token>`。 |
| `expiresAt` | 过期时间RFC3339与签发侧一致当前默认为登录时起算 **7 天**)。 |
| `account` | 账户名(与 JWT `sub` 一致)。 |
| `username` | 展示用昵称,可能为空。 |
| `state` | 若登录请求携带了 `state`,则原样返回。 |
业务站点回调页应用脚本读取 `location.hash`,解析后**仅在 HTTPS 环境**将 `token` 存于内存或安全存储,并尽快用后端 `**POST /api/auth/verify`** 校验(勿仅信任哈希中的明文字段)。
### 第三方后端接入建议
@@ -62,31 +70,33 @@
## 应用接入记录authClients
用户在统一登录前端的 **个人中心 → 应用接入** 中可看到已与账号发生过认证关联的第三方应用列表。每条记录对应一个稳定的 **`client_id`**,并展示展示名(若有)、**首次接入时间**、**最近接入时间**(均为 RFC3339。持久化在 **MySQL `users` 表**中对应用户的 **`auth_clients` JSON 列**;对外 API 字段名仍为 **`authClients`**(与历史 JSON 文件存储时的形状一致)。
用户在统一登录前端的 **个人中心 → 应用接入** 中可看到已与账号发生过认证关联的第三方应用列表。每条记录对应一个稳定的 `**client_id`**,并展示展示名(若有)、首次接入时间最近接入时间(均为 RFC3339。持久化在 MySQL `users` 表中对应用户的 `**auth_clients` JSON 列**;对外 API 字段名仍为 `**authClients`**(与历史 JSON 文件存储时的形状一致)。
示例展示含义(与界面文案一致):**infogenie**(万象口袋)首次 `2026-03-22T16:33:24+08:00` · 最近 `2026-03-30T19:22:45+08:00` —— 其中括号内为 `client_name` / `X-Auth-Client-Name` / 登录体 `clientName` 带来的展示名,`infogenie``client_id`
### `client_id` 与名称规则
- **`client_id`(必填才能记一条)**:长度 164必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。
- **`client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`
- `**client_id`(必填才能记一条)**:长度 164必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。
- `**client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`
### 在哪些接口上累计记录
记录仅在 **令牌有效、用户未封禁、且 `client_id` 通过校验** 时写入或刷新 **`lastSeenAt`**;已存在同一 `client_id` 时更新 **`lastSeenAt`**,必要时更新 **`displayName`****不改动** **`firstSeenAt`**
记录仅在 **令牌有效、用户未封禁、且 `client_id` 通过校验** 时写入或刷新 `**lastSeenAt`**;已存在同一 `client_id` 时更新 `**lastSeenAt`**,必要时更新 `**displayName**`,不改动 `**firstSeenAt**`
| 方式 | 接口 / 场景 | 如何传递 `client_id` 与展示名 |
| ------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **A. 统一登录 URL** | 用户经前端完成登录 | 授权页 URL 查询参数 `**client_id`**、`**client_name`**(见上表)。前端写入 session登录请求 `POST /api/auth/login` 的 JSON 中会带 `**clientId**` / `**clientName**`。 |
| **B. 资源拥有者密码登录** | `POST /api/auth/login` | JSON `**clientId`**、`**clientName`**(可选,规则同上)。 |
| **C. 服务端持 JWT 调用** | `POST /api/auth/verify` | 请求头 `**X-Auth-Client`**(必须为合法 id`**X-Auth-Client-Name`**(可选)。**Body 仅含 `token`,不能替代请求头。** |
| **D. 服务端持 JWT 调用** | `GET /api/auth/me` | 同上:`**X-Auth-Client`**、`**X-Auth-Client-Name`**,与 `**Authorization: Bearer**` 同时使用。 |
| 方式 | 接口 / 场景 | 如何传递 `client_id` 与展示名 |
|------|----------------|--------------------------------|
| **A. 统一登录 URL** | 用户经前端完成登录 | 授权页 URL 查询参数 **`client_id`**、**`client_name`**(见上表)。前端写入 session登录请求 `POST /api/auth/login` 的 JSON 中会带 **`clientId`** / **`clientName`**。 |
| **B. 资源拥有者密码登录** | `POST /api/auth/login` | JSON **`clientId`**、**`clientName`**(可选,规则同上)。 |
| **C. 服务端持 JWT 调用** | `POST /api/auth/verify` | 请求头 **`X-Auth-Client`**(必须为合法 id、**`X-Auth-Client-Name`**(可选)。**Body 仅含 `token`,不能替代请求头。** |
| **D. 服务端持 JWT 调用** | `GET /api/auth/me` | 同上:**`X-Auth-Client`**、**`X-Auth-Client-Name`**,与 **`Authorization: Bearer`** 同时使用。 |
说明:
- **A + B**:在登录成功、签发 JWT **之后**写库;`POST /api/auth/login` 的响应 `user` 为 `OwnerPublic()`**含** `authClients`(若本次写入了新记录)。
- **C**`POST /api/auth/verify` 在校验通过且未封禁后写库;响应 `user`**`Public()`,不含 `authClients`**,避免向第三方泄露用户在其他应用上的关联列表。
- **D**`GET /api/auth/me` 在校验通过且未封禁后写库;响应 `user`**`OwnerPublic()`,含 `authClients`**,与前端个人中心一致。
- **C**`POST /api/auth/verify` 在校验通过且未封禁后写库;响应 `user``**Public()`,不含 `authClients`**,避免向第三方泄露用户在其他应用上的关联列表。
- **D**`GET /api/auth/me` 在校验通过且未封禁后写库;响应 `user``**OwnerPublic()`,含 `authClients`**,与前端个人中心一致。
**封禁用户**`POST /api/auth/verify``GET /api/auth/me` 在命中封禁或令牌无效时 **不会** 更新应用接入记录(在写库逻辑之前即返回错误)。
@@ -106,9 +116,11 @@
## 认证与统一登录
### 登录获取统一令牌
`POST /api/auth/login`
请求:
```json
{
"account": "demo",
@@ -121,6 +133,7 @@
`clientId` / `clientName` 可选;规则与应用接入请求头一致。传入且格式合法时,会在登录成功后写入 **应用接入记录**(见上文 **「应用接入记录authClients」**)。
响应:
```json
{
"token": "jwt-token",
@@ -155,22 +168,26 @@
**常见 HTTP 状态码(登录)**
| 状态码 | 含义 |
|--------|------|
| 200 | 成功,返回 `token``expiresAt``user` |
| 400 | 请求体非法或缺少 `account` / `password` |
| 状态码 | 含义 |
| --- | --------------------------------------- |
| 200 | 成功,返回 `token``expiresAt``user` |
| 400 | 请求体非法或缺少 `account` / `password`。 |
| 401 | 账户不存在或密码错误(统一文案 `invalid credentials`)。 |
| 403 | 账户已封禁(见上文 JSON。 |
| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |
| 403 | 账户已封禁(见上文 JSON |
| 500 | 服务器内部错误(读库、签发 JWT 失败等)。 |
**JWT 概要**:算法 **HS256**;载荷含 `account`(与 `sub` 一致)、`iss`(见 `data/config/auth.json`)、`iat` / `exp`。客户端只需透传字符串,**勿在前端解析密钥**。
### 校验令牌
`POST /api/auth/verify`
请求头:**`Content-Type: application/json`**。可选 **`X-Auth-Client`**、**`X-Auth-Client-Name`**(累计应用接入记录,见专章)。
请求头:`**Content-Type: application/json`**。可选 `**X-Auth-Client`**、`**X-Auth-Client-Name**`(累计应用接入记录,见专章)。
请求体:
```json
{
"token": "jwt-token"
@@ -178,6 +195,7 @@
```
响应:
```json
{
"valid": true,
@@ -199,24 +217,29 @@
`verify``me` 的取舍:**仅校验身份、默认不更新「最后访问」等侧写**时用 `verify`;需要最新资料、签到状态或写入「最后访问」时用 `GET /api/auth/me`(需 Bearer
**应用接入记录**:在 **`verify` / `me`** 上使用 **`X-Auth-Client`**、**`X-Auth-Client-Name`** 的约定,以及响应 `user` 是否包含 `authClients`,见上文 **「应用接入记录authClients」**。
**应用接入记录**:在 `**verify` / `me`** 上使用 `**X-Auth-Client`**、`**X-Auth-Client-Name**` 的约定,以及响应 `user` 是否包含 `authClients`,见上文 **「应用接入记录authClients」**。
### 获取当前用户信息
`GET /api/auth/me`
请求头:
- **`Authorization: Bearer <jwt-token>`**(必填)
- `**Authorization: Bearer <jwt-token>`**(必填)
可选 — **应用接入记录**(与 `verify` 相同语义,见 **「应用接入记录authClients」**
- **`X-Auth-Client`**、**`X-Auth-Client-Name`**
- `**X-Auth-Client`**、`**X-Auth-Client-Name`**
可选(由前端调用 `https://cf-ip-geo.smyhub.com/api` 等接口解析后传入,用于记录「最后访问 IP」与「最后显示位置」
- `X-Visit-Ip`:客户端公网 IP与地理接口返回的 `ip` 一致即可)
- `X-Visit-Location`:展示用位置文案(例如将 `geo.countryName``regionName``cityName` 拼接为 `中国 四川 成都`
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求环境变量 **`GEO_LOOKUP_URL`** 指定的基址(默认与 `internal/clientgeo.DefaultLookupURL` 一致:`https://cf-ip-geo.smyhub.com/api`,实际请求会附加 **`?ip=`**)解析展示位置并写入用户记录。
**服务端回退(避免浏览器跨域导致头缺失)**:若未传 `X-Visit-Location`,后端会用 `X-Visit-Ip`;若也未传 `X-Visit-Ip`,则用连接的 `ClientIP()`(请在前置反向代理上正确传递 `X-Forwarded-For` 等,并在生产环境为 Gin 配置可信代理)。随后服务端请求环境变量 `**GEO_LOOKUP_URL`** 指定的基址(默认与 `internal/clientgeo.DefaultLookupURL` 一致:`https://cf-ip-geo.smyhub.com/api`,实际请求会附加 `**?ip=`**)解析展示位置并写入用户记录。
响应:
```json
{
"user": { "account": "demo", "...": "..." },
@@ -232,19 +255,21 @@
> `user` 还会包含 `lastVisitAt`、`lastVisitDate`、`checkInDays`、`checkInStreak`、`visitDays`、`visitStreak` 等统计字段。
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。**`POST /api/auth/verify` 返回的 `user``Public()`)中不包含这两项**(避免第三方校验响应携带访问隐私)。**`GET /api/public/users/:account` 的 `user``PublicProfile()`)会包含这两项**,与公开主页展示策略一致。
> 在登录用户本人、管理员列表等场景下,`user` 还可包含 `lastVisitIp`、`lastVisitDisplayLocation`(最近一次通过 `/api/auth/me` 上报的访问 IP 与位置文案)。`**POST /api/auth/verify` 返回的 `user``Public()`)中不包含这两项**(避免第三方校验响应携带访问隐私)。`**GET /api/public/users/:account` 的 `user``PublicProfile()`)会包含这两项**,与公开主页展示策略一致。
> 说明:密码不会返回。
若账户在登录后被封禁,持旧 JWT 调用 `GET /api/auth/me``PUT /api/auth/profile``POST /api/auth/check-in`、辅助邮箱等需登录接口时,返回 **403**,正文同登录封禁响应(`error` + 可选 `banReason`)。客户端应作废本地令牌。
### 每日签到
`POST /api/auth/check-in`
请求头:
`Authorization: Bearer <jwt-token>`
响应:
```json
{
"checkedIn": true,
@@ -257,12 +282,14 @@
```
### 更新当前用户资料
`PUT /api/auth/profile`
请求头:
`Authorization: Bearer <jwt-token>`
请求(字段可选):
```json
{
"password": "newpass",
@@ -277,6 +304,7 @@
说明:`websiteUrl` 须为 `http`/`https` 地址;可传空字符串清除;未写协议时服务端会补全为 `https://`
响应:
```json
{
"user": { "account": "demo", "...": "..." }
@@ -286,11 +314,13 @@
## 用户广场
### 获取公开用户目录
`GET /api/public/users`
无需鉴权。返回未封禁用户的简要列表,默认按 **`createdAt`**(注册时间)升序。
无需鉴权。返回未封禁用户的简要列表,默认按 `**createdAt**`(注册时间)升序。
响应:
```json
{
"total": 2,
@@ -310,17 +340,20 @@
```
### 获取用户公开主页
`GET /api/public/users/{account}`
说明:
- 仅支持路径参数 **账户名** `account`(可与存储大小写不同,服务端按不区分大小写匹配),不支持昵称查询。
- 适合第三方应用展示用户公开资料。
- 若该账户已被封禁,返回 **404** `{"error":"user not found"}`(与不存在账户相同,避免公开资料泄露)。
- 响应体在 `user` 之外包含累计赞数 **`profileLikeCount`**。
- **`Authorization: Bearer`** 可选:若传入**有效未封禁**用户令牌且非主页主人,响应可额外包含 `viewerHasLikedToday``viewerLikesRemainingToday``profileLikeDailyMax`(当日是否已赞该主页、当日还可给多少人点赞、每自然日上限当前为 **5**,以后端常量为准)。若浏览者即主页主人,可返回 `viewerIsOwner: true`
- 响应体在 `user` 之外包含累计赞数 `**profileLikeCount`**。
- `**Authorization: Bearer`** 可选:若传入**有效未封禁**用户令牌且非主页主人,响应可额外包含 `viewerHasLikedToday``viewerLikesRemainingToday``profileLikeDailyMax`(当日是否已赞该主页、当日还可给多少人点赞、每自然日上限当前为 **5**,以后端常量为准)。若浏览者即主页主人,可返回 `viewerIsOwner: true`
- `user` 中含该用户**最近一次被服务端记录的**访问 IP`lastVisitIp`)与展示地理位置(`lastVisitDisplayLocation``POST /api/auth/verify` 返回的用户 JSON **不含**上述两项。
响应示例(未带 Bearer
```json
{
"user": {
@@ -339,17 +372,19 @@
```
### 公开主页点赞
`POST /api/public/users/{account}/like`
请求头:**`Authorization: Bearer <jwt-token>`**(必填)。
请求头:`**Authorization: Bearer <jwt-token>**`(必填)。
为路径中 **`account`** 对应用户的公开主页点赞。规则摘要(与 `internal/storage` 一致):
为路径中 `**account**` 对应用户的公开主页点赞。规则摘要(与 `internal/storage` 一致):
- 不能给自己点赞;每个点赞者对同一主页 **每个自然日最多一次**
- 每个点赞者每个自然日最多给 **5** 位**不同**用户点赞(超限返回 **400**,正文中可含 `viewerLikesRemainingToday``profileLikeDailyMax`)。
- 目标用户不存在或已封禁:**404**;点赞者封禁或令牌无效:**401** / **403** 等,以服务端 `error` 为准。
成功响应示例:
```json
{
"profileLikeCount": 13,
@@ -360,11 +395,13 @@
```
### 公开注册策略
`GET /api/public/registration-policy`
无需鉴权。用于前端判断是否展示「邀请码」输入框。
响应:
```json
{
"requireInviteCode": false
@@ -374,9 +411,11 @@
`requireInviteCode`**true** 时,`POST /api/auth/register` 必须携带有效 `inviteCode`(见下节)。
### 注册账号(发送邮箱验证码)
`POST /api/auth/register`
请求:
```json
{
"account": "demo",
@@ -390,6 +429,7 @@
- `inviteCode`:可选。若服务端开启「强制邀请码」,则必填且须为管理员发放的未过期、未用尽邀请码。邀请码**不区分大小写**;成功完成 `verify-email` 创建用户后才会扣减使用次数。
响应:
```json
{
"sent": true,
@@ -398,9 +438,11 @@
```
### 验证邮箱并完成注册
`POST /api/auth/verify-email`
请求:
```json
{
"account": "demo",
@@ -409,6 +451,7 @@
```
响应:
```json
{
"created": true,
@@ -417,9 +460,11 @@
```
### 忘记密码(发送重置验证码)
`POST /api/auth/forgot-password`
请求:
```json
{
"account": "demo",
@@ -428,6 +473,7 @@
```
响应:
```json
{
"sent": true,
@@ -436,9 +482,11 @@
```
### 重置密码
`POST /api/auth/reset-password`
请求:
```json
{
"account": "demo",
@@ -448,17 +496,20 @@
```
响应:
```json
{ "reset": true }
```
### 申请添加辅助邮箱(发送验证码)
`POST /api/auth/secondary-email/request`
请求头:
`Authorization: Bearer <jwt-token>`
请求:
```json
{
"email": "demo2@example.com"
@@ -466,6 +517,7 @@
```
响应:
```json
{
"sent": true,
@@ -474,12 +526,14 @@
```
### 验证辅助邮箱
`POST /api/auth/secondary-email/verify`
请求头:
`Authorization: Bearer <jwt-token>`
请求:
```json
{
"email": "demo2@example.com",
@@ -488,6 +542,7 @@
```
响应:
```json
{
"verified": true,
@@ -499,20 +554,24 @@
管理员 Token 存放在 `data/config/admin.json` 中;如果文件不存在,后端启动时会自动生成并写入该文件。
请求时可使用以下任一方式携带:
- Query`?token=<admin-token>`
- Header`X-Admin-Token: <admin-token>`
### 签到奖励设置
`GET /api/admin/check-in/config`
`PUT /api/admin/check-in/config`
请求:
```json
{
"rewardCoins": 1
}
```
- Header`Authorization: Bearer <admin-token>`
### 注册策略与邀请码
@@ -524,6 +583,7 @@
`PUT /api/admin/registration`
请求:
```json
{ "requireInviteCode": true }
```
@@ -531,6 +591,7 @@
`POST /api/admin/registration/invites`
请求:
```json
{
"note": "内测批次",
@@ -546,9 +607,11 @@
删除指定邀请码(`code` 与存储大小写可能不同,按不区分大小写匹配)。
### 获取用户列表
`GET /api/admin/users`
响应:
```json
{
"total": 1,
@@ -557,9 +620,11 @@
```
### 新建用户
`POST /api/admin/users`
请求:
```json
{
"account": "demo",
@@ -577,9 +642,11 @@
```
### 更新用户
`PUT /api/admin/users/{account}`
请求(字段可选):
```json
{
"password": "newpass",
@@ -599,9 +666,11 @@
管理员列表 `GET /api/admin/users` 中每条 `user``OwnerPublic()`,可含 `banned``banReason``bannedAt`(及 `authClients` 等)——以后端实际 JSON 为准。
### 删除用户
`DELETE /api/admin/users/{account}`
响应:
```json
{ "deleted": true }
```
@@ -610,18 +679,20 @@
当前发行版**以 MySQL 为唯一运行时数据源**GORM `AutoMigrate`),不再使用 `data/users/*.json` 等文件作为线上读写路径。表与用途概要:
| 表名 | 用途 |
|------|------|
| `users` | 用户主数据;`auth_clients` 等为 JSON 列 |
| `pending_users` | 注册邮箱待验证 |
| `password_resets` | 找回密码验证码 |
| `secondary_email_verifications` | 辅助邮箱验证 |
| `app_configs` | `admin` / `auth` / `email` / `checkin` / `registration` 等 JSON 配置 |
| `invite_codes` | 邀请码 |
| `profile_likes` | 公开主页点赞记录 |
| `profile_like_daily_quota` | 点赞每日额度辅助 |
从旧版 **`sproutgate-backend/data/`** 目录迁移时,使用 **`go run ./cmd/migrate --data-dir ./data`**(详见 **`sproutgate-backend/后端文档.md`**)。
| 表名 | 用途 |
| ------------------------------- | ----------------------------------------------------------------- |
| `users` | 用户主数据;`auth_clients` 等为 JSON 列 |
| `pending_users` | 注册邮箱待验证 |
| `password_resets` | 找回密码验证码 |
| `secondary_email_verifications` | 辅助邮箱验证 |
| `app_configs` | `admin` / `auth` / `email` / `checkin` / `registration` 等 JSON 配置 |
| `invite_codes` | 邀请码 |
| `profile_likes` | 公开主页点赞记录 |
| `profile_like_daily_quota` | 点赞每日额度辅助 |
从旧版 `**sproutgate-backend/data/`** 目录迁移时,使用 `**go run ./cmd/migrate --data-dir ./data`**(详见 `**sproutgate-backend/后端文档.md**`)。
## 快速联调用示例
@@ -653,3 +724,4 @@ curl http://localhost:8080/api/auth/me \
-H 'X-Auth-Client: infogenie' \
-H 'X-Auth-Client-Name: 万象口袋'
```