186 lines
5.7 KiB
Markdown
186 lines
5.7 KiB
Markdown
# 使用 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` 作为跨登录稳定主键;邮箱可能变化,慎单独依赖。
|