Files
SproutGate/docs/oauth-providers/github.md
2026-05-13 12:19:36 +08:00

197 lines
6.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 使用 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可改名