Update SproutGate
This commit is contained in:
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