
最近折腾 Goravel 的 JWT(JSON Web Token)认证,踩了几个容易忽略的坑,这篇把注册、登录、读取令牌、退出和刷新令牌完整串起来。
这是 Goravel 系列的第 9 篇。DevChalk 原版提供了配色代码、插图和完整差异对比,本篇代码位于 krishankumar01/goravel-taskflow/tree/part-09。
TaskFlow 已经有用户模型,却没人能注册或登录。它的客户端可能是移动应用或自动化脚本,不一定来自浏览器,因此这里不使用会话 Cookie,而是登录后返回令牌,由客户端后续放进请求头。
这一篇会实现四件事:注册时保存密码哈希,登录时签发令牌,在后续请求中读取并验证令牌,以及退出登录和刷新令牌。
先从“使用 Form Request 验证”的代码继续:
git clone https://github.com/krishankumar01/goravel-taskflow
cd goravel-taskflow
git checkout part-08
cp .env.example .env
go run . artisan key:generate
docker compose up -d
go run . artisan migrate:fresh --seed
go install github.com/air-verse/air@latest
air
注册时,客户端提交姓名、邮箱和密码。数据入库前必须先校验:邮箱格式正确、没有被占用,密码长度也要达标。Form Request 可以完成这些检查,和“使用 Form Request 验证”一节的做法相同:
$ go run . artisan make:request RegisterRequest SUCCESS Request created successfully
填好请求类,并删除不用的方法:
app/http/requests/register_request.go
package requests import ( "github.com/goravel/framework/contracts/http"
) type RegisterRequest struct { Name string `form:"name" json:"name"` Email string `form:"email" json:"email"` Password string `form:"password" json:"password"`
} func (r *RegisterRequest) Authorize(ctx http.Context) error { return nil
} func (r *RegisterRequest) Rules(ctx http.Context) map[string]any { return map[string]any{ "name": "required|string|max:255", "email": "bail|required|string|email|max:255|unique:users,email", "password": "bail|required|string|min:8|max_bytes:72", }
} func (r *RegisterRequest) Filters(ctx http.Context) map[string]any { return map[string]any{ "name": "trim", "email": "trim|lower", }
}
lower 会把 Ben@Example.com 转为 ben@example.com。过滤器先于校验规则运行,而 PostgreSQL 文本比较是区分大小写的,不做转换就可能让同一个人重复注册。unique 会在 users 表中查询邮箱。bail 遇到第一条失败的规则就停止,因此格式错误的邮箱不会继续进入 PostgreSQL。unique。最终由“模型与迁移”一节创建的邮箱唯一索引拦住第二个请求。role 字段,客户端无法自行把自己注册成管理员。数据库里不能保存明文密码,只保存 bcrypt 哈希。bcrypt 最多读取密码的 72 字节,超长会直接报错,因此密码必须按字节设置上限。Goravel 没有现成规则,这里自己补一个。
为什么不直接使用 max:72?
max:72 计算的是字符数,不是字节数。一个 emoji 占 4 字节,20 个 emoji 能通过 max:72,随后却会在 Hash().Make 内部失败,最终返回 500。
$ go run . artisan make:rule MaxBytes SUCCESS Rule created successfully SUCCESS Rule registered successfully
app/rules/max_bytes.go
package rules import ( "context"
+ "fmt"
+ "strconv" "github.com/goravel/framework/contracts/validation" ) // ... 9 lines not shown // Passes Determine if the validation rule passes. func (receiver *MaxBytes) Passes(ctx context.Context, data validation.Data, val any, options ...any) bool {
- return true
+ value, ok := val.(string)
+ if !ok || len(options) == 0 {
+ return false
+ }
+
+ limit, err := strconv.Atoi(fmt.Sprint(options[0]))
+ if err != nil {
+ return false
+ }
+
+ return len(value) <= limit } // Message Get the validation error message. func (receiver *MaxBytes) Message(ctx context.Context) string {
- return ""
+ return "The :attribute must not be longer than :option0 bytes." }
max_bytes:72 中的 72 会以 options[0] 传入,错误消息中通过 :option0 展示。:value,否则密码原文会出现在响应中。make:rule 同时把规则注册到了 bootstrap/rules.go。接下来写处理器。注册、登录等功能属于一组业务,单独放在认证控制器里更清晰:
$ go run . artisan make:controller AuthController SUCCESS Controller created successfully
把默认的 Index 替换成 Register:
app/http/controllers/auth_controller.go
package controllers import (
+ "taskflow/app/facades"
+ "taskflow/app/http/requests"
+ "taskflow/app/models"
+ "github.com/goravel/framework/contracts/http" ) // ... 9 lines not shown
-func (r *AuthController) Index(ctx http.Context) http.Response {
- return nil
+func (r *AuthController) Register(ctx http.Context) http.Response {
+ var request requests.RegisterRequest
+ validationErrors, err := ctx.Request().ValidateRequest(&request)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not create the account.",
+ })
+ }
+ if validationErrors != nil {
+ return ctx.Response().Json(422, map[string]any{
+ "errors": validationErrors.All(),
+ })
+ }
+
+ password, err := facades.Hash().Make(request.Password)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not create the account.",
+ })
+ }
+
+ user := models.User{
+ Name: request.Name,
+ Email: request.Email,
+ Password: password,
+ }
+ if err := facades.Orm().WithContext(ctx).Query().Create(&user); err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not create the account.",
+ })
+ }
+
+ return ctx.Response().Json(201, user) }
Hash().Make 会把密码转换为 bcrypt 哈希,数据库只保存哈希。WithContext(ctx) 把当前请求上下文传给查询,客户端断开连接后,数据库查询也能及时取消。err.Error() 可能暴露表名、字段名或 SQL(结构化查询语言)语句。前面几个处理器还没处理后两项,等实现任务接口时再一起补上。
routes/api.go
// ... 9 lines not shown func Api() {
+ authController := controllers.NewAuthController() taskController := controllers.NewTaskController() userController := controllers.NewUserController() facades.Route().Prefix("api/v1").Group(func(router route.Router) {
+ router.Post("register", authController.Register)
+ router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) }
requests.http
+### Register
+POST http://localhost:3000/api/v1/register
+Content-Type: application/json
+
+{
+ "name": "Ben",
+ "email": "ben@example.com",
+ "password": "secret123"
+}
+ ### Create task POST http://localhost:3000/api/v1/tasks Content-Type: application/json # ... 29 lines not shown
先给 Ben 注册:
$ curl -X POST http://localhost:3000/api/v1/register \ -H "Content-Type: application/json" \ -d '{"name":"Ben","email":"ben@example.com","password":"secret123"}'
POST /api/v1/register
{ "created_at": "2026-10-24 13:34:22", "updated_at": "2026-10-24 13:34:22", "id": 5, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": null, "assigned_tasks": null
}
接口返回 201 Created,响应里没有密码,因为模型的 json:"-" 标签会隐藏它。“关系:Belongs To”一节还提到过这个标签。
查询日志会先检查邮箱,再插入带有 Hash().Make 哈希值的记录。GORM(Go ORM 对象关系映射工具)根据模型中的 default 标签自动写入 member:
[2026-10-24 13:34:21.965] local.info: [6.707ms] [rows:1] SELECT count(*) FROM "users" WHERE "email" = 'ben@example.com'
[2026-10-24 13:34:22.148] local.info: [2.475ms] [rows:1] INSERT INTO "users" ("created_at","updated_at","name","email","password","role") VALUES ('2026-10-24 13:34:22.145','2026-10-24 13:34:22.145','Ben','ben@example.com','$2a$12$Q.2kAvGjrc25EDfZAv6FEeGnD5EuyLn4BRjkRWcswCwvQgSy.eH7y','member') RETURNING "id"
再次使用大写邮箱注册,会被 unique 拒绝:
$ curl -X POST http://localhost:3000/api/v1/register \ -H "Content-Type: application/json" \ -d '{"name":"Ben","email":"Ben@Example.com","password":"secret123"}'
POST /api/v1/register
{ "errors": { "email": { "unique": "The email has already been taken." } }
}
日志能看清原因:lower 先把邮箱转成小写,随后 unique 才执行查询:
[2026-10-24 13:34:22.162] local.info: [0.863ms] [rows:1] SELECT count(*) FROM "users" WHERE "email" = 'ben@example.com'
请求体里即使带有 "role": "manager" 也会被忽略,Cara 最终仍然得到普通成员账号:
$ curl -X POST http://localhost:3000/api/v1/register \ -H "Content-Type: application/json" \ -d '{"name":"Cara","email":"cara@example.com","password":"secret123","role":"manager"}'
POST /api/v1/register
{ "created_at": "2026-10-24 13:34:22", "updated_at": "2026-10-24 13:34:22", "id": 6, "name": "Cara", "email": "cara@example.com", "role": "member", "created_tasks": null, "assigned_tasks": null
}
Ben 已经有账号,现在需要在每次请求中证明身份。登录成功后,API(应用程序接口)会签发一个 JWT(JSON Web Token),里面记录了当前用户。这个令牌带有签名,任何内容被改动都会被发现。签名前还需要配置用于签名的密钥。
服务端使用 .env 中的 JWT_SECRET 签发令牌,新克隆的项目里这个值是空的。先生成一个:
$ go run . artisan jwt:secret SUCCESS Jwt Secret set successfully
任何拿到密钥的人都可以为任意用户签发令牌,所以它只能放在被 Git 忽略的 .env 中。再次执行命令会输出 exist jwt secret,原有密钥不会被覆盖。
这个命令只会填充已有的 JWT_SECRET= 配置行。如果整行不存在,命令依然会打印 SUCCESS,但不会写入任何内容,这个细节很容易踩坑。
签名逻辑由 Guard(守卫)负责,它决定请求如何证明用户身份。项目已经提供了名为 user 的 Guard,它使用 JWT,并通过 orm Provider(用户提供器)从数据库读取用户:
config/auth.go
// ... 17 lines not shown // ... 11 lines not shown "guards": map[string]any{ "user": map[string]any{ "driver": "jwt", "provider": "user", }, }, // Supported: "orm" "providers": map[string]any{ "user": map[string]any{ "driver": "orm", }, }, })
}
config/jwt.go 中有两个配置项决定令牌有效期。JWT_TTL 默认是 60 分钟,JWT_REFRESH_TTL 默认是两周,后面“刷新令牌”一节会详细说明。
注意 没有配置
JWT_SECRET时,即使登录信息完全正确,所有读取令牌的接口也只会返回空响应体的500 Internal Server Error。真正原因authentication secret is missing or required只记录在storage/logs中。
登录过程包括按邮箱查找用户、校验密码并返回令牌。和注册一样,先创建 Form Request:
$ go run . artisan make:request LoginRequest SUCCESS Request created successfully
app/http/requests/login_request.go
package requests import ( "github.com/goravel/framework/contracts/http"
) type LoginRequest struct { Email string `form:"email" json:"email"` Password string `form:"password" json:"password"`
} func (r *LoginRequest) Authorize(ctx http.Context) error { return nil
} func (r *LoginRequest) Rules(ctx http.Context) map[string]any { return map[string]any{ "email": "required|string|email", "password": "bail|required|string|max_bytes:72", }
} func (r *LoginRequest) Filters(ctx http.Context) map[string]any { return map[string]any{ "email": "trim|lower", }
}
app/http/controllers/auth_controller.go
package controllers import (
+ "sync"
+ "taskflow/app/facades" "taskflow/app/http/requests" "taskflow/app/models" "github.com/goravel/framework/contracts/http" )
+
+// noUserHash is a bcrypt hash of a password nobody has, made with the same cost as the
+// real ones. An unknown email is checked against it, so it takes as long to refuse as a
+// wrong password.
+var noUserHash = sync.OnceValue(func() string {
+ hash, err := facades.Hash().Make("no user has this password")
+ if err != nil {
+ panic(err)
+ }
+
+ return hash
+}) // ... 44 lines not shown
+
+func (r *AuthController) Login(ctx http.Context) http.Response {
+ var request requests.LoginRequest
+ validationErrors, err := ctx.Request().ValidateRequest(&request)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log in.",
+ })
+ }
+ if validationErrors != nil {
+ return ctx.Response().Json(422, map[string]any{
+ "errors": validationErrors.All(),
+ })
+ }
+
+ var user models.User
+ if err := facades.Orm().WithContext(ctx).Query().Where("email", request.Email).First(&user); err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log in.",
+ })
+ }
+
+ hash := user.Password
+ if user.ID == 0 {
+ hash = noUserHash()
+ }
+ if !facades.Hash().Check(request.Password, hash) || user.ID == 0 {
+ return ctx.Response().Json(401, map[string]any{
+ "error": "The email or password is wrong.",
+ })
+ }
+
+ token, err := facades.Auth(ctx).Login(&user)
+ if err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log in.",
+ })
+ }
+
+ return ctx.Response().Json(200, map[string]any{
+ "token": token,
+ })
+}
Login 严格按以下顺序执行:
First 不会返回错误,但 user.ID 仍然是 0。Hash().Check 用提交的密码和数据库里的哈希进行比较。这个检查发生在 user.ID 判断之前,因此无论邮箱是否存在都会执行 bcrypt。user.ID == 0。即使有人恰好提交了 noUserHash 对应的正确密码,也无法绕过邮箱不存在这一事实。Auth().Login 为当前用户签发令牌。密码错误和邮箱不存在都会得到相同的 401,登录接口不会泄露哪些邮箱已经注册。注册接口仍会返回“已被占用”,这是注册表单常见的信息与安全性取舍。
但响应时间仍可能暴露差异。bcrypt 本来就是故意设计得慢,如果未知邮箱跳过 bcrypt,响应会明显更快。noUserHash 让两种情况都执行 bcrypt。它只在首次使用时创建,成本参数与 config/hashing.go 中的 rounds 保持一致。
LoginRequest 同样设置了 max_bytes:72。bcrypt 比较密码时只读取前 72 字节,不加这个限制,用户在 72 字节密码后再追加内容,依然可以登录成功。
bcrypt 能拖慢每次猜测,但目前还没有限制脚本发送多少次。登录路由的限流会在“认证中间件与限流”一节实现。
routes/api.go
// ... 9 lines not shown func Api() { authController := controllers.NewAuthController() taskController := controllers.NewTaskController() userController := controllers.NewUserController() facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register)
+ router.Post("login", authController.Login) router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) }
登录请求会保存返回的 token,后面的请求就能继续携带它。> 后面的脚本会在响应到达时执行,这是 GoLand 的响应处理器语法。
requests.http
# ... 4 lines not shown { "name": "Ben", "email": "ben@example.com", "password": "secret123" }
+
+### Log in
+POST http://localhost:3000/api/v1/login
+Content-Type: application/json
+
+{
+ "email": "ben@example.com",
+ "password": "secret123"
+}
+
+> {%
+ client.global.set("token", response.body.token);
+%} ### Create task POST http://localhost:3000/api/v1/tasks Content-Type: application/json # ... 29 lines not shown
在 VS Code 中使用 REST Client 执行同一个文件时,在登录请求上方添加 # @name login,后续引用令牌的位置改用 {{login.response.body.$.token}}。
让 Ben 登录:
$ curl -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" \ -d '{"email":"ben@example.com","password":"secret123"}'
POST /api/v1/login
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJrZXkiOiI1Iiwic3ViIjoidXNlciIsImV4cCI6MTc5Mjg1MjQ2NSwiaWF0IjoxNzkyODQ4ODY1fQ.A55FXego6oS5Usv9ae95rIN2J4vFqMR5W3oFz6AurEw"
}
使用 curl 时,把完整令牌放进变量,供后续请求使用:
$ TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…
服务端不会保存登录记录,只读取一行数据,也不会写入任何内容:
[2026-10-24 13:34:25.59] local.info: [8.000ms] [rows:1] SELECT * FROM "users" WHERE "email" = 'ben@example.com' ORDER BY "users"."id" LIMIT 1
密码错误和邮箱不存在都会得到:
POST /api/v1/login
{ "error": "The email or password is wrong."
}
再对比两种失败请求的耗时:
由于都会执行 bcrypt,响应时间基本一致。下面是当前机器的测试结果,其他机器会有差异,但两者应该接近。服务重启后第一次遇到未知邮箱时会更慢,因为那时才创建 noUserHash:
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \ -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" -d '{"email":"ben@example.com","password":"wrong-one"}'
401 0.166280s $ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \ -X POST http://localhost:3000/api/v1/login \ -H "Content-Type: application/json" -d '{"email":"nobody@example.com","password":"wrong-one"}'
401 0.166348s
Ben 现在拿到了令牌,可以拆开看看。一个令牌由三部分组成,通过点号连接:Header(头部)、Payload(载荷)和 Signature(签名)。前两部分是经过 base64url 编码的 JSON,任何人都能读取。
DevChalk 提供了交互式工具,可以粘贴令牌并填写 JWT_SECRET 检查签名。只能拿本地密钥测试,绝不要使用生产环境密钥。
示例令牌属于 Ben,使用密钥 taskflow-demo-secret 签名,目前已经过期。你自己的令牌会在登录后一小时内有效,载荷包含以下声明:
key 是用户 ID。sub 是 Guard 名称,这里是 user。Goravel 把 Guard 名称放在这里,而多数系统会在这个字段中放用户 ID。iat 和 exp 分别表示令牌签发和过期时间,使用 Unix 时间戳。客户端可以直接读取 exp 判断何时刷新,不必请求服务端。载荷中没有任何秘密,真正保护它的是签名。签名由 Header、Payload 和 JWT_SECRET 共同计算。把载荷中的用户 ID 改为 1、姓名改为 Ana、角色改为 manager 后,签名不再匹配,API 会拒绝该令牌。
令牌只有被 API 读回来才算有用。先实现一个最简单的接口,回答“我是谁”:
app/http/controllers/auth_controller.go
package controllers import (
+ "errors" "sync" "taskflow/app/facades" "taskflow/app/http/requests" "taskflow/app/models" "github.com/goravel/framework/contracts/http"
+ frameworkerrors "github.com/goravel/framework/errors" ) // ... 99 lines not shown
+
+func (r *AuthController) Me(ctx http.Context) http.Response {
+ if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+ return ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ var user models.User
+ if err := facades.Auth(ctx).User(&user); err != nil {
+ if errors.Is(err, frameworkerrors.OrmRecordNotFound) {
+ return ctx.Response().Json(401, map[string]any{
+ "error": "The user no longer exists.",
+ })
+ }
+
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not load the user.",
+ })
+ }
+
+ return ctx.Response().Json(200, user)
+}
Parse 接收 Authorization 请求头,并自动去掉 Bearer。它返回的错误是 authentication token has expired 这类固定消息,可以安全地返回客户端。User 会根据 key 中的用户 ID 加载用户,并使用当前请求上下文。Me 会明确返回用户不存在;其他错误则留在服务端处理。每个需要登录用户的路由都要重复这几行,等“认证中间件与限流”一节再把它们统一移到路由前面。
routes/api.go
// ... 9 lines not shown func Api() { authController := controllers.NewAuthController() taskController := controllers.NewTaskController() userController := controllers.NewUserController() facades.Route().Prefix("api/v1").Group(func(router route.Router) { router.Post("register", authController.Register) router.Post("login", authController.Login)
+ router.Get("me", authController.Me) router.Post("tasks", taskController.Store).Name("tasks.create") router.Get("tasks/{id}", taskController.Show) router.Get("tasks", taskController.Index) router.Get("users", userController.Index) router.Get("users/{id}", userController.Show) }) }
{{token}} 就是登录请求保存下来的令牌:
requests.http
# ... 19 lines not shown > {% client.global.set("token", response.body.token); %}
+
+### Me
+GET http://localhost:3000/api/v1/me
+Authorization: Bearer {{token}} ### Create task POST http://localhost:3000/api/v1/tasks Content-Type: application/json # ... 29 lines not shown
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
GET /api/v1/me
{ "created_at": "2026-10-24 13:34:22", "updated_at": "2026-10-24 13:34:22", "id": 5, "name": "Ben", "email": "ben@example.com", "role": "member", "created_tasks": null, "assigned_tasks": null
}
API 验证签名后,根据令牌中的用户 ID 查询用户:
[2026-10-24 13:35:11.682] local.info: [2.067ms] [rows:1] SELECT * FROM "users" WHERE "users"."id" = '5'
没有请求头和篡改后的令牌都会得到相同的 401:
$ curl http://localhost:3000/api/v1/me
{"error":"authentication token is invalid"} $ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer ${TOKEN}x"
{"error":"authentication token is invalid"}
要观察过期令牌,可以把令牌有效期改成一分钟。命令行设置的环境变量会覆盖 .env。停止 air,使用 JWT_TTL=1 重新启动,登录并设置 TOKEN,等待一分钟:
$ JWT_TTL=1 air
$ curl http://localhost:3000/api/v1/me -H "Authorization: Bearer $TOKEN"
{"error":"authentication token has expired"}
然后停止服务,重新执行普通的 air,再次登录并把 TOKEN 设置为新令牌。
退出登录看起来简单,但服务端无法收回已经签发的令牌,它会一直有效到过期。为此,服务端需要记录令牌状态。Goravel 会把已退出的令牌标记到缓存中,这里使用 Redis;Parse 在处理其他逻辑前会先检查这个标记。
DevChalk 的交互式图展示了完整会话中的每条请求流向。
注册时,唯一性规则统计相同邮箱的用户数量,API 使用 bcrypt 哈希密码并插入一行记录。登录时,API 按邮箱查询用户、校验密码并使用 JWT_SECRET 签发令牌,PostgreSQL 和 Redis 都没有写入数据。
请求 /me 时,API 先询问 Redis 是否已经退出该令牌,再检查签名,最后按令牌中的用户 ID 加载用户。退出时,API 把令牌作为禁用状态写入 Redis,保留 60 分钟。再次请求 /me 时,Redis 已经存在该令牌,API 会在验证签名前直接返回 401。
Logout 必须先知道要禁用哪个令牌,因此先解析请求头:
app/http/controllers/auth_controller.go
// ... 136 lines not shown
+
+func (r *AuthController) Logout(ctx http.Context) http.Response {
+ if _, err := facades.Auth(ctx).Parse(ctx.Request().Header("Authorization")); err != nil {
+ return ctx.Response().Json(401, map[string]any{
+ "error": err.Error(),
+ })
+ }
+
+ if err := facades.Auth(ctx).Logout(); err != nil {
+ return ctx.Response().Json(500, map[string]any{
+ "error": "Could not log out.",
+ })
+ }
+
+ return ctx.Response().NoContent()