安全认证实现模式Skill secure-auth

这个技能专注于安全认证的实现,提供生产就绪的代码模式和最佳实践,覆盖会话管理、JWT认证、密码重置、OAuth集成和多因素认证等,旨在避免常见安全漏洞。关键词:安全认证、会话管理、JWT、OAuth、密码哈希、CSRF保护、速率限制、Web安全。

后端开发 0 次安装 0 次浏览 更新于 3/15/2026

name: secure-auth description: 安全认证实现模式。在实现用户登录、注册、密码重置、会话管理、JWT认证或OAuth集成时使用。提供生产就绪的模式,避免常见的教程陷阱,如不安全的令牌存储、弱密码哈希和会话固定。

安全认证

生产就绪的认证模式。这些不是最简单的实现——它们是那些不会让你被起诉的实现。

认证架构决策

会话 vs JWTs

使用会话当:

  • 服务器渲染的应用程序
  • 需要立即注销/撤销
  • 单域名
  • 更易于正确实现

使用JWTs当:

  • 多个服务需要验证认证
  • 需要无状态架构
  • 移动应用 + API
  • 第三方集成

常见错误: 因为教程使用了JWTs,所以使用它们,然后将它们存储在localStorage(XSS易受攻击)并且没有撤销策略。

基于会话的认证

完整的Express.js实现

const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const { createClient } = require('redis');
const bcrypt = require('bcrypt');
const crypto = require('crypto');

const app = express();

// Redis客户端用于会话存储
const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.connect();

// 会话配置
app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET, // 至少32随机字节
  name: 'sessionId', // 不要使用默认的'connect.sid'
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production', // 仅在生产环境中使用HTTPS
    httpOnly: true,  // 不能通过JavaScript访问
    sameSite: 'lax', // CSRF保护
    maxAge: 24 * 60 * 60 * 1000 // 24小时
  }
}));

// 认证端点的速率限制
const loginAttempts = new Map();

function checkRateLimit(ip) {
  const attempts = loginAttempts.get(ip) || { count: 0, resetAt: Date.now() + 900000 };

  if (Date.now() > attempts.resetAt) {
    attempts.count = 0;
    attempts.resetAt = Date.now() + 900000; // 15分钟窗口
  }

  if (attempts.count >= 5) {
    return false;
  }

  attempts.count++;
  loginAttempts.set(ip, attempts);
  return true;
}

// 注册
app.post('/auth/register', async (req, res) => {
  const { email, password } = req.body;

  // 验证输入
  if (!email || !password) {
    return res.status(400).json({ error: '需要邮箱和密码' });
  }

  if (password.length < 12) {
    return res.status(400).json({ error: '密码必须至少12个字符' });
  }

  // 检查用户是否存在
  const existingUser = await db.query(
    'SELECT id FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  if (existingUser.rows.length > 0) {
    // 不揭示邮箱是否存在 - 使用相同的消息/时间
    return res.status(400).json({ error: '注册失败' });
  }

  // 哈希密码
  const hashedPassword = await bcrypt.hash(password, 12);

  // 创建用户
  const result = await db.query(
    'INSERT INTO users (email, password_hash) VALUES ($1, $2) RETURNING id',
    [email.toLowerCase(), hashedPassword]
  );

  // 创建会话
  req.session.userId = result.rows[0].id;
  req.session.createdAt = Date.now();

  res.json({ success: true });
});

// 登录
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const clientIp = req.ip;

  // 速率限制
  if (!checkRateLimit(clientIp)) {
    return res.status(429).json({ error: '尝试过多。请稍后再试。' });
  }

  // 验证输入
  if (!email || !password) {
    return res.status(400).json({ error: '需要邮箱和密码' });
  }

  // 查找用户
  const result = await db.query(
    'SELECT id, password_hash FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  if (result.rows.length === 0) {
    // 定时攻击预防:仍然进行bcrypt比较
    await bcrypt.compare(password, '$2b$12$invalidhashtopreventtiming');
    return res.status(401).json({ error: '无效凭证' });
  }

  const user = result.rows[0];

  // 验证密码
  const isValid = await bcrypt.compare(password, user.password_hash);

  if (!isValid) {
    return res.status(401).json({ error: '无效凭证' });
  }

  // 重新生成会话以防止固定
  req.session.regenerate((err) => {
    if (err) {
      return res.status(500).json({ error: '会话错误' });
    }

    req.session.userId = user.id;
    req.session.createdAt = Date.now();

    // 成功登录后清除速率限制
    loginAttempts.delete(clientIp);

    res.json({ success: true });
  });
});

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

// 认证中间件
function requireAuth(req, res, next) {
  if (!req.session.userId) {
    return res.status(401).json({ error: '需要认证' });
  }

  // 可选:检查会话年龄
  const maxAge = 24 * 60 * 60 * 1000; // 24小时
  if (Date.now() - req.session.createdAt > maxAge) {
    req.session.destroy();
    return res.status(401).json({ error: '会话已过期' });
  }

  next();
}

// 受保护的路由
app.get('/api/profile', requireAuth, async (req, res) => {
  const user = await db.query(
    'SELECT id, email, created_at FROM users WHERE id = $1',
    [req.session.userId]
  );
  res.json(user.rows[0]);
});

JWT认证

带有刷新令牌的完整实现

const jwt = require('jsonwebtoken');
const crypto = require('crypto');

// 令牌配置
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY = '7d';

// 存储刷新令牌(在生产中使用Redis)
const refreshTokens = new Map();

function generateAccessToken(userId) {
  return jwt.sign(
    { userId, type: 'access' },
    ACCESS_TOKEN_SECRET,
    { expiresIn: ACCESS_TOKEN_EXPIRY }
  );
}

function generateRefreshToken(userId) {
  const tokenId = crypto.randomBytes(32).toString('hex');
  const token = jwt.sign(
    { userId, tokenId, type: 'refresh' },
    REFRESH_TOKEN_SECRET,
    { expiresIn: REFRESH_TOKEN_EXPIRY }
  );

  // 存储令牌ID用于撤销
  refreshTokens.set(tokenId, {
    userId,
    createdAt: Date.now(),
    revoked: false
  });

  return token;
}

// 登录 - 返回两个令牌
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;

  // ... 验证和密码检查 ...

  const accessToken = generateAccessToken(user.id);
  const refreshToken = generateRefreshToken(user.id);

  // 将刷新令牌设置为httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7天
  });

  // 在响应体中返回访问令牌
  res.json({ accessToken });
});

// 刷新端点
app.post('/auth/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ error: '需要刷新令牌' });
  }

  try {
    const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);

    // 检查令牌是否被撤销
    const storedToken = refreshTokens.get(decoded.tokenId);
    if (!storedToken || storedToken.revoked) {
      return res.status(401).json({ error: '令牌已撤销' });
    }

    // 生成新的访问令牌
    const accessToken = generateAccessToken(decoded.userId);
    res.json({ accessToken });

  } catch (err) {
    return res.status(401).json({ error: '无效刷新令牌' });
  }
});

// 注销 - 撤销刷新令牌
app.post('/auth/logout', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (refreshToken) {
    try {
      const decoded = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
      const storedToken = refreshTokens.get(decoded.tokenId);
      if (storedToken) {
        storedToken.revoked = true;
      }
    } catch (err) {
      // 令牌无效,无需操作
    }
  }

  res.clearCookie('refreshToken');
  res.json({ success: true });
});

// 受保护路由的认证中间件
function requireAuth(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: '需要访问令牌' });
  }

  const token = authHeader.substring(7);

  try {
    const decoded = jwt.verify(token, ACCESS_TOKEN_SECRET);

    if (decoded.type !== 'access') {
      return res.status(401).json({ error: '无效令牌类型' });
    }

    req.userId = decoded.userId;
    next();

  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: '令牌已过期', code: 'TOKEN_EXPIRED' });
    }
    return res.status(401).json({ error: '无效令牌' });
  }
}

前端令牌处理

// auth.js - 前端令牌管理

class AuthManager {
  constructor() {
    this.accessToken = null;
  }

  async login(email, password) {
    const response = await fetch('/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // 对于cookie很重要
      body: JSON.stringify({ email, password })
    });

    if (!response.ok) {
      throw new Error('登录失败');
    }

    const { accessToken } = await response.json();
    this.accessToken = accessToken;

    return true;
  }

  async refreshToken() {
    const response = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include'
    });

    if (!response.ok) {
      this.accessToken = null;
      throw new Error('会话已过期');
    }

    const { accessToken } = await response.json();
    this.accessToken = accessToken;

    return accessToken;
  }

  async fetchWithAuth(url, options = {}) {
    if (!this.accessToken) {
      throw new Error('未认证');
    }

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${this.accessToken}`
      }
    });

    // 如果令牌过期,尝试刷新并重试
    if (response.status === 401) {
      const body = await response.json();

      if (body.code === 'TOKEN_EXPIRED') {
        await this.refreshToken();

        // 重试原始请求
        return fetch(url, {
          ...options,
          headers: {
            ...options.headers,
            'Authorization': `Bearer ${this.accessToken}`
          }
        });
      }
    }

    return response;
  }

  async logout() {
    await fetch('/auth/logout', {
      method: 'POST',
      credentials: 'include'
    });

    this.accessToken = null;
  }
}

export const auth = new AuthManager();

密码重置流程

安全实现

const crypto = require('crypto');

// 请求密码重置
app.post('/auth/forgot-password', async (req, res) => {
  const { email } = req.body;

  // 总是返回成功以防止邮箱枚举
  res.json({ message: '如果账户存在,重置链接已发送。' });

  // 查找用户(异步,在响应后)
  const result = await db.query(
    'SELECT id FROM users WHERE email = $1',
    [email.toLowerCase()]
  );

  if (result.rows.length === 0) {
    return; // 用户不存在,但不揭示
  }

  const user = result.rows[0];

  // 生成安全令牌
  const token = crypto.randomBytes(32).toString('hex');
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
  const expiresAt = new Date(Date.now() + 3600000); // 1小时

  // 存储哈希令牌(不是明文令牌)
  await db.query(
    'INSERT INTO password_resets (user_id, token_hash, expires_at) VALUES ($1, $2, $3)',
    [user.id, tokenHash, expiresAt]
  );

  // 发送带有明文令牌的邮箱
  const resetUrl = `https://yourapp.com/reset-password?token=${token}`;
  await sendEmail(email, '密码重置', `重置您的密码:${resetUrl}`);
});

// 重置密码
app.post('/auth/reset-password', async (req, res) => {
  const { token, newPassword } = req.body;

  if (!token || !newPassword) {
    return res.status(400).json({ error: '需要令牌和新密码' });
  }

  if (newPassword.length < 12) {
    return res.status(400).json({ error: '密码必须至少12个字符' });
  }

  // 哈希提供的令牌以与存储的哈希比较
  const tokenHash = crypto.createHash('sha256').update(token).digest('hex');

  // 查找有效重置令牌
  const result = await db.query(
    `SELECT user_id FROM password_resets
     WHERE token_hash = $1 AND expires_at > NOW() AND used = false`,
    [tokenHash]
  );

  if (result.rows.length === 0) {
    return res.status(400).json({ error: '无效或已过期令牌' });
  }

  const userId = result.rows[0].user_id;

  // 哈希新密码
  const hashedPassword = await bcrypt.hash(newPassword, 12);

  // 更新密码并使令牌无效
  await db.query('UPDATE users SET password_hash = $1 WHERE id = $2', [hashedPassword, userId]);
  await db.query('UPDATE password_resets SET used = true WHERE token_hash = $1', [tokenHash]);

  // 使此用户的所有现有会话无效
  await db.query('DELETE FROM sessions WHERE user_id = $1', [userId]);

  res.json({ success: true });
});

OAuth集成(Google示例)

服务器端流程(推荐)

const { OAuth2Client } = require('google-auth-library');

const oauth2Client = new OAuth2Client(
  process.env.GOOGLE_CLIENT_ID,
  process.env.GOOGLE_CLIENT_SECRET,
  process.env.GOOGLE_REDIRECT_URI
);

// 步骤1:重定向到Google
app.get('/auth/google', (req, res) => {
  // 生成状态用于CSRF保护
  const state = crypto.randomBytes(32).toString('hex');
  req.session.oauthState = state;

  const authUrl = oauth2Client.generateAuthUrl({
    access_type: 'offline',
    scope: ['email', 'profile'],
    state: state,
    prompt: 'consent'
  });

  res.redirect(authUrl);
});

// 步骤2:处理回调
app.get('/auth/google/callback', async (req, res) => {
  const { code, state } = req.query;

  // 验证状态以防止CSRF
  if (state !== req.session.oauthState) {
    return res.status(400).send('无效状态参数');
  }

  delete req.session.oauthState;

  try {
    // 交换代码为令牌
    const { tokens } = await oauth2Client.getToken(code);
    oauth2Client.setCredentials(tokens);

    // 获取用户信息
    const ticket = await oauth2Client.verifyIdToken({
      idToken: tokens.id_token,
      audience: process.env.GOOGLE_CLIENT_ID
    });

    const payload = ticket.getPayload();
    const { sub: googleId, email, name, picture } = payload;

    // 查找或创建用户
    let user = await db.query(
      'SELECT id FROM users WHERE google_id = $1',
      [googleId]
    );

    if (user.rows.length === 0) {
      // 创建新用户
      user = await db.query(
        `INSERT INTO users (google_id, email, name, avatar_url)
         VALUES ($1, $2, $3, $4) RETURNING id`,
        [googleId, email, name, picture]
      );
    }

    // 创建会话
    req.session.regenerate((err) => {
      if (err) {
        return res.status(500).send('会话错误');
      }

      req.session.userId = user.rows[0].id;
      res.redirect('/dashboard');
    });

  } catch (error) {
    console.error('OAuth错误:', error);
    res.status(400).send('认证失败');
  }
});

多因素认证(TOTP)

服务器实现

const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// 为用户启用MFA
app.post('/auth/mfa/enable', requireAuth, async (req, res) => {
  // 生成密钥
  const secret = speakeasy.generateSecret({
    name: `YourApp:${req.user.email}`,
    issuer: 'YourApp'
  });

  // 存储密钥(加密)直到验证
  await db.query(
    'UPDATE users SET mfa_secret_temp = $1 WHERE id = $2',
    [encrypt(secret.base32), req.userId]
  );

  // 生成QR码
  const qrCode = await QRCode.toDataURL(secret.otpauth_url);

  res.json({
    secret: secret.base32, // 显示此作为备份
    qrCode: qrCode
  });
});

// 验证并激活MFA
app.post('/auth/mfa/verify', requireAuth, async (req, res) => {
  const { code } = req.body;

  const result = await db.query(
    'SELECT mfa_secret_temp FROM users WHERE id = $1',
    [req.userId]
  );

  const secret = decrypt(result.rows[0].mfa_secret_temp);

  const verified = speakeasy.totp.verify({
    secret: secret,
    encoding: 'base32',
    token: code,
    window: 1 // 允许1步容差
  });

  if (!verified) {
    return res.status(400).json({ error: '无效代码' });
  }

  // 将密钥从临时移动到永久
  await db.query(
    'UPDATE users SET mfa_secret = mfa_secret_temp, mfa_secret_temp = NULL, mfa_enabled = true WHERE id = $1',
    [req.userId]
  );

  res.json({ success: true });
});

// 使用MFA登录
app.post('/auth/login', async (req, res) => {
  const { email, password, mfaCode } = req.body;

  // ... 先验证邮箱/密码 ...

  if (user.mfa_enabled) {
    if (!mfaCode) {
      return res.status(401).json({
        error: '需要MFA代码',
        requiresMfa: true
      });
    }

    const verified = speakeasy.totp.verify({
      secret: decrypt(user.mfa_secret),
      encoding: 'base32',
      token: mfaCode,
      window: 1
    });

    if (!verified) {
      return res.status(401).json({ error: '无效MFA代码' });
    }
  }

  // ... 创建会话/令牌 ...
});

安全考虑清单

密码存储

  • [ ] 使用bcrypt/scrypt/Argon2,成本因子12+
  • [ ] 从不存储明文密码
  • [ ] 从不记录密码

会话管理

  • [ ] 会话存储在服务器端(不仅是在cookie中)
  • [ ] 会话ID是密码学随机的
  • [ ] 登录时重新生成会话(防止固定)
  • [ ] 注销时使会话无效
  • [ ] 会话有最大生命周期

JWT安全

  • [ ] 短期访问令牌生命周期(15分钟或更短)
  • [ ] 刷新令牌存储为httpOnly cookie
  • [ ] 实现刷新令牌轮换
  • [ ] 存在令牌撤销机制
  • [ ] 密钥至少256位

速率限制

  • [ ] 每个IP的登录尝试有限制
  • [ ] N次失败后账户锁定
  • [ ] 密码重置请求有限制
  • [ ] MFA验证尝试有限制

CSRF保护

  • [ ] 设置SameSite cookie属性
  • [ ] 状态更改操作使用CSRF令牌
  • [ ] 验证OAuth状态参数

信息泄露

  • [ ] 有效/无效用户的相同错误消息
  • [ ] 缓解定时攻击
  • [ ] 没有通过注册/重置的用户枚举