惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
L
Lohrmann on Cybersecurity
aimingoo的专栏
aimingoo的专栏
V
V2EX
S
Security Affairs
T
Threatpost
C
CXSECURITY Database RSS Feed - CXSecurity.com
IT之家
IT之家
J
Java Code Geeks
The Register - Security
The Register - Security
U
Unit 42
C
CERT Recently Published Vulnerability Notes
月光博客
月光博客
A
About on SuperTechFans
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
The Blog of Author Tim Ferriss
Cisco Talos Blog
Cisco Talos Blog
Project Zero
Project Zero
S
Schneier on Security
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
D
DataBreaches.Net
博客园 - 司徒正美
V
Vulnerabilities – Threatpost
T
Tor Project blog
Security Latest
Security Latest
T
The Exploit Database - CXSecurity.com
T
Threat Research - Cisco Blogs
Scott Helme
Scott Helme
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
M
MIT News - Artificial intelligence
云风的 BLOG
云风的 BLOG
小众软件
小众软件
L
LangChain Blog
Attack and Defense Labs
Attack and Defense Labs
Recent Commits to openclaw:main
Recent Commits to openclaw:main
P
Palo Alto Networks Blog
A
Arctic Wolf
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
C
Cyber Attacks, Cyber Crime and Cyber Security
博客园 - 叶小钗
D
Darknet – Hacking Tools, Hacker News & Cyber Security
L
LINUX DO - 最新话题
MongoDB | Blog
MongoDB | Blog
Webroot Blog
Webroot Blog
H
Hacker News: Front Page
Know Your Adversary
Know Your Adversary
Spread Privacy
Spread Privacy
AWS News Blog
AWS News Blog
Engineering at Meta
Engineering at Meta

半方池水半方田

小球称重问题及其引申的思考 蓝色的结构色 病毒是不是生物? 把博客发布交给 GitHub Actions 省外居民身份证的补换领(苏州) hexo-filter-titlebased-link:构建你的数字花园 Vercel 应用实践学习 非公开的文章 Soft Mode 非公开的文章 Strict Mode 试试用代码块在 Hexo 中插入实况照片 动态图片测试 Hexo-Butterfly 主题 Preloader 加载页定制 Keycloak 的部署以及 Hexo-Butterfly 网页应用的接入 随机访问文章的实现(Sitemap+本地缓存优化) Authentik 的部署记录 Waline 自建 Auth 认证 记一次行李托运理赔流程(南航) Docker 镜像的制作、拉取与运行 Hexo-Butterfly 主题中对 Algolia 搜索框 Power By 的定制 Hexo 动态加载配置(钩子函数) 设计原则 VSCode 中的配置 Spring AI 中使用 PGVector 实现向量存储 提示词工程速查手册 2025 WePlay 上海两日游 和随机数生成相关的题目 一起撸串(字符串) 树状数组上手了就十分简单 缓存置换算法的实现与 Java 中相关的数据结构 关于海量数据的若干问题 访问者模式填补单分派语言的缺陷 备忘录模式:拍下照片
通过与 Keycloak 配合实现博客文章的受限访问功能
wuanqin · 2026-04-21 · via 半方池水半方田

|总字数:3k|阅读时长:12分钟|浏览量:|

|出链:2|入链:2

站内文章上一篇文章 中,我们已经已经完成了认证系统的部署和登录态的检测。现在我们打算利用这一功能,实现对部分博客文章的受限访问。

使用效果

在需要受限访问的文章的 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





const AUTH_CONFIG = {

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;
const path = contentNode.dataset.path;
const localToken = localStorage.getItem('kc_token_data');

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

try {

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');
}


const isValid = typeof verifySignature === 'function' ? await verifySignature(localToken) : true;
if (!isValid) throw new Error('Signature Invalid');



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


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);
}



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)');


innerNode.innerHTML = originalHtml;
}


placeholderNode.remove();
contentNode.style.display = 'block';
contentNode.classList.add('show');


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:

- <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




'use strict';

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

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


const authConfig = hexo.config.page_lock;

if (!authConfig || !authConfig.master_secret) {

return data;
}

const MASTER_SECRET = authConfig.master_secret;


if (access && access.requires_auth && access.mode === 'strict') {


let postPath = data.path;
if (!postPath.startsWith('/')) postPath = '/' + postPath;
if (!postPath.endsWith('/')) postPath = postPath + '/';


const articleKey = CryptoJS.HmacSHA256(postPath, MASTER_SECRET).toString();


const originalContent = data.content;
const cipherText = CryptoJS.AES.encrypt(originalContent, articleKey).toString();


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

后记

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

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

本文参考

版权声明 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 半方池水半方田

封面生成即梦 AI - 图片 5.0 Lite

Prompt印象派风格的花园篱笆,色彩绚丽,笔触奔放,无文字

赞助

  • 微信

    微信

  • 支付宝

    支付宝


avatar

wuanqin

只能懂一点点

最新文章

蓝色的结构色