Update SproutGate
This commit is contained in:
564
api-docs/sproutgate-api.json
Normal file
564
api-docs/sproutgate-api.json
Normal 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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
185
docs/cloudflare-turnstile.md
Normal file
185
docs/cloudflare-turnstile.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Cloudflare Turnstile 网站接入指南
|
||||
|
||||
本文说明如何在任意网站中集成 [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) 人机验证:从控制台拿密钥、前端展示控件、把一次性 token 交给后端、由服务端向 Cloudflare 校验。内容适用于自建站点与 API,不限定具体技术栈,文末可对照本仓库中的实现。
|
||||
|
||||
---
|
||||
|
||||
## 1. 概念与流程
|
||||
|
||||
|
||||
| 项 | 说明 |
|
||||
| ------------------- | -------------------------------------------------------------------------------------- |
|
||||
| **Site Key** | 公钥,可出现在前端代码或 HTML 中。 |
|
||||
| **Secret Key** | 私钥,**仅**能用于你的服务端调用校验接口。 |
|
||||
| **Token(response)** | 用户通过验证后,Turnstile 在浏览器中生成的一串临时字符串,需在你自己的业务请求里随表单/JSON 提交到**你的后端**。 |
|
||||
| **二次校验** | 前端 token **不能**单独作为“已通过人机验证”的依据;必须由服务器使用 Secret Key 调用 Cloudflare 的 `siteverify` 接口确认。 |
|
||||
|
||||
|
||||
典型流程:
|
||||
|
||||
1. 页面加载 Cloudflare 提供的 JS,在指定 DOM 中渲染小组件。
|
||||
2. 用户通过挑战后,前端拿到 `token`,写入状态或随登录/注册请求提交。
|
||||
3. 你的后端在执行业务逻辑**之前**用 Secret Key + `token`(及可选的客户端 IP)请求 `siteverify`;`success: true` 才继续。
|
||||
4. 每个 token 仅应用一次、且有时效;提交失败后应**重置**控件以获取新 token(见第 3 节)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 在 Cloudflare 控制台创建站点
|
||||
|
||||
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/),进入 **Turnstile**。
|
||||
2. 选择 **Add widget** / 添加站点。
|
||||
3. 填写网站域名、选择 **Managed**(或按需选择可见性/交互方式)。
|
||||
4. 创建后得到 **Site Key** 与 **Secret Key**;为生产环境可单独为生产域名建 widget,勿把 Secret Key 写进仓库或静态资源。
|
||||
|
||||
**本地开发**:在 Turnstile 站点里把 `localhost` 及测试域名加入允许的主机名(与 Cloudflare 当前 UI 中「主机名」/域名列表一致),否则可能无法加载或校验失败。
|
||||
|
||||
---
|
||||
|
||||
## 3. 前端:加载脚本与渲染控件
|
||||
|
||||
### 3.1 官方脚本
|
||||
|
||||
推荐按文档使用官方脚本,例如**显式渲染**(`render=explicit`)便于在单页应用里把控件挂在指定容器上:
|
||||
|
||||
- 脚本地址:
|
||||
`https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit`
|
||||
|
||||
在页面中插入一次即可(注意避免重复注入同一脚本多份,除非你有意为之)。
|
||||
|
||||
```html
|
||||
<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 客户端设置合理超时(如 8~15 秒),失败时对用户显示通用错误,**不要**在错误信息中泄露 Secret Key 或内部堆栈。
|
||||
- `siteverify` 应仅在服务端调用;不要从浏览器直接带 Secret 请求。
|
||||
|
||||
### 4.4 伪代码示例
|
||||
|
||||
```text
|
||||
if secret_key is empty: return 500 # 未配置
|
||||
if user_token is empty: return 400 # 请完成人机验证
|
||||
POST form: secret, response, (optional) remoteip
|
||||
if JSON.success != true: return 400 # 验证失败
|
||||
# 再执行登录/注册等逻辑
|
||||
```
|
||||
|
||||
语言无关:Go 可用 `http.PostForm`,Node 可用 `fetch` + `URLSearchParams`,Python 可用 `httpx`/`requests` 的 `data=` 等。
|
||||
|
||||
---
|
||||
|
||||
## 5. 安全与产品注意点
|
||||
|
||||
- **Site Key 可公开,Secret Key 绝不可进前端、公开仓库、日志。** 使用环境变量或密钥管理。
|
||||
- **每次敏感操作**若需要防护,可要求新的 token,而不是长缓存同一条 token。
|
||||
- 与**速率限制、账户锁定、异常 IP 检测**等组合,而不是只依赖 Turnstile。
|
||||
- 若使用**反向代理**,`remoteip` 应取真实客户端 IP(与 Turnstile 可见一致),与信任代理头配置统一。
|
||||
- 多环境(dev/staging/prod)使用不同 widget 与密钥,避免误用。
|
||||
|
||||
---
|
||||
|
||||
## 6. 排障简表
|
||||
|
||||
|
||||
| 现象 | 可能原因 |
|
||||
| ------------------- | ------------------------------------------------ |
|
||||
| 前端不显示控件 | 域名未在 Turnstile 站点中、脚本被 CSP/广告拦截、脚本未加载完就 `render` |
|
||||
| 总是 `error-callback` | Site Key 错误、域名校验失败、网络问题 |
|
||||
| `siteverify` 失败 | token 已用过/过期、Secret Key 错误、时间偏差过大、请求不是 POST 表单 |
|
||||
| 生产正常、本地失败 | 未把 localhost 加入允许主机名、混用不同环境的 Key |
|
||||
|
||||
|
||||
更完整的错误码与行为以 Cloudflare 官方文档为准:
|
||||
[https://developers.cloudflare.com/turnstile/](https://developers.cloudflare.com/turnstile/)
|
||||
|
||||
---
|
||||
|
||||
## 7. 与本项目(SproutGate)的对应关系(参考)
|
||||
|
||||
本仓库中可作为对照的实现位置(不保证与上文逐字相同,以代码为准):
|
||||
|
||||
- 前端组件:`sproutgate-frontend/src/components/oauth/TurnstileWidget.jsx`(显式 `render`、token 回调、`reset`)。
|
||||
- 前端在登录/注册中附带 `turnstileToken`:`UserPortal.jsx`、`UserPortalAuthSection.jsx`。
|
||||
- 后端校验:`sproutgate-backend/internal/handlers/turnstile.go` 中 `verifyTurnstileToken` 及 `turnstileVerifyURL`。
|
||||
- 管理端配置 Site/Secret 与开关:`/api/admin/turnstile`、存储层 `TurnstileConfig`。
|
||||
|
||||
第三方站点只需遵守本文第 1~5 节的通用步骤即可;具体路由与配置项以各自系统为准。
|
||||
230
docs/oauth-github-gitea-testing.md
Normal file
230
docs/oauth-github-gitea-testing.md
Normal 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`
|
||||
- Google(Google Cloud「OAuth 2.0 客户端 ID」,类型为 **Web 应用** 时的「已获授权的重定向 URI」):
|
||||
`{API根}/api/auth/oauth/google/callback`
|
||||
- Microsoft / Azure / Entra(**应用注册** → 身份验证 → 添加平台 **Web** 的「重定向 URI」):
|
||||
`{API根}/api/auth/oauth/microsoft/callback`
|
||||
本服务使用 **v2.0 授权终结点**;租户在管理后台为 **Microsoft 租户** 字段,默认 `common`(多租户 + 个人 Microsoft 账号)。若你只需单一组织目录,可改为**具体租户 ID**。
|
||||
- LINUX DO(Connect 应用接入):
|
||||
`{API根}/api/auth/oauth/linuxdo/callback`
|
||||
与你在 [应用接入](https://connect.linux.do) 中填写的回调一致即可(开发时可为 `http://localhost:8080/...`)。
|
||||
|
||||
开发时 `{API根}` 多为 `http://localhost:8080`;生产时为 `https://你的 API 域名`(无尾部斜杠)。
|
||||
|
||||
### 1.3 后端环境变量(与部署相关)
|
||||
|
||||
|
||||
| 变量 | 作用 |
|
||||
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PUBLIC_API_BASE` | **建议生产必设**。值为 **纯 API 根**(无路径),如 `https://auth.api.example.com`。用于拼 OAuth `redirect_uri`。本地若访问 API 用 `http://localhost:8080`,可设 `PUBLIC_API_BASE=http://localhost:8080`,与浏览器访问后端的地址一致。 |
|
||||
| `GITHUB_CLIENT_SECRET` / `GITEA_CLIENT_SECRET` / `GOOGLE_CLIENT_SECRET` / `MICROSOFT_CLIENT_SECRET` / `LINUXDO_CLIENT_SECRET` | 可选;若设置,会覆盖数据库里保存的对应 Secret,便于不把 Secret 写进库。 |
|
||||
| `PORT` | 后端监听端口,默认 `8080`。 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 二、开发环境(本机)
|
||||
|
||||
### 2.1 默认假设
|
||||
|
||||
- 后端:`http://localhost:8080`(`go run .` 或 `sproutgate.bat dev`)
|
||||
- 前端:`http://localhost:5173`(`npm run dev`)
|
||||
- 前端已配置 `VITE_API_BASE=http://localhost:8080`(或留空走开发默认)
|
||||
|
||||
### 2.2 在 GitHub 创建 OAuth App
|
||||
|
||||
1. 打开 GitHub → **Settings** → **Developer settings** → **OAuth Apps** → **New OAuth App**(或 Organization 下创建)。
|
||||
2. 填写示例:
|
||||
- **Application name**:任意,如 `SproutGate Dev`
|
||||
- **Homepage URL**:`http://localhost:5173`
|
||||
- **Authorization callback URL**:
|
||||
`http://localhost:8080/api/auth/oauth/github/callback`
|
||||
3. 创建后记录 **Client ID**,生成 **Client secrets** 并复制保存(只显示一次)。
|
||||
|
||||
GitHub 允许使用 `http://localhost` 作为回调,无需公网。
|
||||
|
||||
### 2.3 在 LINUX DO Connect 创建应用
|
||||
|
||||
1. 打开 [https://connect.linux.do](https://connect.linux.do)(需登录),侧栏 **应用接入** → 新建接入。
|
||||
2. **回调地址** 填 `http://localhost:8080/api/auth/oauth/linuxdo/callback`(生产改为 `https://{API域名}/api/auth/oauth/linuxdo/callback`)。
|
||||
3. 保存后记录 **Client ID**、**Client Secret**;管理后台「LINUX DO Connect 根地址」通常填 `https://connect.linux.do`(与官方端点一致即可)。
|
||||
|
||||
### 2.4 在 Gitea 创建 OAuth2 应用
|
||||
|
||||
1. 登录你的 Gitea(如 `https://git.shumengya.top`)→ **站点管理** 或 **用户设置** 中的 **Applications** / **OAuth2**,创建新应用(不同版本菜单位置略有差异)。
|
||||
2. **Redirect URI**(重定向 URI):
|
||||
`http://localhost:8080/api/auth/oauth/gitea/callback`
|
||||
3. 记录 **Client ID**、**Client Secret**,勾选所需权限(至少能读用户资料,一般 `read:user` 或界面勾「读取用户信息」类选项)。
|
||||
|
||||
### 2.4a 在 Google Cloud 创建 OAuth 2.0 客户端(可选)
|
||||
|
||||
1. 打开 [Google Cloud Console](https://console.cloud.google.com/),选择或创建项目 → **API 和服务** → **OAuth 同意屏幕**(先配置为测试/内部/公开视需求)。
|
||||
2. **凭据** → **创建凭据** → **OAuth 客户端 ID** → 应用类型选 **Web 应用**。
|
||||
3. **已获授权的重定向 URI** 填 `http://localhost:8080/api/auth/oauth/google/callback`(生产改为 `https://{API域名}/api/auth/oauth/google/callback`)。
|
||||
4. 记录 **客户端 ID**、**客户端密钥**;在 SproutGate 管理后台「第三方登录」中勾选 **Google** 并填入。
|
||||
|
||||
### 2.4b 在 Microsoft Entra(原 Azure AD)应用注册(可选)
|
||||
|
||||
1. 打开 [Microsoft Entra 管理中心](https://entra.microsoft.com/) → **应用注册** → **新注册**。
|
||||
2. 支持的帐户类型中,若与后台 **Microsoft 租户** 填 `common` 一致,通常选「**任何组织目录中的帐户和个人 Microsoft 帐户**」。
|
||||
3. **身份验证** → 添加平台 **Web** → **重定向 URI** 填 `http://localhost:8080/api/auth/oauth/microsoft/callback`(生产用 HTTPS 与真实 API 域)。
|
||||
4. 在 **证书和密码** 中创建 **客户端密码**,并记录**应用程序(客户端) ID** 与 secret;管理后台 **Microsoft 租户** 可填 `common` 或你的目录租户 ID(单租户时便于限制登录范围)。
|
||||
|
||||
### 2.5 在 SproutGate 管理后台配置
|
||||
|
||||
1. 打开管理页,填写 **管理员 Token**,进入 **第三方登录**。
|
||||
2. 按需勾选 **GitHub** / **Gitea** / **Google** / **Microsoft** / **LINUX DO 登录**。
|
||||
3. 分别填入各平台的 **Client ID** 与 **Client Secret**;Microsoft 另填 **Microsoft 租户**(默认 `common`);LINUX DO 另需确认 **Connect 根地址**(默认 `https://connect.linux.do`)。
|
||||
4. **Gitea 根地址**:如 `https://git.shumengya.top`(无尾斜杠也可,保存时会规范化)。
|
||||
5. **允许的回跳地址**:至少包含(每行一个,**建议带尾斜杠**):
|
||||
- `http://localhost:5173/`
|
||||
- 若用 `127.0.0.1` 打开前端,再加一行:
|
||||
`http://127.0.0.1:5173/`
|
||||
6. **需邀请时仍允许 OAuth 新用户**:若你开启了「强制邀请码」自助注册,又想用 OAuth **直接注册**新账号,需勾选此项;否则邀请制下 OAuth 只能登录**已绑定**的账号。
|
||||
7. 保存。
|
||||
|
||||
### 2.6 本地设置 `PUBLIC_API_BASE`(推荐)
|
||||
|
||||
使后端生成的 OAuth `redirect_uri` 与你在各平台应用里填的完全一致:
|
||||
|
||||
**Windows PowerShell(当前终端)**
|
||||
|
||||
```powershell
|
||||
$env:PUBLIC_API_BASE = "http://localhost:8080"
|
||||
```
|
||||
|
||||
**或在启动脚本里** 写死上述一行再启动后端。
|
||||
|
||||
然后重启后端,再测登录。
|
||||
|
||||
### 2.7 验证步骤(开发)
|
||||
|
||||
1. 打开 `http://localhost:5173`,**退出**当前登录(如有)。
|
||||
2. 在登录界面应看到已启用的第三方按钮(**GitHub**、**Gitea**、**谷歌**、**微软**、**LINUX DO** 等,仅当对应「启用」已打开)。
|
||||
3. 点击 **GitHub**:应跳转到 GitHub 授权页,授权后回到 `http://localhost:5173/#oauth_token=...`(地址栏 hash 中有 token),页面应显示已登录。
|
||||
4. 退出后测 **Gitea** / **Google** / **Microsoft** / **LINUX DO**,流程同上(LINUX DO 会先到 `connect.linux.do` 授权页)。
|
||||
5. **绑定**:先普通登录,进入用户中心 → **第三方账号** → **绑定**,应跳转到对应平台,返回后应显示「已绑定」。
|
||||
6. **解绑**:点「解绑」,确认后状态应更新。
|
||||
|
||||
若返回后 URL 带 `#oauth_error=1`,查看 `oauth_error_description` 片段内容(多为白名单、`redirect_uri` 不匹配、或策略禁止注册等)。
|
||||
|
||||
---
|
||||
|
||||
## 三、生产环境
|
||||
|
||||
### 3.1 前置条件
|
||||
|
||||
- API 对外为 **HTTPS**(GitHub 生产回调通常要求 HTTPS;本地 localhost 除外)。
|
||||
- 用户浏览器能访问:**前端域名**、**API 域名**、**Gitea 域名**(若用 Gitea 登录)。
|
||||
|
||||
### 3.2 在 GitHub 生产 OAuth App
|
||||
|
||||
1. 新建或使用独立 **Production** OAuth App(勿与开发混用同一回调,易错)。
|
||||
2. **Authorization callback URL** 填:
|
||||
`https://{你的API域名}/api/auth/oauth/github/callback`
|
||||
例如:`https://auth.api.shumengya.top/api/auth/oauth/github/callback`
|
||||
3. 记录 Client ID / Secret。
|
||||
|
||||
### 3.3 在 Gitea 生产 OAuth 应用
|
||||
|
||||
1. **Redirect URI**:
|
||||
`https://{你的API域名}/api/auth/oauth/gitea/callback`
|
||||
2. 记录 Client ID / Secret。
|
||||
|
||||
### 3.4 服务器环境变量
|
||||
|
||||
示例(按你的实际域名修改):
|
||||
|
||||
```bash
|
||||
export PUBLIC_API_BASE="https://auth.api.shumengya.top"
|
||||
export GITHUB_CLIENT_SECRET="..." # 可选
|
||||
export GITEA_CLIENT_SECRET="..." # 可选
|
||||
export GOOGLE_CLIENT_SECRET="..." # 可选
|
||||
export MICROSOFT_CLIENT_SECRET="..." # 可选
|
||||
```
|
||||
|
||||
确保反向代理(Nginx/Caddy 等)转发的 **Host** / **X-Forwarded-Proto** 正确,否则未设 `PUBLIC_API_BASE` 时,程序可能错误拼出 `http` 或错误主机,导致与 GitHub/Gitea 登记的回调不一致。
|
||||
|
||||
### 3.5 前端
|
||||
|
||||
- 生产构建时设置 `VITE_API_BASE=https://{你的API域名}`(与浏览器实际请求后端的地址一致,无多余斜杠)。
|
||||
|
||||
### 3.6 管理后台
|
||||
|
||||
在 **允许的回跳地址** 中加入生产前端的根地址,例如:
|
||||
|
||||
```text
|
||||
https://user.shumengya.top/
|
||||
```
|
||||
|
||||
(具体以你实际用户访问的「用户中心 / 登录页」域名为准,**须带尾斜杠**,与代码里 `getOAuthReturnTo()` 生成的前缀匹配。)
|
||||
|
||||
保存各平台的 Client ID;Secret 可用环境变量注入,界面里可留空以保留原值。
|
||||
|
||||
### 3.7 验证步骤(生产)
|
||||
|
||||
与 **2.6** 相同,只是把 `http://localhost:5173` 换成线上前端地址,并确认:
|
||||
|
||||
- 授权完成后地址栏出现 `oauth_token` 或绑定成功后的 `oauth_bound`;
|
||||
- 新用户(未注册过)在允许策略下可完成首次 OAuth 注册(视「邀请码」与「需邀请时仍允许 OAuth 新用户」组合而定)。
|
||||
|
||||
---
|
||||
|
||||
## 四、常见问题
|
||||
|
||||
|
||||
| 现象 | 可能原因 | 处理 |
|
||||
| -------------------------------------------- | --------------------------------------------- | --------------------------------------------------------- |
|
||||
| GitHub 提示 `redirect_uri` 不匹配 | 回调 URL 与 OAuth 应用配置不一致,或 `PUBLIC_API_BASE` 错误 | 对齐三处:GitHub 后台、`PUBLIC_API_BASE`、实际访问 API 的协议与主机 |
|
||||
| 回到前端后 `#oauth_error`,描述含 `invalid return_to` | 前端 `return_to` 不在白名单 | 在管理后台增加该前端的 **根地址前缀**(含 `https://` 与尾 `/`) |
|
||||
| 邀请制下无法 OAuth 新用户 | 策略限制 | 开启「需邀请时仍允许 OAuth 新用户」,或关闭「强制邀请码」,或先用邮箱+邀请码注册再绑定 |
|
||||
| Gitea 授权后失败 | Gitea 实例不能访问、或 Token 请求失败 | 检查服务器能否访问 Gitea;Client ID/Secret;Gitea OAuth 应用回调是否填对 |
|
||||
| LINUX DO 提示 scope 或 `userinfo_failed` | 应用权限、或 `/api/user` 返回非 200 | 确认 Connect 应用已同意;本服务使用 scope `read:user`;可查看后端日志中 HTTP 状态 |
|
||||
| 本地能行、生产不行 | 多为 HTTPS、反代头、`PUBLIC_API_BASE`、白名单未含生产前端 | 逐项对照第二节、第三节 |
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 五、快速检查清单
|
||||
|
||||
**开发**
|
||||
|
||||
- `http://localhost:8080/api/health` 可访问
|
||||
- 各平台回调填 `http://localhost:8080/api/auth/oauth/{github|gitea|google|microsoft|linuxdo}/callback`
|
||||
- 管理后台白名单含 `http://localhost:5173/`(及实际使用的前端 origin)
|
||||
- 已设 `PUBLIC_API_BASE=http://localhost:8080`(推荐)
|
||||
- `VITE_API_BASE` 指向本机 API
|
||||
|
||||
**生产**
|
||||
|
||||
- API 与前端均为 HTTPS(或符合你安全策略)
|
||||
- OAuth 应用回调为 `https://{API}/api/auth/oauth/.../callback`
|
||||
- `PUBLIC_API_BASE` 与对外 API 一致
|
||||
- 白名单含生产前端根 URL(尾斜杠)
|
||||
- 前端 `VITE_API_BASE` 为生产 API
|
||||
|
||||
按上述配置后,即可在对应环境完成 GitHub、Gitea、Google、Microsoft、LINUX DO 登录及绑定验证。
|
||||
19
docs/oauth-providers/README.md
Normal file
19
docs/oauth-providers/README.md
Normal 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 ID,v2.0 终结点 + Microsoft Graph |
|
||||
|
||||
**共同前提**
|
||||
|
||||
- 浏览器中完成授权;`redirect_uri` 必须在 IdP 控制台**逐字登记**(含 `http`/`https`、主机、端口、路径)。
|
||||
- 换 token 与调用户 API 一般由**你的服务端**发起;请保证服务器出网可达对应域名(企业网络/地区网络可能需代理)。
|
||||
- 生产环境使用 **HTTPS**;`state` 参数防 CSRF,勿省略。
|
||||
|
||||
以下各篇相互独立,可按需只读其一。
|
||||
123
docs/oauth-providers/gitea.md
Normal file
123
docs/oauth-providers/gitea.md
Normal 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
|
||||
}
|
||||
|
||||
// 示例:构造 Config(BASE、ClientID、Secret、RedirectURL 从环境或配置读取)
|
||||
func newGiteaConfig() *oauth2.Config {
|
||||
base := os.Getenv("GITEA_BASE_URL") // 如 https://git.example.com
|
||||
return &oauth2.Config{
|
||||
ClientID: os.Getenv("GITEA_CLIENT_ID"),
|
||||
ClientSecret: os.Getenv("GITEA_CLIENT_SECRET"),
|
||||
RedirectURL: os.Getenv("GITEA_REDIRECT_URL"),
|
||||
Scopes: []string{"read:user"},
|
||||
Endpoint: giteaEndpoint(base),
|
||||
}
|
||||
}
|
||||
|
||||
type giteaUser struct {
|
||||
ID int64 `json:"id"`
|
||||
Login string `json:"login"`
|
||||
FullName string `json:"full_name"`
|
||||
Email string `json:"email"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
func fetchGiteaUser(ctx context.Context, base, access string) (*giteaUser, error) {
|
||||
base = trimRightSlash(base)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/user", nil)
|
||||
req.Header.Set("Authorization", "token "+access)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
b, _ := io.ReadAll(res.Body)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gitea: %d %s", res.StatusCode, b)
|
||||
}
|
||||
var u giteaUser
|
||||
if err := json.Unmarshal(b, &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在 `main` 中挂载 HTTP 路由:首页生成 `state` 并重定向到 `cfg.AuthCodeURL`;在 `/oauth/gitea/callback` 中 `cfg.Exchange` 后调用 `fetchGiteaUser` 即可。整体与 [github.md](github.md) 中示例同构,仅将 `Config.Endpoint` 换为 `giteaEndpoint`、将 GitHub `Bearer` 换为 Gitea 的 `token` 头。
|
||||
|
||||
## 5. 常见错误
|
||||
|
||||
- **401**:token 头格式或 API 版本与实例不一致。
|
||||
- **redirect_uri 不匹配**:授权与回调的 host/scheme/端口与 Gitea 中登记的不一致。
|
||||
- 多 Gitea 实例时:每个 **Client** 与 **base URL** 一一对应,勿混用。
|
||||
|
||||
## 6. 安全建议
|
||||
|
||||
- Secret 只放服务端。
|
||||
- 以 Gitea 返回的**数字 user id** 作为绑定主键更稳妥。
|
||||
196
docs/oauth-providers/github.md
Normal file
196
docs/oauth-providers/github.md
Normal 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(可改名)。
|
||||
185
docs/oauth-providers/google.md
Normal file
185
docs/oauth-providers/google.md
Normal 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` 作为跨登录稳定主键;邮箱可能变化,慎单独依赖。
|
||||
75
docs/oauth-providers/linuxdo.md
Normal file
75
docs/oauth-providers/linuxdo.md
Normal 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 已绑其他用户”的冲突。
|
||||
|
||||
199
docs/oauth-providers/microsoft.md
Normal file
199
docs/oauth-providers/microsoft.md
Normal 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 仅存服务端。
|
||||
@@ -5,6 +5,6 @@
|
||||
|
||||
data
|
||||
node_modules
|
||||
dist
|
||||
# 勿忽略 dist/:默认 Dockerfile 从 dist/sproutgate-backend 复制(先 build-linux-amd64.bat)
|
||||
|
||||
*.log
|
||||
|
||||
@@ -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"]
|
||||
|
||||
5
sproutgate-backend/build-Linux-amd.bat
Normal file
5
sproutgate-backend/build-Linux-amd.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
call "%~dp0build-linux-amd64.bat"
|
||||
exit /b %errorlevel%
|
||||
58
sproutgate-backend/build-linux-amd64.bat
Normal file
58
sproutgate-backend/build-linux-amd64.bat
Normal 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
|
||||
2190
sproutgate-backend/docs/docs.go
Normal file
2190
sproutgate-backend/docs/docs.go
Normal file
File diff suppressed because it is too large
Load Diff
2170
sproutgate-backend/docs/swagger.json
Normal file
2170
sproutgate-backend/docs/swagger.json
Normal file
File diff suppressed because it is too large
Load Diff
1428
sproutgate-backend/docs/swagger.yaml
Normal file
1428
sproutgate-backend/docs/swagger.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
961
sproutgate-backend/internal/handlers/oauth.go
Normal file
961
sproutgate-backend/internal/handlers/oauth.go
Normal 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))
|
||||
}
|
||||
123
sproutgate-backend/internal/handlers/oauth_admin.go
Normal file
123
sproutgate-backend/internal/handlers/oauth_admin.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
95
sproutgate-backend/internal/handlers/turnstile.go
Normal file
95
sproutgate-backend/internal/handlers/turnstile.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
72
sproutgate-backend/internal/storage/config_hot_reload.go
Normal file
72
sproutgate-backend/internal/storage/config_hot_reload.go
Normal 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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
509
sproutgate-backend/internal/storage/oauth.go
Normal file
509
sproutgate-backend/internal/storage/oauth.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
sproutgate-backend/internal/storage/turnstile.go
Normal file
33
sproutgate-backend/internal/storage/turnstile.go
Normal 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
|
||||
}
|
||||
@@ -1,3 +1,22 @@
|
||||
//go:generate swag init -g main.go -o docs --parseInternal
|
||||
|
||||
// @title 萌芽账户认证中心 API
|
||||
// @version 0.1.0
|
||||
// @description 统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。
|
||||
// @host localhost:8080
|
||||
// @BasePath /
|
||||
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description JWT:Authorization 头填写「Bearer 」+ token(示例:Bearer eyJ...)。
|
||||
|
||||
// @securityDefinitions.apikey AdminToken
|
||||
// @in header
|
||||
// @name X-Admin-Token
|
||||
// @description 管理端令牌;也可使用 Query `token` 或与部分客户端相同的 `Authorization` 头(见实现)。
|
||||
|
||||
// @schemes http https
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -8,12 +27,72 @@ import (
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
|
||||
_ "sproutgate-backend/docs"
|
||||
"sproutgate-backend/internal/database"
|
||||
"sproutgate-backend/internal/handlers"
|
||||
"sproutgate-backend/internal/storage"
|
||||
)
|
||||
|
||||
func apiIntroPayload() gin.H {
|
||||
return gin.H{
|
||||
"name": "SproutGate API",
|
||||
"title": "萌芽账户认证中心",
|
||||
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
|
||||
"version": "0.1.0",
|
||||
"links": gin.H{
|
||||
"health": "GET /api/health",
|
||||
"docs": "GET /swagger/index.html",
|
||||
},
|
||||
"routePrefixes": []string{
|
||||
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
|
||||
"/api/public — 公开用户目录与资料、主页点赞、注册策略",
|
||||
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GetRoot
|
||||
// @Summary API 简介 JSON
|
||||
// @Description 与 GET /api 相同,返回名称、版本与路由前缀说明。
|
||||
// @Tags meta
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router / [get]
|
||||
func GetRoot(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntroPayload())
|
||||
}
|
||||
|
||||
// GetAPI
|
||||
// @Summary API 简介 JSON
|
||||
// @Description 与 GET / 相同。
|
||||
// @Tags meta
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api [get]
|
||||
func GetAPI(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntroPayload())
|
||||
}
|
||||
|
||||
// GetHealth
|
||||
// @Summary 健康检查
|
||||
// @Tags meta
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]interface{}
|
||||
// @Router /api/health [get]
|
||||
func GetHealth(c *gin.Context) {
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "" {
|
||||
env = "development"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"env": env,
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
db, err := database.Open()
|
||||
if err != nil {
|
||||
@@ -35,37 +114,15 @@ func main() {
|
||||
|
||||
handler := handlers.NewHandler(store)
|
||||
|
||||
apiIntro := gin.H{
|
||||
"name": "SproutGate API",
|
||||
"title": "萌芽账户认证中心",
|
||||
"description": "统一认证、用户资料、每日签到、公开用户主页与管理端等 JSON HTTP 接口。",
|
||||
"version": "0.1.0",
|
||||
"links": gin.H{
|
||||
"health": "GET /api/health",
|
||||
},
|
||||
"routePrefixes": []string{
|
||||
"/api/auth — 登录、注册、邮箱验证、令牌校验、当前用户、资料、签到、辅助邮箱;可选 X-Auth-Client 记录应用接入",
|
||||
"/api/public — 公开用户目录与资料、主页点赞、注册策略",
|
||||
"/api/admin — 用户 CRUD、签到与注册/邀请码配置(请求头 X-Admin-Token 或 Query token)",
|
||||
},
|
||||
}
|
||||
router.GET("/", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntro)
|
||||
})
|
||||
router.GET("/api", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, apiIntro)
|
||||
})
|
||||
router.GET("/", GetRoot)
|
||||
router.GET("/api", GetAPI)
|
||||
|
||||
router.GET("/api/health", func(c *gin.Context) {
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "" {
|
||||
env = "development"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ok",
|
||||
"env": env,
|
||||
})
|
||||
router.GET("/api/health", GetHealth)
|
||||
|
||||
router.GET("/api/docs", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/swagger/index.html")
|
||||
})
|
||||
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
|
||||
router.POST("/api/auth/login", handler.Login)
|
||||
router.POST("/api/auth/register", handler.Register)
|
||||
@@ -78,6 +135,10 @@ func main() {
|
||||
router.GET("/api/auth/me", handler.Me)
|
||||
router.POST("/api/auth/check-in", handler.CheckIn)
|
||||
router.PUT("/api/auth/profile", handler.UpdateProfile)
|
||||
router.GET("/api/auth/oauth/:provider/start", handler.OAuthStart)
|
||||
router.GET("/api/auth/oauth/:provider/callback", handler.OAuthCallback)
|
||||
router.POST("/api/auth/oauth/:provider/bind", handler.OAuthBindURL)
|
||||
router.DELETE("/api/auth/oauth/:provider", handler.OAuthUnlink)
|
||||
router.GET("/api/public/users", handler.ListPublicUsers)
|
||||
router.GET("/api/public/users/:account", handler.GetPublicUser)
|
||||
router.POST("/api/public/users/:account/like", handler.PostPublicProfileLike)
|
||||
@@ -95,6 +156,10 @@ func main() {
|
||||
admin.PUT("/registration", handler.PutAdminRegistrationPolicy)
|
||||
admin.POST("/registration/invites", handler.PostAdminInvite)
|
||||
admin.DELETE("/registration/invites/:code", handler.DeleteAdminInvite)
|
||||
admin.GET("/oauth", handler.GetAdminOAuth)
|
||||
admin.PUT("/oauth", handler.PutAdminOAuth)
|
||||
admin.GET("/turnstile", handler.GetAdminTurnstile)
|
||||
admin.PUT("/turnstile", handler.PutAdminTurnstile)
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 Key」和「Secret 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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="加载中">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 图片 URL(https)
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
79
sproutgate-frontend/src/components/oauth/TurnstileWidget.jsx
Normal file
79
sproutgate-frontend/src/components/oauth/TurnstileWidget.jsx
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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}`} />
|
||||
|
||||
@@ -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`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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&state=abc123&client_id=my-app&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`(必填才能记一条)**:长度 1–64;必须以 **英文字母或数字** 开头;其余字符仅可为 `[A-Za-z0-9_.:-]`(与后端正则一致)。
|
||||
- **`client_name` / 展示名(可选)**:任意去首尾空白后的字符串,最长 **128** 字符,超出由服务端截断;对应存储字段为 `displayName`。
|
||||
- `**client_id`(必填才能记一条)**:长度 1–64;必须以 **英文字母或数字** 开头;其余字符仅可为 `[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: 万象口袋'
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user