0%

Go 后端实现 Google 一键登录(ID Token 验证方案)

前言

做海外项目时,Google 一键登录几乎是标配。相比传统的邮箱注册流程(填邮箱→收验证码→设密码),用户只需要点一下 Google 按钮就能完成注册/登录,转化率能高不少。

本文以我自己的一个 Go 后端项目为例,分享 Google 一键登录的后端实现,技术栈是 GoFrame + JWT + MySQL。前端用的是 Google 官方的 Sign In With Google 按钮,后端只需要验证前端传过来的 ID Token 就行。

整体流程

Google 一键登录的核心流程其实很简单:

            sequenceDiagram
            participant U as 用户(浏览器)
participant G as Google
participant B as 后端

    U->>G: 1. 点击 Google 登录按钮,弹出授权窗口
    G->>G: 2. 用户在 Google 页面完成身份验证
    G->>U: 3. 返回 ID Token(JWT 格式)
    U->>B: 4. POST /user/google-login {idToken}
    B->>G: 5. 拉取 Google JWKS 公钥(有缓存)
    G-->>B: 6. 返回公钥
    B->>B: 7. 用公钥本地验证 ID Token 签名和有效期
    B->>B: 8. 根据 googleID 查找/创建用户,签发 JWT
    B->>U: 9. 返回 JWT + 用户信息

          

后端要做的事只有三件:

  1. 验证 ID Token——确认这个 token 确实是 Google 颁发的,且是给你的应用的
  2. 查找或创建用户——第一次登录自动注册,后续登录直接查
  3. 签发自己的 JWT——后续请求都走自己的鉴权体系

前置准备:Google Cloud Console 配置

环境依赖:

  • Go 1.24
  • GoFrame v2.10.0
  • google.golang.org/api v0.259.0
  • github.com/golang-jwt/jwt/v5 v5.3.0

在写代码之前,你需要去 Google Cloud Console 创建一个 OAuth 2.0 客户端:

  1. 进入 APIs & Services → Credentials
  2. Create Credentials → OAuth client ID
  3. 应用类型选 Web application
  4. 配置 Authorized JavaScript origins(你的前端域名,比如 https://www.example.com
  5. 创建后拿到 Client IDClient Secret

后端验证 ID Token 只需要 Client ID,不需要 Client Secret

配置文件里加上:

1
2
google:
clientId: "你的-client-id.apps.googleusercontent.com"

后端实现

1. 定义 API 接口

用 GoFrame 的结构化 API 定义:

1
2
3
4
5
6
7
8
9
type GoogleLoginReq struct {
g.Meta `path:"/user/google-login" method:"post" tags:"用户" summary:"Google一键登录"`
IDToken string `json:"idToken" v:"required" dc:"Google ID Token"`
}

type GoogleLoginRes struct {
Token string `json:"token" dc:"JWT Token"`
User *UserInfo `json:"user" dc:"用户信息"`
}

前端只需要传一个 idToken 字段,就是 Google Sign In 回调里拿到的 credential。

2. 核心逻辑:验证 Token + 查建用户

这是整个 Google 登录的核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
package user

import (
"context"

v1 "your-project/api/user/v1"
"your-project/internal/consts"
"your-project/internal/dao"
"your-project/internal/model/do"
"your-project/internal/model/entity"
"your-project/internal/service/jwt"
"your-project/internal/utility/snowflake"

"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"google.golang.org/api/idtoken"
)

func GoogleLogin(ctx context.Context, req *v1.GoogleLoginReq) (*v1.GoogleLoginRes, error) {
// 1. 验证 Google ID Token
clientID := g.Cfg().MustGet(ctx, "google.clientId").String()
payload, err := idtoken.Validate(ctx, req.IDToken, clientID)
if err != nil {
g.Log().Error(ctx, "Google登录,Token验证失败", err)
return nil, gerror.New("Google登录验证失败")
}

// 2. 从 payload 中提取用户信息
googleID := payload.Subject
email := payload.Claims["email"].(string)

// 3. 查找已有用户
var user *entity.Users
err = dao.Users.Ctx(ctx).Where("google_id", googleID).Scan(&user)
if err != nil {
g.Log().Error(ctx, "Google登录,查询用户信息失败", err)
return nil, gerror.New("系统错误")
}

// 4. 用户不存在则自动注册
if user == nil {
uid := snowflake.GenerateID()
_, err = dao.Users.Ctx(ctx).Insert(do.Users{
Uid: uid,
Email: email,
GoogleId: googleID,
RegisterType: consts.RegisterTypeGoogle,
MembershipStatus: consts.MembershipStatusNonMember,
Status: consts.UserStatusNormal,
})
if err != nil {
g.Log().Error(ctx, "Google登录,创建用户失败", err)
return nil, gerror.New("系统错误")
}

err = dao.Users.Ctx(ctx).Where("uid", uid).Scan(&user)
if err != nil {
return nil, gerror.New("系统错误")
}
}

// 5. 检查账号状态
if user.Status != consts.UserStatusNormal {
return nil, gerror.New("账号已被禁用")
}

// 6. 签发自己的 JWT
token, err := jwt.GenerateToken(user.Uid, user.Email)
if err != nil {
return nil, gerror.New("系统错误")
}

return &v1.GoogleLoginRes{
Token: token,
User: &v1.UserInfo{
UID: user.Uid,
Email: user.Email,
// ... 其他字段
},
}, nil
}

逐步拆解:

验证 ID Token

1
payload, err := idtoken.Validate(ctx, req.IDToken, clientID)

google.golang.org/api/idtoken 包帮你做了所有脏活:

  • 从 Google 的 JWKS 端点拉取公钥
  • 验证 token 签名
  • 检查 aud(audience)是否匹配你的 Client ID
  • 检查 token 是否过期

验证通过后,payload.Subject 就是用户的 Google ID(全局唯一且不变),payload.Claims 里有 email、name、picture 等信息。

查找或创建用户

google_id 作为关联字段查用户表。如果查不到说明是新用户,自动创建一条记录。这里用了 Snowflake 算法生成分布式唯一 ID 作为业务主键。

为什么用 google_id 而不是 email 来关联? 因为用户可能会改 Google 账号的邮箱,但 Subject(Google ID)是永远不变的。

签发 JWT

验证完 Google 身份后,后续的鉴权还是走自己的 JWT 体系。这样做的好处是:

  • 不依赖 Google 的 token 有效期
  • 可以统一处理邮箱注册和 Google 注册的用户
  • 中间件只需要解析自己的 JWT,不用区分登录方式

3. JWT 签发与验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func GenerateToken(uid, email string) (string, error) {
ctx := context.Background()
secret := g.Cfg().MustGet(ctx, "jwt.secret").String()
expire := g.Cfg().MustGet(ctx, "jwt.expire", 7200).Int()

claims := Claims{
UID: uid,
Email: email,
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expire) * time.Second)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(secret))
}

每个 JWT 都有一个 UUID 作为 jti(JWT ID),配合 Redis 黑名单可以实现主动吊销 token(比如用户退出登录时)。

4. 路由注册

Google 登录接口放在无需鉴权的公开路由组里:

1
group.POST("/user/google-login", user.NewV1().GoogleLogin)

跟普通的邮箱登录接口并列,登录成功后返回的数据格式完全一致——前端拿到 token 存起来,后续请求带上就行。

数据库设计要点

users 表需要加几个字段:

1
2
3
ALTER TABLE users ADD COLUMN google_id VARCHAR(128) DEFAULT NULL COMMENT 'Google用户ID';
ALTER TABLE users ADD COLUMN register_type TINYINT UNSIGNED DEFAULT 1 COMMENT '注册类型:1-邮箱 2-Google';
ALTER TABLE users ADD UNIQUE INDEX idx_google_id (google_id);
  • google_id 加唯一索引,防止重复绑定
  • register_type 区分注册来源,后续做数据分析有用
  • email 字段对 Google 用户也保留,方便发通知邮件

安全注意事项

  1. 永远在后端验证 ID Token——前端传过来的 token 不可信,必须后端调 Google API 验证
  2. 校验 audience——idtoken.Validate 的第三个参数就是你的 Client ID,防止其他应用的 token 被拿来登录你的系统
  3. 不要把 Client Secret 暴露到前端——ID Token 验证方案不需要 Client Secret,但如果你用 Authorization Code 方案就需要,记得只放后端
  4. google_id 用唯一索引——防止并发场景下创建重复用户

前端怎么接?

前端用的是 Google Identity Services(GIS),这是 Google 2021 年推出的新版登录 SDK,取代了老版的 google-signin 库。整个接入只需三步。

第一步:引入 GIS 脚本

在 HTML 的 <head> 里加上:

1
<script src="https://accounts.google.com/gsi/client" async defer></script>

async defer 让脚本异步加载,不阻塞页面渲染。

第二步:放置登录按钮

有两种方式,按需选一种:

方式一:纯 HTML(最简单)

直接在页面放两个 div,GIS 脚本加载后会自动处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!-- 配置项:Client ID 和回调函数名 -->
<div id="g_id_onload"
data-client_id="你的-client-id.apps.googleusercontent.com"
data-callback="handleCredentialResponse">
</div>

<!-- 渲染 Google 样式的登录按钮 -->
<div class="g_id_signin"
data-type="standard"
data-size="large"
data-theme="outline"
data-text="sign_in_with"
data-shape="rectangular">
</div>
  • data-client_id:换成你在 Google Cloud Console 拿到的 Client ID
  • data-callback:登录成功后调用的 JavaScript 函数名
  • g_id_signindata-themedata-size 等属性控制按钮外观,Google 官方文档有完整参数列表

方式二:JavaScript API(更灵活)

需要在特定时机触发登录、或者想启用 One Tap 弹窗时,用 JS 控制更方便:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
window.onload = function () {
google.accounts.id.initialize({
client_id: "你的-client-id.apps.googleusercontent.com",
callback: handleCredentialResponse,
});

// 把按钮渲染到指定容器
google.accounts.id.renderButton(
document.getElementById("googleSignInBtn"),
{ theme: "outline", size: "large" }
);

// 同时弹出 One Tap 提示(可选)
// google.accounts.id.prompt();
};
1
<div id="googleSignInBtn"></div>

第三步:处理回调、调后端接口

登录成功后,Google 会调用你配置的回调函数,参数里的 credential 字段就是 ID Token——一段很长的 JWT 字符串,直接传给后端就行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function handleCredentialResponse(response) {
// response.credential 就是 Google 颁发的 ID Token
fetch("/api/user/google-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken: response.credential }),
})
.then((res) => res.json())
.then((data) => {
if (data.code === 0) {
// 登录成功,存自己系统的 JWT
localStorage.setItem("token", data.data.token);
window.location.href = "/dashboard";
} else {
console.error("登录失败", data.message);
}
})
.catch((err) => {
console.error("请求出错", err);
});
}

完整示例

把上面三步拼在一起,一个能直接跑的最简登录页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>Google 登录示例</title>
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<h1>欢迎</h1>

<div id="g_id_onload"
data-client_id="你的-client-id.apps.googleusercontent.com"
data-callback="handleCredentialResponse">
</div>
<div class="g_id_signin" data-type="standard" data-size="large"></div>

<script>
function handleCredentialResponse(response) {
fetch("/api/user/google-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ idToken: response.credential }),
})
.then((res) => res.json())
.then((data) => {
if (data.code === 0) {
localStorage.setItem("token", data.data.token);
window.location.href = "/dashboard";
}
});
}
</script>
</body>
</html>

几个容易踩的坑

本地开发必须用 http://localhost

在 Google Cloud Console 配置 Authorized JavaScript origins 时,本地开发要加 http://localhost(不是 127.0.0.1,也不是 http://localhost:3000 这样带端口的)。否则会报 idpiframe_initialization_failed 错误。

不要在 file:// 协议下测试

GIS 不支持 file://,必须通过 HTTP/HTTPS 服务器访问。本地可以用 python3 -m http.server 8000 起一个简单服务器。

跨域问题

前后端分域部署时(比如前端 app.example.com,后端 api.example.com),后端 /user/google-login 接口要配置 CORS,允许前端域名,否则 fetch 会被浏览器拦截。

response.credential 是一次性的,不要在前端 decode

ID Token 有效期很短(约 1 小时),而且里面的内容只有后端拿公钥验过签之后才可信。不要在前端用 atob 或 jwt-decode 库解析它来做业务判断,直接传给后端就行。

ID Token 方案 vs Authorization Code 方案

Google 一键登录的后端实现并不复杂,核心就是 idtoken.Validate 这一步。整个流程:

  1. 前端拿到 Google 的 ID Token
  2. 后端用 google.golang.org/api/idtoken 包验证
  3. payload.Subject 关联/创建用户
  4. 签发自己的 JWT 返回

相比 OAuth2 Authorization Code 方案,ID Token 方案更简单——不需要后端跟 Google 换 access token,一个 HTTP 请求都不用发(idtoken 包会缓存 Google 的公钥)。适合”只需要登录,不需要调 Google API”的场景。

-------------------本文结束,感谢您的阅读!-------------------