零信任认证在Web应用中的完整实现指南

本文详细介绍了如何在Web应用中实施零信任认证体系,包含多因素认证、JWT令牌管理、会话安全、基于角色的访问控制等核心技术,提供完整的代码示例和架构设计。

如何实现零信任认证

您最大的安全问题可能就在您自己的网络内部。黑客不再需要入侵 - 他们只需使用被盗密码登录即可。旧的安全系统信任任何进入网络的人。但现在没有明确的"内部"或"外部"。人们在家工作、使用云服务,并容易受到虚假电子邮件的影响。攻击者可以伪装成真实用户数周而不会被发现。

零信任认证解决了这个问题。它不会在用户登录后就信任他们,而是每次都会检查每个人、每个设备和每个请求。规则很简单:“不信任任何人,验证一切”。

这不仅仅是理论 - 它确实有效。使用零信任安全的公司遭受的数据泄露更少,更容易满足合规要求,并能控制谁可以查看哪些数据。这很重要,因为95%的数据泄露是由于人为错误造成的,而现在的平均泄露成本为488万美元。

在本文中,您将逐步学习如何在Web应用中构建完整的零信任认证系统。从多因素认证(MFA)到行为异常检测,我们将讨论架构决策、代码示例以及一些您可以立即实施的现实方法。

目录

  • 先决条件
  • 什么是零信任认证?
  • 架构概述
  • 多因素认证(MFA)
  • JWT令牌管理
  • 会话安全
  • 基于角色的访问控制(RBAC)
  • 使用中间件强制执行RBAC
  • 测试访问控制逻辑
  • 持续验证
  • 行为分析
  • 升级认证
  • 安全监控
  • 自动化威胁响应
  • 结论

先决条件

在实施零信任之前,请确保您的技术栈能够支持频繁的令牌检查、大量日志记录和额外的认证步骤,同时不会影响用户的系统性能。

您至少应该具备以下知识:

  • JWT和安全会话处理
  • MFA,特别是理解TOTP
  • 中间件设计的基本理解

审核您的系统:检查登录流程、令牌处理、受保护路由、会话终止,并识别弱点,如长时间会话或未受保护的路由。

什么是零信任认证?

零信任认证(ZTA)重新定义了现代应用程序中的访问授权方式。它不考虑网络位置或单一登录事件 - 它要求持续验证身份、上下文和意图。

基于边界防护的模型认为网络内部的任何人都"安全",而零信任假定每个请求都可能被破坏。这意味着访问决策是基于已验证的身份、设备状态和行为信号实时做出的。简而言之,这是一种为云原生、威胁感知世界设计的"安全优先"方法。

架构概述

构建ZTA系统意味着始终检查每个人和每件事。您可以在下面看到的架构展示了这种"永不信任,始终验证"方法的实际运作:

架构图

工作原理:

  1. 每个请求都会检查:当任何人尝试访问您的网络(从办公室、家庭或移动设备)时,他们首先会触及认证层。没有例外。

  2. 身份+上下文验证:系统不仅检查密码。它会查看您是谁、使用什么设备、从哪里连接以及尝试访问什么。

  3. 持续保护:一旦进入内部,系统会持续监视。通过持续监控和访问控制来保护您的数据、设备、网络、人员和工作负载。

重大变化:传统安全创建了"可信内部"和"不可信外部"。零信任消除了这个边界。无论您是连接到云服务(AWS、Office 365)还是内部系统,每个请求都经过相同的验证过程。

多因素认证(MFA)

MFA是零信任安全的基础。它要求用户在获得访问权限之前提供多个证据来证明自己的身份。在ZTA中,即使是最强的密码本身也不足够。

首先从强密码开始,然后添加第二个因素。例如,基于时间的一次性密码(TOTP)是最安全的。TOTP是最好的第二个因素,因为它可以离线工作且不依赖SMS或电子邮件(这些可能被拦截)。像Google Authenticator这样的应用每30秒生成一个新代码。

以下是示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
const speakeasy = require('speakeasy');
const QRCode = require('qrcode');

// 为新用户生成TOTP密钥
function generateTOTPSecret(userEmail) {
  const secret = speakeasy.generateSecret({
    name: userEmail,

    length: 32
  });

  return {
    secret: secret.base32,
    qrCodeUrl: secret.otpauth_url
  };
}

当新用户注册时,此函数会为他们创建唯一的密钥。名称是他们的电子邮件,发行者是您的应用名称,length: 32使其更加安全。它返回两件事:密钥(base32格式)和一个特殊的URL,用于创建QR码以便轻松设置。

要验证来自其应用的代码,您可以对照存储的密钥进行检查:

1
2
3
4
5
6
7
8
9
// 验证TOTP令牌
function verifyTOTP(token, secret) {
  return speakeasy.totp.verify({
    secret: secret,
    token: token,
    window: 2,
    encoding: 'base32'
  });
}

当用户输入其6位代码时,此函数检查是否正确。window: 2很智能 - 它允许时间差异(例如手机时钟略有偏差)。如果代码有效则返回true,否则返回false。

SMS验证可以作为备用选项。它不如TOTP安全,但可以作为备用。始终限制某人可以请求的SMS代码数量以防止滥用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 带速率限制的SMS验证
async function sendSMSVerification(phoneNumber, userId) {
  const attempts = await getRecentSMSAttempts(userId);
  if (attempts >= 3) {
    throw new Error('Too many SMS attempts. Please try again later.');
  }

  const code = generateRandomCode(6);
  await storeSMSCode(userId, code, 300); // 5分钟过期

  await smsProvider.send(phoneNumber, `Your verification code: ${code}`);
}

在发送SMS之前,它会检查此用户已经请求了多少次代码。如果他们尝试了3次,则会阻止他们(防止垃圾邮件/滥用)。如果他们在限制以下,它会创建一个随机的6位代码,保存5分钟(300秒),然后通过SMS发送。

但是如果用户丢失了手机或认证应用怎么办?备用代码提供紧急访问:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 生成备用代码
function generateBackupCodes(userId) {
  const codes = [];
  for (let i = 0; i < 10; i++) {
    codes.push(generateRandomCode(8));
  }

  const hashedCodes = codes.map(code => hashCode(code));
  storeBackupCodes(userId, hashedCodes);

  return codes; // 仅向用户显示一次
}

这会创建10个紧急备用代码(每个8个字符长)。for循环运行10次,每次创建一个新的随机代码。在将它们存储到数据库之前,它会"哈希"它们(为了安全而进行加扰)。然后它返回原始代码给用户显示一次,但存储加扰版本,这样即使有人入侵您的数据库,他们也无法看到真实代码。

JWT令牌管理

JSON Web Tokens(JWT)是零信任系统中的无状态认证。安全使用它们至关重要,因为您需要仔细考虑有效载荷设计,实施短期过期策略,并实施令牌轮换和阻止列表,这些可以防止令牌盗窃、令牌重用或权限升级。

让我们逐步了解如何在Web应用中安全地实施和管理JWT。

首先,为您的访问令牌定义一个最小且安全的结构。仅添加做出授权决策所需的信息,并且即使加密也不要放置任何敏感信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// JWT有效载荷结构
const tokenPayload = {
  sub: userId,           // 主题(用户ID)
  email: userEmail,      // 用户标识符
  roles: userRoles,      // 用户角色数组
  permissions: userPermissions, // 特定权限
  iat: Math.floor(Date.now() / 1000), // 签发时间
  exp: Math.floor(Date.now() / 1000) + 900, // 15分钟后过期
  jti: generateUniqueId(), // 用于阻止列表的JWT ID
  aud: 'your-app',       // 受众
  iss: 'your-auth-service' // 发行者
};

在上面的代码中,有效载荷包括用户身份、角色、权限和元数据,如签发时间(iat)、过期时间(exp)和唯一令牌ID(jti)。虽然aud和iss描述了令牌的来源和受众以进行验证,但jti用于撤销。因此,它保持有效载荷尽可能精简,以最小化暴露和开销。

为了安全性和可用性,最好使用具有短生命周期的访问令牌和具有相当长持续时间的刷新令牌,这最小化了潜在利用受损令牌的窗口,同时提供流畅的用户会话。

让我们看这个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 令牌生成服务
class TokenService {
  generateTokenPair(user) {
    const accessToken = jwt.sign(
      this.createAccessTokenPayload(user),
      process.env.JWT_SECRET,
      { expiresIn: '15m', algorithm: 'HS256' }
    );

    const refreshToken = jwt.sign(
      { sub: user.id, type: 'refresh' },
      process.env.REFRESH_SECRET,
      { expiresIn: '7d', algorithm: 'HS256' }
    );

    return { accessToken, refreshToken };
  }

  async refreshAccessToken(refreshToken) {
    try {
      const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);

      // 检查刷新令牌是否被阻止
      if (await this.isTokenBlocklisted(decoded.jti)) {
        throw new Error('Token has been revoked');
      }

      const user = await getUserById(decoded.sub);
      return this.generateTokenPair(user);
    } catch (error) {
      throw new Error('Invalid refresh token');
    }
  }
}

generateTokenPair将生成两个签名的JWT - 即一个15分钟过期的访问令牌和一个7天有效期的刷新令牌。刷新令牌被验证以授予新令牌,并对照阻止列表进行检查。这确保即使撤销的令牌在技术上仍然有效,也无法重用。

如果您选择,可以实现滑动会话,通过为活跃用户续订令牌来减少摩擦,而不违反您的过期策略。

现在,让我们实现一个滑动会话,当JWT接近过期且用户仍然活跃时自动刷新它们。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 滑动会话实现
async function extendSessionIfActive(token) {
  const decoded = jwt.decode(token);
  const timeUntilExpiry = decoded.exp - Math.floor(Date.now() / 1000);

  // 如果令牌在5分钟内过期且用户活跃,刷新
  if (timeUntilExpiry < 300 && await isUserActive(decoded.sub)) {
    const user = await getUserById(decoded.sub);
    return this.generateTokenPair(user);
  }

  return null;
}

上述函数检查令牌过期。如果令牌在5分钟内过期且用户继续交互,则发出新的访问令牌对。这样,会话在真实活动期间保持活动状态,但仍然强制空闲用户过期。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// 令牌阻止列表服务
class TokenBlocklistService {
  async blocklistToken(token) {
    const decoded = jwt.decode(token);
    const expiresAt = new Date(decoded.exp * 1000);

    // 存储在Redis中并自动过期
    await redis.setex(
      `blocklist:${decoded.jti}`,
      Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)),
      'revoked'
    );
  }

  async isTokenBlocklisted(jti) {
    const result = await redis.get(`blocklist:${jti}`);
    return result !== null;
  }
}

在上面的代码中,当用户注销或令牌受损时,jti存储在Redis中,过期时间为令牌剩余生命期。您可以通过检查其ID是否存在于阻止列表中来阻止将来使用令牌。这允许即时失效,即使JWT是无状态的。

会话安全

在零信任环境中,会话管理远不止保持用户登录。会话必须被视为用户、其设备和系统之间不断评估的合同 - 并且应该在信任破裂时立即撤销。

在这里,我们将构建一个会话系统,该系统包含自适应信任评分、动态超时、实时可见性和撤销机制 - 所有这些都与零信任原则保持一致。

例如,当用户成功认证时,您不仅存储会话ID。相反,您收集上下文元数据以评估持续风险。下面的函数演示了如何初始化既安全又具有上下文感知的会话。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 全面的会话创建
async function createSecureSession(userId, deviceInfo, clientInfo) {
  const sessionId = generateSecureSessionId();

  const session = {
    id: sessionId,
    userId: userId,
    deviceFingerprint: generateDeviceFingerprint(deviceInfo),
    ipAddress: clientInfo.ipAddress,
    userAgent: clientInfo.userAgent,
    location: await resolveLocation(clientInfo.ipAddress),
    createdAt: new Date(),
    lastActivity: new Date(),
    trustScore: calculateInitialTrustScore(deviceInfo, clientInfo),
    securityLevel: determineSecurityLevel(userId, deviceInfo)
  };

  await storeSession(session);
  return session;
}

许多其他工具在会话创建期间跟踪相关细节。收集设备指纹、IP地址、地理位置和浏览器代理数据。这些元数据用于计算信任分数,最后为会话分配安全级别,用于以后动态调整策略。

通过在会话创建期间捕获此上下文信息,系统可以在会话期间发现可疑行为,并相应地调整策略,如重新认证用户或终止会话。

并非所有会话都应平等对待。如果用户通过不熟悉的设备或风险位置登录,他们的会话生命周期应该比受信任设置的时间更短。以下实现根据信任和风险因素更改超时期限:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 自适应会话超时
class SessionTimeoutManager {
  calculateTimeoutPeriod(session) {
    const baseTimeout = 30 * 60 * 1000; // 30分钟
    const trustMultiplier = session.trustScore / 100;
    const securityMultiplier = this.getSecurityMultiplier(session.securityLevel);

    return Math.max(
      5 * 60 * 1000, // 最少5分钟
      baseTimeout * trustMultiplier * securityMultiplier
    );
  }

  async checkSessionValidity(sessionId) {
    const session = await getSession(sessionId);
    if (!session) return false;

    const now = Date.now();
    const timeout = this.calculateTimeoutPeriod(session);

    // 检查空闲超时和绝对超时
    const idleExpired = (now - session.lastActivity) > timeout;
    const absoluteExpired = (now - session.createdAt) > 8 * 60 * 60 * 1000; // 最多8小时

    return !idleExpired && !absoluteExpired;
  }
}

上面的代码使会话持续时间适应手头的风险上下文。通过根据信任和安全级别调整基值来计算超时,同时施加最小和最大界限。

然后系统定期干预,查看会话是否由于不活动(空闲超时)而变得无效,或者只是超过其初始持续时间(绝对超时)。这提供了一种更灵活但可执行的方式来减轻陈旧或被劫持会话背后的风险。

零信任还应该意味着跨所有访问点的可见性。用户应该能够查看与其帐户关联的所有活动会话,安全系统还应该允许他们以细粒度控制这些会话。以下代码让您跨设备管理这些活动会话。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 跨设备会话管理
class SessionManager {
  async getUserSessions(userId) {
    const sessions = await getActiveSessionsForUser(userId);

    return sessions.map(session => ({
      id: session.id,
      deviceType: this.identifyDeviceType(session.userAgent),
      location: session.location,
      lastActivity: session.lastActivity,
      current: session.id === currentSessionId
    }));
  }

  async revokeSession(sessionId, requestingSessionId) {
    const session = await getSession(sessionId);
    if (!session) throw new Error('Session not found');

    // 验证请求会话有权限
    const requestingSession = await getSession(requestingSessionId);
    if (requestingSession.userId !== session.userId) {
      throw new Error('Unauthorized');
    }

    await this.terminateSession(sessionId);
    await this.logSecurityEvent('session_revoked', session);
  }
}

在这里,用户获取其活动会话列表以及标识信息,如设备类型和位置。用户可以安全地撤销任何会话,防止会话ID受损时的未经授权访问。

这也允许用户及时检测可疑活动。所有撤销都记录用于审计目的,以支持事件后调查和合规报告。

当由于凭据盗窃、可疑活动或用户级操作(如密码重置)导致信任破裂时,必须立即撤销所有会话。此示例保证完全撤销,立即应用于所有设备:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 实时会话撤销
class SessionRevocationService {
  async revokeAllUserSessions(userId, reason) {
    const sessions = await getActiveSessionsForUser(userId);

    // 阻止此用户的所有令牌
    await Promise.all(sessions.map(session => 
      this.blocklistSessionTokens(session.id)
    ));

    // 通知所有活动客户端
    await Promise.all(sessions.map(session => 
      this.notifySessionTermination(session.id, reason)
    ));

    // 清除会话数据
    await clearUserSessions(userId);

    // 记录安全事件
    await this.logSecurityEvent('all_sessions_revoked', {
      userId,
      reason,
      sessionCount: sessions.length
    });
  }
}

上面的代码允许全面撤销。它阻止所有会话令牌,向活动客户端发送终止通知(例如通过WebSockets),清除服务器端的会话记录,并记录事件用于审计。这是对受损帐户或用户风险非常高的状态的即时和完整响应。它是任何严肃认证系统中实时零信任执行的最重要组件。

基于角色的访问控制(RBAC)

身份验证确定用户登录后可以访问什么。作为任何了解权限并遵循最小权限原则的系统的基础,RBAC不会基于个人授予访问权限 - 它将用户分组到定义他们允许执行的操作的角色中。

在为用户分配角色之前,您需要一个结构化系统来定义每个角色可以做什么。首先识别一组细粒度权限,然后将这些权限聚合到这些角色下,可选地允许继承和层次结构。下面的代码显示如何构建基本权限系统:

1
2
3
4
5
6
// RBAC权限系统
class PermissionSystem {
  constructor() {
    this.permissions = new Map();
    this.roles = new Map();
    this.roleHier
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计