# 使用 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（可改名）。
