Hono 前后端分离系列 06:JWT 鉴权和权限中间件
前后端分离项目里,登录不是一个按钮。
它是一整套请求身份识别机制。
很多项目一开始只写:
返回一个 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 统一处理
|
前后端分离项目里,不要把权限逻辑只写在前端。
前端负责让用户少点错按钮。
后端负责确保错按钮真的点不动。
参考资料