Node.js 认证与授权
核心知识点
认证与授权的区别
- 认证(Authentication):验证用户身份,确认用户是其声称的人
- 授权(Authorization):确定已认证用户是否有权限访问特定资源
密码安全
密码安全的最佳实践:
- 密码哈希:使用专门的哈希算法处理密码,而不是明文存储
- 盐值(Salt):为每个密码添加唯一的盐值,防止彩虹表攻击
- 工作因子(Work Factor):调整哈希算法的复杂度,适应硬件发展
常用的密码哈希库:
- bcrypt:基于 Blowfish 算法
- argon2:现代密码哈希算法,推荐使用
- scrypt:内存密集型哈希算法
JWT(JSON Web Token)
JWT 是一种基于 JSON 的开放标准,用于在各方之间安全地传输信息:
- 结构:由头部(Header)、载荷(Payload)和签名(Signature)三部分组成
- 优势:无状态、可跨域、便于水平扩展
- 劣势:一旦签发无法撤销(除非实现令牌黑名单)
会话管理
会话管理的方式:
- 基于 Cookie 的会话:服务器存储会话数据,客户端存储会话 ID
- 基于 Token 的会话:客户端存储完整的令牌,服务器验证令牌有效性
OAuth 2.0
OAuth 2.0 是一种授权框架,允许用户授权第三方应用访问其资源:
- 角色:资源所有者、客户端、授权服务器、资源服务器
- 授权类型:授权码、隐式、密码、客户端凭证
- 流程:客户端请求授权 → 用户同意 → 客户端获取令牌 → 使用令牌访问资源
Passport.js
Passport.js 是 Node.js 的认证中间件,支持多种认证策略:
- 本地策略:用户名/密码认证
- 社交媒体策略:Google、Facebook、Twitter 等
- 企业策略:LDAP、SAML 等
实用案例
案例一:密码哈希与验证
// 1. 安装依赖
// npm install bcrypt
const bcrypt = require('bcrypt');
const saltRounds = 10;
// 哈希密码
async function hashPassword(password) {
try {
const salt = await bcrypt.genSalt(saltRounds);
const hash = await bcrypt.hash(password, salt);
return hash;
} catch (error) {
console.error('哈希密码失败:', error);
throw error;
}
}
// 验证密码
async function verifyPassword(password, hash) {
try {
const result = await bcrypt.compare(password, hash);
return result;
} catch (error) {
console.error('验证密码失败:', error);
throw error;
}
}
// 使用示例
async function main() {
const password = 'mySecurePassword123';
// 哈希密码
const hashedPassword = await hashPassword(password);
console.log('哈希后的密码:', hashedPassword);
// 验证密码(正确)
const isValid1 = await verifyPassword(password, hashedPassword);
console.log('密码验证(正确):', isValid1);
// 验证密码(错误)
const isValid2 = await verifyPassword('wrongPassword', hashedPassword);
console.log('密码验证(错误):', isValid2);
}
main();案例二:JWT 认证
// 1. 安装依赖
// npm install jsonwebtoken express bcrypt
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
const port = 3000;
app.use(express.json());
// 模拟用户数据库
const users = [];
const JWT_SECRET = 'your-secret-key'; // 实际应用中应使用环境变量
// 注册路由
app.post('/api/register', async (req, res) => {
try {
const { username, password, email } = req.body;
// 检查用户是否已存在
if (users.find(user => user.username === username)) {
return res.status(400).json({ error: '用户名已存在' });
}
// 哈希密码
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// 创建用户
const newUser = {
id: users.length + 1,
username,
email,
password: hashedPassword
};
users.push(newUser);
res.status(201).json({ message: '注册成功' });
} catch (error) {
res.status(500).json({ error: '服务器内部错误' });
}
});
// 登录路由
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
// 查找用户
const user = users.find(user => user.username === username);
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 验证密码
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 生成 JWT
const token = jwt.sign(
{ userId: user.id, username: user.username },
JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ message: '登录成功', token });
} catch (error) {
res.status(500).json({ error: '服务器内部错误' });
}
});
// 保护路由的中间件
function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '未提供认证令牌' });
}
jwt.verify(token, JWT_SECRET, (error, user) => {
if (error) {
return res.status(403).json({ error: '无效的认证令牌' });
}
req.user = user;
next();
});
}
// 受保护的路由
app.get('/api/protected', authenticateToken, (req, res) => {
res.json({
message: '访问受保护资源成功',
user: req.user
});
});
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});案例三:会话管理
// 1. 安装依赖
// npm install express express-session bcrypt
const express = require('express');
const session = require('express-session');
const bcrypt = require('bcrypt');
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 配置会话
app.use(session({
secret: 'your-secret-key', // 实际应用中应使用环境变量
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // 生产环境中应设置为 true(使用 HTTPS)
httpOnly: true,
maxAge: 3600000 // 1小时
}
}));
// 模拟用户数据库
const users = [];
// 注册路由
app.post('/api/register', async (req, res) => {
try {
const { username, password, email } = req.body;
// 检查用户是否已存在
if (users.find(user => user.username === username)) {
return res.status(400).json({ error: '用户名已存在' });
}
// 哈希密码
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// 创建用户
const newUser = {
id: users.length + 1,
username,
email,
password: hashedPassword
};
users.push(newUser);
res.status(201).json({ message: '注册成功' });
} catch (error) {
res.status(500).json({ error: '服务器内部错误' });
}
});
// 登录路由
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
// 查找用户
const user = users.find(user => user.username === username);
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 验证密码
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 设置会话
req.session.user = {
id: user.id,
username: user.username
};
res.json({ message: '登录成功' });
} catch (error) {
res.status(500).json({ error: '服务器内部错误' });
}
});
// 保护路由的中间件
function requireAuth(req, res, next) {
if (!req.session.user) {
return res.status(401).json({ error: '未认证' });
}
next();
}
// 受保护的路由
app.get('/api/protected', requireAuth, (req, res) => {
res.json({
message: '访问受保护资源成功',
user: req.session.user
});
});
// 登出路由
app.post('/api/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
return res.status(500).json({ error: '登出失败' });
}
res.json({ message: '登出成功' });
});
});
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});案例四:使用 Passport.js
// 1. 安装依赖
// npm install express passport passport-local bcrypt express-session
const express = require('express');
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');
const session = require('express-session');
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// 配置会话
app.use(session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: false
}));
// 初始化 Passport
app.use(passport.initialize());
app.use(passport.session());
// 模拟用户数据库
const users = [];
// 配置本地策略
passport.use(new LocalStrategy(
(username, password, done) => {
// 查找用户
const user = users.find(user => user.username === username);
if (!user) {
return done(null, false, { message: '用户名或密码错误' });
}
// 验证密码
bcrypt.compare(password, user.password, (err, isValid) => {
if (err) {
return done(err);
}
if (!isValid) {
return done(null, false, { message: '用户名或密码错误' });
}
return done(null, user);
});
}
));
// 序列化用户到会话
passport.serializeUser((user, done) => {
done(null, user.id);
});
// 从会话反序列化用户
passport.deserializeUser((id, done) => {
const user = users.find(user => user.id === id);
done(null, user);
});
// 注册路由
app.post('/api/register', async (req, res) => {
try {
const { username, password, email } = req.body;
// 检查用户是否已存在
if (users.find(user => user.username === username)) {
return res.status(400).json({ error: '用户名已存在' });
}
// 哈希密码
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// 创建用户
const newUser = {
id: users.length + 1,
username,
email,
password: hashedPassword
};
users.push(newUser);
res.status(201).json({ message: '注册成功' });
} catch (error) {
res.status(500).json({ error: '服务器内部错误' });
}
});
// 登录路由
app.post('/api/login', passport.authenticate('local', {
successRedirect: '/api/profile',
failureRedirect: '/api/login/failure'
}));
app.get('/api/login/failure', (req, res) => {
res.status(401).json({ error: '登录失败' });
});
// 保护路由的中间件
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.status(401).json({ error: '未认证' });
}
// 受保护的路由
app.get('/api/profile', isAuthenticated, (req, res) => {
res.json({
message: '访问个人资料成功',
user: {
id: req.user.id,
username: req.user.username,
email: req.user.email
}
});
});
// 登出路由
app.post('/api/logout', (req, res) => {
req.logout(err => {
if (err) {
return res.status(500).json({ error: '登出失败' });
}
res.json({ message: '登出成功' });
});
});
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});案例五:OAuth 2.0 集成
// 1. 安装依赖
// npm install express passport passport-google-oauth20 express-session
const express = require('express');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const session = require('express-session');
const app = express();
const port = 3000;
app.use(express.json());
// 配置会话
app.use(session({
secret: 'your-secret-key',
resave: false,
saveUninitialized: false
}));
// 初始化 Passport
app.use(passport.initialize());
app.use(passport.session());
// 配置 Google 策略
passport.use(new GoogleStrategy({
clientID: 'your-google-client-id', // 实际应用中应使用环境变量
clientSecret: 'your-google-client-secret', // 实际应用中应使用环境变量
callbackURL: 'http://localhost:3000/auth/google/callback'
},
(accessToken, refreshToken, profile, done) => {
// 这里可以查找或创建用户
// 为了演示,直接使用 Google 资料
const user = {
id: profile.id,
name: profile.displayName,
email: profile.emails[0].value,
photo: profile.photos[0].value
};
return done(null, user);
}
));
// 序列化用户到会话
passport.serializeUser((user, done) => {
done(null, user.id);
});
// 从会话反序列化用户
passport.deserializeUser((id, done) => {
// 这里应该从数据库中查找用户
// 为了演示,直接返回模拟用户
const user = {
id: id,
name: 'Google User',
email: 'user@example.com'
};
done(null, user);
});
// Google 登录路由
app.get('/auth/google',
passport.authenticate('google', { scope: ['profile', 'email'] })
);
// Google 回调路由
app.get('/auth/google/callback',
passport.authenticate('google', { failureRedirect: '/login' }),
(req, res) => {
// 登录成功,重定向到主页或其他页面
res.json({ message: '登录成功', user: req.user });
}
);
// 保护路由的中间件
function isAuthenticated(req, res, next) {
if (req.isAuthenticated()) {
return next();
}
res.status(401).json({ error: '未认证' });
}
// 受保护的路由
app.get('/api/protected', isAuthenticated, (req, res) => {
res.json({
message: '访问受保护资源成功',
user: req.user
});
});
// 登出路由
app.post('/api/logout', (req, res) => {
req.logout(err => {
if (err) {
return res.status(500).json({ error: '登出失败' });
}
res.json({ message: '登出成功' });
});
});
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});案例六:基于角色的授权
// 1. 安装依赖
// npm install express jsonwebtoken bcrypt
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
const app = express();
const port = 3000;
app.use(express.json());
// 模拟用户数据库
const users = [];
const JWT_SECRET = 'your-secret-key';
// 注册路由
app.post('/api/register', async (req, res) => {
try {
const { username, password, email, role } = req.body;
// 检查用户是否已存在
if (users.find(user => user.username === username)) {
return res.status(400).json({ error: '用户名已存在' });
}
// 哈希密码
const salt = await bcrypt.genSalt(10);
const hashedPassword = await bcrypt.hash(password, salt);
// 创建用户
const newUser = {
id: users.length + 1,
username,
email,
password: hashedPassword,
role: role || 'user' // 默认角色为 user
};
users.push(newUser);
res.status(201).json({ message: '注册成功' });
} catch (error) {
res.status(500).json({ error: '服务器内部错误' });
}
});
// 登录路由
app.post('/api/login', async (req, res) => {
try {
const { username, password } = req.body;
// 查找用户
const user = users.find(user => user.username === username);
if (!user) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 验证密码
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({ error: '用户名或密码错误' });
}
// 生成 JWT
const token = jwt.sign(
{ userId: user.id, username: user.username, role: user.role },
JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ message: '登录成功', token });
} catch (error) {
res.status(500).json({ error: '服务器内部错误' });
}
});
// 认证中间件
function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '未提供认证令牌' });
}
jwt.verify(token, JWT_SECRET, (error, user) => {
if (error) {
return res.status(403).json({ error: '无效的认证令牌' });
}
req.user = user;
next();
});
}
// 授权中间件
function authorizeRoles(...roles) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: '未认证' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: '权限不足' });
}
next();
};
}
// 受保护的路由
app.get('/api/user', authenticateToken, (req, res) => {
res.json({
message: '用户访问成功',
user: req.user
});
});
// 需要管理员权限的路由
app.get('/api/admin', authenticateToken, authorizeRoles('admin'), (req, res) => {
res.json({
message: '管理员访问成功',
user: req.user
});
});
app.listen(port, () => {
console.log(`服务器运行在 http://localhost:${port}`);
});学习目标
- 理解认证与授权:掌握认证与授权的基本概念和区别
- 密码安全:学会使用现代密码哈希算法保护用户密码
- JWT 实现:能够使用 JWT 实现无状态认证
- 会话管理:掌握基于会话的认证方式
- Passport.js:学会使用 Passport.js 集成多种认证策略
- OAuth 2.0:理解 OAuth 2.0 授权流程并实现第三方登录
- 基于角色的授权:能够实现基于角色的访问控制
代码优化建议
1. 使用环境变量存储敏感信息
不好的做法:
const JWT_SECRET = 'your-secret-key'; // 硬编码密钥
const googleClientId = 'your-google-client-id'; // 硬编码客户端 ID好的做法:
require('dotenv').config();
const JWT_SECRET = process.env.JWT_SECRET;
const googleClientId = process.env.GOOGLE_CLIENT_ID;2. 实现令牌黑名单
不好的做法:
// JWT 一旦签发无法撤销
app.post('/api/logout', (req, res) => {
res.json({ message: '登出成功' });
});好的做法:
// 实现令牌黑名单
const tokenBlacklist = new Set();
app.post('/api/logout', authenticateToken, (req, res) => {
const token = req.headers.authorization.split(' ')[1];
tokenBlacklist.add(token);
res.json({ message: '登出成功' });
});
// 修改认证中间件
function authenticateToken(req, res, next) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
if (!token) {
return res.status(401).json({ error: '未提供认证令牌' });
}
// 检查令牌是否在黑名单中
if (tokenBlacklist.has(token)) {
return res.status(403).json({ error: '令牌已被撤销' });
}
jwt.verify(token, JWT_SECRET, (error, user) => {
if (error) {
return res.status(403).json({ error: '无效的认证令牌' });
}
req.user = user;
next();
});
}3. 密码策略验证
不好的做法:
app.post('/api/register', async (req, res) => {
const { username, password } = req.body;
// 直接哈希密码,没有验证密码强度
const hashedPassword = await bcrypt.hash(password, 10);
// ...
});好的做法:
function validatePassword(password) {
// 密码长度至少 8 位
if (password.length < 8) {
return { valid: false, message: '密码长度至少 8 位' };
}
// 包含至少一个数字
if (!/\d/.test(password)) {
return { valid: false, message: '密码必须包含至少一个数字' };
}
// 包含至少一个字母
if (!/[a-zA-Z]/.test(password)) {
return { valid: false, message: '密码必须包含至少一个字母' };
}
return { valid: true };
}
app.post('/api/register', async (req, res) => {
const { username, password } = req.body;
// 验证密码强度
const passwordValidation = validatePassword(password);
if (!passwordValidation.valid) {
return res.status(400).json({ error: passwordValidation.message });
}
const hashedPassword = await bcrypt.hash(password, 10);
// ...
});4. 速率限制
不好的做法:
// 登录路由没有速率限制,容易受到暴力破解攻击
app.post('/api/login', async (req, res) => {
// ...
});好的做法:
// 实现简单的速率限制
const rateLimit = new Map();
const WINDOW_SIZE = 15 * 60 * 1000; // 15分钟
const MAX_ATTEMPTS = 5;
function rateLimiter(req, res, next) {
const ip = req.ip;
const now = Date.now();
if (!rateLimit.has(ip)) {
rateLimit.set(ip, []);
}
const attempts = rateLimit.get(ip);
// 移除过期的尝试
const recentAttempts = attempts.filter(timestamp => now - timestamp < WINDOW_SIZE);
rateLimit.set(ip, recentAttempts);
if (recentAttempts.length >= MAX_ATTEMPTS) {
return res.status(429).json({ error: '请求过于频繁,请稍后再试' });
}
// 记录本次尝试
recentAttempts.push(now);
next();
}
// 应用速率限制到登录路由
app.post('/api/login', rateLimiter, async (req, res) => {
// ...
});5. 安全头部
不好的做法:
const express = require('express');
const app = express();
// 没有设置安全头部好的做法:
const express = require('express');
const helmet = require('helmet');
const app = express();
// 设置安全头部
app.use(helmet());常见问题与解决方案
问题1:密码哈希性能问题
原因:
- 密码哈希算法过于复杂,导致注册和登录速度慢
解决方案:
- 选择合适的工作因子,平衡安全性和性能
- 考虑使用异步哈希函数,避免阻塞事件循环
问题2:JWT 令牌泄露
原因:
- 令牌存储在客户端,可能被 XSS 攻击获取
解决方案:
- 使用 HttpOnly Cookie 存储令牌
- 实现令牌过期机制
- 考虑使用刷新令牌
问题3:会话固定攻击
原因:
- 会话 ID 在认证前后保持不变
解决方案:
- 在认证成功后重新生成会话 ID
- 设置合理的会话过期时间
问题4:OAuth 回调 URL 安全
原因:
- 回调 URL 配置不当,可能导致安全问题
解决方案:
- 在 OAuth 提供商处严格配置回调 URL
- 验证回调中的状态参数,防止 CSRF 攻击
问题5:权限提升
原因:
- 授权检查不严格,导致用户获取超出其权限的访问权
解决方案:
- 实现细粒度的权限控制
- 在每个需要授权的操作前都进行权限检查
- 审计关键操作的权限使用
总结
通过本教程的学习,你应该能够:
- 理解认证与授权的基本概念和区别
- 掌握密码安全的最佳实践,使用现代哈希算法保护用户密码
- 实现基于 JWT 的无状态认证系统
- 使用会话管理实现传统的认证方式
- 集成 Passport.js 实现多种认证策略
- 实现基于 OAuth 2.0 的第三方登录
- 构建基于角色的访问控制系统
- 识别和解决常见的认证授权安全问题
认证与授权是构建安全应用的基础,正确实现这些功能对于保护用户数据和系统资源至关重要。在实际开发中,你应该根据应用的具体需求,选择合适的认证授权方案,并遵循安全最佳实践,不断更新和改进安全措施,以应对不断演变的安全威胁。