Passport.js 最佳实践 #
安全最佳实践 #
1. 密码安全 #
使用强哈希算法 #
javascript
// 使用 bcryptjs
const bcrypt = require('bcryptjs');
// 注册时加密密码
const saltRounds = 12; // 推荐值 10-12
const hashedPassword = await bcrypt.hash(password, saltRounds);
// 登录时验证密码
const isMatch = await bcrypt.compare(password, hashedPassword);
密码强度验证 #
javascript
// utils/passwordValidator.js
function validatePassword(password) {
const errors = [];
if (password.length < 8) {
errors.push('密码至少8个字符');
}
if (!/[A-Z]/.test(password)) {
errors.push('密码需要包含大写字母');
}
if (!/[a-z]/.test(password)) {
errors.push('密码需要包含小写字母');
}
if (!/[0-9]/.test(password)) {
errors.push('密码需要包含数字');
}
if (!/[!@#$%^&*]/.test(password)) {
errors.push('密码需要包含特殊字符');
}
return {
isValid: errors.length === 0,
errors
};
}
module.exports = { validatePassword };
密码泄露检测 #
javascript
// 使用 haveibeenpwned API 检测密码泄露
const crypto = require('crypto');
async function isPasswordBreached(password) {
const sha1 = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
const prefix = sha1.substring(0, 5);
const suffix = sha1.substring(5);
const response = await fetch(`https://api.pwnedpasswords.com/range/${prefix}`);
const data = await response.text();
return data.includes(suffix);
}
// 注册时检查
router.post('/register', async (req, res) => {
const { password } = req.body;
if (await isPasswordBreached(password)) {
return res.status(400).json({
success: false,
message: '该密码已被泄露,请使用其他密码'
});
}
// 继续注册流程
});
2. Session 安全 #
安全的 Session 配置 #
javascript
// 生产环境 Session 配置
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
name: 'sessionId', // 不使用默认名称
resave: false,
saveUninitialized: false,
rolling: true, // 每次请求刷新 Cookie
cookie: {
secure: true, // 仅 HTTPS
httpOnly: true, // 防止 XSS
sameSite: 'strict', // 防止 CSRF
maxAge: 24 * 60 * 60 * 1000, // 24 小时
domain: process.env.COOKIE_DOMAIN
}
}));
Session 固定攻击防护 #
javascript
// 登录成功后重新生成 Session
router.post('/login', (req, res, next) => {
passport.authenticate('local', (err, user, info) => {
if (err) return next(err);
if (!user) return res.status(401).json(info);
// 重新生成 Session
req.session.regenerate((err) => {
if (err) return next(err);
req.logIn(user, (err) => {
if (err) return next(err);
// 设置登录时间
req.session.loginTime = Date.now();
res.json({ success: true, user });
});
});
})(req, res, next);
});
3. JWT 安全 #
安全的 JWT 配置 #
javascript
// 生成安全的 JWT
const jwt = require('jsonwebtoken');
function generateTokens(user) {
// 访问令牌:短有效期
const accessToken = jwt.sign(
{
sub: user._id,
role: user.role,
iat: Math.floor(Date.now() / 1000)
},
process.env.JWT_SECRET,
{
expiresIn: '15m', // 15 分钟
issuer: 'your-app',
audience: 'your-app',
jwtid: crypto.randomBytes(16).toString('hex') // 唯一 ID
}
);
// 刷新令牌:长有效期
const refreshToken = jwt.sign(
{
sub: user._id,
type: 'refresh'
},
process.env.JWT_REFRESH_SECRET,
{
expiresIn: '7d',
jwtid: crypto.randomBytes(16).toString('hex')
}
);
return { accessToken, refreshToken };
}
Token 存储安全 #
javascript
// 使用 HttpOnly Cookie 存储 Token
router.post('/login', async (req, res) => {
const { accessToken, refreshToken } = generateTokens(user);
// 设置 HttpOnly Cookie
res.cookie('accessToken', accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 15 * 60 * 1000 // 15 分钟
});
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 天
});
res.json({ success: true, user });
});
4. CSRF 防护 #
bash
npm install csurf
javascript
// CSRF 防护
const csrf = require('csurf');
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict'
}
});
// 应用到路由
app.use(csrfProtection);
// 提供 CSRF Token
app.get('/api/csrf-token', (req, res) => {
res.json({ csrfToken: req.csrfToken() });
});
// 表单提交需要 CSRF Token
app.post('/form', (req, res) => {
// 自动验证 CSRF Token
});
5. 速率限制 #
bash
npm install express-rate-limit
javascript
// 登录速率限制
const rateLimit = require('express-rate-limit');
// 通用限制
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 100, // 每个 IP 最多 100 次请求
message: {
success: false,
message: '请求过于频繁,请稍后再试'
}
});
// 登录限制(更严格)
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 分钟
max: 5, // 每个 IP 最多 5 次登录尝试
message: {
success: false,
message: '登录尝试过多,请15分钟后再试'
},
skipSuccessfulRequests: true // 成功的请求不计入限制
});
// 应用限制
app.use('/api/', generalLimiter);
app.use('/auth/login', loginLimiter);
6. 安全头 #
bash
npm install helmet
javascript
// 使用 Helmet 设置安全头
const helmet = require('helmet');
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "trusted-cdn.com"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", "api.your-app.com"],
fontSrc: ["'self'", "fonts.gstatic.com"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: []
}
},
crossOriginEmbedderPolicy: true,
crossOriginOpenerPolicy: true,
crossOriginResourcePolicy: { policy: "same-origin" },
dnsPrefetchControl: { allow: false },
frameguard: { action: 'deny' },
hidePoweredBy: true,
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
},
ieNoOpen: true,
noSniff: true,
originAgentCluster: true,
permittedCrossDomainPolicies: { permittedPolicies: "none" },
referrerPolicy: { policy: "strict-origin-when-cross-origin" },
xssFilter: true
}));
常见问题解决 #
1. 认证状态丢失 #
javascript
// 问题:刷新页面后用户状态丢失
// 原因排查
app.use((req, res, next) => {
console.log('Session ID:', req.sessionID);
console.log('Session:', req.session);
console.log('User:', req.user);
next();
});
// 解决方案
// 1. 确保 session 中间件在 passport 之前
app.use(session({...}));
app.use(passport.initialize());
app.use(passport.session());
// 2. 确保 serializeUser 和 deserializeUser 正确配置
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id);
done(null, user);
} catch (error) {
done(error, null);
}
});
// 3. 检查 Cookie 设置
app.use(session({
cookie: {
secure: false, // 开发环境设为 false
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000
}
}));
2. CORS 问题 #
javascript
// 问题:跨域请求失败
// 解决方案
const cors = require('cors');
app.use(cors({
origin: process.env.CLIENT_URL, // 允许的前端域名
credentials: true, // 允许携带 Cookie
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
// 前端配置
// fetch 请求需要设置 credentials
fetch('http://api.example.com/data', {
credentials: 'include' // 或 'same-origin'
});
3. 重定向循环 #
javascript
// 问题:登录后无限重定向
// 原因:认证检查逻辑错误
// 错误示例
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return res.redirect('/dashboard'); // 错误:已登录还重定向
}
next();
}
// 正确示例
function ensureAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next(); // 已登录,继续
}
res.redirect('/login'); // 未登录,重定向到登录页
}
// 避免登录页面重定向循环
function ensureNotAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return res.redirect('/dashboard');
}
next();
}
router.get('/login', ensureNotAuthenticated, (req, res) => {
res.render('login');
});
4. OAuth 回调失败 #
javascript
// 问题:OAuth 回调返回错误
// 排查步骤
// 1. 检查回调 URL 配置
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: process.env.GOOGLE_CALLBACK_URL // 确保与 OAuth 应用配置一致
}, verifyCallback));
// 2. 检查环境变量
console.log('Google Client ID:', process.env.GOOGLE_CLIENT_ID);
console.log('Google Callback URL:', process.env.GOOGLE_CALLBACK_URL);
// 3. 添加错误处理
router.get('/google/callback',
passport.authenticate('google', {
failureRedirect: '/auth/login',
failureFlash: true
}),
(req, res) => {
res.redirect('/dashboard');
}
);
// 4. 自定义错误处理
router.get('/google/callback', (req, res, next) => {
passport.authenticate('google', (err, user, info) => {
if (err) {
console.error('OAuth error:', err);
return res.redirect('/auth/login?error=oauth_error');
}
if (!user) {
console.log('OAuth no user:', info);
return res.redirect('/auth/login?error=no_user');
}
req.logIn(user, (err) => {
if (err) return next(err);
res.redirect('/dashboard');
});
})(req, res, next);
});
5. Token 过期处理 #
javascript
// 问题:Token 过期后用户体验差
// 解决方案:自动刷新 Token
// 前端拦截器
const axios = require('axios');
const api = axios.create({
baseURL: '/api'
});
// 请求拦截器
api.interceptors.request.use(config => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 响应拦截器
api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
// Token 过期
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
// 刷新 Token
const response = await axios.post('/auth/refresh', {
refreshToken: localStorage.getItem('refreshToken')
});
const { accessToken } = response.data;
localStorage.setItem('accessToken', accessToken);
// 重试原请求
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return api(originalRequest);
} catch (refreshError) {
// 刷新失败,跳转登录
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
性能优化 #
1. 数据库查询优化 #
javascript
// 反序列化时只查询必要字段
passport.deserializeUser(async (id, done) => {
try {
const user = await User.findById(id)
.select('username email role avatar'); // 只选择必要字段
done(null, user);
} catch (error) {
done(error, null);
}
});
2. 缓存用户数据 #
javascript
// 使用 Redis 缓存用户数据
const redis = require('redis');
const client = redis.createClient();
passport.deserializeUser(async (id, done) => {
try {
// 先从缓存获取
const cachedUser = await client.get(`user:${id}`);
if (cachedUser) {
return done(null, JSON.parse(cachedUser));
}
// 缓存未命中,查询数据库
const user = await User.findById(id);
if (user) {
// 缓存用户数据,10分钟过期
await client.setex(`user:${id}`, 600, JSON.stringify(user));
}
done(null, user);
} catch (error) {
done(error, null);
}
});
// 用户信息更新时清除缓存
async function updateUser(userId, data) {
const user = await User.findByIdAndUpdate(userId, data, { new: true });
await client.del(`user:${userId}`);
return user;
}
3. Session 存储优化 #
javascript
// 使用 Redis 存储 Session
const RedisStore = require('connect-redis').default;
app.use(session({
store: new RedisStore({
client: redisClient,
prefix: 'sess:',
ttl: 86400, // 24 小时
disableTouch: false // 允许刷新
}),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
rolling: true // 自动刷新
}));
生产环境部署 #
1. 环境变量 #
bash
# .env.production
NODE_ENV=production
# Session
SESSION_SECRET=your-very-long-random-secret-key-at-least-32-chars
COOKIE_DOMAIN=.yourdomain.com
# JWT
JWT_SECRET=another-very-long-random-secret-key
JWT_REFRESH_SECRET=refresh-token-secret-key
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# OAuth
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GOOGLE_CALLBACK_URL=https://yourdomain.com/auth/google/callback
# Database
MONGODB_URI=mongodb://user:password@host:port/database
REDIS_URL=redis://user:password@host:port
2. 健康检查 #
javascript
// 健康检查端点
app.get('/health', async (req, res) => {
const health = {
status: 'ok',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
checks: {
database: 'unknown',
redis: 'unknown'
}
};
// 检查数据库
try {
await mongoose.connection.db.admin().ping();
health.checks.database = 'ok';
} catch (error) {
health.checks.database = 'error';
health.status = 'degraded';
}
// 检查 Redis
try {
await redisClient.ping();
health.checks.redis = 'ok';
} catch (error) {
health.checks.redis = 'error';
health.status = 'degraded';
}
const statusCode = health.status === 'ok' ? 200 : 503;
res.status(statusCode).json(health);
});
3. 日志记录 #
javascript
// 使用 Winston 记录日志
const winston = require('winston');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' })
]
});
// 认证事件日志
app.use((req, res, next) => {
if (req.path.startsWith('/auth')) {
logger.info('Auth request', {
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.headers['user-agent']
});
}
next();
});
// 登录成功日志
passport.serializeUser((user, done) => {
logger.info('User logged in', {
userId: user._id,
username: user.username,
ip: req?.ip
});
done(null, user.id);
});
4. 监控告警 #
javascript
// 登录失败监控
let failedLoginAttempts = {};
const MAX_FAILED_ATTEMPTS = 10;
const BLOCK_DURATION = 15 * 60 * 1000; // 15 分钟
function recordFailedLogin(ip) {
if (!failedLoginAttempts[ip]) {
failedLoginAttempts[ip] = {
count: 0,
firstAttempt: Date.now()
};
}
failedLoginAttempts[ip].count++;
failedLoginAttempts[ip].lastAttempt = Date.now();
// 检测异常
if (failedLoginAttempts[ip].count >= MAX_FAILED_ATTEMPTS) {
logger.warn('Suspicious login activity detected', {
ip,
attempts: failedLoginAttempts[ip].count
});
// 发送告警
sendAlert(`Suspicious login activity from IP: ${ip}`);
}
}
// 定期清理
setInterval(() => {
const now = Date.now();
Object.keys(failedLoginAttempts).forEach(ip => {
if (now - failedLoginAttempts[ip].lastAttempt > BLOCK_DURATION) {
delete failedLoginAttempts[ip];
}
});
}, 60 * 1000); // 每分钟清理
检查清单 #
部署前检查 #
text
□ 环境变量已正确配置
□ Session Secret 足够复杂(至少32字符)
□ JWT Secret 足够复杂
□ 数据库连接已加密
□ Redis 连接已加密
□ HTTPS 已启用
□ Cookie 设置了 secure 和 httpOnly
□ CSRF 防护已启用
□ 速率限制已配置
□ 安全头已设置
□ 日志记录已配置
□ 健康检查端点已配置
□ 监控告警已配置
□ 错误处理已完善
□ 敏感信息不在日志中输出
安全检查 #
text
□ 密码使用强哈希算法
□ 密码强度验证已启用
□ 登录失败次数限制已配置
□ Session 固定攻击防护已启用
□ Token 有效期合理
□ Token 刷新机制已实现
□ Token 黑名单已实现(如需要)
□ CORS 配置正确
□ XSS 防护已启用
□ SQL 注入防护已启用
□ 输入验证已完善
□ 输出编码已完善
总结 #
Passport.js 是一个强大且灵活的认证中间件。遵循这些最佳实践,可以构建安全、可靠的认证系统:
- 安全第一:始终优先考虑安全性,使用强加密、安全配置
- 防御深度:多层防护,不依赖单一安全措施
- 最小权限:只授予必要的权限
- 监控告警:实时监控认证活动,及时发现异常
- 定期审计:定期检查和更新安全配置
继续学习和实践,不断提升认证系统的安全性和可靠性!
最后更新:2026-03-28