Update SproutGate
This commit is contained in:
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 仅存服务端。
|
||||
Reference in New Issue
Block a user