Update SproutGate

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

View File

@@ -0,0 +1,185 @@
# Cloudflare Turnstile 网站接入指南
本文说明如何在任意网站中集成 [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/) 人机验证:从控制台拿密钥、前端展示控件、把一次性 token 交给后端、由服务端向 Cloudflare 校验。内容适用于自建站点与 API不限定具体技术栈文末可对照本仓库中的实现。
---
## 1. 概念与流程
| 项 | 说明 |
| ------------------- | -------------------------------------------------------------------------------------- |
| **Site Key** | 公钥,可出现在前端代码或 HTML 中。 |
| **Secret Key** | 私钥,**仅**能用于你的服务端调用校验接口。 |
| **Tokenresponse** | 用户通过验证后Turnstile 在浏览器中生成的一串临时字符串,需在你自己的业务请求里随表单/JSON 提交到**你的后端**。 |
| **二次校验** | 前端 token **不能**单独作为“已通过人机验证”的依据;必须由服务器使用 Secret Key 调用 Cloudflare 的 `siteverify` 接口确认。 |
典型流程:
1. 页面加载 Cloudflare 提供的 JS在指定 DOM 中渲染小组件。
2. 用户通过挑战后,前端拿到 `token`,写入状态或随登录/注册请求提交。
3. 你的后端在执行业务逻辑**之前**用 Secret Key + `token`(及可选的客户端 IP请求 `siteverify``success: true` 才继续。
4. 每个 token 仅应用一次、且有时效;提交失败后应**重置**控件以获取新 token见第 3 节)。
---
## 2. 在 Cloudflare 控制台创建站点
1. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/),进入 **Turnstile**
2. 选择 **Add widget** / 添加站点。
3. 填写网站域名、选择 **Managed**(或按需选择可见性/交互方式)。
4. 创建后得到 **Site Key****Secret Key**;为生产环境可单独为生产域名建 widget勿把 Secret Key 写进仓库或静态资源。
**本地开发**:在 Turnstile 站点里把 `localhost` 及测试域名加入允许的主机名(与 Cloudflare 当前 UI 中「主机名」/域名列表一致),否则可能无法加载或校验失败。
---
## 3. 前端:加载脚本与渲染控件
### 3.1 官方脚本
推荐按文档使用官方脚本,例如**显式渲染**`render=explicit`)便于在单页应用里把控件挂在指定容器上:
- 脚本地址:
`https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit`
在页面中插入一次即可(注意避免重复注入同一脚本多份,除非你有意为之)。
```html
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
defer
></script>
```
全局会暴露 `window.turnstile`,主要方法包括:
- `turnstile.render(container, options)`:在 `container`DOM 元素或选择器)内渲染,返回 `widgetId`
- `turnstile.reset(widgetId)`:同一会话内换发新 token失败重试、重复提交时很有用
- `turnstile.remove(widgetId)`:销毁实例。
### 3.2 常用 `render` 参数
| 参数 | 含义 |
| ------------------ | ------------------------------- |
| `sitekey` | 必填,即 Site Key。 |
| `callback` | 验证通过时回调,参数为 `token` 字符串。 |
| `expired-callback` | token 过期时。 |
| `error-callback` | 发生错误时。 |
| `theme` | 可选 `light` / `dark` / `auto` 等。 |
建议在 `expired-callback``error-callback` 里把本地产出的 token 清空,避免用失效 token 提交。
### 3.3 把 token 交给你的后端
验证通过后,在 `callback` 中保存 `token`(如 React 的 state、Vue 的 ref。用户点击「登录/提交」时,将 **同一请求** 中的其它字段与 `turnstileToken`(或你命名的字段名)一起发给**你自己的** API。字段名仅前后端一致即可例如 JSON
```json
{
"username": "user",
"password": "…",
"turnstileToken": "0.xxx…"
}
```
**注意**
- 未启用 Turnstile 的页面或测试环境,前后端要约定好是否“可不传 token”避免生产误关校验。
- 提交后若失败,应调用 `reset` 并清空本地 token避免复用。
### 3.4 与 Content Security Policy (CSP) 的兼容
若站点启用了严格 CSP需允许 Cloudflare 相关源,例如(按实际策略合并):
- `script-src``https://challenges.cloudflare.com`
- `frame-src``child-src`:同上(部分实现会以 iframe 呈现挑战)
- 有时还需 `https://challenges.cloudflare.com/cdn-cgi/...` 等,以浏览器控制台与官方文档为准。
---
## 4. 后端siteverify 校验
在**执行业务逻辑之前**用服务器端向 Cloudflare 验证 token。不应信任客户端声称的“已验证”。
### 4.1 端点
```
POST https://challenges.cloudflare.com/turnstile/v0/siteverify
```
使用 `application/x-www-form-urlencoded` 或等价的表单编码提交。
### 4.2 参数
| 参数 | 必填 | 说明 |
| ---------- | --- | ------------------------------------- |
| `secret` | 是 | Secret Key。 |
| `response` | 是 | 前端传来的 token。 |
| `remoteip` | 否 | 发起请求的用户 IP可与 Cloudflare 侧风控一致,建议有则传。 |
### 4.3 响应
JSON 中至少包含 `success`(布尔值)。`success``true` 才应继续登录、注册、发帖等业务。
失败时 JSON 中常带有 `error-codes` 数组,便于排障,例如 `invalid-input-response``timeout-or-duplicate` 等(以[官方说明](https://developers.cloudflare.com/turnstile/get-started/server-side-validation/)为准)。
**实践建议**
- 为 HTTP 客户端设置合理超时(如 815 秒),失败时对用户显示通用错误,**不要**在错误信息中泄露 Secret Key 或内部堆栈。
- `siteverify` 应仅在服务端调用;不要从浏览器直接带 Secret 请求。
### 4.4 伪代码示例
```text
if secret_key is empty: return 500 # 未配置
if user_token is empty: return 400 # 请完成人机验证
POST form: secret, response, (optional) remoteip
if JSON.success != true: return 400 # 验证失败
# 再执行登录/注册等逻辑
```
语言无关Go 可用 `http.PostForm`Node 可用 `fetch` + `URLSearchParams`Python 可用 `httpx`/`requests``data=` 等。
---
## 5. 安全与产品注意点
- **Site Key 可公开Secret Key 绝不可进前端、公开仓库、日志。** 使用环境变量或密钥管理。
- **每次敏感操作**若需要防护,可要求新的 token而不是长缓存同一条 token。
- 与**速率限制、账户锁定、异常 IP 检测**等组合,而不是只依赖 Turnstile。
- 若使用**反向代理**`remoteip` 应取真实客户端 IP与 Turnstile 可见一致),与信任代理头配置统一。
- 多环境dev/staging/prod使用不同 widget 与密钥,避免误用。
---
## 6. 排障简表
| 现象 | 可能原因 |
| ------------------- | ------------------------------------------------ |
| 前端不显示控件 | 域名未在 Turnstile 站点中、脚本被 CSP/广告拦截、脚本未加载完就 `render` |
| 总是 `error-callback` | Site Key 错误、域名校验失败、网络问题 |
| `siteverify` 失败 | token 已用过/过期、Secret Key 错误、时间偏差过大、请求不是 POST 表单 |
| 生产正常、本地失败 | 未把 localhost 加入允许主机名、混用不同环境的 Key |
更完整的错误码与行为以 Cloudflare 官方文档为准:
[https://developers.cloudflare.com/turnstile/](https://developers.cloudflare.com/turnstile/)
---
## 7. 与本项目SproutGate的对应关系参考
本仓库中可作为对照的实现位置(不保证与上文逐字相同,以代码为准):
- 前端组件:`sproutgate-frontend/src/components/oauth/TurnstileWidget.jsx`(显式 `render`、token 回调、`reset`)。
- 前端在登录/注册中附带 `turnstileToken``UserPortal.jsx``UserPortalAuthSection.jsx`
- 后端校验:`sproutgate-backend/internal/handlers/turnstile.go``verifyTurnstileToken``turnstileVerifyURL`
- 管理端配置 Site/Secret 与开关:`/api/admin/turnstile`、存储层 `TurnstileConfig`
第三方站点只需遵守本文第 15 节的通用步骤即可;具体路由与配置项以各自系统为准。

View File

@@ -0,0 +1,230 @@
# GitHub / Gitea / Google / Microsoft / LINUX DO 登录:开发与生产环境测试指南
本文说明如何在**本地开发**与**生产环境**中配置、测试并验证萌芽统一账户的第三方 OAuth 登录GitHub、自建 Gitea、[Google 账号](https://console.cloud.google.com/)、[Microsoft / Entra ID](https://entra.microsoft.com/)、以及 [LINUX DO Connect](https://connect.linux.do))。
---
## 一、共同概念
### 1.1 两次「地址」要分清
| 角色 | 说明 | 典型开发值 | 典型生产值 |
| -------------------- | ----------------------------------------------------------------- | --------------------------------- | ------------------------------ |
| **API 根地址** | 浏览器与 SproutGate 后端通信的基址OAuth **回调**由后端路径承担 | `http://localhost:8080` | `https://auth.api.example.com` |
| **前端回跳 `return_to`** | 用户授权完成后,后端把浏览器重定向到该地址,并在 **URL fragment# 后)** 写入 `oauth_token` 等 | `http://localhost:5173/`Vite 默认) | `https://你的前端站/` |
| **允许的回跳前缀** | 管理后台「第三方登录」里配置的 **白名单**`return_to` 必须以其中某一项为前缀,否则拒绝 | 需包含开发用前端根 | 需包含生产前端根 |
### 1.2 在各 IdP 里要填的「回调 / Redirect URI」
OAuth 应用里登记的 **Redirect URI** 必须与后端实际对外地址一致(协议、主机、端口、路径一字不差):
- GitHub
`{API根}/api/auth/oauth/github/callback`
- Gitea
`{API根}/api/auth/oauth/gitea/callback`
- GoogleGoogle Cloud「OAuth 2.0 客户端 ID」类型为 **Web 应用** 时的「已获授权的重定向 URI」
`{API根}/api/auth/oauth/google/callback`
- Microsoft / Azure / Entra**应用注册** → 身份验证 → 添加平台 **Web** 的「重定向 URI」
`{API根}/api/auth/oauth/microsoft/callback`
本服务使用 **v2.0 授权终结点**;租户在管理后台为 **Microsoft 租户** 字段,默认 `common`(多租户 + 个人 Microsoft 账号)。若你只需单一组织目录,可改为**具体租户 ID**。
- LINUX DOConnect 应用接入):
`{API根}/api/auth/oauth/linuxdo/callback`
与你在 [应用接入](https://connect.linux.do) 中填写的回调一致即可(开发时可为 `http://localhost:8080/...`)。
开发时 `{API根}` 多为 `http://localhost:8080`;生产时为 `https://你的 API 域名`(无尾部斜杠)。
### 1.3 后端环境变量(与部署相关)
| 变量 | 作用 |
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_API_BASE` | **建议生产必设**。值为 **纯 API 根**(无路径),如 `https://auth.api.example.com`。用于拼 OAuth `redirect_uri`。本地若访问 API 用 `http://localhost:8080`,可设 `PUBLIC_API_BASE=http://localhost:8080`,与浏览器访问后端的地址一致。 |
| `GITHUB_CLIENT_SECRET` / `GITEA_CLIENT_SECRET` / `GOOGLE_CLIENT_SECRET` / `MICROSOFT_CLIENT_SECRET` / `LINUXDO_CLIENT_SECRET` | 可选;若设置,会覆盖数据库里保存的对应 Secret便于不把 Secret 写进库。 |
| `PORT` | 后端监听端口,默认 `8080`。 |
---
## 二、开发环境(本机)
### 2.1 默认假设
- 后端:`http://localhost:8080``go run .``sproutgate.bat dev`
- 前端:`http://localhost:5173``npm run dev`
- 前端已配置 `VITE_API_BASE=http://localhost:8080`(或留空走开发默认)
### 2.2 在 GitHub 创建 OAuth App
1. 打开 GitHub → **Settings****Developer settings****OAuth Apps****New OAuth App**(或 Organization 下创建)。
2. 填写示例:
- **Application name**:任意,如 `SproutGate Dev`
- **Homepage URL**`http://localhost:5173`
- **Authorization callback URL**
`http://localhost:8080/api/auth/oauth/github/callback`
3. 创建后记录 **Client ID**,生成 **Client secrets** 并复制保存(只显示一次)。
GitHub 允许使用 `http://localhost` 作为回调,无需公网。
### 2.3 在 LINUX DO Connect 创建应用
1. 打开 [https://connect.linux.do](https://connect.linux.do)(需登录),侧栏 **应用接入** → 新建接入。
2. **回调地址**`http://localhost:8080/api/auth/oauth/linuxdo/callback`(生产改为 `https://{API域名}/api/auth/oauth/linuxdo/callback`)。
3. 保存后记录 **Client ID**、**Client Secret**管理后台「LINUX DO Connect 根地址」通常填 `https://connect.linux.do`(与官方端点一致即可)。
### 2.4 在 Gitea 创建 OAuth2 应用
1. 登录你的 Gitea`https://git.shumengya.top`)→ **站点管理****用户设置** 中的 **Applications** / **OAuth2**,创建新应用(不同版本菜单位置略有差异)。
2. **Redirect URI**(重定向 URI
`http://localhost:8080/api/auth/oauth/gitea/callback`
3. 记录 **Client ID**、**Client Secret**,勾选所需权限(至少能读用户资料,一般 `read:user` 或界面勾「读取用户信息」类选项)。
### 2.4a 在 Google Cloud 创建 OAuth 2.0 客户端(可选)
1. 打开 [Google Cloud Console](https://console.cloud.google.com/),选择或创建项目 → **API 和服务****OAuth 同意屏幕**(先配置为测试/内部/公开视需求)。
2. **凭据****创建凭据****OAuth 客户端 ID** → 应用类型选 **Web 应用**
3. **已获授权的重定向 URI**`http://localhost:8080/api/auth/oauth/google/callback`(生产改为 `https://{API域名}/api/auth/oauth/google/callback`)。
4. 记录 **客户端 ID**、**客户端密钥**;在 SproutGate 管理后台「第三方登录」中勾选 **Google** 并填入。
### 2.4b 在 Microsoft Entra原 Azure AD应用注册可选
1. 打开 [Microsoft Entra 管理中心](https://entra.microsoft.com/) → **应用注册****新注册**
2. 支持的帐户类型中,若与后台 **Microsoft 租户**`common` 一致,通常选「**任何组织目录中的帐户和个人 Microsoft 帐户**」。
3. **身份验证** → 添加平台 **Web****重定向 URI**`http://localhost:8080/api/auth/oauth/microsoft/callback`(生产用 HTTPS 与真实 API 域)。
4.**证书和密码** 中创建 **客户端密码**,并记录**应用程序(客户端) ID** 与 secret管理后台 **Microsoft 租户** 可填 `common` 或你的目录租户 ID单租户时便于限制登录范围
### 2.5 在 SproutGate 管理后台配置
1. 打开管理页,填写 **管理员 Token**,进入 **第三方登录**
2. 按需勾选 **GitHub** / **Gitea** / **Google** / **Microsoft** / **LINUX DO 登录**
3. 分别填入各平台的 **Client ID****Client Secret**Microsoft 另填 **Microsoft 租户**(默认 `common`LINUX DO 另需确认 **Connect 根地址**(默认 `https://connect.linux.do`)。
4. **Gitea 根地址**:如 `https://git.shumengya.top`(无尾斜杠也可,保存时会规范化)。
5. **允许的回跳地址**:至少包含(每行一个,**建议带尾斜杠**
- `http://localhost:5173/`
- 若用 `127.0.0.1` 打开前端,再加一行:
`http://127.0.0.1:5173/`
6. **需邀请时仍允许 OAuth 新用户**:若你开启了「强制邀请码」自助注册,又想用 OAuth **直接注册**新账号,需勾选此项;否则邀请制下 OAuth 只能登录**已绑定**的账号。
7. 保存。
### 2.6 本地设置 `PUBLIC_API_BASE`(推荐)
使后端生成的 OAuth `redirect_uri` 与你在各平台应用里填的完全一致:
**Windows PowerShell当前终端**
```powershell
$env:PUBLIC_API_BASE = "http://localhost:8080"
```
**或在启动脚本里** 写死上述一行再启动后端。
然后重启后端,再测登录。
### 2.7 验证步骤(开发)
1. 打开 `http://localhost:5173`**退出**当前登录(如有)。
2. 在登录界面应看到已启用的第三方按钮(**GitHub**、**Gitea**、**谷歌**、**微软**、**LINUX DO** 等,仅当对应「启用」已打开)。
3. 点击 **GitHub**:应跳转到 GitHub 授权页,授权后回到 `http://localhost:5173/#oauth_token=...`(地址栏 hash 中有 token页面应显示已登录。
4. 退出后测 **Gitea** / **Google** / **Microsoft** / **LINUX DO**流程同上LINUX DO 会先到 `connect.linux.do` 授权页)。
5. **绑定**:先普通登录,进入用户中心 → **第三方账号****绑定**,应跳转到对应平台,返回后应显示「已绑定」。
6. **解绑**:点「解绑」,确认后状态应更新。
若返回后 URL 带 `#oauth_error=1`,查看 `oauth_error_description` 片段内容(多为白名单、`redirect_uri` 不匹配、或策略禁止注册等)。
---
## 三、生产环境
### 3.1 前置条件
- API 对外为 **HTTPS**GitHub 生产回调通常要求 HTTPS本地 localhost 除外)。
- 用户浏览器能访问:**前端域名**、**API 域名**、**Gitea 域名**(若用 Gitea 登录)。
### 3.2 在 GitHub 生产 OAuth App
1. 新建或使用独立 **Production** OAuth App勿与开发混用同一回调易错
2. **Authorization callback URL** 填:
`https://{你的API域名}/api/auth/oauth/github/callback`
例如:`https://auth.api.shumengya.top/api/auth/oauth/github/callback`
3. 记录 Client ID / Secret。
### 3.3 在 Gitea 生产 OAuth 应用
1. **Redirect URI**
`https://{你的API域名}/api/auth/oauth/gitea/callback`
2. 记录 Client ID / Secret。
### 3.4 服务器环境变量
示例(按你的实际域名修改):
```bash
export PUBLIC_API_BASE="https://auth.api.shumengya.top"
export GITHUB_CLIENT_SECRET="..." # 可选
export GITEA_CLIENT_SECRET="..." # 可选
export GOOGLE_CLIENT_SECRET="..." # 可选
export MICROSOFT_CLIENT_SECRET="..." # 可选
```
确保反向代理Nginx/Caddy 等)转发的 **Host** / **X-Forwarded-Proto** 正确,否则未设 `PUBLIC_API_BASE` 时,程序可能错误拼出 `http` 或错误主机,导致与 GitHub/Gitea 登记的回调不一致。
### 3.5 前端
- 生产构建时设置 `VITE_API_BASE=https://{你的API域名}`(与浏览器实际请求后端的地址一致,无多余斜杠)。
### 3.6 管理后台
**允许的回跳地址** 中加入生产前端的根地址,例如:
```text
https://user.shumengya.top/
```
(具体以你实际用户访问的「用户中心 / 登录页」域名为准,**须带尾斜杠**,与代码里 `getOAuthReturnTo()` 生成的前缀匹配。)
保存各平台的 Client IDSecret 可用环境变量注入,界面里可留空以保留原值。
### 3.7 验证步骤(生产)
**2.6** 相同,只是把 `http://localhost:5173` 换成线上前端地址,并确认:
- 授权完成后地址栏出现 `oauth_token` 或绑定成功后的 `oauth_bound`
- 新用户(未注册过)在允许策略下可完成首次 OAuth 注册(视「邀请码」与「需邀请时仍允许 OAuth 新用户」组合而定)。
---
## 四、常见问题
| 现象 | 可能原因 | 处理 |
| -------------------------------------------- | --------------------------------------------- | --------------------------------------------------------- |
| GitHub 提示 `redirect_uri` 不匹配 | 回调 URL 与 OAuth 应用配置不一致,或 `PUBLIC_API_BASE` 错误 | 对齐三处GitHub 后台、`PUBLIC_API_BASE`、实际访问 API 的协议与主机 |
| 回到前端后 `#oauth_error`,描述含 `invalid return_to` | 前端 `return_to` 不在白名单 | 在管理后台增加该前端的 **根地址前缀**(含 `https://` 与尾 `/` |
| 邀请制下无法 OAuth 新用户 | 策略限制 | 开启「需邀请时仍允许 OAuth 新用户」,或关闭「强制邀请码」,或先用邮箱+邀请码注册再绑定 |
| Gitea 授权后失败 | Gitea 实例不能访问、或 Token 请求失败 | 检查服务器能否访问 GiteaClient ID/SecretGitea OAuth 应用回调是否填对 |
| LINUX DO 提示 scope 或 `userinfo_failed` | 应用权限、或 `/api/user` 返回非 200 | 确认 Connect 应用已同意;本服务使用 scope `read:user`;可查看后端日志中 HTTP 状态 |
| 本地能行、生产不行 | 多为 HTTPS、反代头、`PUBLIC_API_BASE`、白名单未含生产前端 | 逐项对照第二节、第三节 |
---
## 五、快速检查清单
**开发**
- `http://localhost:8080/api/health` 可访问
- 各平台回调填 `http://localhost:8080/api/auth/oauth/{github|gitea|google|microsoft|linuxdo}/callback`
- 管理后台白名单含 `http://localhost:5173/`(及实际使用的前端 origin
- 已设 `PUBLIC_API_BASE=http://localhost:8080`(推荐)
- `VITE_API_BASE` 指向本机 API
**生产**
- API 与前端均为 HTTPS或符合你安全策略
- OAuth 应用回调为 `https://{API}/api/auth/oauth/.../callback`
- `PUBLIC_API_BASE` 与对外 API 一致
- 白名单含生产前端根 URL尾斜杠
- 前端 `VITE_API_BASE` 为生产 API
按上述配置后,即可在对应环境完成 GitHub、Gitea、Google、Microsoft、LINUX DO 登录及绑定验证。

View File

@@ -0,0 +1,19 @@
# OAuth 2.0 第三方登录分提供商教程Golang
本目录为**通用**接入说明,适用于任意自建后端或 BFF在对应**身份提供商IdP**注册应用,使用 **OAuth 2.0 授权码Authorization Code**`access_token`,再调用各平台用户 API。语言示例统一为 **Go**`golang.org/x/oauth2`)。
| 文档 | 说明 |
|------|------|
| [github.md](github.md) | GitHub OAuth App标准 `github.Endpoint` |
| [gitea.md](gitea.md) | 自建/托管 Gitea 的 OAuth2端点随实例 URL 变化 |
| [linuxdo.md](linuxdo.md) | LINUX DO Connect官方 Connect 端点) |
| [google.md](google.md) | Google 账号,官方 `google.Endpoint` + userinfo |
| [microsoft.md](microsoft.md) | Microsoft / Entra IDv2.0 终结点 + Microsoft Graph |
**共同前提**
- 浏览器中完成授权;`redirect_uri` 必须在 IdP 控制台**逐字登记**(含 `http`/`https`、主机、端口、路径)。
- 换 token 与调用户 API 一般由**你的服务端**发起;请保证服务器出网可达对应域名(企业网络/地区网络可能需代理)。
- 生产环境使用 **HTTPS**`state` 参数防 CSRF勿省略。
以下各篇相互独立,可按需只读其一。

View File

@@ -0,0 +1,123 @@
# 使用 Gitea 登录OAuth 2.0 接入指南Golang
**Gitea** 实例提供与平台绑定的 OAuth2 端点:授权地址、令牌地址都带**你的 Gitea 根 URL** 前缀。本文说明通用注册方式与 Go 中的 `oauth2.Endpoint` 拼法,与具体业务项目无关。
## 1. 协议与端点
对 Gitea 根地址 `BASE`(无尾斜杠,如 `https://git.example.com`
| 步骤 | URL |
|------|-----|
| 授权 | `{BASE}/login/oauth/authorize` |
| 换 token | `{BASE}/login/oauth/access_token` |
| 当前用户 API | `{BASE}/api/v1/user`Header`Authorization: token {access_token}` |
Scope 常用:`read:user`(以你 Gitea 版本界面为准)。
官方概念说明见 Gitea 文档中的 OAuth2 / Applications 章节(不同版本路径可能为站点管理或用户 **Applications**)。
## 2. 在 Gitea 中注册应用
1. 在目标 Gitea 上创建 **OAuth2 应用** / **应用OAuth2**,填写 **Redirect URI**(你自有服务的回调,须 HTTPS 生产或本地开发约定)。
2. 记录 **Client ID**、**Client Secret**。
**注意**`redirect_uri` 在授权与换 token 两阶段须一致,且与 Gitea 中登记项一致。
## 3. Golang`oauth2.Endpoint` 与示例
`golang.org/x/oauth2``Config.Endpoint` 需手动设为:
```go
endpoint := oauth2.Endpoint{
AuthURL: base + "/login/oauth/authorize",
TokenURL: base + "/login/oauth/access_token",
}
```
调用 Gitea `GET /api/v1/user` 时,使用 **token** 头:`Authorization: token ` + `accessToken`Gitea 传统写法;若你的版本支持 Bearer 以实际文档为准)。
## 4. 最小示例片段
```go
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"golang.org/x/oauth2"
)
func giteaEndpoint(base string) oauth2.Endpoint {
base = trimRightSlash(base)
return oauth2.Endpoint{
AuthURL: base + "/login/oauth/authorize",
TokenURL: base + "/login/oauth/access_token",
}
}
func trimRightSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
// 示例:构造 ConfigBASE、ClientID、Secret、RedirectURL 从环境或配置读取)
func newGiteaConfig() *oauth2.Config {
base := os.Getenv("GITEA_BASE_URL") // 如 https://git.example.com
return &oauth2.Config{
ClientID: os.Getenv("GITEA_CLIENT_ID"),
ClientSecret: os.Getenv("GITEA_CLIENT_SECRET"),
RedirectURL: os.Getenv("GITEA_REDIRECT_URL"),
Scopes: []string{"read:user"},
Endpoint: giteaEndpoint(base),
}
}
type giteaUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
FullName string `json:"full_name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
func fetchGiteaUser(ctx context.Context, base, access string) (*giteaUser, error) {
base = trimRightSlash(base)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/v1/user", nil)
req.Header.Set("Authorization", "token "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gitea: %d %s", res.StatusCode, b)
}
var u giteaUser
if err := json.Unmarshal(b, &u); err != nil {
return nil, err
}
return &u, nil
}
```
`main` 中挂载 HTTP 路由:首页生成 `state` 并重定向到 `cfg.AuthCodeURL`;在 `/oauth/gitea/callback``cfg.Exchange` 后调用 `fetchGiteaUser` 即可。整体与 [github.md](github.md) 中示例同构,仅将 `Config.Endpoint` 换为 `giteaEndpoint`、将 GitHub `Bearer` 换为 Gitea 的 `token` 头。
## 5. 常见错误
- **401**token 头格式或 API 版本与实例不一致。
- **redirect_uri 不匹配**:授权与回调的 host/scheme/端口与 Gitea 中登记的不一致。
- 多 Gitea 实例时:每个 **Client****base URL** 一一对应,勿混用。
## 6. 安全建议
- Secret 只放服务端。
- 以 Gitea 返回的**数字 user id** 作为绑定主键更稳妥。

View File

@@ -0,0 +1,196 @@
# 使用 GitHub 登录OAuth 2.0 接入指南Golang
本文介绍在自有应用中接入 **GitHub 账号** 的通用做法:在 GitHub 注册 OAuth App使用 **授权码流程** 换取 `access_token`,并调用 GitHub REST API 获取用户信息。与具体业务系统无关。
## 1. 协议与端点
GitHub 支持标准 OAuth 2.0。Go 中可直接使用:
```text
import "golang.org/x/oauth2/github"
Endpoint: github.Endpoint // AuthURL + TokenURL 已内置
```
| 步骤 | URL |
|------|-----|
| 引导用户授权 | `https://github.com/login/oauth/authorize`(由 `oauth2.Config` 生成) |
| 用 `code` 换 token | `https://github.com/login/oauth/access_token``Exchange` 自动 POST |
| 读当前用户(示例) | `GET https://api.github.com/user``Authorization: Bearer {token}` |
| 主邮箱(若 user 里无 email | `GET https://api.github.com/user/emails` |
常用 **Scope**`read:user``user:email`(需要邮箱时建议带 `user:email`)。
官方文档:[Authorizing OAuth Apps](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps)
## 2. 在 GitHub 注册应用
1. 个人:`Settings``Developer settings``OAuth Apps``New OAuth App`
2. **Authorization callback URL** 填你**真实**的回调地址,例如:
`http://localhost:8080/oauth/github/callback`(开发)
`https://你的域名/oauth/github/callback`(生产)
3. 保存 **Client ID****Client secrets**(机密只显示一次,可轮换)。
## 3. `redirect_uri` 与换 token
- 授权请求中的 `redirect_uri` 与在 GitHub 登记的 **必须完全一致**
-`code` 换 token 时,客户端库会带上**同一** `redirect_uri`,否则换 token 失败。
## 4. Golang 最小示例(授权码 + 拉取用户)
依赖:`go get golang.org/x/oauth2`,并安装子包引用 `github.Endpoint`(同一 module
```go
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/github"
)
func main() {
redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/github/callback")
cfg := &oauth2.Config{
ClientID: os.Getenv("GITHUB_CLIENT_ID"),
ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
RedirectURL: redirectURL,
Scopes: []string{"read:user", "user:email"},
Endpoint: github.Endpoint,
}
if cfg.ClientID == "" || cfg.ClientSecret == "" {
log.Fatal("set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET")
}
states := newStateStore()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 16)
_, _ = rand.Read(b)
state := base64.RawURLEncoding.EncodeToString(b)
states.Put(state, time.Now().Add(10*time.Minute))
url := cfg.AuthCodeURL(state, oauth2.AccessTypeOnline)
http.Redirect(w, r, url, http.StatusFound)
})
http.HandleFunc("/oauth/github/callback", func(w http.ResponseWriter, r *http.Request) {
if err := r.URL.Query().Get("error"); err != "" {
http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest)
return
}
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
if !states.Consume(state) {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
ctx := r.Context()
tok, err := cfg.Exchange(ctx, code)
if err != nil {
http.Error(w, "token exchange: "+err.Error(), http.StatusBadRequest)
return
}
u, err := fetchGitHubUser(ctx, tok.AccessToken)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(u)
})
log.Println("open http://127.0.0.1:8080/ (callback must match GitHub app:", redirectURL+")")
log.Fatal(http.ListenAndServe(":8080", nil))
}
type ghUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
}
func fetchGitHubUser(ctx context.Context, access string) (*ghUser, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/user", nil)
req.Header.Set("Authorization", "Bearer "+access)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "oauth-demo")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("github user: %d %s", res.StatusCode, b)
}
var u ghUser
if err := json.Unmarshal(b, &u); err != nil {
return nil, err
}
return &u, nil
}
type stateStore struct {
mu sync.Mutex
m map[string]time.Time
}
func newStateStore() *stateStore {
return &stateStore{m: make(map[string]time.Time)}
}
func (s *stateStore) Put(key string, exp time.Time) {
s.mu.Lock()
s.m[key] = exp
s.mu.Unlock()
}
func (s *stateStore) Consume(key string) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.m[key]
if !ok || time.Now().After(t) {
return false
}
delete(s.m, key)
return true
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
```
**运行前**
- 在 GitHub OAuth App 的回调里填写与 `OAUTH_REDIRECT_URL` **完全一致**的 URL未设置时默认 `http://127.0.0.1:8080/oauth/github/callback`)。
- `export GITHUB_CLIENT_ID=...``GITHUB_CLIENT_SECRET=...`
## 5. 常见错误
| 现象 | 原因 |
|------|------|
| `redirect_uri` 与登记不符 | 修改了端口、`localhost` vs `127.0.0.1``http`/`https` 不一致 |
| 换 token 失败 | 同上;或 `Client Secret` 错误 |
| 用户 email 为空 | 邮箱可能未公开,需调 `user/emails` 并带 `user:email` scope |
## 6. 安全建议
- `state` 应随机且**一次性**;生产可用加签或服务端 Session。
- `Client Secret` 仅放服务端;勿写入前端。
- 对 GitHub 用户 ID数字 id做账号绑定主键勿仅用 login可改名

View File

@@ -0,0 +1,185 @@
# 使用 Google 账号登录OAuth 2.0 接入指南Golang
本文介绍通过 **Google OAuth 2.0** 获取用户身份:在 Google Cloud 创建 **Web 应用** 凭据,使用授权码换 `access_token`,再调用 **OAuth2 userinfo****OpenID Connect**。与具体站点/产品无关。
## 1. 协议与端点
Go 可使用官方端点常量:
```go
import "golang.org/x/oauth2/google"
oauth2.Config{
Endpoint: google.Endpoint,
// ...
}
```
| 步骤 | URL |
|------|-----|
| 授权 | `https://accounts.google.com/o/oauth2/auth`(由库生成,实际以库为准) |
| 换 token | `https://oauth2.googleapis.com/token` |
| 用户信息(常用) | `GET https://www.googleapis.com/oauth2/v2/userinfo``Authorization: Bearer {token}` |
Scope 常用:`openid``email``profile`userinfo v2 常见组合)。
官方文档:[Using OAuth 2.0 to Access Google APIs](https://developers.google.com/identity/protocols/oauth2)
## 2. 在 Google Cloud 注册
1. [Google Cloud Console](https://console.cloud.google.com/) → 选择项目 → **API 和服务****OAuth 同意屏幕**(先配置完毕)。
2. **凭据****创建凭据****OAuth 客户端 ID** → 类型 **Web 应用**
3. **已获授权的重定向 URI** 必须包含你后端的**真实回调**,例如:
`http://127.0.0.1:8080/oauth/google/callback`(开发)
`https://你的域名/oauth/google/callback`(生产)
注意:**不是**前端开发机上的随便一条路径,除非你的换码服务就挂在那台机且路径一致。
4. 记录 **客户端 ID**、**客户端密钥**。
## 3. 服务端出网
换 token 请求发往 `oauth2.googleapis.com`;若服务器在受限网络,需配置代理或出站策略,否则会出现 `token_exchange` 类错误(浏览器能打开 Google 不代表服务器能连 token 端点)。
## 4. Golang 最小示例
```go
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
func main() {
redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/google/callback")
cfg := &oauth2.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
RedirectURL: redirectURL,
Scopes: []string{"openid", "email", "profile"},
Endpoint: google.Endpoint,
}
if cfg.ClientID == "" || cfg.ClientSecret == "" {
log.Fatal("set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET")
}
states := newStateStore()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 16)
_, _ = rand.Read(b)
state := base64.RawURLEncoding.EncodeToString(b)
states.Put(state, time.Now().Add(10*time.Minute))
url := cfg.AuthCodeURL(state)
http.Redirect(w, r, url, http.StatusFound)
})
http.HandleFunc("/oauth/google/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") != "" {
http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest)
return
}
if !states.Consume(r.URL.Query().Get("state")) {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
ctx := r.Context()
tok, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
http.Error(w, "exchange: "+err.Error(), http.StatusBadRequest)
return
}
u, err := fetchGoogleUserinfo(ctx, tok.AccessToken)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(u)
})
log.Println("Google console redirect URI must be:", redirectURL)
log.Fatal(http.ListenAndServe(":8080", nil))
}
type googleUserinfo struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func fetchGoogleUserinfo(ctx context.Context, access string) (*googleUserinfo, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://www.googleapis.com/oauth2/v2/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("userinfo: %d %s", res.StatusCode, b)
}
var u googleUserinfo
if err := json.Unmarshal(b, &u); err != nil {
return nil, err
}
return &u, nil
}
type stateStore struct {
mu sync.Mutex
m map[string]time.Time
}
func newStateStore() *stateStore {
return &stateStore{m: make(map[string]time.Time)}
}
func (s *stateStore) Put(k string, exp time.Time) {
s.mu.Lock()
s.m[k] = exp
s.mu.Unlock()
}
func (s *stateStore) Consume(k string) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.m[k]
if !ok || time.Now().After(t) {
return false
}
delete(s.m, k)
return true
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
```
## 5. 常见错误
| 错误 | 说明 |
|------|------|
| `redirect_uri_mismatch` | Google 控制台未登记与程序**完全一致**的回调(含协议、主机、端口、路径) |
| 换 token 失败 | `Client Secret` 错误;或授权与换码时 `redirect_uri` 不一致 |
| `access blocked` | OAuth 同意屏幕为「测试」且当前用户不在测试用户列表等,见 Google 控制台提示 |
## 6. 安全建议
- `state` 必校验Secret 仅服务端。
-`sub`/`id` 作为跨登录稳定主键;邮箱可能变化,慎单独依赖。

View File

@@ -0,0 +1,75 @@
# 使用 LINUX DO Connect 登录OAuth 2.0 接入指南Golang
**LINUX DO Connect** 提供标准 OAuth2 式授权与用户接口,官方入口为 [connect.linux.do](https://connect.linux.do)。本文说明其端点形态、在 Connect 中登记回调及 Go 侧接入方式,**与任意自建应用兼容**(不限定具体产品名)。
> 以 Connect 当前文档与控制台为准;若端点有变更,请以官网「应用接入」说明为准。
## 1. 协议与端点
**Connect 根地址** `BASE`(无尾斜杠,常见为 `https://connect.linux.do`
| 步骤 | 典型 URL |
| ------- | ---------------------------------------------------------------------- |
| 授权 | `{BASE}/oauth2/authorize` |
| 换 token | `{BASE}/oauth2/token` |
| 当前用户 | `{BASE}/api/user``Authorization: Bearer {access_token}`Accept: JSON |
Scope 以 Connect 应用接入要求为准,常见为读取用户信息类 scope例如文档中的 `read:user` 等,以实际申请为准)。
## 2. 在 Connect 注册应用
1. 登录 [connect.linux.do](https://connect.linux.do),在 **应用接入** 中创建应用。
2. **回调地址** 填写你服务的真实 `redirect_uri`(与下文 Go 中 `RedirectURL` 一致)。
3. 记录 **Client ID**、**Client Secret**。
## 3. Golang`Endpoint` 配置
```go
import "golang.org/x/oauth2"
func linuxDoEndpoint(base string) oauth2.Endpoint {
// 去掉 base 尾斜杠
for len(base) > 0 && base[len(base)-1] == '/' {
base = base[:len(base)-1]
}
return oauth2.Endpoint{
AuthURL: base + "/oauth2/authorize",
TokenURL: base + "/oauth2/token",
}
}
```
`oauth2.Config` 示例:
- `RedirectURL`:与 Connect 中登记的回调**完全一致**
- `Scopes`:与创建应用时一致
- 回调里:`cfg.Exchange(ctx, code)``access_token`
## 4. 拉取用户资料
成功换 token 后,请求:
- `GET {BASE}/api/user`
- Header`Authorization: Bearer {access_token}`
响应为 JSON。常见字段含用户 id、用户名、邮箱、头像等**建议用返回的稳定 id 作为账号主键**(不要假设字段名永久不变,对接时以你抓包/文档为准)。
## 5. 网络与排障
- 换 token、访问 `/api/user` 由**你的服务器**发出;若机器无法访问 `connect.linux.do`(代理、墙、内网限制),会表现为超时或 `token exchange` 失败。
- 浏览器能打开 Connect 授权页,不代表**后端**能连上 `token` URL请在部署环境上测试出站 HTTPS。
## 6. 与 GitHub 示例同构的整合方式
实现步骤与 [github.md](github.md) 相同:根路径生成 `state` 并重定向到 `AuthCodeURL``oauth2/authorize` 的回调路径上取 `code``Exchange` 后带 Bearer 调 `/api/user`。仅替换:
- `oauth2.Config.Endpoint``linuxDoEndpoint(os.Getenv("LINUXDO_BASE"))`
- 用户 JSON 的解析按 Connect 实际返回字段编写 `struct``map`
## 7. 安全建议
- 校验 `state``Client Secret` 驻留服务端。
- 对 Connect 用户 id 与本地用户做一对一绑定,并处理“该 id 已绑其他用户”的冲突。

View File

@@ -0,0 +1,199 @@
# 使用 Microsoft / Entra ID 登录OAuth 2.0 接入指南Golang
本文介绍通过 **Microsoft 身份平台v2.0** 使用个人或工作/学校账号登录:在 **Microsoft Entra原 Azure AD** 注册应用,走授权码流程,再用 **Microsoft Graph** 读取用户资料。与具体业务系统无关。
## 1. 协议与端点
v2.0 形式(`{tenant}` 可为 `common`、组织租户 ID、`consumers` 等,依你的「支持的帐户类型」选择):
| 步骤 | URL |
|------|-----|
| 授权 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` |
| 换 token | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` |
| 当前用户 | `GET https://graph.microsoft.com/v1.0/me``Authorization: Bearer {token}` |
Go 中手动拼 `oauth2.Endpoint`
```go
func microsoftV2Endpoint(tenant string) oauth2.Endpoint {
if tenant == "" {
tenant = "common"
}
base := "https://login.microsoftonline.com/" + tenant
return oauth2.Endpoint{
AuthURL: base + "/oauth2/v2.0/authorize",
TokenURL: base + "/oauth2/v2.0/token",
}
}
```
Scope 至少包含:`openid``profile``email``https://graph.microsoft.com/User.Read`(读 `/me`)。
官方文档:[Microsoft identity platform and OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)
## 2. 在 Entra 注册应用
1. [Entra 管理中心](https://entra.microsoft.com/) → **应用注册****新注册**
2. **重定向 URI****Web**,填你的回调,例如:
`https://你的服务/oauth/microsoft/callback`
3. **证书和密码** → 新建客户端机密;记录 **应用程序(客户端) ID** 与机密。
4. **API 权限**:确保可登录并调用 Graph`User.Read` 等,与 scope 一致)。
## 3. Golang 最小示例
```go
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"golang.org/x/oauth2"
)
func main() {
tenant := getenv("MS_TENANT", "common")
redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/microsoft/callback")
cfg := &oauth2.Config{
ClientID: os.Getenv("MS_CLIENT_ID"),
ClientSecret: os.Getenv("MS_CLIENT_SECRET"),
RedirectURL: redirectURL,
Scopes: []string{
"openid", "profile", "email",
"https://graph.microsoft.com/User.Read",
},
Endpoint: microsoftV2Endpoint(tenant),
}
if cfg.ClientID == "" || cfg.ClientSecret == "" {
log.Fatal("set MS_CLIENT_ID and MS_CLIENT_SECRET")
}
states := newStateStore()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 16)
_, _ = rand.Read(b)
state := base64.RawURLEncoding.EncodeToString(b)
states.Put(state, time.Now().Add(10*time.Minute))
http.Redirect(w, r, cfg.AuthCodeURL(state), http.StatusFound)
})
http.HandleFunc("/oauth/microsoft/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") != "" {
http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest)
return
}
if !states.Consume(r.URL.Query().Get("state")) {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
ctx := r.Context()
tok, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
http.Error(w, "exchange: "+err.Error(), http.StatusBadRequest)
return
}
u, err := fetchGraphMe(ctx, tok.AccessToken)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(u)
})
log.Println("Entra redirect URI must match:", redirectURL)
log.Fatal(http.ListenAndServe(":8080", nil))
}
type graphMe struct {
ID string `json:"id"`
Mail string `json:"mail"`
UserPrincipalName string `json:"userPrincipalName"`
DisplayName string `json:"displayName"`
}
func fetchGraphMe(ctx context.Context, access string) (*graphMe, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://graph.microsoft.com/v1.0/me", nil)
req.Header.Set("Authorization", "Bearer "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph: %d %s", res.StatusCode, b)
}
var u graphMe
if err := json.Unmarshal(b, &u); err != nil {
return nil, err
}
return &u, nil
}
func microsoftV2Endpoint(tenant string) oauth2.Endpoint {
if tenant == "" {
tenant = "common"
}
base := "https://login.microsoftonline.com/" + tenant
return oauth2.Endpoint{
AuthURL: base + "/oauth2/v2.0/authorize",
TokenURL: base + "/oauth2/v2.0/token",
}
}
type stateStore struct {
mu sync.Mutex
m map[string]time.Time
}
func newStateStore() *stateStore { return &stateStore{m: make(map[string]time.Time)} }
func (s *stateStore) Put(k string, exp time.Time) {
s.mu.Lock()
s.m[k] = exp
s.mu.Unlock()
}
func (s *stateStore) Consume(k string) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.m[k]
if !ok || time.Now().After(t) {
return false
}
delete(s.m, k)
return true
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
```
## 4. 租户 `common` 与单租户
- **`common`**:适合「多组织 + 个人账户」类应用;与 Entra 注册时「支持的帐户类型」需一致。
- **单一组织**:将 `MS_TENANT` 设为**目录租户 ID**,并在应用注册中选择仅本组织,减少误登范围。
## 5. 常见错误
- **换 token 失败**`redirect_uri` 与注册应用时不一致;或 Secret 错误。
- **Graph 403**:未授予 `User.Read` 或 token 中无对应 scope。
## 6. 安全建议
- 使用返回的 **用户 `id`GUID** 作为外部账号主键。
- `state` 防重放Secret 仅存服务端。