# 使用 Google 账号登录：OAuth 2.0 接入指南（Golang）

本文介绍通过 **Google OAuth 2.0** 获取用户身份：在 Google Cloud 创建 **Web 应用** 凭据，使用授权码换 `access_token`，再调用 **OAuth2 userinfo** 或 **OpenID Connect**。与具体站点/产品无关。

## 1. 协议与端点

Go 可使用官方端点常量：

```go
import "golang.org/x/oauth2/google"

oauth2.Config{
	Endpoint: google.Endpoint,
	// ...
}
```

| 步骤 | URL |
|------|-----|
| 授权 | `https://accounts.google.com/o/oauth2/auth`（由库生成，实际以库为准） |
| 换 token | `https://oauth2.googleapis.com/token` |
| 用户信息（常用） | `GET https://www.googleapis.com/oauth2/v2/userinfo`，`Authorization: Bearer {token}` |

Scope 常用：`openid`、`email`、`profile`（userinfo v2 常见组合）。

官方文档：[Using OAuth 2.0 to Access Google APIs](https://developers.google.com/identity/protocols/oauth2)

## 2. 在 Google Cloud 注册

1. [Google Cloud Console](https://console.cloud.google.com/) → 选择项目 → **API 和服务** → **OAuth 同意屏幕**（先配置完毕）。  
2. **凭据** → **创建凭据** → **OAuth 客户端 ID** → 类型 **Web 应用**。  
3. **已获授权的重定向 URI** 必须包含你后端的**真实回调**，例如：  
   `http://127.0.0.1:8080/oauth/google/callback`（开发）  
   `https://你的域名/oauth/google/callback`（生产）  
   注意：**不是**前端开发机上的随便一条路径，除非你的换码服务就挂在那台机且路径一致。  
4. 记录 **客户端 ID**、**客户端密钥**。

## 3. 服务端出网

换 token 请求发往 `oauth2.googleapis.com`；若服务器在受限网络，需配置代理或出站策略，否则会出现 `token_exchange` 类错误（浏览器能打开 Google 不代表服务器能连 token 端点）。

## 4. Golang 最小示例

```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/google"
)

func main() {
	redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/google/callback")
	cfg := &oauth2.Config{
		ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
		ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
		RedirectURL:  redirectURL,
		Scopes:       []string{"openid", "email", "profile"},
		Endpoint:     google.Endpoint,
	}
	if cfg.ClientID == "" || cfg.ClientSecret == "" {
		log.Fatal("set GOOGLE_CLIENT_ID and GOOGLE_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)
		http.Redirect(w, r, url, http.StatusFound)
	})

	http.HandleFunc("/oauth/google/callback", func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Query().Get("error") != "" {
			http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest)
			return
		}
		if !states.Consume(r.URL.Query().Get("state")) {
			http.Error(w, "invalid state", http.StatusBadRequest)
			return
		}
		ctx := r.Context()
		tok, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
		if err != nil {
			http.Error(w, "exchange: "+err.Error(), http.StatusBadRequest)
			return
		}
		u, err := fetchGoogleUserinfo(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("Google console redirect URI must be:", redirectURL)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

type googleUserinfo struct {
	ID      string `json:"id"`
	Email   string `json:"email"`
	Name    string `json:"name"`
	Picture string `json:"picture"`
}

func fetchGoogleUserinfo(ctx context.Context, access string) (*googleUserinfo, error) {
	req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
		"https://www.googleapis.com/oauth2/v2/userinfo", nil)
	req.Header.Set("Authorization", "Bearer "+access)
	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("userinfo: %d %s", res.StatusCode, b)
	}
	var u googleUserinfo
	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(k string, exp time.Time) {
	s.mu.Lock()
	s.m[k] = exp
	s.mu.Unlock()
}
func (s *stateStore) Consume(k string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	t, ok := s.m[k]
	if !ok || time.Now().After(t) {
		return false
	}
	delete(s.m, k)
	return true
}
func getenv(k, d string) string {
	if v := os.Getenv(k); v != "" {
		return v
	}
	return d
}
```

## 5. 常见错误

| 错误 | 说明 |
|------|------|
| `redirect_uri_mismatch` | Google 控制台未登记与程序**完全一致**的回调（含协议、主机、端口、路径） |
| 换 token 失败 | `Client Secret` 错误；或授权与换码时 `redirect_uri` 不一致 |
| `access blocked` | OAuth 同意屏幕为「测试」且当前用户不在测试用户列表等，见 Google 控制台提示 |

## 6. 安全建议

- `state` 必校验；Secret 仅服务端。  
- 用 `sub`/`id` 作为跨登录稳定主键；邮箱可能变化，慎单独依赖。
