L22 · JWT 认证:注册、登录与鉴权
🎯 本节目标:实现 JWT 认证全流程——注册/登录/路由守卫/Token 自动刷新
📦 本节产出:AuthStore + 登录注册页面 + 路由鉴权 + Token 刷新机制
🔗 前置钩子:L21 的 Axios 拦截器(Token 注入 + 401 处理)
🔗 后续钩子:L23 的商品列表页(部分操作需要登录)1. JWT 认证流程
2. 后端:认证 API
在 server/ 安装固定主版本。bcryptjs 3 自带类型,不再安装 @types/bcryptjs:
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,避免误以为它能覆盖代码。
// 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 官方说明
// 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 官方用法
// server/src/types/express.d.ts:被 L19 tsconfig 的 src/**/* 包含
export {}
declare global {
namespace Express {
interface Request { userId?: string }
}
}// 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()
}// 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()
}// 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)。然后替换商品路由文件;查询公开,写入必须同时通过认证与管理员检查:
// 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。本课只实现单标签页流程,跨标签退出同步、服务端撤销与轮换未实现。
// 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 不存在的页面。
// 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。
<!-- 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><!-- 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><!-- 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,避免循环依赖。
// 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 不再尝试刷新;网络故障也不会被误判为凭证失效。重试上限为一次。
// 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')<!-- 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 提交
git add .
git commit -m "L22: JWT 认证 + Auth Store + 路由守卫 + Token 刷新"🔗 → 下一节
L23 将实现商品列表页——分页、防抖搜索、URL 查询参数同步。