Files
2026-05-13 12:19:36 +08:00

186 lines
5.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 使用 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` 作为跨登录稳定主键;邮箱可能变化,慎单独依赖。