CF的webchat加密码访问
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 | 你的密码 | 加密文本 |
设置步骤:
- 登录 Cloudflare Dashboard
- 进入 Workers 和 Pages → 你的 Worker
- 点击 设置 → 变量
- 添加加密文本环境变量
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 个 |
| 用户名验证 | ❌ 任意用户名 | ✅ 必须匹配 |
| 安全性 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 用户体验 | 简单(只输密码) | 标准(输账号密码) |
注意事项
- 浏览器原生 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;
}- 密码建议使用强密码,包含大小写字母、数字、特殊字符。
- 环境变量设置为"加密文本",不要在代码中硬编码密码。
- 时序安全比较 函数
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 方案。