6.2 KiB
6.2 KiB
使用 GitHub 登录:OAuth 2.0 接入指南(Golang)
本文介绍在自有应用中接入 GitHub 账号 的通用做法:在 GitHub 注册 OAuth App,使用 授权码流程 换取 access_token,并调用 GitHub REST API 获取用户信息。与具体业务系统无关。
1. 协议与端点
GitHub 支持标准 OAuth 2.0。Go 中可直接使用:
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)。
2. 在 GitHub 注册应用
- 个人:
Settings→Developer settings→OAuth Apps→New OAuth App。 - Authorization callback URL 填你真实的回调地址,例如:
http://localhost:8080/oauth/github/callback(开发)
https://你的域名/oauth/github/callback(生产) - 保存 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)。
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(可改名)。