Hono 前后端分离系列 06:JWT 鉴权和权限中间件
wxk1991 Lv5

Hono 前后端分离系列 06:JWT 鉴权和权限中间件

前后端分离项目里,登录不是一个按钮。

它是一整套请求身份识别机制。

很多项目一开始只写:

1
POST /api/login

返回一个 token,前端存起来。

看起来完成了。

但真正的问题才刚开始:

1
2
3
4
5
6
token 放哪里?
怎么过期?
怎么刷新?
后端怎么识别用户?
管理员接口怎么限制?
前端隐藏按钮算不算权限?

这篇文章用 Hono 讲一个清楚的 JWT 鉴权和权限中间件结构。


一、前端隐藏按钮不是权限

先说最重要的一点。

前端可以隐藏按钮,但不能只靠隐藏按钮做权限。

用户可以直接发请求:

1
curl -X DELETE https://api.example.com/api/users/u_1

所以后端必须校验权限。

前端负责体验:

1
2
3
普通用户不显示删除按钮
管理员显示管理入口
未登录跳转登录页

后端负责安全:

1
2
3
4
没有 token,返回 401
token 无效,返回 401
角色不够,返回 403
资源不属于当前用户,返回 403 或 404

这条边界不能模糊。


二、JWT 的基本流程

典型流程:

1
2
3
4
5
6
7
8
用户提交账号密码
后端验证成功
后端签发 access token
前端保存 token
后续请求携带 Authorization header
后端验证 token
后端把用户信息放入上下文
业务 handler 使用当前用户

请求头通常是:

1
Authorization: Bearer <token>

前端请求:

1
2
3
4
5
await fetch('/api/me', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
})

三、Hono JWT middleware

Hono 内置 JWT middleware。

基础写法:

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
import { Hono } from 'hono'
import { jwt } from 'hono/jwt'
import type { JwtVariables } from 'hono/jwt'

type Variables = JwtVariables

const app = new Hono<{
Variables: Variables
Bindings: {
JWT_SECRET: string
}
}>()

app.use('/api/private/*', async (c, next) => {
const middleware = jwt({
secret: c.env.JWT_SECRET,
})

return middleware(c, next)
})

app.get('/api/private/me', (c) => {
const payload = c.get('jwtPayload')

return c.json({
data: {
id: payload.sub,
role: payload.role,
},
})
})

Cloudflare Workers 环境里,secret 应该来自 c.env.JWT_SECRET,不要写死在代码里。

Node.js 环境则可以从 process.env.JWT_SECRET 读取。


四、登录接口不要和鉴权中间件混在一起

登录接口本身不需要 JWT middleware。

它负责验证账号密码,然后签发 token:

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
import { sign } from 'hono/jwt'

app.post('/api/auth/login', async (c) => {
const { email, password } = await c.req.json()

const user = await authService.verifyPassword(email, password)

if (!user) {
return c.json({
error: {
code: 'INVALID_CREDENTIALS',
message: '邮箱或密码错误',
},
}, 401)
}

const token = await sign(
{
sub: user.id,
role: user.role,
exp: Math.floor(Date.now() / 1000) + 60 * 60,
},
c.env.JWT_SECRET
)

return c.json({
data: {
accessToken: token,
},
})
})

注意 exp

token 不应该永久有效。


五、把当前用户放进上下文

JWT payload 只是声明,不一定等于完整用户。

实际项目里,我们通常需要查一次数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
app.use('/api/private/*', async (c, next) => {
const payload = c.get('jwtPayload')
const user = await userService.findById(String(payload.sub))

if (!user) {
return c.json({
error: {
code: 'USER_NOT_FOUND',
message: '用户不存在',
},
}, 401)
}

c.set('user', user)
await next()
})

为了类型安全,可以定义 Variables:

1
2
3
4
5
6
7
8
9
10
11
12
type User = {
id: string
role: 'user' | 'admin'
}

type Variables = JwtVariables & {
user: User
}

const app = new Hono<{
Variables: Variables
}>()

后面 route 里就可以:

1
const user = c.get('user')

这比每个接口自己解析 token 干净很多。


六、权限中间件

鉴权解决的是“你是谁”。

授权解决的是“你能做什么”。

可以写一个简单的角色中间件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function requireRole(role: 'admin' | 'user') {
return async (c, next) => {
const user = c.get('user')

if (user.role !== role) {
return c.json({
error: {
code: 'FORBIDDEN',
message: '没有权限访问该资源',
},
}, 403)
}

await next()
}
}

使用:

1
2
3
4
5
6
7
8
9
app.delete(
'/api/private/admin/users/:id',
requireRole('admin'),
async (c) => {
const id = c.req.param('id')
await userService.remove(id)
return c.body(null, 204)
}
)

实际项目里,权限可能不是简单 role。

但先把中间件边界建立起来很重要。


七、token 放哪里

前端常见两种选择。

第一种:放在内存或状态管理里,然后用 Authorization header 发送。

优点是简单,适合 API-first 项目。

缺点是刷新页面会丢,需要 refresh token 或重新登录。

第二种:放在 HttpOnly cookie。

优点是 JavaScript 读不到,降低 XSS 直接偷 token 的风险。

缺点是跨域、SameSite、CSRF、credentials、HTTPS 都要一起处理。

没有绝对正确答案。

如果是管理后台和普通 SPA,用 Bearer Token 更直观。

如果是强安全要求、同站点部署、需要更严格浏览器保护,可以考虑 HttpOnly cookie,但要认真处理 CSRF。


八、最后的建议

Hono 的 JWT middleware 只是起点。

完整鉴权至少要考虑:

1
2
3
4
5
6
7
8
9
登录接口
密码校验
token 签发
token 过期
鉴权中间件
当前用户上下文
权限中间件
前端请求封装
401/403 统一处理

前后端分离项目里,不要把权限逻辑只写在前端。

前端负责让用户少点错按钮。

后端负责确保错按钮真的点不动。


参考资料