5.7 KiB
5.7 KiB
使用 Google 账号登录:OAuth 2.0 接入指南(Golang)
本文介绍通过 Google OAuth 2.0 获取用户身份:在 Google Cloud 创建 Web 应用 凭据,使用授权码换 access_token,再调用 OAuth2 userinfo 或 OpenID Connect。与具体站点/产品无关。
1. 协议与端点
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
2. 在 Google Cloud 注册
- Google Cloud Console → 选择项目 → API 和服务 → OAuth 同意屏幕(先配置完毕)。
- 凭据 → 创建凭据 → OAuth 客户端 ID → 类型 Web 应用。
- 已获授权的重定向 URI 必须包含你后端的真实回调,例如:
http://127.0.0.1:8080/oauth/google/callback(开发)
https://你的域名/oauth/google/callback(生产)
注意:不是前端开发机上的随便一条路径,除非你的换码服务就挂在那台机且路径一致。 - 记录 客户端 ID、客户端密钥。
3. 服务端出网
换 token 请求发往 oauth2.googleapis.com;若服务器在受限网络,需配置代理或出站策略,否则会出现 token_exchange 类错误(浏览器能打开 Google 不代表服务器能连 token 端点)。
4. Golang 最小示例
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作为跨登录稳定主键;邮箱可能变化,慎单独依赖。