在 站内文章 Keycloak 的部署以及 Hexo-Butterfly 网页应用的接入 中,我们已经已经完成了认证系统的部署和登录态的检测。现在我们打算利用这一功能,实现对部分博客文章的受限访问。
使用效果
在需要受限访问的文章的 Front-Matter 中,我们加入以下内容:
1 2 3 access: requires_auth: false mode: soft
当 requires_auth 为 false 时,文章和普通文章一样正常访问;如果为 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: 内容最终呈现
被受限文章将展示以下界面:
两种模式利弊一览:
文章受限方式
前端遮罩(soft)
物理加密(strict)
安全性
防君子不放小人
加密程度较高,但存在对称加密的普遍风险
加载速度
取决于登录态获取速度
可能需要发送一次服务器验签请求
实现复杂度
前端修改
需要额外部署验签服务
SEO 表现
不影响
内容不可见
适用场景
低敏感内容
较私密内容
其实,mode 这个字段也为未来其他模式的实现做好了预留。未来,我们可以实现验证用户是否具有相应的权限的功能。
基本思路:
Vercel 或服务器部署验签函数
Hexo-Butterfly 主题增加遮罩页
博客引入 JavaScript 进行文章解密
博客增加 Hexo-Filter 实现文章加密
Vercel 或服务器部署验签函数
我们需要由一个服务器以处理客户端发来的解密需求。客户端携带文章路径和自身的 Token 到服务器中,服务器要做的事情是验证 Token 的有效性并生成对称解密密钥给客户端。
源码详见:
项目地址: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 主题增加遮罩页
只需要改两个地方:
修改 \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.yml 中 inject 以下 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 中存放的脚本是自动执行的,是插件诞生的子宫。
首先,我们需要安装相应的加密库,插件才能正确运行:
另外,脚本需要读取一些配置,比如对称加密的核心 master_secret(此处填写必须要和 Vercel 中相应环境变量填写一致)。这些配置写在 _config.yml 中(原因是为以后插件化做准备):
1 2 3 page_lock: master_secret: <千万不能暴露> debug: true
后记
这应该是近期博客建设计划的最后一块砖头。引入受限文章功能并不是说要在博客写什么机密文章(机密文章为啥发博客?),而是为了分层级构建阅读门槛,将一些比较个人的、敏感的、未成熟的东西保护起来,为自己的表达腾出了更大的空间。此外,受限文章可以反爬,防止不相干的人无意闯入。
它像是花园外的篱笆,在纷扰的世界中守护那一点正在成长的幼苗。这也是我对曾经博客「公私内容」结合问题的一个回答。
本文参考