摘要生成中...
AI 摘要
Hunyuan-lite

站内文章Keycloak 的部署以及 Hexo-Butterfly 网页应用的接入 中,我们已经已经完成了认证系统的部署和登录态的检测。现在我们打算利用这一功能,实现对部分博客文章的受限访问。

前置内容

本文基于 站内文章Keycloak 的部署以及 Hexo-Butterfly 网页应用的接入 中的登录态获取功能进行实现。

使用效果

在需要受限访问的文章的 Front-Matter 中,我们加入以下内容:

1
2
3
access:
requires_auth: false
mode: soft

requires_authfalse 时,文章和普通文章一样正常访问;如果为 true 则文章访问受限,受限模式取决于后面的 mode 参数配置。

本篇文章实现了两种受限模式:

  • mode: soft:该文章需要用户登录,但文章加密方式为前端遮罩。内容并未实质加密,爬虫可以获取文章的内容。
  • mode: strict:该文章需要用户登录,文章实质进行了物理加密,首次获取文章时需联网向服务器发送 token 验签,并接收服务器传来的密钥进行文章解密。

基本交互时序图如下:

sequenceDiagram
    autonumber
    participant H as Hexo (本地/CI)
    participant U as 用户 (浏览器)
    participant K as Keycloak (认证)
    participant V as Vercel (云函数)

    Note over H, V: 【阶段一:编译期 - 物理加密】
    H->>H: 扫描 Front-matter (mode: strict)
    H->>H: 计算 Key = HMAC(Path, Master_Secret)
    H->>H: 执行 AES 加密 (Content + Key)
    H->>U: 发布静态 HTML (包含密文 + data-mode)

    Note over H, V: 【阶段二:访问期 - 身份识别】
    U->>U: 页面加载,检查 localStorage (Token)
    alt 无有效 Token
        U->>K: 重定向至登录页
        K-->>U: 登录成功,带 Code 回调
        U->>U: 换取并存入 kc_token_data
    else 已有有效 Token
        U->>U: verifySignature (本地验签)
    end

    Note over H, V: 【阶段三:解锁期 - 分级防御】
    U->>U: 读取 DOM (data-mode)
    
    alt mode == "soft" (L1 软遮罩)
        U->>U: display: block (直接显示明文)
    else mode == "strict" (L2 强加密)
        U->>V: POST /api/get-key (Path + JWT)
        V->>V: 验证 JWT 签名 & Origin
        V->>V: 计算 Key = HMAC(Path, Master_Secret)
        V-->>U: 返回加密钥匙 (Article Key)
        U->>U: CryptoJS.AES.decrypt (密文 + Key)
        U->>U: 注入 innerHTML 并渲染
    end

    U-->>U: 内容最终呈现

被受限文章将展示以下界面:

image.png

两种模式利弊一览:

文章受限方式 前端遮罩(soft 物理加密(strict
安全性 防君子不放小人 加密程度较高,但存在对称加密的普遍风险
加载速度 取决于登录态获取速度 可能需要发送一次服务器验签请求
实现复杂度 前端修改 需要额外部署验签服务
SEO 表现 不影响 内容不可见
适用场景 低敏感内容 较私密内容

其实,mode 这个字段也为未来其他模式的实现做好了预留。未来,我们可以实现验证用户是否具有相应的权限的功能。

基本思路:

  • Vercel 或服务器部署验签函数
  • Hexo-Butterfly 主题增加遮罩页
  • 博客引入 JavaScript 进行文章解密
  • 博客增加 Hexo-Filter 实现文章加密

Vercel 或服务器部署验签函数

我们需要由一个服务器以处理客户端发来的解密需求。客户端携带文章路径和自身的 Token 到服务器中,服务器要做的事情是验证 Token 的有效性并生成对称解密密钥给客户端。

源码详见:

Readme Card

项目地址:uuanqin/keycloak-auth: 博客 Keycloak 验签函数

其实就是个边缘计算 API,无后端,用 Vercel 部署起来非常合适。项目中,需要配置的环境变量有:

环境变量 说明 示例值
ALLOWED_ORIGINS 跨域设置 https://uuanqin.top, http://localhost:4000
KC_PUB_KEY Keycloak 公钥,用于验签 MIIBIjAxxxxxxQIDAQAB
MASTER_SECRET 对称加密主密钥 abcd(建议设置复杂一些)

推荐在 Vercel 搞好自定义域名。

Postman 测试示例:

1
2
3
4
5
6
curl --location --request POST 'https://article.auth.uuanqin.top/api/get_post_key' \
--header 'Authorization: Bearer <your-token>' \
--header 'Content-Type: application/json' \
--data-raw '{
"path": "/p/test/"
}'

Hexo-Butterfly 主题增加遮罩页

只需要改两个地方:

  • 修改主题 post 模板
  • 增加相应的样式

修改 \themes\butterfly\layout\post.pug 文件为:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
extends includes/layout.pug  

block content

if page.comments !== false && theme.comments.use
- var commentsJsLoad = true

#post
if (page.access && page.access.requires_auth) || top_img === false
include includes/header/post-info.pug

if page.access && page.access.requires_auth
#auth-lock-placeholder.auth-placeholder
.lock-icon 🔒
h2= page.access.mode === 'strict' ? '加密文章' : '受限文章'
p= page.access.mode === 'strict' ? '此内容已物理加密,需验证身份获取密钥' : '访问此内容需要统一身份认证'
button.kc-login-btn(onclick="window.location.href=getKeycloakLoginUrl()") 统一认证登录

//- 认证内容容器
//- data-mode 和 data-path 用于 JS 识别逻辑
#auth-real-content(
style="display: none;"
data-mode=page.access.mode
data-path=url_for(page.path)
)
//- 如果是严格模式,page.content 此时已被 Hexo 插件替换为 AES 密文
//- 如果是软模式,page.content 依然是明文 HTML +article_full_body()
else
//- 普通公开文章
+article_full_body()

mixin article_full_body()
article#article-container.container.post-content
if page.summary && theme.ai_summary.enable
include includes/post/post-summary.pug

#auth-content-inner
if theme.noticeOutdate.enable && page.noticeOutdate !== false
include includes/post/outdate-notice.pug
else
!=page.content

//- 无论是否加密都显示的页脚信息(版权、标签等)
include includes/post/post-copyright.pug
include includes/post/post-ai-info.pug
.tag_share
if (page.tags.length > 0 && theme.post_meta.post.tags)
.post-meta__tag-list
each item, index in page.tags.data
a(href=url_for(item.path)).post-meta__tags #[=item.name]
include includes/third-party/share/index.pug

if theme.reward.enable && theme.reward.QR_code
!=partial('includes/post/reward', {}, {cache: true})

if theme.ad && theme.ad.post
.ads-wrap!=theme.ad.post

if theme.post_pagination
include includes/pagination.pug
if theme.related_post && theme.related_post.enable
!= related_posts(page,site.posts)

if page.comments !== false && theme.comments.use
!=partial('includes/third-party/comments/index', {}, {cache: true})

添加 \themes\butterfly\source\css\_layout\lock-page.styl

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 变量定义,方便后续微调
$lock-color = #34495e
$btn-bg = var(--btn-bg)
$btn-hover = var(--btn-hover-color)

.auth-placeholder
display: flex
flex-direction: column
align-items: center
justify-content: center
padding: 50px 20px
margin: 20px auto
border: 2px dashed dashed-color // 使用主题变量
border-radius: 12px
background: var(--card-bg)
text-align: center
transition: all 0.3s ease

&:hover
border-color: $btn-bg

.lock-icon
font-size: 3.5rem
margin-bottom: 1rem
color: $lock-color
animation: lock-shake 2s infinite ease-in-out

h2
margin-top: 0
font-size: 1.5rem
color: var(--text-highlight-color)

p
margin: 10px 0 20px
color: var(--text-highlight-color)

.kc-login-btn
padding: 10px 24px
background: $btn-bg
color: var(--btn-color)
border-radius: 5px
border: none
cursor: pointer
transition: background 0.3s ease
box-shadow: 0 4px 6px fade($btn-bg, 0.2)

&:hover
background: $btn-hover
box-shadow: 0 4px 12px fade($btn-hover, 0.3)

// 锁头抖动小动画,增加趣味性
@keyframes lock-shake
0%, 100%
transform: rotate(0)
10%, 30%, 50%, 70%, 90%
transform: rotate(-10deg)
20%, 40%, 60%, 80%
transform: rotate(10deg)

// 保证隐藏内容的平滑过渡
#auth-real-content
&.show
display: block !important
animation: content-fade-in 0.6s ease-out

@keyframes content-fade-in
from
opacity: 0
transform: translateY(10px)
to
opacity: 1
transform: translateY(0)

博客引入 JavaScript 进行文章解密

在博客项目 source/js/keycloak-pageLock.js 引入下面的 JavaScript 用于处理密钥获取和解密:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
* 文章权限门禁系统 (Auth Gate)
* 支持:L1 软遮罩 / L2 物理加密解密
*/

const AUTH_CONFIG = {
// 你的 Vercel 云函数地址
keyServer: 'https://article.auth.uuanqin.top/api/get_post_key',
};

const unlockArticle = async () => {
const contentNode = document.getElementById('auth-real-content');
const placeholderNode = document.getElementById('auth-lock-placeholder');
const innerNode = document.getElementById('auth-content-inner');

if (!contentNode || !placeholderNode) return;

const mode = contentNode.dataset.mode; // soft 或 strict
const path = contentNode.dataset.path; // 文章路径
const localToken = localStorage.getItem('kc_token_data');

if (!localToken) {
console.log('%c[Auth-Gate] 未发现登录凭证,请先登录', 'color: #dc3545;');
return;
}

try {
// 1. 验证 Token 时效性
const payload = JSON.parse(atob(localToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token Expired');
}

// 2. 核心验签
const isValid = typeof verifySignature === 'function' ? await verifySignature(localToken) : true;
if (!isValid) throw new Error('Signature Invalid');

// --- 权限校验通过,开始分模式解锁 ---

if (mode === 'strict') {
console.log('[Auth-Gate] 检测到严格加密模式,正在获取解密密钥...');

// 3. 获取钥匙 (优先缓存)
let articleKey = sessionStorage.getItem(`key_${path}`);
if (!articleKey) {
const response = await fetch(AUTH_CONFIG.keyServer, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: path })
});

if (!response.ok) throw new Error('Failed to fetch decryption key');
const data = await response.json();
articleKey = data.key;
sessionStorage.setItem(`key_${path}`, articleKey);
}

// 4. 执行 AES 解密
// 此时 innerNode.innerHTML 里的内容是 Hexo 插件生成的密文
const cipherText = innerNode.innerText.trim();
const bytes = CryptoJS.AES.decrypt(cipherText, articleKey);
const originalHtml = bytes.toString(CryptoJS.enc.Utf8);

if (!originalHtml) throw new Error('Decryption failed (empty result)');

// 5. 注入明文
innerNode.innerHTML = originalHtml;
}

// --- 统一 UI 解锁 ---
placeholderNode.remove();
contentNode.style.display = 'block';
contentNode.classList.add('show');

// 6. 激活主题组件(重绘、懒加载、公式等)
window.dispatchEvent(new Event('resize'));
if (window.MathJax) MathJax.typeset();
if (window.prism) Prism.highlightAll();

console.log(`%c[Auth-Gate] ${mode === 'strict' ? '密文已解密' : '身份验证通过'},内容解锁成功`, 'color: #28a745; font-weight: bold;');

} catch (e) {
console.error('[Auth-Gate] 解锁失败:', e.message);
}
};

document.addEventListener('DOMContentLoaded', unlockArticle);

// 强制解锁方法适配
window.forceUnlockArticle = unlockArticle;

这个脚本能运行成功,还需要在主题配置文件 _config.butterfly.ymlinject 以下 JavaScript 库以进行文章 HTML 加密:

1
2
3
4
inject:  
bottom:
# Keycloak 登录集成
- <script src="/js/public/crypto-js.min.js"></script>

博客增加 Hexo-Filter 实现文章加密

在博客项目引入下面的文件 scripts/keycloak-encrypt-post.js

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
35
36
37
38
39
40
41
42
43
44
45
46
/**
* Hexo 自动化加密过滤器 (配置版)
*/

'use strict';

const CryptoJS = require('crypto-js');

hexo.extend.filter.register('after_post_render', function(data) {
const { access } = data;

// 从 hexo.config 中读取配置
const authConfig = hexo.config.page_lock;

if (!authConfig || !authConfig.master_secret) {
// 如果你在写插件,这里可以报个警告,提示用户配置 master_secret
return data;
}

const MASTER_SECRET = authConfig.master_secret;

// 判定是否需要执行“严格加密”
if (access && access.requires_auth && access.mode === 'strict') {

// 确定文章路径 (保持与云函数、Pug 模板完全一致)
let postPath = data.path;
if (!postPath.startsWith('/')) postPath = '/' + postPath;
if (!postPath.endsWith('/')) postPath = postPath + '/';

// 3. 动态计算该文章的专属钥匙 (HMAC-SHA256)
const articleKey = CryptoJS.HmacSHA256(postPath, MASTER_SECRET).toString();

// 4. 执行 AES 加密
const originalContent = data.content;
const cipherText = CryptoJS.AES.encrypt(originalContent, articleKey).toString();

// 5. 替换正文
data.content = cipherText;

if (authConfig.debug) {
console.log(`[Auth-Encrypt] 文章已物理加密: ${postPath}`);
}
}

return data;
});

Hexo 项目中 scripts 中存放的脚本是自动执行的,是插件诞生的子宫。

首先,我们需要安装相应的加密库,插件才能正确运行:

1
npm install crypto-js

另外,脚本需要读取一些配置,比如对称加密的核心 master_secret(此处填写必须要和 Vercel 中相应环境变量填写一致)。这些配置写在 _config.yml 中(原因是为以后插件化做准备):

1
2
3
page_lock:  
master_secret: <千万不能暴露>
debug: true

后记

这应该是近期博客建设计划的最后一块砖头。引入受限文章功能并不是说要在博客写什么机密文章(机密文章为啥发博客?),而是为了分层级构建阅读门槛,将一些比较个人的、敏感的、未成熟的东西保护起来,为自己的表达腾出了更大的空间。此外,受限文章可以反爬,防止不相干的人无意闯入。

它像是花园外的篱笆,在纷扰的世界中守护那一点正在成长的幼苗。这也是我对曾经博客「公私内容」结合问题的一个回答。

本文参考