Cloudflare Workers WebSocket 聊天室 - 添加访问密码保护

概述

本文档介绍如何为 Cloudflare Workers 上的 WebSocket 聊天室添加密码/账号密码访问保护。


项目文件结构

your-project/
├── worker/
│   ├── index.js      ← 需要修改的主文件
│   └── utils.js      ← 不需要修改
├── wrangler.toml
└── ...

需要修改的文件数量:1 个worker/index.js


方案一:仅密码验证

用户可以不填用户名,只需输入密码即可访问。

1. 修改 fetch 函数

worker/index.js 中找到 fetch 函数,在 WebSocket 升级前添加验证:

// worker/index.js
var index_default = {
  async fetch(request, env2, ctx) {
    const url = new URL(request.url);
    const upgradeHeader = request.headers.get("Upgrade");
    
    // ========== Basic Auth(仅密码验证)==========
    const authResult = requireAuth(request, env2.WEBSOCKET_PASSWORD);
    if (!authResult.authorized) {
      if (upgradeHeader === "websocket") {
        return new Response("Unauthorized", { 
          status: 401,
          headers: { 'WWW-Authenticate': 'Basic realm="WebSocket Chat"' }
        });
      }
      return new Response("Unauthorized", { 
        status: 401,
        headers: { 'WWW-Authenticate': 'Basic realm="WebSocket Chat"' }
      });
    }
    // ============================================
    
    if (upgradeHeader && upgradeHeader === "websocket") {
      const id = env2.CHAT_ROOM.idFromName("chat-room");
      const stub = env2.CHAT_ROOM.get(id);
      return stub.fetch(request);
    }
    
    if (url.pathname.startsWith("/api/")) {
      return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } });
    }
    
    return env2.ASSETS.fetch(request);
  }
};

2. 添加认证函数

在文件末尾添加 requireAuth 函数:

// ========== 仅密码验证函数 ==========
function requireAuth(request, password) {
  const auth = request.headers.get('Authorization');
  if (!auth || !auth.startsWith('Basic ')) {
    return { authorized: false };
  }
  
  try {
    const base64 = auth.slice(6);
    const credentials = atob(base64);
    const [, inputPassword] = credentials.split(':'); // 只取密码部分,忽略用户名
    
    // 时序安全比较
    const encoder = new TextEncoder();
    const p1 = encoder.encode(inputPassword);
    const p2 = encoder.encode(password || '');
    
    if (p1.length !== p2.length) return { authorized: false };
    
    let valid = true;
    for (let i = 0; i < p1.length; i++) {
      if (p1[i] !== p2[i]) valid = false;
    }
    
    return { authorized: valid };
  } catch {
    return { authorized: false };
  }
}

3. 设置环境变量

在 Cloudflare 仪表板中添加:

变量名类型
WEBSOCKET_PASSWORD你的密码加密文本

设置步骤:

  1. 登录 Cloudflare Dashboard
  2. 进入 Workers 和 Pages → 你的 Worker
  3. 点击 设置变量
  4. 添加加密文本环境变量

4. 访问方式

浏览器弹出登录框时:

  • 用户名:任意(可留空)
  • 密码:输入 WEBSOCKET_PASSWORD 的值

方案二:账号 + 密码验证(推荐)

需要同时输入正确的用户名和密码才能访问。

1. 修改 fetch 函数

// worker/index.js
var index_default = {
  async fetch(request, env2, ctx) {
    const url = new URL(request.url);
    const upgradeHeader = request.headers.get("Upgrade");
    
    // ========== Basic Auth(账号+密码验证)==========
    const authResult = requireAuth(request, env2);
    if (!authResult.authorized) {
      if (upgradeHeader === "websocket") {
        return new Response("Unauthorized", { 
          status: 401,
          headers: { 'WWW-Authenticate': 'Basic realm="WebSocket Chat"' }
        });
      }
      return new Response("Unauthorized", { 
        status: 401,
        headers: { 'WWW-Authenticate': 'Basic realm="WebSocket Chat"' }
      });
    }
    // ================================================
    
    if (upgradeHeader && upgradeHeader === "websocket") {
      const id = env2.CHAT_ROOM.idFromName("chat-room");
      const stub = env2.CHAT_ROOM.get(id);
      return stub.fetch(request);
    }
    
    if (url.pathname.startsWith("/api/")) {
      return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } });
    }
    
    return env2.ASSETS.fetch(request);
  }
};

2. 添加认证函数和安全比较函数

在文件末尾添加:

// ========== 账号+密码验证函数 ==========
function requireAuth(request, env) {
  const auth = request.headers.get('Authorization');
  if (!auth || !auth.startsWith('Basic ')) {
    return { authorized: false };
  }
  
  try {
    const base64 = auth.slice(6);
    const credentials = atob(base64);
    const [inputUsername, inputPassword] = credentials.split(':');
    
    // 从环境变量读取正确的账号密码
    const validUsername = env.WEBSOCKET_USERNAME || 'admin';
    const validPassword = env.WEBSOCKET_PASSWORD || '';
    
    // 使用安全比较函数
    const usernameMatch = secureCompare(inputUsername, validUsername);
    const passwordMatch = secureCompare(inputPassword, validPassword);
    
    return { authorized: usernameMatch && passwordMatch };
  } catch {
    return { authorized: false };
  }
}

// 安全字符串比较(防止时序攻击)
function secureCompare(a, b) {
  if (!a || !b) return a === b;
  const encoder = new TextEncoder();
  const aBuf = encoder.encode(a);
  const bBuf = encoder.encode(b);
  
  if (aBuf.length !== bBuf.length) return false;
  
  let result = 0;
  for (let i = 0; i < aBuf.length; i++) {
    result |= aBuf[i] ^ bBuf[i];
  }
  return result === 0;
}
注意:函数名使用 secureCompare 而不是 timingSafeEqual,避免与 Cloudflare Workers 内置函数冲突。

3. 设置环境变量

在 Cloudflare 仪表板中添加两个变量:

变量名类型
WEBSOCKET_USERNAME你的用户名(如 admin加密文本
WEBSOCKET_PASSWORD你的密码(如 mypassword123加密文本

4. 访问方式

浏览器弹出登录框时:

  • 用户名:输入 WEBSOCKET_USERNAME 的值
  • 密码:输入 WEBSOCKET_PASSWORD 的值

部署命令

修改完成后,在项目根目录运行:

npx wrangler deploy

🔧 方案对比

特性方案一(仅密码)方案二(账号+密码)
需要修改的文件1 个1 个
需要添加的代码行数~25 行~40 行
需要设置的环境变量1 个2 个
用户名验证❌ 任意用户名✅ 必须匹配
安全性⭐⭐⭐⭐⭐⭐⭐⭐
用户体验简单(只输密码)标准(输账号密码)

注意事项

  1. 浏览器原生 WebSocket API 不支持自定义 HTTP 头,如果需要 WebSocket 连接也带认证,建议使用 URL 参数方式:
// 替代方案:从 URL 参数获取密码
function checkUrlAuth(request, password) {
  const url = new URL(request.url);
  const token = url.searchParams.get('token');
  return token === password;
}
  1. 密码建议使用强密码,包含大小写字母、数字、特殊字符。
  2. 环境变量设置为"加密文本",不要在代码中硬编码密码。
  3. 时序安全比较 函数 secureCompare 可以防止时序攻击,建议保留。

验证效果

部署后访问你的 Worker 域名:

  • 会弹出 HTTP Basic Auth 登录框
  • 输入错误的用户名/密码 → 401 Unauthorized
  • 输入正确的凭证 → 正常访问聊天室

HTTP Basic Auth 认证后的访问持久性分析

问题概述

在为 Cloudflare Workers WebSocket 聊天室添加 HTTP Basic Auth 认证后,用户在同一个浏览器中关闭网页但不关闭浏览器,是否能够持续访问。

结论

是的,用户会一直能够访问,直到手动清除浏览器数据或关闭浏览器进程。

原因分析

HTTP Basic Auth 的工作机制决定了浏览器的凭证记忆行为:

用户行为认证状态
第一次访问并输入密码浏览器在内存中记住账号密码
关闭网页标签页认证状态保持
关闭整个浏览器进程部分浏览器会清除内存中的凭证
重启操作系统凭证丢失
手动清除浏览器缓存或历史数据凭证被清除

不同浏览器的行为差异

浏览器关闭标签页关闭浏览器进程重启操作系统
Chrome认证保持凭证清除凭证清除
Firefox认证保持认证保持凭证清除
Safari认证保持认证保持凭证清除
Edge认证保持凭证清除凭证清除

注:Firefox 若设置了"记住打开的标签页"功能,重启后会恢复会话状态。

如需更短的有效期

如果希望每次关闭网页就需要重新输入密码,可以使用 Session Cookie 方案替代 Basic Auth。

Session Cookie 方案代码示例

在 worker/index.js 中实现:

var index_default = {
  async fetch(request, env2, ctx) {
    const url = new URL(request.url);
    const upgradeHeader = request.headers.get("Upgrade");
    
    // Session Cookie 验证(会话级)
    const cookie = parseCookie(request.headers.get("Cookie") || "");
    
    // 处理登录请求
    if (url.pathname === "/login" && request.method === "POST") {
      const formData = await request.formData();
      const password = formData.get("password");
      
      if (password === env2.WEBSOCKET_PASSWORD) {
        const res = new Response(JSON.stringify({ success: true }), {
          headers: { "Content-Type": "application/json" }
        });
        // 不设置 Max-Age,关闭浏览器即失效
        res.headers.append("Set-Cookie", "auth=1; Path=/; HttpOnly; SameSite=Lax");
        return res;
      }
      return new Response(JSON.stringify({ success: false }), { status: 401 });
    }
    
    // 检查登录状态
    if (cookie.auth !== "1") {
      if (upgradeHeader === "websocket") {
        return new Response("Unauthorized", { status: 401 });
      }
      return new Response(LOGIN_HTML, {
        headers: { "Content-Type": "text/html;charset=UTF-8" }
      });
    }
    
    // 正常处理 WebSocket 升级
    if (upgradeHeader && upgradeHeader === "websocket") {
      const id = env2.CHAT_ROOM.idFromName("chat-room");
      const stub = env2.CHAT_ROOM.get(id);
      return stub.fetch(request);
    }
    
    if (url.pathname.startsWith("/api/")) {
      return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" } });
    }
    
    return env2.ASSETS.fetch(request);
  }
};

// Cookie 解析函数
function parseCookie(cookieHeader) {
  const cookies = {};
  if (!cookieHeader) return cookies;
  cookieHeader.split(';').forEach(part => {
    const [name, ...rest] = part.trim().split('=');
    cookies[name] = rest.join('=');
  });
  return cookies;
}

// 登录页面 HTML
const LOGIN_HTML = `
<!DOCTYPE html>
<html>
<head><title>登录聊天室</title></head>
<body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:sans-serif;">
  <form id="loginForm" style="background:#f5f5f5;padding:30px;border-radius:8px;">
    <h2>请输入密码</h2>
    <input type="password" id="password" placeholder="密码" style="width:100%;padding:8px;margin:10px 0;" />
    <button type="submit" style="width:100%;padding:8px;background:#007cba;color:white;border:none;cursor:pointer;">登录</button>
    <div id="error" style="color:red;margin-top:10px;"></div>
  </form>
  <script>
    document.getElementById('loginForm').addEventListener('submit', async (e) => {
      e.preventDefault();
      const password = document.getElementById('password').value;
      const res = await fetch('/login', {
        method: 'POST',
        body: new URLSearchParams({ password })
      });
      if (res.ok) {
        location.reload();
      } else {
        document.getElementById('error').textContent = '密码错误';
      }
    });
  </script>
</body>
</html>
`;

各认证方案持久性对比

方案关闭标签页关闭浏览器重启电脑实现复杂度
Basic Auth保持因浏览器而异清除简单
Session Cookie(无 Max-Age)需重新登录需重新登录需重新登录中等
Session Cookie(有 Max-Age)保持保持需重新登录中等
LocalStorage + Token保持保持保持复杂

建议

根据不同使用场景选择合适的方案:

个人私密聊天室:使用 Basic Auth 方案,方便快捷

公共但需要控制访问的场景:使用 Session Cookie 方案,关闭即失效

需要长期记住登录状态的场景:使用 Basic Auth 或设置 Cookie Max-Age=604800(7天有效期)

总结

Basic Auth 方案中,只要不关闭浏览器进程(Chrome/Edge),或不手动清除浏览器数据,认证状态会一直保持。这是浏览器的默认行为,不是代码层面的控制范围。

如果需要每次关闭标签页就要求重新认证,必须改用 Session Cookie 方案。

标签: none

添加新评论