# 使用 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(可改名)。