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

6.2 KiB
Raw Blame History

使用 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_tokenExchange 自动 POST
读当前用户(示例) GET https://api.github.com/userAuthorization: Bearer {token}
主邮箱(若 user 里无 email GET https://api.github.com/user/emails

常用 Scoperead:useruser:email(需要邮箱时建议带 user:email)。

官方文档:Authorizing OAuth Apps

2. 在 GitHub 注册应用

  1. 个人:SettingsDeveloper settingsOAuth AppsNew OAuth App
  2. Authorization callback URL 填你真实的回调地址,例如:
    http://localhost:8080/oauth/github/callback(开发)
    https://你的域名/oauth/github/callback(生产)
  3. 保存 Client IDClient 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.1http/https 不一致
换 token 失败 同上;或 Client Secret 错误
用户 email 为空 邮箱可能未公开,需调 user/emails 并带 user:email scope

6. 安全建议

  • state 应随机且一次性;生产可用加签或服务端 Session。
  • Client Secret 仅放服务端;勿写入前端。
  • 对 GitHub 用户 ID数字 id做账号绑定主键勿仅用 login可改名