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}`);
});

学习目标

  1. 理解认证与授权:掌握认证与授权的基本概念和区别
  2. 密码安全:学会使用现代密码哈希算法保护用户密码
  3. JWT 实现:能够使用 JWT 实现无状态认证
  4. 会话管理:掌握基于会话的认证方式
  5. Passport.js:学会使用 Passport.js 集成多种认证策略
  6. OAuth 2.0:理解 OAuth 2.0 授权流程并实现第三方登录
  7. 基于角色的授权:能够实现基于角色的访问控制

代码优化建议

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:权限提升

原因

  • 授权检查不严格,导致用户获取超出其权限的访问权

解决方案

  • 实现细粒度的权限控制
  • 在每个需要授权的操作前都进行权限检查
  • 审计关键操作的权限使用

总结

通过本教程的学习,你应该能够:

  1. 理解认证与授权的基本概念和区别
  2. 掌握密码安全的最佳实践,使用现代哈希算法保护用户密码
  3. 实现基于 JWT 的无状态认证系统
  4. 使用会话管理实现传统的认证方式
  5. 集成 Passport.js 实现多种认证策略
  6. 实现基于 OAuth 2.0 的第三方登录
  7. 构建基于角色的访问控制系统
  8. 识别和解决常见的认证授权安全问题

认证与授权是构建安全应用的基础,正确实现这些功能对于保护用户数据和系统资源至关重要。在实际开发中,你应该根据应用的具体需求,选择合适的认证授权方案,并遵循安全最佳实践,不断更新和改进安全措施,以应对不断演变的安全威胁。

« 上一篇 Node.js 数据库集成 下一篇 » Node.js 错误处理与调试