前言
之前在这篇文章里加入了给文章进行密码加密的功能。
但是不用脑子想都知道,这种加密方式只是掩耳盗铃图一乐。为什么呢?来看一下它的实现原理:
- 前端密码存储:
1const correctPassword = {{ .Params.password }};
这里的密码是从 Hugo 的文章前置参数 (front matter) 中获取的,直接插入到 JavaScript 代码中。
- 用户输入密码:
1const enteredPassword = prompt('请输入文章密码:');
使用浏览器内置的 prompt 弹窗,让用户输入密码。
- 密码校验:
1if (enteredPassword !== correctPassword) {
2 alert('密码错误!');
3 if (history.length <= 1) {
4 window.opener = null;
5 window.open('', '_self');
6 window.close();
7 } else {
8 history.back();
9 }
10}
如果用户输入的密码与前端存储的密码不一致,则提示密码错误,并尝试关闭页面或返回上一页。
所以,存在的问题与安全隐患也就很明显了:
1. 密码明文暴露问题:
- 由于 Hugo 是静态网站生成器,所有模板渲染后的内容都是静态
HTML和JavaScript文件。因此,{{ .Params.password }}会直接以明文形式暴露在生成的 HTML 源代码中。用户只需打开浏览器的“查看源代码”或开发者工具,即可轻松看到密码明文,完全失去了密码保护的意义。
2. 前端校验不安全:
- 纯前端的密码校验本质上是不安全的,因为前端代码完全暴露给用户,用户可以轻松绕过或修改校验逻辑。用户甚至可以直接在浏览器控制台中修改
JavaScript变量,绕过密码检查。
- 例如,用户可以在控制台中执行:
1enteredPassword = correctPassword; // 直接绕过密码检查
3. 用户体验问题(次要):
- 使用浏览器原生的
prompt弹窗,界面不美观,用户体验较差。
那么一般来讲,要怎么解决呢?
1. 后端校验:
- 真正安全的密码保护必须在后端实现。静态网站本身无法安全地实现密码保护功能。
- 可以使用服务器端(如
Node.js、PHP、Python等)实现密码校验,只有密码正确时才返回文章内容。
2. 第三方服务或 API 校验:
- 如果一定要使用静态网站,可以考虑使用第三方服务(如
Netlify Identity、Firebase Authentication)实现安全的身份验证和密码保护。
3. 客户端加密(次优方案):
- 如果一定要纯前端实现,可以考虑使用客户端加密方案(如
AES加密),密码不直接暴露在源码中,而是存储加密后的内容,用户输入密码后再解密内容。 - 但即使如此,密钥管理仍然是个问题,安全性仍然有限。
然而,Hugo 是静态网站生成器,生成的内容都是静态文件;Cloudflare Pages 作为一个静态网站托管服务,专门用于托管静态内容(HTML、CSS、JS 等),并不支持动态后端代码(如 PHP、Node.js 等)。
应用到 Cloudflare Pages 上面,这些加密手段如何呢?
一、后端校验(不可行)
- 原因:
Cloudflare Pages仅支持静态文件托管,不支持运行后端代码(如Node.js、PHP、Python等)。 - 结论:无法直接在
Cloudflare Pages上实现后端密码校验。
二、第三方服务或 API 校验(推荐,可行)
虽然 Cloudflare Pages 本身不支持后端代码,但你可以借助第三方服务实现安全的密码保护:
实现思路:
- 使用第三方身份验证服务(如
Firebase Authentication、Auth0、Netlify Identity等)实现用户登录和密码保护。 - 用户访问页面时,前端调用第三方服务的
API进行身份验证,验证通过后再加载受保护的内容。 - 受保护的内容可以存储在
Cloudflare Pages上,但内容本身是加密的,只有通过第三方服务验证后才能解密显示。
优点:
- 安全性高,密码不会暴露在前端代码中。
- 用户体验较好,支持多种登录方式(邮箱、Google、GitHub 等)。
缺点:
- 需要额外的第三方服务,增加了一定的复杂性。
三、客户端加密方案(可行,但安全性有限)
实现思路:
- 使用 AES 等对称加密算法,在发布文章前对文章内容进行加密。
- 加密后的内容存储在 Hugo 生成的静态页面中。
- 用户访问页面时,输入密码后,前端
JavaScript使用用户输入的密码尝试解密内容。 - 如果密码正确,内容解密成功并显示;密码错误则无法解密,显示错误提示。
具体实现步骤:
- 在本地使用
AES加密工具(如CryptoJS)对文章内容进行加密。 - 将加密后的内容放入 Hugo 的
Markdown文件或模板中。 - 在 Hugo 模板中引入
CryptoJS库,用户输入密码后尝试解密内容。
示例代码(简化版):
1<script src="https://cdn.jsdelivr.net/npm/[email protected]/crypto-js.min.js"></script>
2<script>
3 const encryptedContent = "{{ .Params.encryptedContent }}"; // 加密后的内容
4 const password = prompt("请输入密码:");
5 try {
6 const bytes = CryptoJS.AES.decrypt(encryptedContent, password);
7 const originalText = bytes.toString(CryptoJS.enc.Utf8);
8 if (!originalText) throw new Error("解密失败");
9 document.getElementById("content").innerHTML = originalText;
10 } catch (e) {
11 alert("密码错误或解密失败!");
12 }
13</script>
14<div id="content"></div>
优点:
- 不需要额外的第三方服务,纯静态实现。
- 密码不会直接暴露在源码中(但加密后的内容仍然暴露)。
缺点:
- 安全性有限,攻击者仍然可以暴力破解或尝试解密。
- 用户体验一般,且每次修改内容都需要重新加密。
Hugo Encryptor 加密
还有这个Hugo的加密项目:
它的实现原理是:
- 在
Hugo构建阶段(本地构建时),使用AES算法对文章内容进行加密。 - 加密后的内容以密文形式存储在生成的静态
HTML文件中。 - 用户访问页面时,前端
JavaScript提示用户输入密码,输入密码后尝试解密内容。 - 如果密码正确,内容解密成功并显示;密码错误则无法解密,提示错误。
这种方式属于我们之前提到的客户端 AES 加密方案。
由于 Cloudflare Pages 是纯静态托管服务,hugo_encryptor 生成的内容也是纯静态的 HTML 和 JavaScript 文件,完全适合 Cloudflare Pages。而且 hugo_encryptor 不需要任何后端代码或动态服务,完全依靠客户端 JavaScript 解密,理论上是适合 Cloudflare Pages 这种静态托管环境的。
缺点也很明显:
- 密文内容仍然暴露在前端,理论上存在暴力破解的可能性(但 AES 算法本身安全性较高,暴力破解难度较大)。
- 密码管理和更改内容时需要重新加密,稍显麻烦。
主要是每次修改提交都要加密一次,实在是有点不胜其烦,于是不想用这个方法。
Workers 加密
所以,还有什么方法吗?那当然是继续薅赛博佛祖的羊毛啦!用万能的 Cloudflare Workers 啦!
新建一个 Workers,然后替换成以下代码:
1// 内存中存储随机令牌与路径的映射关系
2const tokenStore = new Map();
3
4async function handleRequest(request, env) {
5 const url = new URL(request.url);
6 const pathname = url.pathname;
7
8 // 强制 HTTPS 协议访问
9 if (url.protocol !== "https:") {
10 url.protocol = "https:";
11 return Response.redirect(url.toString(), 301);
12 }
13
14 // 配置受保护的文章
15 const ARTICLES = {
16 "/post/your-private-post/": { // 你要加密的文章URL
17 password: env.PRIVATE_POST_PASSWORD, // 环境变量名称
18 authType: "permanent", // 永久授权
19 },
20 "/post/your-secret-post/": { // 你要加密的文章URL
21 password: env.SECRET_POST_PASSWORD, // 环境变量名称
22 authType: "temporary", // 临时授权
23 },
24 };
25
26 const articleConfig = ARTICLES[pathname];
27 if (!articleConfig) {
28 return fetch(request); // 如果路径未配置保护,直接返回原始请求
29 }
30
31 const { password: correctPassword, authType } = articleConfig;
32
33 // 检查 Cookie 中的随机令牌
34 const cookie = request.headers.get("Cookie") || "";
35 const cookies = Object.fromEntries(cookie.split("; ").map((c) => c.split("=")));
36 const token = cookies[`auth_${pathname}`];
37
38 if (token && tokenStore.get(token) === pathname) {
39 // 如果令牌有效,返回原始请求
40 return fetch(request);
41 }
42
43 // 如果是 POST 请求,验证密码
44 if (request.method === "POST") {
45 const formData = await request.formData();
46 const enteredPassword = formData.get("password");
47
48 if (enteredPassword === correctPassword) {
49 // 密码正确,生成随机令牌
50 const randomToken = crypto.randomUUID();
51 tokenStore.set(randomToken, pathname); // 将令牌与路径映射存储
52
53 // 设置 Cookie
54 const headers = {
55 Location: pathname,
56 };
57 if (authType === "permanent") {
58 // 永久授权:Cookie 有效期 1 年
59 headers["Set-Cookie"] = `auth_${pathname}=${randomToken}; Path=${pathname}; HttpOnly; Secure; SameSite=Lax; Max-Age=31536000`;
60 } else if (authType === "temporary") {
61 // 临时授权:Cookie 有效期 1 分钟
62 headers["Set-Cookie"] = `auth_${pathname}=${randomToken}; Path=${pathname}; HttpOnly; Secure; SameSite=Lax; Max-Age=1`;
63 }
64
65 return new Response(null, {
66 status: 302,
67 headers,
68 });
69 } else {
70 // 密码错误,返回密码输入页面
71 return new Response(passwordPage(pathname, true), {
72 headers: { "Content-Type": "text/html;charset=UTF-8" },
73 });
74 }
75 }
76
77 // 默认返回密码输入页面
78 return new Response(passwordPage(pathname), {
79 headers: { "Content-Type": "text/html;charset=UTF-8" },
80 });
81}
82
83function passwordPage(pathname, error = false) {
84 return `
85 <!DOCTYPE html>
86 <html lang="zh">
87 <head>
88 <meta charset="UTF-8">
89 <meta name="viewport" content="width=device-width, initial-scale=1.0">
90 <title>请输入密码</title>
91 <style>
92 body {
93 font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
94 background-image: url('your img url'); //你的验证页面背景图URL
95 background-size: cover;
96 background-position: center;
97 background-repeat: no-repeat;
98 display: flex;
99 justify-content: center;
100 align-items: center;
101 height: 100vh;
102 margin: 0;
103 padding: 0;
104 }
105 body::before {
106 content: '';
107 position: fixed;
108 top: 0;
109 left: 0;
110 width: 100%;
111 height: 100%;
112 z-index: -1;
113 backdrop-filter: blur(2px);
114 background-color: rgba(255, 255, 255, 0.7);
115 }
116 .container {
117 background-color: rgba(255, 255, 255, 0.9);
118 padding: 30px;
119 border-radius: 10px;
120 box-shadow: 0 4px 10px rgba(0,0,0,0.3);
121 text-align: center;
122 width: 90%;
123 max-width: 320px;
124 margin: 15px;
125 }
126 h3 {
127 margin-bottom: 20px;
128 color: #333;
129 font-size: 18px;
130 }
131 input[type="password"] {
132 width: 90%;
133 padding: 12px;
134 margin-bottom: 15px;
135 border-radius: 5px;
136 border: 1px solid #ccc;
137 font-size: 16px;
138 }
139 button {
140 padding: 12px 24px;
141 background-color: #4CAF50;
142 color: white;
143 border: none;
144 border-radius: 5px;
145 cursor: pointer;
146 font-size: 16px;
147 width: 100%;
148 }
149 button:hover {
150 background-color: #45a049;
151 }
152 .error {
153 color: red;
154 margin-bottom: 15px;
155 font-size: 14px;
156 }
157 </style>
158 </head>
159 <body>
160 <div class="container">
161 <h3>此文章受密码保护,请输入密码:</h3>
162 ${error ? "<p class='error'>密码错误,请重试!</p>" : ""}
163 <form method="POST">
164 <input type="password" name="password" required placeholder="请输入密码" />
165 <button type="submit">提交</button>
166 </form>
167 </div>
168 </body>
169 </html>
170 `;
171}
172
173export default {
174 async fetch(request, env) {
175 return handleRequest(request, env);
176 },
177};
实现原理
实现原理:
- 身份验证流程:
- 用户访问受保护的文章路径
- 系统检查用户是否有有效的认证令牌(
Cookie) - 如果没有有效令牌,显示密码输入页面
- 用户输入密码后提交,系统验证密码
- 密码验证成功后,生成随机令牌并设置对应的
Cookie - 最后重定向用户到原始文章页面
- 令牌管理:
- 使用内存中的
Map(tokenStore)存储令牌与路径的映射 - 支持两种授权类型:永久授权(
Cookie有效期1年)和临时授权(Cookie有效期1分钟)
使用方法
部署好 Workers 后在 设置 -> 变量和机密 点击 添加
变量名称 一定要大写,对应的是 Workers 里的 PRIVATE_POST_PASSWORD 或者 SECRET_POST_PASSWORD(如 TEST),值就是你要加密文章所要设定的密码(如 password)。
变量名称一定要大写!变量名称一定要大写!变量名称一定要大写!
然后同样是在设置里,选择第一项 域和路由,添加 路由,区域 选你的主域名,路由填你要加密的文章路径,就完成咯!
举例与测试
以我测试的两篇文章为例:
- 首先创建了两篇测试文章,一篇用于永久验证的URL是
/post/cf-auth,另一篇用于单次验证的URL是/post/cf-pwd。然后在Workers里修改参数:
1// 配置受保护的文章
2const ARTICLES = {
3 "/post/cf-auth/": { // 你要加密的文章URL
4 password: env.AUTH_PWD, // 环境变量名称
5 authType: "permanent", // 永久授权
6 },
7 "/post/cf-pwd/": { // 你要加密的文章URL
8 password: env.TEST_PWD, // 环境变量名称
9 authType: "temporary", // 临时授权
10 },
11};
- 修改完部署后去配置环境变量:
- 添加路由
区域选择你的博客域名,我这里是 iftcblog.me。然后路由要填你要加密的文章路径,但是前面不要加 https://,也不要把后面的星号 * 忘了。
前面不要加 https://!记得后面要加上星号 *!
然后访问永久验证文章与短暂验证文章,就搞定啦!(密码已经写在上面了哦)
小结
当然还是有潜在的安全问题,如 CSRF 保护和适当的令牌存储机制等等。但是我又不是专业的,感觉做到这个程度已经差不太多了。顺便贴一篇查到的关于 cookies 的文章:Cookie 的 SameSite 属性 | 阮一峰的网络日志
至于页面的美化么,我又完全不懂前端。不想折腾了,先这么将就着吧,等日后有好的想法再说。