{T}

Session与Cookie

在 Web 应用中,Session 和 Cookie 是实现用户会话管理的两种主要方式。理解它们的工作原理和安全配置对于构建安全的认证系统至关重要。

会话管理架构

整体架构图

code
┌─────────────────────────────────────────────────────────────────────┐
│                            客户端(浏览器)                           │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │                      Cookie 存储                               │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐           │  │
│  │  │ Session ID  │  │  用户偏好   │  │  访问令牌   │           │  │
│  │  └─────────────┘  └─────────────┘  └─────────────┘           │  │
│  └───────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                          HTTP 请求携带 Cookie
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│                            服务端(Node.js)                         │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │                    会话中间件层                                 │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐           │  │
│  │  │ Cookie 解析 │→│ Session 验证│→│ 权限检查    │           │  │
│  │  └─────────────┘  └─────────────┘  └─────────────┘           │  │
│  └───────────────────────────────────────────────────────────────┘  │
│                                    │                                 │
│                                    ▼                                 │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │                    Session 存储层                              │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐           │  │
│  │  │   Redis     │  │   MySQL     │  │  MongoDB    │           │  │
│  │  │  (推荐)     │  │             │  │             │           │  │
│  │  └─────────────┘  └─────────────┘  └─────────────┘           │  │
│  └───────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

认证流程图

code
┌────────┐                                           ┌────────┐
│ 客户端 │                                           │ 服务端 │
└───┬────┘                                           └───┬────┘
    │                                                     │
    │  1. POST /login (username, password)                │
    │────────────────────────────────────────────────────>│
    │                                                     │
    │                                         2. 验证用户凭证
    │                                         3. 创建 Session
    │                                         4. 生成 Session ID
    │                                                     │
    │  5. Set-Cookie: sessionId=xxx; HttpOnly; Secure     │
    │<────────────────────────────────────────────────────│
    │                                                     │
    │  6. GET /profile                                    │
    │     Cookie: sessionId=xxx                           │
    │────────────────────────────────────────────────────>│
    │                                                     │
    │                                         7. 验证 Session ID
    │                                         8. 获取 Session 数据
    │                                                     │
    │  9. 返回用户信息                                     │
    │<────────────────────────────────────────────────────│
    │                                                     │
    │  10. POST /logout                                   │
    │────────────────────────────────────────────────────>│
    │                                                     │
    │                                         11. 销毁 Session
    │                                         12. 清除 Cookie
    │                                                     │
    │  13. Set-Cookie: sessionId=; Max-Age=0              │
    │<────────────────────────────────────────────────────│
    │                                                     │

Cookie 是服务器发送到用户浏览器并保存在本地的小型数据片段。浏览器会在后续请求中自动携带 Cookie,从而实现状态的保持。

Cookie 的特点:

  • 大小限制:单个 Cookie 通常不超过 4KB
  • 数量限制:每个域名通常不超过 20-50 个 Cookie
  • 自动携带:浏览器自动在同域请求中携带 Cookie
  • 可配置属性:支持多种安全和功能属性
属性类型说明示例
name/valuestringCookie 名称和值sessionId=abc123
maxAgenumber过期时间(毫秒),优先于 expiresmaxAge: 86400000
expiresDate过期日期expires: new Date('2024-12-31')
httpOnlyboolean仅 HTTP 访问,防止 XSS 窃取httpOnly: true
secureboolean仅 HTTPS 传输secure: true
sameSitestringCSRF 防护,控制跨站发送sameSite: 'strict'
domainstring作用域名domain: '.example.com'
pathstring作用路径path: '/admin'
signedbooleanCookie 签名防篡改signed: true
prioritystringCookie 优先级(Chrome)priority: 'high'
javascript
const express = require('express')
const cookieParser = require('cookie-parser')

const app = express()

// Cookie 解析中间件
app.use(cookieParser(process.env.COOKIE_SECRET))

// ==================== 设置 Cookie ====================

// 基本设置
app.get('/set-cookie', (req, res) => {
  res.cookie('name', 'value', {
    maxAge: 900000,        // 15分钟过期
    httpOnly: true,        // 防止 JavaScript 访问
    secure: true,          // 仅 HTTPS
    sameSite: 'strict'     // 严格的 CSRF 防护
  })
  res.send('Cookie 已设置')
})

// 不同类型的 Cookie 设置
app.get('/set-cookies', (req, res) => {
  // 会话 Cookie(浏览器关闭后删除)
  res.cookie('sessionToken', 'temp-token')
  
  // 持久化 Cookie(指定过期时间)
  res.cookie('preferences', JSON.stringify({ theme: 'dark' }), {
    maxAge: 30 * 24 * 60 * 60 * 1000, // 30天
    httpOnly: false // 允许 JavaScript 读取
  })
  
  // 签名 Cookie(防篡改)
  res.cookie('userId', '12345', {
    signed: true,
    httpOnly: true,
    maxAge: 24 * 60 * 60 * 1000
  })
  
  // 跨子域名 Cookie
  res.cookie('sharedData', 'value', {
    domain: '.example.com', // 所有子域名共享
    path: '/'
  })
  
  res.json({ success: true })
})

// ==================== 读取 Cookie ====================

app.get('/get-cookie', (req, res) => {
  // 读取普通 Cookie
  const name = req.cookies.name
  
  // 读取签名 Cookie(自动验证签名)
  const userId = req.signedCookies.userId
  
  // 检查 Cookie 是否存在
  if (!req.signedCookies.userId) {
    return res.status(400).json({ error: '无效的 Cookie' })
  }
  
  res.json({
    name,
    userId,
    allCookies: req.cookies,
    allSignedCookies: req.signedCookies
  })
})

// ==================== 删除 Cookie ====================

app.get('/clear-cookie', (req, res) => {
  // 删除单个 Cookie
  res.clearCookie('name')
  
  // 删除时需要匹配 domain 和 path
  res.clearCookie('sharedData', {
    domain: '.example.com',
    path: '/'
  })
  
  res.json({ success: true })
})

// ==================== Cookie 工具函数 ====================

// 安全设置 Cookie 的封装函数
function setAuthCookie(res, name, value, options = {}) {
  const defaultOptions = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/',
    ...options
  }
  
  res.cookie(name, value, defaultOptions)
}

// 设置刷新令牌(长期有效)
function setRefreshTokenCookie(res, token) {
  setAuthCookie(res, 'refreshToken', token, {
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
    path: '/auth/refresh' // 仅在刷新接口发送
  })
}

// 设置访问令牌(短期有效)
function setAccessTokenCookie(res, token) {
  setAuthCookie(res, 'accessToken', token, {
    maxAge: 15 * 60 * 1000 // 15分钟
  })
}

1. XSS 窃取 Cookie

javascript
// ❌ 危险:未设置 httpOnly
res.cookie('sessionId', 'xxx', {
  httpOnly: false // JavaScript 可以通过 document.cookie 读取
})

// ✅ 安全:设置 httpOnly
res.cookie('sessionId', 'xxx', {
  httpOnly: true // 防止 XSS 窃取
})

2. 中间人攻击

javascript
// ❌ 危险:未加密传输
res.cookie('sessionId', 'xxx', {
  secure: false // HTTP 明文传输,可被窃听
})

// ✅ 安全:强制 HTTPS
res.cookie('sessionId', 'xxx', {
  secure: true // 仅 HTTPS 传输
})

3. CSRF 攻击

javascript
// ❌ 危险:无 CSRF 防护
res.cookie('sessionId', 'xxx')

// ✅ 安全:SameSite 属性
res.cookie('sessionId', 'xxx', {
  sameSite: 'strict' // 完全禁止跨站发送
})

// sameSite 值对比
// 'strict' - 完全禁止跨站发送(最安全,可能影响用户体验)
// 'lax'    - 允许顶级导航的 GET 请求携带(推荐)
// 'none'   - 允许跨站发送(需配合 secure: true)

4. Cookie 篡改

javascript
// ❌ 危险:未签名的 Cookie
res.cookie('userId', '12345') // 可被用户篡改

// ✅ 安全:签名 Cookie
app.use(cookieParser(process.env.COOKIE_SECRET))
res.cookie('userId', '12345', { signed: true })

// 读取时自动验证签名
const userId = req.signedCookies.userId // 篡改后返回 false

Session 基础

什么是 Session

Session 是服务器端存储的会话数据,通过 Session ID 与客户端关联。Session ID 通常通过 Cookie 传递给客户端。

Session 与 Cookie 的关系:

code
┌─────────────────────────────────────────────────────────────┐
│                        客户端                                │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Cookie: sessionId = "abc123def456"                 │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                            │
                    HTTP 请求携带 Cookie
                            │
                            ▼
┌─────────────────────────────────────────────────────────────┐
│                        服务端                                │
│  ┌────────────────────┐    ┌────────────────────────────┐  │
│  │ Session Store      │    │ Session Data               │  │
│  │ (Redis/Memory/DB)  │    │ {                          │  │
│  │                    │    │   userId: "12345",         │  │
│  │ sessionId → Data   │    │   username: "john",        │  │
│  │                    │    │   role: "admin",           │  │
│  │ abc123 → {...}     │    │   createdAt: "2024-01-01"  │  │
│  │ def456 → {...}     │    │ }                          │  │
│  └────────────────────┘    └────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

Session 工作流程

code
1. 客户端首次访问
   ↓
2. 服务器创建 Session,生成 Session ID
   ↓
3. 服务器通过 Set-Cookie 返回 Session ID
   ↓
4. 客户端保存 Session ID 到 Cookie
   ↓
5. 后续请求自动携带 Session ID
   ↓
6. 服务器根据 Session ID 查找 Session 数据

Session 存储方案

存储方案对比

存储方案优点缺点适用场景
内存存储简单、快速重启丢失、无法扩展开发环境
Redis高性能、分布式、持久化需要额外部署生产环境推荐
MySQL数据持久化、易管理性能较低中小型应用
MongoDB灵活、高性能需要 MongoDB已有 MongoDB 的项目

1. 内存存储

适合开发环境,不适合生产环境(重启丢失、无法多进程共享)。

javascript
const session = require('express-session')

// 基本配置
app.use(session({
  secret: 'your-secret-key',
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: false, // 开发环境
    maxAge: 24 * 60 * 60 * 1000
  }
}))

// 完整示例
app.get('/login', (req, res) => {
  req.session.userId = '12345'
  req.session.username = 'john'
  req.session.role = 'admin'
  res.send('登录成功')
})

app.get('/profile', (req, res) => {
  if (req.session.userId) {
    res.json({
      userId: req.session.userId,
      username: req.session.username,
      role: req.session.role
    })
  } else {
    res.status(401).send('未登录')
  }
})

app.get('/logout', (req, res) => {
  req.session.destroy(err => {
    if (err) {
      return res.status(500).send('登出失败')
    }
    res.clearCookie('connect.sid')
    res.send('登出成功')
  })
})

2. Redis 存储(推荐)

生产环境推荐,支持分布式、持久化。

javascript
const session = require('express-session')
const RedisStore = require('connect-redis').default
const redis = require('redis')

// ==================== Redis 客户端配置 ====================

// 创建 Redis 客户端
const redisClient = redis.createClient({
  socket: {
    host: process.env.REDIS_HOST || 'localhost',
    port: process.env.REDIS_PORT || 6379,
    reconnectStrategy: (retries) => {
      // 重连策略
      if (retries > 10) {
        console.error('Redis 连接失败次数过多')
        return new Error('Redis 连接失败')
      }
      return Math.min(retries * 100, 3000) // 重连延迟
    }
  },
  password: process.env.REDIS_PASSWORD,
  database: process.env.REDIS_DB || 0
})

redisClient.on('error', err => {
  console.error('Redis 错误:', err)
})

redisClient.on('connect', () => {
  console.log('Redis 已连接')
})

// 连接 Redis
redisClient.connect().catch(console.error)

// ==================== Session 配置 ====================

app.use(session({
  store: new RedisStore({
    client: redisClient,
    prefix: 'sess:',           // Session 键前缀
    ttl: 24 * 60 * 60,         // 过期时间(秒)
    disableTouch: false        // 允许刷新过期时间
  }),
  secret: process.env.SESSION_SECRET,
  name: 'sessionId',          // 自定义 Cookie 名称
  resave: false,              // 不强制重新保存
  saveUninitialized: false,   // 不保存未初始化的 Session
  rolling: true,              // 每次请求刷新过期时间
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'strict',
    maxAge: 24 * 60 * 60 * 1000, // 24小时
    domain: process.env.COOKIE_DOMAIN
  }
}))

// ==================== Session 操作 ====================

// 登录
app.post('/login', async (req, res) => {
  const { username, password } = req.body
  
  // 验证用户
  const user = await verifyUser(username, password)
  
  if (user) {
    // 登录后重新生成 Session ID(防止固定攻击)
    req.session.regenerate(err => {
      if (err) {
        return res.status(500).json({ error: '登录失败' })
      }
      
      req.session.userId = user.id
      req.session.username = user.username
      req.session.role = user.role
      req.session.loginTime = new Date().toISOString()
      req.session.ipAddress = req.ip
      
      res.json({ 
        success: true, 
        user: {
          id: user.id,
          username: user.username,
          role: user.role
        }
      })
    })
  } else {
    res.status(401).json({ error: '用户名或密码错误' })
  }
})

// 获取 Session 信息
app.get('/session-info', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: '未登录' })
  }
  
  res.json({
    userId: req.session.userId,
    username: req.session.username,
    role: req.session.role,
    loginTime: req.session.loginTime,
    sessionId: req.sessionID
  })
})

// 更新 Session 数据
app.put('/session', (req, res) => {
  if (!req.session.userId) {
    return res.status(401).json({ error: '未登录' })
  }
  
  // 更新部分数据
  if (req.body.preferences) {
    req.session.preferences = req.body.preferences
  }
  
  res.json({ success: true })
})

// 登出
app.post('/logout', (req, res) => {
  req.session.destroy(err => {
    if (err) {
      return res.status(500).json({ error: '登出失败' })
    }
    res.clearCookie('sessionId')
    res.json({ success: true, message: '登出成功' })
  })
})

// 优雅关闭
process.on('SIGTERM', async () => {
  await redisClient.quit()
  process.exit(0)
})

3. 数据库存储

使用 MySQL 或 MongoDB 存储 Session。

javascript
// ==================== MongoDB 存储 ====================

const MongoStore = require('connect-mongo')

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: MongoStore.create({
    mongoUrl: process.env.MONGODB_URI,
    collectionName: 'sessions',
    ttl: 24 * 60 * 60,           // 过期时间(秒)
    autoRemove: 'native',         // 自动删除过期 Session
    autoRemoveInterval: 10,       // 清理间隔(分钟)
    touchAfter: 24 * 3600         // 更新间隔(秒)
  }),
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    maxAge: 24 * 60 * 60 * 1000
  }
}))

// ==================== MySQL 存储 ====================

const MySQLStore = require('express-mysql-session')(session)

const options = {
  host: process.env.DB_HOST,
  port: process.env.DB_PORT || 3306,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
  database: process.env.DB_NAME,
  clearExpired: true,           // 自动清理过期 Session
  checkExpirationInterval: 900000, // 检查间隔(毫秒)
  expiration: 86400000,          // 过期时间(毫秒)
  createDatabaseTable: true,     // 自动创建表
  schema: {
    tableName: 'sessions',
    columnNames: {
      session_id: 'session_id',
      expires: 'expires',
      data: 'data'
    }
  }
}

const sessionStore = new MySQLStore(options)

app.use(session({
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  store: sessionStore,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    maxAge: 24 * 60 * 60 * 1000
  }
}))

Session API 接口说明

完整认证 API 示例

javascript
const express = require('express')
const session = require('express-session')
const bcrypt = require('bcrypt')
const { body, validationResult } = require('express-validator')

const app = express()
app.use(express.json())

// ==================== 用户模型 ====================

// 模拟用户数据库
const users = new Map()
const sessions = new Map()

// ==================== 验证中间件 ====================

const validate = (req, res, next) => {
  const errors = validationResult(req)
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() })
  }
  next()
}

const requireAuth = (req, res, next) => {
  if (!req.session.userId) {
    return res.status(401).json({ 
      error: '未授权',
      code: 'UNAUTHORIZED'
    })
  }
  next()
}

// ==================== 认证接口 ====================

/**
 * @route   POST /api/auth/register
 * @desc    用户注册
 * @access  Public
 */
app.post('/api/auth/register',
  [
    body('username').trim().isLength({ min: 3, max: 30 }),
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 8 })
  ],
  validate,
  async (req, res) => {
    const { username, email, password } = req.body
    
    // 检查用户是否存在
    if (users.has(email)) {
      return res.status(409).json({ error: '用户已存在' })
    }
    
    // 创建用户
    const hashedPassword = await bcrypt.hash(password, 12)
    const user = {
      id: Date.now().toString(),
      username,
      email,
      password: hashedPassword,
      createdAt: new Date()
    }
    
    users.set(email, user)
    
    res.status(201).json({
      success: true,
      message: '注册成功'
    })
  }
)

/**
 * @route   POST /api/auth/login
 * @desc    用户登录
 * @access  Public
 */
app.post('/api/auth/login',
  [
    body('email').isEmail(),
    body('password').notEmpty()
  ],
  validate,
  async (req, res) => {
    const { email, password } = req.body
    
    const user = users.get(email)
    
    if (!user || !(await bcrypt.compare(password, user.password))) {
      return res.status(401).json({ error: '邮箱或密码错误' })
    }
    
    // 重新生成 Session ID
    req.session.regenerate(err => {
      if (err) {
        return res.status(500).json({ error: '登录失败' })
      }
      
      req.session.userId = user.id
      req.session.username = user.username
      req.session.email = user.email
      
      res.json({
        success: true,
        user: {
          id: user.id,
          username: user.username,
          email: user.email
        }
      })
    })
  }
)

/**
 * @route   GET /api/auth/me
 * @desc    获取当前用户信息
 * @access  Private
 */
app.get('/api/auth/me', requireAuth, (req, res) => {
  res.json({
    user: {
      id: req.session.userId,
      username: req.session.username,
      email: req.session.email
    }
  })
)

/**
 * @route   POST /api/auth/logout
 * @desc    用户登出
 * @access  Private
 */
app.post('/api/auth/logout', (req, res) => {
  req.session.destroy(err => {
    if (err) {
      return res.status(500).json({ error: '登出失败' })
    }
    res.clearCookie('sessionId')
    res.json({ success: true })
  })
})

/**
 * @route   POST /api/auth/logout-all
 * @desc    登出所有设备
 * @access  Private
 */
app.post('/api/auth/logout-all', requireAuth, async (req, res) => {
  const userId = req.session.userId
  
  // 遍历删除该用户的所有 Session
  for (const [sessionId, session] of sessions) {
    if (session.userId === userId) {
      sessions.delete(sessionId)
    }
  }
  
  req.session.destroy(err => {
    if (err) {
      return res.status(500).json({ error: '操作失败' })
    }
    res.clearCookie('sessionId')
    res.json({ success: true, message: '已登出所有设备' })
  })
})

Session 配置参数详解

express-session 完整配置

javascript
const session = require('express-session')

app.use(session({
  // ==================== 核心参数 ====================
  
  // 签名密钥(必须配置)
  // 支持数组形式,用于密钥轮换
  secret: process.env.SESSION_SECRET || ['key1', 'key2'],
  
  // Session ID Cookie 名称(默认: connect.sid)
  // 建议修改以隐藏技术栈
  name: 'sessionId',
  
  // ==================== 存储相关 ====================
  
  // Session 存储实例(默认: MemoryStore)
  store: new RedisStore({ client: redisClient }),
  
  // Session ID 生成函数
  genid: (req) => {
    return require('crypto').randomUUID()
  },
  
  // ==================== 行为控制 ====================
  
  // 是否在每次请求时强制保存 Session
  // 建议 false,避免不必要的存储操作
  resave: false,
  
  // 是否保存未初始化的 Session
  // 建议 false,减少存储空间
  saveUninitialized: false,
  
  // 是否在每次请求时重置 Cookie 过期时间
  // 实现"滑动过期"
  rolling: true,
  
  // 是否无条件保存 Session(即使未修改)
  // 仅在特定场景使用
  unset: 'destroy', // 或 'keep'
  
  // ==================== 安全相关 ====================
  
  // 代理设置(用于反向代理)
  proxy: true,
  
  // Cookie 配置
  cookie: {
    // 是否仅 HTTPS(生产环境必须 true)
    secure: process.env.NODE_ENV === 'production',
    
    // 是否仅 HTTP(防止 XSS)
    httpOnly: true,
    
    // 同站策略(防止 CSRF)
    sameSite: 'strict', // 或 'lax', 'none'
    
    // 过期时间(毫秒)
    maxAge: 24 * 60 * 60 * 1000, // 24小时
    
    // 作用域名
    domain: process.env.COOKIE_DOMAIN,
    
    // 作用路径
    path: '/',
    
    // 过期日期(优先级低于 maxAge)
    // expires: new Date(Date.now() + 86400000)
  }
}))

配置参数说明表

参数类型默认值说明
secretstring/string[]必填Session 签名密钥
namestring'connect.sid'Cookie 名称
storeStoreMemoryStoreSession 存储实例
genidfunctionuid-safeSession ID 生成函数
resavebooleantrue强制保存 Session
saveUninitializedbooleantrue保存未初始化 Session
rollingbooleanfalse重置 Cookie 过期时间
unsetstring'keep'Session 删除行为
proxyboolean-信任反向代理
cookieobject见下表Cookie 配置
参数类型默认值说明
maxAgenumbernull过期时间(毫秒)
expiresDatenull过期日期
securebooleanfalse仅 HTTPS
httpOnlybooleantrue仅 HTTP 访问
sameSiteboolean/stringfalse同站策略
domainstringnull作用域名
pathstring'/'作用路径

Session 安全配置

1. Session ID 安全生成

javascript
const crypto = require('crypto')

app.use(session({
  secret: process.env.SESSION_SECRET,
  // 使用加密安全的随机 ID
  genid: (req) => {
    // 方案1: UUID v4
    return crypto.randomUUID()
    
    // 方案2: 随机字节
    // return crypto.randomBytes(32).toString('hex')
    
    // 方案3: 基于时间戳 + 随机数
    // return `${Date.now()}-${crypto.randomBytes(16).toString('hex')}`
  },
  // ...
}))

2. Session 固定攻击防护

Session 固定攻击是攻击者获取 Session ID 后,诱导受害者使用该 ID 登录。

javascript
// 登录时必须重新生成 Session ID
app.post('/login', async (req, res) => {
  const { username, password } = req.body
  const user = await verifyUser(username, password)
  
  if (user) {
    // ✅ 登录后重新生成 Session ID
    req.session.regenerate(err => {
      if (err) {
        return res.status(500).json({ error: '登录失败' })
      }
      
      // 设置用户数据
      req.session.userId = user.id
      req.session.username = user.username
      
      res.json({ success: true })
    })
  } else {
    res.status(401).json({ error: '认证失败' })
  }
})

// 权限升级时也要重新生成
app.post('/admin/upgrade', requireAuth, (req, res) => {
  // 权限变更时重新生成 Session ID
  req.session.regenerate(err => {
    if (err) {
      return res.status(500).json({ error: '操作失败' })
    }
    
    req.session.userId = req.user.id
    req.session.role = 'admin'
    
    res.json({ success: true })
  })
})

3. Session 过期管理

javascript
// ==================== 滑动过期 ====================

app.use(session({
  secret: process.env.SESSION_SECRET,
  rolling: true, // 每次请求刷新 Cookie 过期时间
  cookie: {
    maxAge: 30 * 60 * 1000, // 30 分钟
    secure: true,
    httpOnly: true
  }
}))

// ==================== 绝对过期 ====================

app.use((req, res, next) => {
  if (req.session) {
    const now = Date.now()
    const createdAt = req.session.createdAt || now
    const absoluteTimeout = 8 * 60 * 60 * 1000 // 8小时绝对过期
    
    if (now - createdAt > absoluteTimeout) {
      return req.session.destroy(() => {
        res.status(401).json({ 
          error: 'Session 已过期,请重新登录',
          code: 'SESSION_EXPIRED'
        })
      })
    }
    
    // 记录创建时间
    if (!req.session.createdAt) {
      req.session.createdAt = now
    }
  }
  next()
})

// ==================== 空闲超时 ====================

app.use((req, res, next) => {
  if (req.session) {
    const now = Date.now()
    const lastActivity = req.session.lastActivity || now
    const inactiveTimeout = 30 * 60 * 1000 // 30分钟无活动
    
    if (now - lastActivity > inactiveTimeout) {
      return req.session.destroy(() => {
        res.status(401).json({ 
          error: '长时间未活动,请重新登录',
          code: 'INACTIVE_TIMEOUT'
        })
      })
    }
    
    req.session.lastActivity = now
  }
  next()
})

4. 多设备登录控制

javascript
const redis = require('redis')
const client = redis.createClient()

// ==================== 单设备登录 ====================

app.post('/login', async (req, res) => {
  const { username, password } = req.body
  const user = await verifyUser(username, password)
  
  if (user) {
    // 检查是否已有登录 Session
    const existingSessionId = await client.get(`user:${user.id}:session`)
    
    if (existingSessionId) {
      // 删除旧 Session
      await client.del(`sess:${existingSessionId}`)
    }
    
    req.session.regenerate(async err => {
      if (err) {
        return res.status(500).json({ error: '登录失败' })
      }
      
      req.session.userId = user.id
      req.session.username = user.username
      
      // 保存 Session 映射
      await client.set(
        `user:${user.id}:session`,
        req.sessionID,
        'EX',
        24 * 60 * 60
      )
      
      res.json({ success: true })
    })
  } else {
    res.status(401).json({ error: '认证失败' })
  }
})

// ==================== 多设备管理 ====================

// 获取所有登录设备
app.get('/sessions', requireAuth, async (req, res) => {
  const userId = req.session.userId
  
  // 获取用户的所有 Session
  const sessionIds = await client.sMembers(`user:${userId}:sessions`)
  const sessions = []
  
  for (const sid of sessionIds) {
    const data = await client.get(`sess:${sid}`)
    if (data) {
      const session = JSON.parse(data)
      sessions.push({
        sessionId: sid.substring(0, 8) + '...',
        userAgent: session.userAgent,
        ipAddress: session.ipAddress,
        lastActivity: session.lastActivity,
        current: sid === req.sessionID
      })
    }
  }
  
  res.json({ sessions })
})

// 登出指定设备
app.delete('/sessions/:sessionId', requireAuth, async (req, res) => {
  const { sessionId } = req.params
  const userId = req.session.userId
  
  // 验证 Session 属于当前用户
  const isMember = await client.sIsMember(`user:${userId}:sessions`, sessionId)
  
  if (!isMember) {
    return res.status(404).json({ error: 'Session 不存在' })
  }
  
  // 删除 Session
  await client.del(`sess:${sessionId}`)
  await client.sRem(`user:${userId}:sessions`, sessionId)
  
  res.json({ success: true })
})

// 登录时记录设备信息
app.post('/login', async (req, res) => {
  const user = await verifyUser(req.body.username, req.body.password)
  
  if (user) {
    req.session.regenerate(async err => {
      if (err) return res.status(500).json({ error: '登录失败' })
      
      req.session.userId = user.id
      req.session.username = user.username
      req.session.userAgent = req.get('User-Agent')
      req.session.ipAddress = req.ip
      req.session.lastActivity = Date.now()
      
      // 记录到用户的 Session 列表
      await client.sAdd(`user:${user.id}:sessions`, req.sessionID)
      await client.expire(`user:${user.id}:sessions`, 24 * 60 * 60)
      
      res.json({ success: true })
    })
  }
})

分布式 Session 方案

多服务器架构

code
┌───────────┐     ┌───────────┐     ┌───────────┐
│ Server 1  │     │ Server 2  │     │ Server 3  │
│ Node.js   │     │ Node.js   │     │ Node.js   │
└─────┬─────┘     └─────┬─────┘     └─────┬─────┘
      │                 │                 │
      └────────────────┬┴─────────────────┘
                       │
                       ▼
              ┌────────────────┐
              │     Redis      │
              │  Session Store │
              │   (共享存储)   │
              └────────────────┘
javascript
// 所有服务器使用相同的 Redis 配置
const session = require('express-session')
const RedisStore = require('connect-redis').default
const redis = require('redis')

// 统一的 Redis 配置
const redisClient = redis.createClient({
  socket: {
    host: process.env.REDIS_HOST, // Redis 服务器地址
    port: process.env.REDIS_PORT
  },
  password: process.env.REDIS_PASSWORD
})

redisClient.connect()

// 所有实例使用相同的 Session 配置
app.use(session({
  store: new RedisStore({
    client: redisClient,
    prefix: 'sess:'
  }),
  secret: process.env.SESSION_SECRET, // 所有实例使用相同密钥
  name: 'sessionId',
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true,
    httpOnly: true,
    sameSite: 'strict',
    maxAge: 24 * 60 * 60 * 1000,
    domain: '.example.com' // 跨子域名共享
  }
}))

性能优化

1. Session 数据优化

javascript
// ❌ 避免在 Session 中存储大量数据
req.session.user = {
  id: '123',
  // ❌ 不要存储完整用户信息
  profile: largeUserData,
  posts: allUserPosts
}

// ✅ 只存储必要信息
req.session.userId = user.id
req.session.role = user.role

// 需要时从数据库获取完整信息
app.get('/profile', async (req, res) => {
  const user = await User.findById(req.session.userId)
  res.json(user)
})

2. 缓存策略

javascript
// 使用 Redis 缓存用户数据
async function getUserWithCache(userId) {
  // 先查缓存
  const cached = await redisClient.get(`user:${userId}`)
  if (cached) {
    return JSON.parse(cached)
  }
  
  // 查数据库
  const user = await User.findById(userId)
  
  // 写入缓存
  await redisClient.setEx(
    `user:${userId}`,
    3600, // 1小时
    JSON.stringify(user)
  )
  
  return user
}

3. 连接池配置

javascript
// Redis 连接池
const { Redis } = require('ioredis')

const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
  password: process.env.REDIS_PASSWORD,
  db: 0,
  // 连接池配置
  enableReadyCheck: true,
  maxRetriesPerRequest: 3,
  retryDelayOnFailover: 100,
  lazyConnect: true,
  keepAlive: 10000
})

Session vs JWT 对比

特性SessionJWT
存储位置服务器端客户端
扩展性需要共享存储无状态,易扩展
安全性较高(数据在服务器)一般(数据在客户端)
性能需查询存储无需查询,验证签名
注销立即生效需额外机制(黑名单)
跨域需要特殊处理天然支持
数据大小无限制受 Cookie/Header 大小限制
续期简单(rolling)需要刷新令牌机制

选择建议:

  • 使用 Session:单体应用、传统 Web 应用、需要即时注销、敏感操作
  • 使用 JWT:微服务架构、移动应用、跨域场景、无状态 API

最佳实践

完整配置示例

javascript
const express = require('express')
const session = require('express-session')
const RedisStore = require('connect-redis').default
const redis = require('redis')
const helmet = require('helmet')
const rateLimit = require('express-rate-limit')
const crypto = require('crypto')

const app = express()

// ==================== 安全中间件 ====================

app.use(helmet())

// 速率限制
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { error: '登录尝试次数过多,请稍后重试' }
})

// ==================== Redis 连接 ====================

const redisClient = redis.createClient({
  socket: {
    host: process.env.REDIS_HOST,
    port: process.env.REDIS_PORT
  },
  password: process.env.REDIS_PASSWORD
})

redisClient.connect().catch(console.error)

// ==================== Session 配置 ====================

app.use(session({
  store: new RedisStore({
    client: redisClient,
    prefix: 'sess:',
    ttl: 24 * 60 * 60
  }),
  secret: process.env.SESSION_SECRET,
  name: 'sessionId',
  genid: () => crypto.randomUUID(),
  resave: false,
  saveUninitialized: false,
  rolling: true,
  cookie: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'strict',
    maxAge: 24 * 60 * 60 * 1000,
    domain: process.env.COOKIE_DOMAIN
  }
}))

// ==================== 认证中间件 ====================

function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: '请先登录' })
  }
  next()
}

// ==================== 路由 ====================

app.post('/login', loginLimiter, async (req, res) => {
  // 登录逻辑
})

app.get('/protected', requireAuth, (req, res) => {
  res.json({ message: '受保护的内容' })
})

app.post('/logout', (req, res) => {
  req.session.destroy(err => {
    if (err) return res.status(500).json({ error: '登出失败' })
    res.clearCookie('sessionId')
    res.json({ success: true })
  })
})

常见问题解答

Q1: Session 丢失或无法保持登录状态?

原因分析:

  1. Session 存储配置问题
  2. Cookie 设置问题
  3. 跨域配置问题
  4. HTTPS 配置问题

解决方案:

javascript
// 1. 检查 Session 存储
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false, // 确保设置为 false
  cookie: {
    // 2. 开发环境允许非 HTTPS
    secure: process.env.NODE_ENV === 'production' ? true : false,
    httpOnly: true,
    sameSite: process.env.NODE_ENV === 'production' ? 'strict' : 'lax',
    maxAge: 24 * 60 * 60 * 1000
  }
}))

// 3. 跨域配置
app.use(cors({
  origin: 'https://yourdomain.com',
  credentials: true // 允许携带 Cookie
}))

// 4. 代理配置(如果使用反向代理)
app.set('trust proxy', 1)

Q2: 如何实现"记住我"功能?

javascript
app.post('/login', async (req, res) => {
  const { username, password, rememberMe } = req.body
  const user = await verifyUser(username, password)
  
  if (user) {
    req.session.regenerate(err => {
      if (err) return res.status(500).json({ error: '登录失败' })
      
      req.session.userId = user.id
      
      // 根据选择设置过期时间
      const maxAge = rememberMe 
        ? 30 * 24 * 60 * 60 * 1000  // 30天
        : 24 * 60 * 60 * 1000       // 24小时
      
      req.session.cookie.maxAge = maxAge
      
      res.json({ success: true })
    })
  }
})

Q3: 如何在集群环境中共享 Session?

javascript
// 使用 Redis 作为共享存储
const { Redis } = require('ioredis')
const RedisStore = require('connect-redis').default

// 所有服务器实例连接到同一个 Redis
const redisClient = new Redis({
  host: process.env.REDIS_HOST,
  port: process.env.REDIS_PORT,
  password: process.env.REDIS_PASSWORD
})

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET, // 所有实例使用相同密钥
  cookie: {
    domain: '.example.com' // 同域名下共享 Cookie
  }
}))

Q4: 如何防止 Session 劫持?

javascript
// 1. 绑定客户端特征
app.use((req, res, next) => {
  if (req.session.userId) {
    const fingerprint = {
      ip: req.ip,
      userAgent: req.get('User-Agent')
    }
    
    // 首次登录记录
    if (!req.session.fingerprint) {
      req.session.fingerprint = fingerprint
    } else {
      // 验证是否一致
      if (
        req.session.fingerprint.ip !== fingerprint.ip ||
        req.session.fingerprint.userAgent !== fingerprint.userAgent
      ) {
        // 检测到异常,销毁 Session
        return req.session.destroy(() => {
          res.status(401).json({ 
            error: '检测到异常登录,请重新登录',
            code: 'SESSION_HIJACKED'
          })
        })
      }
    }
  }
  next()
})

// 2. 定期重新生成 Session ID
setInterval(() => {
  // 可以通过 Redis 批量更新
}, 60 * 60 * 1000) // 每小时

// 3. 使用 HTTPS 和安全 Cookie
app.use(session({
  cookie: {
    secure: true,    // 仅 HTTPS
    httpOnly: true,  // 防止 JavaScript 访问
    sameSite: 'strict' // 防止跨站发送
  }
}))

Q5: Session 存储数据过大怎么办?

javascript
// ❌ 避免存储大量数据
req.session.cart = {
  items: largeCartData, // 可能有几百KB
  history: allHistory
}

// ✅ 优化方案

// 方案1: 只存储 ID,数据存数据库
req.session.cartId = cartId

// 方案2: 使用 Redis 缓存大数据
async function getCart(sessionId) {
  // Session 只存 ID
  const cartId = await redisClient.get(`session:${sessionId}:cartId`)
  
  // 大数据单独存储
  const cart = await redisClient.get(`cart:${cartId}`)
  return JSON.parse(cart)
}

// 方案3: 分离 Session 数据
// 核心 Session 数据(小)
app.use(session({
  name: 'coreSession',
  store: new RedisStore({ client: redisClient, prefix: 'core:' }),
  cookie: { maxAge: 24 * 60 * 60 * 1000 }
}))

// 购物车 Session(大)
app.use(session({
  name: 'cartSession',
  store: new RedisStore({ client: redisClient, prefix: 'cart:' }),
  cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 } // 更长过期时间
}))

参考资源

官方文档

安全指南

相关工具