Skip to content

L22 · JWT 认证:注册、登录与鉴权 ​

🎯 本节目标:实现 JWT 认证全流程——注册/登录/路由守卫/Token 自动刷新
📦 本节产出:AuthStore + 登录注册页面 + 路由鉴权 + Token 刷新机制
🔗 前置钩子:L21 的 Axios 拦截器(Token 注入 + 401 处理)
🔗 后续钩子:L23 的商品列表页(部分操作需要登录)

1. JWT 认证流程 ​


2. 后端:认证 API ​

在 server/ 安装固定主版本。bcryptjs 3 自带类型,不再安装 @types/bcryptjs:

bash
npm install bcryptjs@3 jsonwebtoken@9
npm install -D @types/jsonwebtoken@9

在 server/.env 中设置不同的 JWT_SECRET 和 JWT_REFRESH_SECRET,各用随机生成的值;可分别运行 node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"。不要提交 .env。下面统一在配置模块读取密钥,缺失时启动失败,不使用固定字符串兜底。L19 的 index.ts 已先加载 dotenv/config。本课明确采用 15 分钟/7 天,删除旧 .env 中未使用的 JWT_EXPIRES_IN,避免误以为它能覆盖代码。

typescript
// server/src/config/jwt.ts
import jwt from 'jsonwebtoken'
import { AppError } from '../utils/AppError'

function secret(name: string): string {
  const value = process.env[name]
  if (!value || value.length < 32) throw new Error(`${name} 至少需要 32 个字符`)
  return value
}
const accessSecret = secret('JWT_SECRET')
const refreshSecret = secret('JWT_REFRESH_SECRET')
if (accessSecret === refreshSecret) throw new Error('两个 JWT 密钥必须不同')

export function generateTokens(userId: string) {
  return {
    accessToken: jwt.sign({ userId, kind: 'access' }, accessSecret, { algorithm: 'HS256', expiresIn: '15m' }),
    refreshToken: jwt.sign({ userId, kind: 'refresh' }, refreshSecret, { algorithm: 'HS256', expiresIn: '7d' }),
  }
}

export function verifyToken(token: string, kind: 'access' | 'refresh'): string {
  try {
    const payload = jwt.verify(token, kind === 'access' ? accessSecret : refreshSecret, {
      algorithms: ['HS256'],
    })
    if (typeof payload === 'string' || payload.kind !== kind ||
        typeof payload.userId !== 'string' || !/^[a-f\d]{24}$/i.test(payload.userId) ||
        typeof payload.exp !== 'number') throw new Error('无效的认证负载')
    return payload.userId
  } catch {
    throw new AppError('Token 无效或已过期', 401)
  }
}

verify 验签并检查有效期,类型断言 as any 不能验证负载。JWT 默认是签名数据,并不加密内容,不能把密码放进去。jsonwebtoken 官方说明

typescript
// server/src/controllers/authController.ts
import type { Request, Response } from 'express'
import bcrypt from 'bcryptjs'
import User, { type IUser } from '../models/User'
import { AppError } from '../utils/AppError'
import { success } from '../utils/response'
import { generateTokens, verifyToken } from '../config/jwt'

function bodyOf(req: Request): Record<string, unknown> {
  if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) {
    throw new AppError('请求体必须是对象')
  }
  return req.body
}
function credentials(body: Record<string, unknown>) {
  const { email, password } = body
  if (typeof email !== 'string' || email.length > 254 || !/^\S+@\S+\.\S+$/.test(email.trim())) {
    throw new AppError('邮箱格式不正确')
  }
  // bcrypt 按 UTF-8 字节截断;拒绝超长输入,而不是悄悄丢掉后半段。
  if (typeof password !== 'string' || password.length < 8 || bcrypt.truncates(password)) {
    throw new AppError('密码至少 8 个字符,且不能超过 72 个 UTF-8 字节')
  }
  return { email: email.trim().toLowerCase(), password }
}
function publicUser(user: IUser) {
  return { id: user._id.toString(), name: user.name, email: user.email, role: user.role, avatar: user.avatar }
}

export async function register(req: Request, res: Response) {
  const body = bodyOf(req)
  const { email, password } = credentials(body)
  if (typeof body.name !== 'string' || !body.name.trim() || body.name.trim().length > 50) {
    throw new AppError('用户名需为 1–50 个字符')
  }
  if (await User.exists({ email })) throw new AppError('该邮箱已注册', 409)
  const user = await User.create({
    name: body.name.trim(), email, password: await bcrypt.hash(password, 10), role: 'user',
  })
  // 并发重复注册仍由唯一索引与 L20 的错误中间件返回 409。
  success(res, { ...generateTokens(user._id.toString()), user: publicUser(user) }, 201)
}

export async function login(req: Request, res: Response) {
  const { email, password } = credentials(bodyOf(req))
  const user = await User.findOne({ email }).select('+password')
  if (!user || !(await bcrypt.compare(password, user.password))) {
    throw new AppError('邮箱或密码错误', 401)
  }
  success(res, { ...generateTokens(user._id.toString()), user: publicUser(user) })
}

export async function refreshToken(req: Request, res: Response) {
  const { refreshToken } = bodyOf(req)
  if (typeof refreshToken !== 'string' || !refreshToken) throw new AppError('缺少 refreshToken')
  const userId = verifyToken(refreshToken, 'refresh')
  if (!(await User.exists({ _id: userId }))) throw new AppError('用户不存在', 401)
  success(res, generateTokens(userId))
}

export async function getProfile(req: Request, res: Response) {
  const user = await User.findById(req.userId)
  if (!user) throw new AppError('用户不存在', 401)
  success(res, publicUser(user))
}

bcrypt 是密码哈希,无法“解密还原”。这里使用异步 API,并按 bcryptjs 的 72 字节限制验证输入。bcryptjs 官方用法

typescript
// server/src/types/express.d.ts:被 L19 tsconfig 的 src/**/* 包含
export {}
declare global {
  namespace Express {
    interface Request { userId?: string }
  }
}
typescript
// server/src/middlewares/auth.ts
import type { Request, Response, NextFunction } from 'express'
import { verifyToken } from '../config/jwt'
import { AppError } from '../utils/AppError'

export function authMiddleware(req: Request, _res: Response, next: NextFunction) {
  const match = /^Bearer (\S+)$/i.exec(req.headers.authorization || '')
  if (!match) throw new AppError('未提供认证 Token', 401)
  req.userId = verifyToken(match[1], 'access')
  next()
}
typescript
// server/src/middlewares/role.ts
import type { Request, Response, NextFunction } from 'express'
import User from '../models/User'
import { AppError } from '../utils/AppError'

export async function adminOnly(req: Request, _res: Response, next: NextFunction) {
  if (!req.userId) throw new AppError('请先登录', 401)
  const user = await User.findById(req.userId)
  if (!user) throw new AppError('用户不存在', 401)
  if (user.role !== 'admin') throw new AppError('需要管理员权限', 403)
  next()
}
typescript
// server/src/routes/authRoutes.ts
import { Router } from 'express'
import { register, login, refreshToken, getProfile } from '../controllers/authController'
import { authMiddleware } from '../middlewares/auth'
const router = Router()
router.post('/register', register)
router.post('/login', login)
router.post('/refresh', refreshToken)
router.get('/profile', authMiddleware, getProfile)
export default router

在 L20 的 app.ts 顶部增加 import authRoutes from './routes/authRoutes',并在 404 中间件之前增加 app.use('/api/auth', authRoutes)。然后替换商品路由文件;查询公开,写入必须同时通过认证与管理员检查:

typescript
// server/src/routes/productRoutes.ts
import { Router } from 'express'
import { getProducts, getProductById, createProduct, updateProduct, deleteProduct } from '../controllers/productController'
import { validate, productRules } from '../middlewares/validate'
import { authMiddleware } from '../middlewares/auth'
import { adminOnly } from '../middlewares/role'
const router = Router()
router.get('/', getProducts)
router.get('/:id', getProductById)
router.post('/', authMiddleware, adminOnly, validate(productRules), createProduct)
router.put('/:id', authMiddleware, adminOnly, validate(productRules), updateProduct)
router.patch('/:id', authMiddleware, adminOnly, validate(productRules, { partial: true }), updateProduct)
router.delete('/:id', authMiddleware, adminOnly, deleteProduct)
export default router

首次创建数据库时,connectDB() 返回并不意味着唯一索引已经建好。在 L19 的 server/src/index.ts 顶部增加 import User from './models/User',并在 await connectDB() 后、app.listen() 前加入 await User.init(),等待本机练习的用户索引就绪。已有重复邮箱时索引创建会失败,应先清理测试数据;不要忽略错误继续监听。生产项目通常用单独的索引迁移流程。

注册永远创建普通用户,不能接受请求体中的 role。本机验证管理操作时,先注册自己的测试账号,再在数据库管理工具中把该用户的 role 改为 admin;重新读取 profile 后前端才显示新角色。L21 的删除练习按钮对普通用户会返回 403,这正是本节新增的保护。

本例 refresh 会重新签发两个 token,但旧 refresh token 不会因此失效;没有服务端会话记录就谈不上轮换、撤销或强制下线。这些是课后扩展,不把本例描述成完整的生产认证系统。


3. 前端:Auth Store ​

WARNING

本例将两个 token 存在 localStorage,页面脚本可以读取它们。HttpOnly Cookie 可以阻止脚本直接读取 Cookie,但不能阻止 XSS 以用户身份发请求;采用 Cookie 时还要设计 SameSite、Secure 和 CSRF 防护。存储位置的取舍见 D14。本课只实现单标签页流程,跨标签退出同步、服务端撤销与轮换未实现。

typescript
// client/src/stores/authStore.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { authApi } from '@/api/auth'
import { ApiError } from '@/utils/request'
import type { User, AuthData, AuthTokens } from '@/types/auth'

export const useAuthStore = defineStore('auth', () => {
  const user = ref<User | null>(null)
  const accessToken = ref<string | null>(localStorage.getItem('access-token'))
  const refreshToken = ref<string | null>(localStorage.getItem('refresh-token'))
  const sessionError = ref('')
  // 这里只表示本地有凭证,实际有效性与权限仍由服务端判断。
  const isLoggedIn = computed(() => !!accessToken.value)
  const isAdmin = computed(() => user.value?.role === 'admin')
  let version = 0
  let refreshing: Promise<void> | null = null
  let restoring: Promise<void> | null = null

  function saveTokens(tokens: AuthTokens) {
    localStorage.setItem('access-token', tokens.accessToken)
    localStorage.setItem('refresh-token', tokens.refreshToken)
    accessToken.value = tokens.accessToken
    refreshToken.value = tokens.refreshToken
  }
  function setAuth(data: AuthData) {
    version++
    saveTokens(data)
    user.value = data.user
    sessionError.value = ''
  }
  async function login(email: string, password: string) {
    const current = version
    const res = await authApi.login(email, password)
    if (current !== version) throw new Error('登录操作已失效,请重试')
    setAuth(res.data)
  }
  async function register(name: string, email: string, password: string) {
    const current = version
    const res = await authApi.register(name, email, password)
    if (current !== version) throw new Error('注册操作已失效,请重新登录')
    setAuth(res.data)
  }
  function logout() {
    version++
    user.value = null
    accessToken.value = null
    refreshToken.value = null
    sessionError.value = ''
    refreshing = null
    restoring = null
    localStorage.removeItem('access-token')
    localStorage.removeItem('refresh-token')
  }
  function refresh(): Promise<void> {
    if (refreshing) return refreshing
    const token = refreshToken.value
    if (!token) return Promise.reject(new ApiError('请重新登录', 401))
    const current = version
    const task = (async () => {
      const res = await authApi.refresh(token)
      if (current !== version) throw new Error('会话已改变')
      saveTokens(res.data)
    })()
    refreshing = task
    void task.finally(() => { if (refreshing === task) refreshing = null }).catch(() => {})
    return task
  }
  function fetchProfile(): Promise<void> {
    if (restoring) return restoring
    if (!accessToken.value) return Promise.resolve()
    const current = version
    const task = (async () => {
      const res = await authApi.getProfile()
      if (current === version) { user.value = res.data; sessionError.value = '' }
    })()
    restoring = task
    void task.finally(() => { if (restoring === task) restoring = null }).catch(() => {})
    return task
  }
  return {
    user, accessToken, refreshToken, isLoggedIn, isAdmin, sessionError,
    login, register, logout, refresh, fetchProfile, sessionVersion: () => version,
  }
})

刷新只更新 token,不增加会话版本;登录和退出增加版本。这样退出或切换账号后,旧请求的迟到响应不能把旧凭证重新写回来。持久化依赖浏览器允许 localStorage;不要给本 store 再叠加 persist: true 保存另一份凭证。


4. 路由守卫鉴权 ​

替换 L21 的路由文件。本节先加入已实现的登录、注册和个人资料页;L25 再加入订单路由,避免提前 import 不存在的页面。

typescript
// client/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', redirect: '/products' },
    { path: '/products', name: 'products', component: () => import('@/views/ProductListView.vue') },
    { path: '/login', name: 'login', component: () => import('@/views/LoginView.vue'), meta: { guestOnly: true } },
    { path: '/register', name: 'register', component: () => import('@/views/RegisterView.vue'), meta: { guestOnly: true } },
    { path: '/account', name: 'account', component: () => import('@/views/AccountView.vue'), meta: { requiresAuth: true } },
    { path: '/:pathMatch(.*)*', redirect: '/products' },
  ],
})
router.beforeEach(async to => {
  const auth = useAuthStore()
  if (auth.isLoggedIn && !auth.user) {
    try { await auth.fetchProfile() }
    catch (error) {
      auth.sessionError = error instanceof Error ? error.message : '无法读取登录信息'
      // 401 会由第 5 节清理会话;网络失败保留凭证,公开页面仍可访问。
      if (auth.isLoggedIn && (to.meta.requiresAuth || to.meta.requiresAdmin)) return false
    }
  }
  if ((to.meta.requiresAuth || to.meta.requiresAdmin) && !auth.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }
  if (to.meta.requiresAdmin && !auth.isAdmin) return { name: 'products' }
  if (to.meta.guestOnly && auth.isLoggedIn) return { name: 'products' }
})
export default router

守卫负责页面导航体验,不能替代后端 authMiddleware / adminOnly。失败提示显示在 App 中;网络恢复后重新点击受保护链接会再次请求 profile。

vue
<!-- client/src/views/LoginView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const email = ref('')
const password = ref('')
const pending = ref(false)
const error = ref('')
async function submit() {
  if (pending.value) return
  pending.value = true
  error.value = ''
  try {
    await auth.login(email.value, password.value)
    const target = route.query.redirect
    // 只接受本站绝对路径,拒绝 //host 形式;不存在的路径由路由兜底。
    await router.replace(typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')
      ? target : '/products')
  } catch (cause) { error.value = cause instanceof Error ? cause.message : '登录失败' }
  finally { pending.value = false }
}
</script>
<template>
  <main><h1>登录</h1>
    <form @submit.prevent="submit">
      <label>邮箱 <input v-model="email" type="email" required autocomplete="email" /></label>
      <label>密码 <input v-model="password" type="password" required autocomplete="current-password" /></label>
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="pending">{{ pending ? '登录中…' : '登录' }}</button>
    </form>
    <RouterLink :to="{ name: 'register', query: route.query }">注册账号</RouterLink>
  </main>
</template>
vue
<!-- client/src/views/RegisterView.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const name = ref('')
const email = ref('')
const password = ref('')
const pending = ref(false)
const error = ref('')
async function submit() {
  if (pending.value) return
  pending.value = true
  error.value = ''
  try {
    await auth.register(name.value, email.value, password.value)
    const target = route.query.redirect
    await router.replace(typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')
      ? target : '/products')
  }
  catch (cause) { error.value = cause instanceof Error ? cause.message : '注册失败' }
  finally { pending.value = false }
}
</script>
<template>
  <main><h1>注册</h1>
    <form @submit.prevent="submit">
      <label>用户名 <input v-model="name" required maxlength="50" autocomplete="name" /></label>
      <label>邮箱 <input v-model="email" type="email" required autocomplete="email" /></label>
      <label>密码 <input v-model="password" type="password" required minlength="8" autocomplete="new-password" /></label>
      <p>至少 8 个字符,且不能超过 72 个 UTF-8 字节。</p>
      <p v-if="error" role="alert">{{ error }}</p>
      <button :disabled="pending">{{ pending ? '注册中…' : '注册' }}</button>
    </form>
    <RouterLink :to="{ name: 'login', query: route.query }">已有账号,去登录</RouterLink>
  </main>
</template>
vue
<!-- client/src/views/AccountView.vue -->
<script setup lang="ts">
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
</script>
<template>
  <main v-if="auth.user"><h1>个人资料</h1><p>{{ auth.user.name }} · {{ auth.user.email }}</p></main>
</template>

5. Token 自动刷新(拦截器增强) ​

先在 L21 request.ts 的 Axios 导入中增加 type InternalAxiosRequestConfig,保留 http、body 包装层与 toApiError。用下面代码替换旧请求/响应拦截器,不要重复注册。通过回调注入 store 能力,request 模块不导入 store 或 router,避免循环依赖。

typescript
// client/src/utils/request.ts:替换拦截器部分
interface AuthAdapter {
  accessToken: () => string | null
  sessionVersion: () => number
  refresh: () => Promise<void>
  onUnauthorized: () => void
}
type AuthRequest = InternalAxiosRequestConfig & { _retry?: boolean; _sessionVersion?: number }
let auth: AuthAdapter | undefined
export function configureAuth(adapter: AuthAdapter) { auth = adapter }
const isAuthEndpoint = (url = '') => /^\/auth\/(login|register|refresh)$/.test(url)

http.interceptors.request.use(config => {
  const current = config as AuthRequest
  current._sessionVersion ??= auth?.sessionVersion()
  const token = auth?.accessToken()
  if (token && !isAuthEndpoint(config.url)) config.headers.set('Authorization', `Bearer ${token}`)
  else config.headers.delete('Authorization')
  return current
})

http.interceptors.response.use(response => response, async cause => {
  const config = (axios.isAxiosError(cause) ? cause.config : undefined) as AuthRequest | undefined
  if (!axios.isAxiosError(cause) || cause.response?.status !== 401 || !config ||
      isAuthEndpoint(config.url) || !auth) throw toApiError(cause)
  const adapter = auth
  const version = config._sessionVersion
  if (version !== adapter.sessionVersion()) throw new ApiError('会话已改变', 401)
  if (config._retry) { adapter.onUnauthorized(); throw toApiError(cause) }
  config._retry = true
  try {
    const sentToken = config.headers.get('Authorization')
    // 迟到的旧 401 可以直接用已经刷新的 token 重试。
    if (!adapter.accessToken() || sentToken === `Bearer ${adapter.accessToken()}`) await adapter.refresh()
    if (version !== adapter.sessionVersion()) throw new Error('会话已改变')
    return await http(config)
  } catch (error) {
    const normalized = toApiError(error)
    if (version === adapter.sessionVersion() && normalized instanceof ApiError && normalized.status === 401) {
      adapter.onUnauthorized()
    }
    throw normalized
  }
})

并发 401 共用 Auth Store 的 refreshing Promise。它成功时所有调用继续,失败时所有调用都拒绝,不会留下无人处理的等待队列。登录/注册/刷新自身的 401 不再尝试刷新;网络故障也不会被误判为凭证失效。重试上限为一次。

typescript
// client/src/main.ts:替换 L21 入口
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import { useAuthStore } from '@/stores/authStore'
import { configureAuth } from '@/utils/request'
import './assets/main.css'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
const app = createApp(App).use(pinia)
const auth = useAuthStore(pinia)
configureAuth({
  accessToken: () => auth.accessToken,
  sessionVersion: auth.sessionVersion,
  refresh: auth.refresh,
  onUnauthorized: () => {
    auth.logout()
    const route = router.currentRoute.value
    if (route.meta.requiresAuth || route.meta.requiresAdmin) {
      void router.replace({ name: 'login', query: { redirect: route.fullPath } })
    }
  },
})
app.use(router).mount('#app')
vue
<!-- client/src/App.vue -->
<script setup lang="ts">
import { RouterLink, RouterView, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore'
const auth = useAuthStore()
const router = useRouter()
function logout() { auth.logout(); void router.replace('/products') }
</script>
<template>
  <header>
    <RouterLink to="/products">商品</RouterLink>
    <template v-if="auth.isLoggedIn">
      <RouterLink to="/account">个人资料</RouterLink><button @click="logout">退出</button>
    </template>
    <RouterLink v-else to="/login">登录</RouterLink>
  </header>
  <p v-if="auth.sessionError" role="alert">{{ auth.sessionError }}</p>
  <RouterView />
</template>

6. 本节总结 ​

检查清单 ​

  • [ ] 能用 bcrypt 哈希密码(不能明文存储)
  • [ ] 能用 jwt.sign / jwt.verify 签发和验证 Token
  • [ ] 能区分 accessToken(短期)和 refreshToken(长期)
  • [ ] 能实现 authStore(login / register / logout / refresh)
  • [ ] 能在路由守卫中检查 meta.requiresAuth
  • [ ] 能实现 Token 自动刷新(拦截器内 + 共享 Promise)
  • [ ] 能处理登录后跳回原页面(redirect query)

本节边界 ​

内容本节状态说明
bcrypt 密码哈希已实现验证输入并异步哈希;未加入密码重置与泄露口令检查
JWT 签发/验证已实现限算法、检查负载与密钥;未加入服务端撤销
双 Token 策略教学简化localStorage 可被页面脚本读取;旧 refresh token 仍有效
Token 自动刷新已实现单标签页流程并发共享 Promise,失败都会结束;未处理跨标签竞争
路由守卫已实现导航限制服务端另外检查身份和权限,不能信任客户端角色

课后练习 ​

练习 1:跟做(15 min) 按照本节代码完整实现注册→登录→路由守卫→Token 刷新全流程。

练习 2:举一反三(20 min) 实现“记住我”:由服务端验证布尔选项并选择有效期,前端不能自行修改 JWT 的 exp。比较延长有效期与会话撤销机制的关系。

挑战题(30 min) 实现 Refresh Token 轮换(Rotation):每次 refresh 时旧 Token 失效,服务端记录 token 标识并原子地消费旧值;仅签发一个新 JWT 不构成轮换。

真实项目补充 ​

实践说明
Refresh Token httpOnly Cookie限制脚本直接读取 Cookie;仍需防范 XSS 代发请求(见 D14)
Token 轮换(Rotation)每次 refresh 颁发全新 Refresh Token,旧 Token 立即失效
速率限制(Rate Limiting)登录接口限制频率,防止暴力破解
CSRF 防护根据 Cookie/跨站使用方式,设计 SameSite、Origin 检查与 CSRF Token 等措施
密码强度校验zxcvbn 库评估密码强度

Git 提交 ​

bash
git add .
git commit -m "L22: JWT 认证 + Auth Store + 路由守卫 + Token 刷新"

🔗 → 下一节 ​

L23 将实现商品列表页——分页、防抖搜索、URL 查询参数同步。