1. 项目概述:构建现代Web应用的身份验证基石
在任何一个需要用户体系的Web应用中,身份验证都是绕不开的核心环节。无论是电商、社交还是企业内部系统,用户登录后,如何安全、高效地维持其登录状态,并让前端应用(尤其是前后端分离的单页应用)能够顺畅地与后端API交互,是每个开发者必须解决的问题。传统的Session-Cookie机制在分布式、跨域场景下显得力不从心,而基于Token的无状态认证方案则成为了主流选择。
本项目聚焦于使用Node.js和Express框架,从零构建一个健壮、安全且支持跨域的用户登录与Token(JWT)验证模块。这不仅仅是写一个登录接口,而是打造一套完整的认证体系,涵盖用户凭证校验、Token生成与签发、请求拦截验证、跨域资源共享(CORS)配置以及安全最佳实践。对于正在开发个人项目或中小型产品的开发者来说,这是一个必须亲手搭建并深刻理解的基础设施。通过这个模块,你将掌握如何让前端Vue、React或任何其他技术栈的应用,安全地与你的Node.js后端“对话”。
2. 核心架构设计与技术选型解析
在动手写代码之前,理清整个认证流程的架构和为什么选择这些技术,至关重要。这能帮助你在遇到问题时,清晰地知道每个环节的作用。
2.1 为何选择JWT而非Session?
Session(会话)机制的工作原理是,服务器在用户登录成功后,在内存或Redis等存储中创建一个会话记录(Session),并将一个唯一的Session ID通过Set-Cookie头返回给浏览器。浏览器后续请求会自动携带此Cookie,服务器通过Session ID查找对应的会话数据来验证用户身份。
这种方式的问题在于:
- 状态存储:服务器需要存储会话状态,在分布式或集群部署时,需要引入额外的会话存储方案(如Redis)并解决数据同步问题。
- 跨域限制:Cookie默认遵循同源策略,在前后端分离(前端域名与API域名不同)的场景下,需要复杂配置(如设置
SameSite=None; Secure)才能跨域发送,且容易受到CSRF攻击。 - 扩展性:对于移动端原生App或第三方服务调用,Cookie并不是一个天然的友好方案。
JWT(JSON Web Token)是一种开放标准(RFC 7519),它定义了一种紧凑且自包含的方式,用于在各方之间安全地传输信息作为JSON对象。其核心优势在于无状态。
- 自包含:Token本身(Payload部分)就包含了用户标识等声明信息,服务器无需存储会话状态,仅需验证Token的签名即可确认其有效性。
- 易于跨域:Token通常通过HTTP请求头(如
Authorization: Bearer <token>)传递,完全不受同源策略限制,天然适合API驱动的架构。 - 多端适用:无论是Web、移动App还是桌面客户端,都能方便地处理HTTP Header。
因此,对于现代前后端分离的SPA(单页应用)或移动端项目,JWT是目前更主流和推荐的身份验证方案。
2.2 整体认证流程拆解
一个完整的基于JWT的登录验证流程,通常包含以下几个核心环节:
- 用户登录:客户端提交用户名/密码到登录接口。
- 凭证验证:服务器校验凭证(常需比对数据库中的加密密码)。
- 生成JWT:验证通过后,服务器使用密钥生成一个JWT,其中包含用户ID等必要信息。
- 返回Token:服务器将JWT返回给客户端(通常通过JSON响应体)。
- 客户端存储:客户端(如浏览器)将Token安全地存储起来(如localStorage、sessionStorage或HttpOnly Cookie,各有优劣)。
- 携带Token请求:客户端在后续需要认证的API请求中,在HTTP头中携带此Token。
- 验证中间件:服务器端设置一个全局或路由级的中间件,拦截请求,验证Token的签名和有效期。
- 授权访问:验证通过后,中间件将解码出的用户信息附加到请求对象(如
req.user)上,供后续业务逻辑使用。
我们的项目模块将实现上述流程的2-7步,并确保第6步的跨域请求能够被正确接收和处理。
2.3 关键技术栈与工具选型
- 运行时:Node.js。选择最新的LTS版本,以获得稳定的性能和安全性更新。
- Web框架:Express。轻量、灵活、生态丰富,是Node.js后端开发的事实标准。
- JWT库:
jsonwebtoken。这是Node.js生态中最流行、最成熟的JWT库,API简洁,功能完整。 - 密码加密:
bcryptjs。用于在存储用户密码前进行哈希加密。相比Node.js内置的crypto,bcrypt的加盐和抗彩虹表特性使其成为存储密码的行业标准。 - 跨域处理:
cors中间件。Express官方推荐的CORS处理包,配置简单且功能强大。 - 环境变量管理:
dotenv。将敏感配置(如JWT密钥、数据库连接串)从代码中分离,提升安全性。 - 数据库交互:本项目核心是认证模块,为保持专注,我们假设用户数据已存在于某数据库中(如MongoDB/Mongoose, MySQL/Sequelize等),仅演示查询逻辑。实际集成时替换为对应的ORM或驱动即可。
注意:在生成JWT的密钥时,绝对不要使用简单的字符串或将其硬编码在代码中。应使用
crypto.randomBytes(32).toString('hex')生成一个强随机字符串,并通过环境变量process.env.JWT_SECRET注入。
3. 项目初始化与核心依赖安装
让我们从创建一个干净的Node.js项目开始,一步步搭建环境。
3.1 创建项目并初始化
首先,在你的工作目录下,创建一个新的项目文件夹并初始化package.json。
mkdir nodejs-auth-module && cd nodejs-auth-module npm init -y3.2 安装必备依赖
我们将安装生产环境依赖和开发环境依赖。
# 生产依赖:项目运行必需的包 npm install express jsonwebtoken bcryptjs cors dotenv # 开发依赖:仅在开发时需要的包,如热重载、代码检查等(可选但推荐) npm install -D nodemonexpress: Web服务器框架。jsonwebtoken: 用于生成和验证JWT。bcryptjs: 用于哈希和验证用户密码。cors: 处理跨域资源共享。dotenv: 从.env文件加载环境变量。nodemon: 监听文件变化,自动重启服务器,提升开发效率。
安装完成后,你的package.json的dependencies部分应该包含以上包。
3.3 基础项目结构规划
一个清晰的项目结构有助于代码维护。我们创建如下目录和文件:
nodejs-auth-module/ ├── .env # 环境变量文件(切勿提交到Git) ├── .gitignore # Git忽略文件 ├── package.json ├── package-lock.json ├── src/ │ ├── app.js # Express应用主入口,中间件配置 │ ├── server.js # 服务器启动文件 │ ├── config/ # 配置文件目录 │ │ └── index.js # 统一导出配置(如数据库、JWT密钥) │ ├── middleware/ # 自定义中间件目录 │ │ └── auth.js # JWT验证中间件 │ ├── controllers/ # 控制器(处理业务逻辑) │ │ └── authController.js # 认证相关控制器(登录、注册等) │ ├── routes/ # 路由定义目录 │ │ └── authRoutes.js # 认证相关路由 │ └── utils/ # 工具函数目录 │ └── jwtUtils.js # JWT生成与验证的工具函数 └── models/ # 数据模型目录(示例,本项目不深入) └── userModel.js # 用户模型现在,让我们从最核心的配置和工具函数开始编写。
4. 核心工具与配置实现
4.1 环境配置与密钥管理
首先,在项目根目录创建.env文件,用于存放敏感信息。
# .env NODE_ENV=development PORT=3000 JWT_SECRET=your_super_secret_jwt_key_change_this_in_production JWT_EXPIRES_IN=7d # Token有效期,例如 7天 (7d), 2小时 (2h)重要安全提示:
JWT_SECRET是签名和验证Token的密钥。在生产环境中,必须使用一个高强度的、随机的字符串,并且绝对不能提交到版本控制系统。可以通过命令行node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"生成一个。
接下来,创建src/config/index.js,使用dotenv加载配置并提供一个统一的配置对象。
// src/config/index.js require('dotenv').config(); // 在应用入口最早调用,确保所有模块都能访问process.env const config = { env: process.env.NODE_ENV || 'development', port: process.env.PORT || 3000, jwtSecret: process.env.JWT_SECRET, jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d', }; // 检查关键配置是否存在 if (!config.jwtSecret) { console.error('FATAL ERROR: JWT_SECRET is not defined in environment variables.'); process.exit(1); } module.exports = config;4.2 JWT工具函数封装
我们将生成和验证JWT的逻辑封装在独立的工具模块中,提高代码复用性和可测试性。
// src/utils/jwtUtils.js const jwt = require('jsonwebtoken'); const config = require('../config'); class JwtUtils { /** * 生成JWT Token * @param {Object} payload - 需要嵌入Token的数据,如用户ID * @param {string} [expiresIn=config.jwtExpiresIn] - 过期时间 * @returns {string} JWT Token字符串 */ static generateToken(payload, expiresIn = config.jwtExpiresIn) { // 确保payload包含一个主题(subject)或用户标识,这是最佳实践 if (!payload.userId) { throw new Error('Payload must contain a `userId` field'); } const options = { expiresIn, // 可以添加更多选项,如 issuer(签发者), audience(受众)等 }; return jwt.sign(payload, config.jwtSecret, options); } /** * 验证并解码JWT Token * @param {string} token - 待验证的Token字符串 * @returns {Object} 解码后的payload数据 * @throws {jwt.JsonWebTokenError | jwt.TokenExpiredError} 验证失败时抛出错误 */ static verifyToken(token) { try { // jwt.verify 会自动检查签名和过期时间 return jwt.verify(token, config.jwtSecret); } catch (error) { // 将JWT库的错误直接抛出,由调用者(如中间件)处理 throw error; } } /** * 从HTTP请求头中提取Token * 支持格式:`Authorization: Bearer <token>` * @param {Object} req - Express请求对象 * @returns {string|null} 提取到的Token,未找到则返回null */ static extractTokenFromHeader(req) { if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { return req.headers.authorization.split(' ')[1]; } return null; } } module.exports = JwtUtils;这个工具类提供了三个核心方法:生成Token、验证Token和从请求头提取Token。注意generateToken方法中我们对payload进行了简单的校验,确保包含userId,这是一个良好的实践。
5. 构建认证中间件与控制器
有了工具函数,我们就可以构建处理HTTP请求的中间件和控制器了。
5.1 JWT认证中间件
认证中间件的作用是拦截需要保护的API路由,验证请求中的Token,并将用户信息附加到req对象上,供后续的控制器使用。
// src/middleware/auth.js const JwtUtils = require('../utils/jwtUtils'); const { TokenExpiredError, JsonWebTokenError } = require('jsonwebtoken'); /** * JWT认证中间件 * 1. 从请求头提取Token * 2. 验证Token有效性(签名、过期) * 3. 将解码后的用户信息挂载到req.user * 4. 验证失败则返回401或403状态码 */ const authenticateJWT = (req, res, next) => { // 1. 提取Token const token = JwtUtils.extractTokenFromHeader(req); if (!token) { // 没有提供Token,返回401 Unauthorized return res.status(401).json({ success: false, message: 'Access denied. No token provided.', }); } try { // 2. 验证并解码Token const decoded = JwtUtils.verifyToken(token); // 3. 将用户信息挂载到请求对象 req.user = decoded; // 通常包含 userId, username, iat, exp 等 next(); // 验证通过,继续下一个中间件或路由处理器 } catch (error) { // 4. 处理验证失败 let statusCode = 403; // Forbidden let message = 'Invalid or expired token.'; if (error instanceof TokenExpiredError) { message = 'Token has expired.'; // 可以在这里实现Token刷新逻辑(后续会讲) } else if (error instanceof JsonWebTokenError) { message = 'Invalid token.'; } return res.status(statusCode).json({ success: false, message, }); } }; module.exports = authenticateJWT;这个中间件是保护API的第一道防线。它清晰地处理了三种情况:无Token、Token过期、Token无效。在实际项目中,你可能还需要根据req.user中的角色信息,实现更细粒度的授权中间件(例如,检查用户是否为管理员)。
5.2 用户认证控制器
控制器负责处理具体的业务逻辑,比如登录。这里我们模拟一个用户数据库查询和密码验证的过程。
// src/controllers/authController.js const bcrypt = require('bcryptjs'); const JwtUtils = require('../utils/jwtUtils'); // 假设我们有一个用户模型,这里用一个模拟的“数据库”数组代替 // 实际项目中,这里应该是从MongoDB、MySQL等数据库查询 const mockUsers = [ { id: 1, username: 'demo', // 密码是 "password123" 经过bcrypt哈希后的值 passwordHash: '$2a$10$N9qo8uLOickgx2ZMRZoMye7Z7MHFQwBv.FuEoJ.9K6mY9z8vqQ1VK', email: 'demo@example.com', }, ]; class AuthController { /** * 用户登录 * @param {Object} req - Express请求对象,body中应包含username和password * @param {Object} res - Express响应对象 */ static async login(req, res) { const { username, password } = req.body; // 1. 基础验证 if (!username || !password) { return res.status(400).json({ success: false, message: 'Username and password are required.', }); } try { // 2. 模拟数据库查询用户 const user = mockUsers.find(u => u.username === username); if (!user) { // 用户不存在也返回通用提示,避免信息泄露 return res.status(401).json({ success: false, message: 'Invalid credentials.', }); } // 3. 验证密码 const isPasswordValid = await bcrypt.compare(password, user.passwordHash); if (!isPasswordValid) { return res.status(401).json({ success: false, message: 'Invalid credentials.', }); } // 4. 密码正确,生成JWT Payload // 注意:不要在Token中放入敏感信息(如密码哈希) const payload = { userId: user.id, username: user.username, // 可以添加角色等信息: role: user.role }; // 5. 生成Token const token = JwtUtils.generateToken(payload); // 6. 返回成功响应和Token // 通常不返回密码哈希等敏感信息 return res.status(200).json({ success: true, message: 'Login successful.', data: { token, // 前端需要保存这个token user: { id: user.id, username: user.username, email: user.email, }, }, }); } catch (error) { console.error('Login error:', error); return res.status(500).json({ success: false, message: 'An internal server error occurred during login.', }); } } /** * 获取当前用户信息(受保护路由示例) * 需要先通过authenticateJWT中间件验证 * @param {Object} req - 请求对象,已由中间件附加了req.user * @param {Object} res - 响应对象 */ static async getProfile(req, res) { // req.user 由认证中间件附加 const userId = req.user.userId; // 再次查询数据库获取完整用户信息(示例) const user = mockUsers.find(u => u.id === userId); if (!user) { return res.status(404).json({ success: false, message: 'User not found.' }); } // 返回脱敏后的用户信息 const { passwordHash, ...safeUserInfo } = user; res.status(200).json({ success: true, data: safeUserInfo, }); } } module.exports = AuthController;在登录控制器中,有几个关键点:
- 密码验证:使用
bcrypt.compare来比对用户输入的明文密码和数据库中存储的哈希值。bcrypt的compare方法能安全地处理时间攻击。 - 通用错误提示:无论是用户名不存在还是密码错误,都返回“Invalid credentials.”,这是安全最佳实践,防止攻击者枚举有效用户名。
- Token Payload:只放入必要的、非敏感的用户标识信息。切勿放入密码、完整用户对象等。
- 响应格式:返回一个结构化的JSON响应,包含
success标志、消息和data数据体,这是一种友好的API设计。
5.3 注册用户密码哈希生成
虽然本项目重点是登录,但注册是前提。这里给出在注册时如何使用bcrypt哈希密码的示例:
// 在注册控制器中 const saltRounds = 10; // 成本因子,值越大越安全但越慢,10是常用值 const plainPassword = req.body.password; const hashedPassword = await bcrypt.hash(plainPassword, saltRounds); // 然后将 hashedPassword 存入数据库6. 配置Express应用与路由
现在,我们将各个部分组装起来,创建Express应用的主文件和路由定义。
6.1 应用主文件与中间件配置
app.js是Express应用的配置中心,所有全局中间件都在这里引入。
// src/app.js const express = require('express'); const cors = require('cors'); const config = require('./config'); // 导入路由 const authRoutes = require('./routes/authRoutes'); // 初始化Express应用 const app = express(); // 1. 全局中间件配置 // 1.1 CORS配置 - 处理跨域请求的核心 // 在生产环境中,应严格限制origin,例如:{ origin: 'https://yourfrontend.com' } const corsOptions = { origin: function (origin, callback) { // 允许的源列表,开发环境可以宽松,生产环境必须指定 const allowedOrigins = ['http://localhost:8080', 'https://your-production-site.com']; // 对于没有origin的请求(如移动端、Postman),可以允许 if (!origin || allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { callback(new Error('Not allowed by CORS')); } }, credentials: true, // 如果前端需要发送Cookie,则设置为true methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], // 允许的HTTP方法 allowedHeaders: ['Content-Type', 'Authorization'], // 允许的请求头 }; app.use(cors(corsOptions)); // 应用CORS中间件 // 1.2 解析请求体(JSON和URL-encoded格式) app.use(express.json()); // for parsing application/json app.use(express.urlencoded({ extended: true })); // for parsing application/x-www-form-urlencoded // 1.3 可选的:请求日志记录(开发用) if (config.env === 'development') { const morgan = require('morgan'); app.use(morgan('dev')); } // 2. 根路由(健康检查) app.get('/', (req, res) => { res.json({ message: 'Auth API Server is running.' }); }); // 3. 注册业务路由 // 认证相关路由(登录、注册等)不需要JWT验证 app.use('/api/auth', authRoutes); // 4. 受保护的路由示例(需要JWT验证) // 假设我们有一个用户信息路由,需要验证 // const authenticateJWT = require('./middleware/auth'); // app.use('/api/users', authenticateJWT, userRoutes); // 后续可以添加userRoutes // 5. 404处理中间件 - 捕获未定义的路由 app.use('*', (req, res) => { res.status(404).json({ success: false, message: `Route ${req.originalUrl} not found on this server.`, }); }); // 6. 全局错误处理中间件 // 注意:必须是四个参数的函数 (err, req, res, next) app.use((err, req, res, next) => { console.error('Global Error Handler:', err.stack); // 处理CORS错误 if (err.message === 'Not allowed by CORS') { return res.status(403).json({ success: false, message: 'CORS policy violation.' }); } // 默认错误响应 const statusCode = err.statusCode || 500; const message = err.message || 'Internal Server Error'; res.status(statusCode).json({ success: false, message, // 开发环境可以返回堆栈信息,生产环境不要返回 ...(config.env === 'development' && { stack: err.stack }), }); }); module.exports = app;关于CORS配置的深度解析:
origin: 这是最重要的选项。在生产环境中,务必将其设置为你的前端应用的确切地址(如https://www.yourdomain.com),而不是通配符*。使用函数进行动态判断更灵活安全。credentials: true: 如果你的前端需要发送身份验证Cookie(例如,你选择将JWT存储在HttpOnly Cookie中而非localStorage),则必须设置此项。同时,前端在发起请求时也需要设置withCredentials: true(在Axios中是axios.defaults.withCredentials = true)。methods和allowedHeaders: 明确声明允许的方法和头,遵循最小权限原则。
6.2 认证路由定义
路由文件负责将HTTP请求路径映射到对应的控制器方法。
// src/routes/authRoutes.js const express = require('express'); const router = express.Router(); const AuthController = require('../controllers/authController'); const authenticateJWT = require('../middleware/auth'); // 引入认证中间件 // 公开路由:不需要Token验证 router.post('/login', AuthController.login); // router.post('/register', AuthController.register); // 可以扩展注册路由 // 受保护路由:需要有效的JWT Token // 将authenticateJWT中间件放在路由路径和控制器之间 router.get('/profile', authenticateJWT, AuthController.getProfile); module.exports = router;路由定义非常清晰:POST /api/auth/login用于登录,GET /api/auth/profile用于获取当前用户信息,后者被authenticateJWT中间件保护。
6.3 服务器启动入口
最后,创建服务器启动文件。
// src/server.js const app = require('./app'); const config = require('./config'); const PORT = config.port; app.listen(PORT, () => { console.log(`🚀 Auth API Server is running in ${config.env} mode on port ${PORT}`); console.log(`📝 API Base URL: http://localhost:${PORT}`); });更新package.json中的脚本,方便启动。
// package.json { "scripts": { "start": "node src/server.js", "dev": "nodemon src/server.js" } }现在,运行npm run dev,你的认证API服务器就启动了!
7. 完整测试与前端集成示例
模块搭建完成,必须经过全面测试。我们使用Postman(或cURL)模拟前端请求。
7.1 测试登录接口
请求:POST http://localhost:3000/api/auth/loginHeaders:Content-Type: application/jsonBody (raw JSON):
{ "username": "demo", "password": "password123" }预期成功响应 (200 OK):
{ "success": true, "message": "Login successful.", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...很长的一串JWT...", "user": { "id": 1, "username": "demo", "email": "demo@example.com" } } }测试错误情况:
- 错误密码:应返回401,消息为“Invalid credentials.”
- 不存在的用户:同样返回401,消息为“Invalid credentials.”
- 缺少字段:返回400,消息为“Username and password are required.”
7.2 测试受保护的用户信息接口
使用上一步获取的Token。
请求:GET http://localhost:3000/api/auth/profileHeaders:Authorization: Bearer <你的Token>
预期成功响应 (200 OK):
{ "success": true, "data": { "id": 1, "username": "demo", "email": "demo@example.com" } }测试错误情况:
- 不提供
Authorization头:返回401,消息为“Access denied. No token provided.” - 提供错误的Token:返回403,消息为“Invalid token.”
- 提供已过期的Token:返回403,消息为“Token has expired.”
7.3 前端集成关键代码示例(以Axios为例)
在前端项目(如Vue/React)中,你需要做以下工作:
登录并存储Token:
import axios from 'axios'; const API_BASE = 'http://localhost:3000/api'; async function login(username, password) { try { const response = await axios.post(`${API_BASE}/auth/login`, { username, password }); if (response.data.success) { const token = response.data.data.token; // 存储Token:localStorage, sessionStorage, 或 Cookie localStorage.setItem('auth_token', token); // 也可以将Token设置到Axios默认头,这样后续请求自动携带 axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; return response.data; } } catch (error) { console.error('Login failed:', error.response?.data); throw error; } }配置Axios全局拦截器(推荐):这样可以在每次请求前自动添加Token,并在收到401/403响应时自动跳转到登录页。
// 请求拦截器 axios.interceptors.request.use( config => { const token = localStorage.getItem('auth_token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }, error => Promise.reject(error) ); // 响应拦截器 axios.interceptors.response.use( response => response, error => { if (error.response && (error.response.status === 401 || error.response.status === 403)) { // Token无效或过期,清除本地存储并跳转到登录页 localStorage.removeItem('auth_token'); delete axios.defaults.headers.common['Authorization']; window.location.href = '/login'; // 根据你的路由调整 } return Promise.reject(error); } );处理跨域与Cookie(如果使用Cookie存储):如果后端CORS配置了
credentials: true且前端将Token存在HttpOnly Cookie中,则需要在Axios请求配置中设置withCredentials。axios.defaults.withCredentials = true; // 或者针对特定请求 axios.get('/api/profile', { withCredentials: true });
8. 高级主题、安全加固与常见问题排查
一个基础的认证模块已经完成,但要用于生产环境,还需要考虑更多。
8.1 Token刷新机制
JWT一旦签发,在过期前无法主动使其失效(除非更换密钥)。常见的解决方案是使用双Token机制:
- Access Token (AT): 短期有效(如15分钟),用于API访问。
- Refresh Token (RT): 长期有效(如7天),存储于数据库或安全的HttpOnly Cookie中,仅用于获取新的AT。
流程:
- 登录时,返回AT和RT。
- 客户端用AT请求API。
- AT过期后,客户端用RT调用
/api/auth/refresh端点。 - 服务器验证RT的有效性(检查数据库或签名),若有效则签发新的AT。
- 客户端用新AT继续访问。
实现要点:
- RT必须安全存储(服务器端数据库关联用户),并可以设置白名单或黑名单实现“登出即失效”。
/refresh端点不应返回新的RT,除非实现RT轮换策略(每次刷新都生成新的RT,使旧的RT失效,提升安全性)。
8.2 安全性最佳实践
- 使用HTTPS:在生产环境,必须使用HTTPS。否则,Token在传输过程中可能被窃听。
- Token存储:
- Web:可以考虑存储在
localStorage(易受XSS攻击)或HttpOnly Cookie(能防XSS,但需注意CSRF)。对于SPA,localStorage+严格的CSP(内容安全策略)和XSS防护是常见选择。如果使用Cookie,务必设置Secure、HttpOnly、SameSite=Strict(或Lax)属性。 - 移动端/桌面端:使用安全的本地存储机制,如Keychain (iOS)、Keystore (Android)、或系统的安全存储API。
- Web:可以考虑存储在
- 设置合理的过期时间:AT应尽可能短(几分钟到几小时),RT可以稍长(几天到几周)。这限制了Token被盗后的影响窗口。
- 黑名单(可选但复杂):如果需要实现即时登出(使未过期的Token失效),可以维护一个Token黑名单(在内存或Redis中),验证Token时额外检查黑名单。这会引入状态,与JWT无状态的理念相悖,需权衡。
- 不要在URL中传递Token:这可能导致Token被记录在服务器日志、浏览器历史或Referer头中。
8.3 常见问题排查实录
问题1:前端请求出现CORS错误,如“No ‘Access-Control-Allow-Origin‘ header”。
- 检查:确保后端正确配置了
cors中间件,且origin配置包含了前端的地址(开发时通常是http://localhost:8080)。 - 排查:检查浏览器开发者工具的“网络(Network)”选项卡,查看预检请求(OPTIONS)和实际请求的响应头。确认
Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers等头信息是否正确返回。 - 注意:如果请求携带了自定义头(如
Authorization),浏览器会先发送一个OPTIONS预检请求。确保服务器能正确处理OPTIONS方法(cors中间件已处理)。
问题2:登录成功,但调用受保护接口返回401/403。
- 检查Token提取:确认前端在请求头中正确设置了
Authorization: Bearer <token>。注意Bearer后面有一个空格。 - 检查Token格式:复制Token到 jwt.io 解码,检查其结构(Header.Payload.Signature)、过期时间(
exp)和签名是否正确。 - 检查服务器时间:如果服务器时间不准确,可能导致Token过早被判定为过期。
- 检查中间件顺序:确保
authenticateJWT中间件被正确添加到需要保护的路由上。
问题3:bcrypt.compare总是返回false,即使密码正确。
- 最常见原因:数据库中的密码哈希值不是由
bcrypt.hash生成的,或者哈希值在存储/读取过程中被损坏或截断。 - 排查:
- 在注册时,打印生成的
hashedPassword,确认其格式(应以$2a$、$2b$或$2y$开头)。 - 确保数据库字段长度足够(
bcrypt哈希值固定为60字符)。 - 在登录时,打印从数据库查出的哈希值,与注册时打印的对比,看是否一致。
- 在注册时,打印生成的
问题4:jsonwebtoken.verify抛出“invalid signature”错误。
- 原因:用于验证的
JWT_SECRET与生成Token时使用的密钥不一致。 - 排查:
- 检查
.env文件中的JWT_SECRET值。 - 确保服务器重启后环境变量已重新加载。
- 如果你有多个服务实例,确保它们使用的
JWT_SECRET完全相同。
- 检查
问题5:在集群部署时,如何保持JWT验证一致?
- 答案:JWT是无状态的,其验证只依赖于密钥(
JWT_SECRET)。只要集群中所有Node.js实例都配置了相同的JWT_SECRET,它们就能独立验证Token,无需共享状态。这是JWT相对于Session的最大优势之一。只需确保在部署时,通过统一的密钥管理服务或环境变量,将相同的密钥注入到所有实例中即可。