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