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

推荐订阅源

博客园 - Franky
N
Netflix TechBlog - Medium
Google Online Security Blog
Google Online Security Blog
月光博客
月光博客
量子位
酷 壳 – CoolShell
酷 壳 – CoolShell
V
V2EX
腾讯CDC
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - 聂微东
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
M
MIT News - Artificial intelligence
Vercel News
Vercel News
The GitHub Blog
The GitHub Blog
Hugging Face - Blog
Hugging Face - Blog
博客园 - 【当耐特】
Apple Machine Learning Research
Apple Machine Learning Research
aimingoo的专栏
aimingoo的专栏
博客园 - 三生石上(FineUI控件)
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
MongoDB | Blog
MongoDB | Blog
H
Help Net Security
The Cloudflare Blog
Blog — PlanetScale
Blog — PlanetScale
F
Full Disclosure
G
Google Developers Blog
罗磊的独立博客
Jina AI
Jina AI
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Y
Y Combinator Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
J
Java Code Geeks
A
About on SuperTechFans
IT之家
IT之家
大猫的无限游戏
大猫的无限游戏
S
SegmentFault 最新的问题
有赞技术团队
有赞技术团队
GbyAI
GbyAI
雷峰网
雷峰网
T
The Blog of Author Tim Ferriss
The Register - Security
The Register - Security
U
Unit 42
D
Docker
Martin Fowler
Martin Fowler
L
LINUX DO - 热门话题
NISL@THU
NISL@THU
阮一峰的网络日志
阮一峰的网络日志
C
Cybersecurity and Infrastructure Security Agency CISA
博客园_首页
Google DeepMind News
Google DeepMind News

半方池水半方田

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

想找一个开箱即用、轻量的、开源的认证系统,要求内含用户管理,支持用户自主注册,允许第三方登录。经过近两周的不断的选型和尝试,现决定将 Keycloak 作为我的 SSO 系统。前期选型如下:

软件 特点 评价
Casdoor 国产、功能很全 第一个测试的认证服务,其实功能挺全的。但是实际体验下来很难用,部分功能的体验不是很好,文档很粗糙,概念多(开箱一坨),配置繁琐。说是以 UI 为重,但我体验下来感觉并不算专业。
Authentik 功能完善 已经很接近我想要的了,但是部分功能反人类,有点吃掉我的耐心了。
Authelia 轻量 没有用户管理。
Ory 微服务式 理想中的认证服务系统,但是学习成本过高,耦合度过低。
Melody Auth 国内开发者,支持 Cloudflare 部署 配置简单。但用户自助注册功能需要自己实现。
Keycloak 功能完善 功能基本符合我的要求,惊喜的是,它提供 JavaScript 适配,简化网页应用的开发。

本文主要内容:

  • 部署和配置 Keycloak
  • 使用 Keycloak JavaScript Adapter,将单点登录的认证集成到博客网页中。

本文以静态博客 Hexo-Butterfly 为例,但核心思路是通用的

毕竟主要集成方式还是 JavaScript,核心思路与博客框架、主题是无关的。而且我在编写相关代码时将一些定制化的内容提前了,以方便各位进行修改。

实现效果:侧边栏登录按钮,实现登录和登出的基本功能。

image.png

Keycloak 的部署和配置

对于 Keycloak 部分,可以阅读官方文档完成大部分内容。因此本节将进行核心步骤的简要提示。

部署

参考官方文档完成 Keycloak 的部署:Docker - Keycloak。我的 Docker 指令参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
docker run -d \
--name keycloak \
--restart=always \
-p 127.0.0.1:8999:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
-e KC_HOSTNAME_STRICT_HTTPS=false \
-e KC_HTTP_MAX_QUEUED_REQUESTS=500 \
-e KC_HTTP_ENABLED=true \
quay.io/keycloak/keycloak:26.5.6 \
start \
--db=mysql \
--db-url-host=yoursql.yourdomain.com \
--db-url-port=12345 \
--db-username=root \
--db-password=your_password \
--hostname-strict=false \
--hostname=auth.uuanqin.top \
--proxy-headers=xforwarded

上面的配置是针对生产环境用的,运行的前提是已经配置好 Nginx、HTTPS、DNS、数据库和安全组。配置命令参考 Configuring Keycloak - KeycloakConfiguring Keycloak for production - Keycloak

直接外网访问 Keycloak 报错

建好 Realm 和用于测试的用户

可参考文档,完成组织和用户的创建:

image.png

创建 Client 并配置

创建一个网页应用 Client:

image.png

配置参考如下。首先是 Client 的基本信息:

image.png

回调地址一定要写对,否则导致认证失败:

image.png

下面是一些认证配置。要点:

  • Client authentication 保持关闭。因为这是个前端应用。
  • Authentication flow 默认 Standard flow 即可

image.png

至于 PKCE 选项理应要配置,但是目前我正在努力尝试中。即便如此,我们的前端应用安全性已经够用了。

记下公钥

在 Realm Setting 中记下公钥,后面要用:

image.png

Hexo-Butterfly 的接入

主题的配置

在 Butterfly 主题配置文件中做如下引入:

1
2
3
4
5
6
7
8
9
inject:
head:

- <link rel="preconnect" href="https://auth.uuanqin.top/" crossorigin>

bottom:

- <script src="/js/public/keycloak-25.0.6.min.js"></script>
- <script src="/js/keycloak-auth.js"></script>

preconnect 预链接到你的 Keycloak 部署的地址,以提升访问速度。

keycloak-25.0.6.min.js 应该是最后一个支持 IIFE / UMD 单文件引用的版本了,地址:cdn.jsdelivr.net/npm/[email protected]/dist/keycloak.min.js。建议下载到项目中,避免访问第三方 CDN 以加速网站(jsdelivr 的速度,你懂的)。

keycloak-auth.js 就是我们接下来要自己写的内容。

魔改主题——增加组件和样式

既然我们要实现网页端的用户登录。我们就需要设计一个组件,允许用户登录、展示用户信息、登出等等。我的基本设想是,在侧边栏设计一个简单的按钮:

  • 当用户未登录时,显示登录文字
  • 用户点击登录按钮后,跳转到 Keycloak 认证界面完成登录
  • 用户登录成功后,跳转至原页面。按钮展示用户信息(用户名)
  • 已登录用户将鼠标悬浮至按钮时,按钮会出现:查看用户信息以及登出选项
    • 查看用户信息:用户点击该按钮后,跳转到 Keycloak 相应组织的用户界面
    • 登出:用户点击按钮后,引导至 Keycloak,完成登出

新建文件 \themes\butterfly\layout\includes\widget\card_login.pug,设计我们的按钮模块。这里是直接借鉴原主题 card_authorbutton 设计:

1
2
3
4
5
6
7
8
9
10
// 暂时不设置主题开关(全部硬编码)
.card-login.text-center
.kc-user-btn-container#kcUserContainer
// 🔥 改为新ID:card-login-btn
a#kc-default-btn(href="javascript:;")
i(class="fa-solid fa-address-card")
span#kcBtnText= "Unified authentication login"
.kc-hover-menu
a#kc-profile-btn 查看信息
a#kc-logout-btn.kc-logout-btn 退出登录

因为我要放在侧边栏中,所以需要在侧边栏相应地方 \themes\butterfly\layout\includes\widget\index.pug 把我们的按钮加上去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@@ -8,6 +8,7 @@
include ./card_post_toc.pug
else
!=partial('includes/widget/card_author', {}, {cache: true})
+ !=partial('includes/widget/card_login', {}, {cache: true})
!=partial('includes/widget/card_announcement', {}, {cache: true})
!=partial('includes/widget/card_top_self', {}, {cache: true})
.sticky_layout
@@ -20,6 +21,7 @@
else
//- page
!=partial('includes/widget/card_author', {}, {cache: true})
+ !=partial('includes/widget/card_login', {}, {cache: true})
!=partial('includes/widget/card_announcement', {}, {cache: true})
!=partial('includes/widget/card_top_self', {}, {cache: true})

样式也是仿照原主题按钮的,这里独立出来是为了方便以后的更改。新建 \themes\butterfly\source\css\_layout\login.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
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98



.card-login
@extend .cardHover
position: relative
overflow: hidden
margin-bottom: 20px
padding: 20px 24px
border-radius: 12px
background: var(--card-bg)
width: 100%

.kc-user-btn-container
position: relative
width: 100%
height: 38px
transition: all 0.3s ease


#kc-default-btn
display: flex
align-items: center
justify-content: center
width: 100%
height: 38px
background-color: var(--btn-bg)
color: var(--btn-color)
text-align: center
line-height: 1
border-radius: 8px
@extend .btn-effects
cursor: pointer
text-decoration: none
gap: 8px
transition: all 0.3s ease
position: absolute
top: 0
left: 0
z-index: 1

&:hover
background-color: var(--btn-hover-color)
transform: translateY(-1px)


.kc-hover-menu
position: absolute
top: 0
left: 0
width: 100%
height: 38px
border-radius: 8px
overflow: hidden
background: var(--btn-bg)
display: flex
justify-content: center
align-items: center
gap: 0
transition: all 0.3s ease
opacity: 0
visibility: hidden
z-index: 3

a
flex: 1
height: 100%
display: flex
align-items: center
justify-content: center
color: var(--btn-color)
cursor: pointer
text-decoration: none
transition: all 0.3s ease

&:hover
background: var(--btn-hover-color)

.kc-logout-btn
&:hover
background-color: #f56c6c !important
color: #fff !important

.kc-user-btn-container.logged-in
#kc-default-btn
z-index: 2
opacity: 1
visibility: visible

&:hover
.kc-hover-menu
opacity: 1
visibility: visible


+maxWidth768()
.card-login
padding: 16px

魔改主题——开放全屏 Loading 页面的启停函数

关于全局加载页的启停,在这篇文章中将有更详细的介绍:站内文章Hexo-Butterfly 主题 Preloader 加载页定制

在集成 Keycloak 过程中,遇到的一个影响体验的问题是在网站切换页面的过程中,登录等部分场景下会产生两次页面的刷新。我想打算重新引入全屏 Loading 页面,等一切准备就绪时才展开。

修改主题配置文件:

1
2
3
4
5
6
7
8

preloader:
enable: true


source: 1

pace_css_url:

但是,单单开启全屏 Loading 还不够,我们要将 Loading 页面的控制权交出来,不让主题来控制。修改以下主题代码 \themes\butterfly\layout\includes\loading\fullpage-loading.pug

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@@ -26,6 +26,9 @@

preloader.initLoading()

+ // 注释掉自动移除逻辑
+ // 我们不希望它在 window.onload 时自动消失,我们要手动控制
+ /*
if (document.readyState
preloader.endLoading()
} else {
@@ -34,6 +37,10 @@
// Add timeout protection: force end after 7 seconds
setTimeout(preloader.endLoading, 7000)
}
+ */
+
+ // 暴露到全局,让外部 JS (Keycloak) 可以调用
+ window.endPreloader = preloader.endLoading

if (!{theme.pjax && theme.pjax.enable}) {
btf.addGlobalFn('pjaxSend', preloader.initLoading, 'preloader_init')

当我们的 JavaScript 代码中决定要关闭 Loading 页面时,调用 window.endPreloader(); 即可。

灵魂 JavaScript 文件

keycloak-auth.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
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
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343

const KEYCLOAK_CONFIG = {
url: "https://auth.uuanqin.top/",
realm: "ABA Inc.",
clientId: "uuanqin_blog",
publicKey: "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7mhwT/kEx3PZZijlk05QbYeJ5VnfBaJZpUBSc/aMFuS3+8oR0C/sKJlxOdhuqS1e8TMBrbWaMDjQ/s6SlKHXxARlZCOBjYn5NN3bsj0KSGaq+6NvuX5dD55idHNkmX4YUsLC+9F7jKZrbPMPNAijKYdZY3rh7rBDowqfsMuXeP7tZC4pGNu3XkT09k4v/B3lgParMQJ3Gk3Cr09Xqjji92O1G/UoD86E130fPNazUyNcWTq572o9dp9nYB33DalBZBHP/SUnnlWVxEwXWleaF3bm9186tAbJr/B/5r9jfW8+pnzF+qo6dFKCWQU+GNpcqaxaMSh/zeP1SDCe8VH6nQIDAQAB"
};

const CACHE_TIMES = {
SECONDS_OF_UNAUTH_HINT: 86400,
SECONDS_OF_TOKEN_MIN_VALID: 30,
SECONDS_OF_REFRESH_THRESHOLD: 80,
MILLISECONDS_OF_REFRESH_INTERVAL: 30000,
MILLISECONDS_OF_LOADING_TIMEOUT: 5000
};



function finish() {
if (isLoaderEnded) return;
if (typeof window.endPreloader === 'function') {
window.endPreloader();
isLoaderEnded = true;
logger.info("认证流程结束,Loading 界面已关闭");
}
}

function updateLoggedInUI(payload) {
const container = document.getElementById('kcUserContainer');
const btnText = document.getElementById('kcBtnText');
const profileBtn = document.getElementById('kc-profile-btn');
const logoutBtn = document.getElementById('kc-logout-btn');

if (!container) return;


const rawName = payload.preferred_username || payload.name || 'User';
if (btnText) btnText.textContent = `欢迎,${truncateString(rawName, 10)}`;
container.classList.add('logged-in');
container.classList.remove('logged-out');


if (profileBtn) {
profileBtn.onclick = () => window.open(KEYCLOAK_ACCOUNT_URL, '_blank');
}


if (logoutBtn) {
logoutBtn.onclick = () => {
window.location.href = getKeycloakLogoutUrl();
};
}
}


function updateLoggedOutUI() {
const container = document.getElementById('kcUserContainer');
const btnText = document.getElementById('kcBtnText');
const defaultBtn = document.getElementById('kc-default-btn');

if (!container) return;


if (btnText) btnText.textContent = "Unified authentication login";
container.classList.add('logged-out');
container.classList.remove('logged-in');


if (defaultBtn) {
defaultBtn.onclick = () => {
localStorage.removeItem(STORAGE_KEYS.GUEST_DATA);
window.location.href = getKeycloakLoginUrl();
};
}
}





let keycloakInstance = null;
let isLoaderEnded = false;
let refreshTimer = null;


const KEYCLOAK_ACCOUNT_URL = `${KEYCLOAK_CONFIG.url}realms/${encodeURIComponent(KEYCLOAK_CONFIG.realm)}/account/`;


function getBaseRedirectUri() {
return window.location.origin + window.location.pathname;
}

function getKeycloakLoginUrl() {
const base = `${KEYCLOAK_CONFIG.url}realms/${encodeURIComponent(KEYCLOAK_CONFIG.realm)}`;
const clientId = encodeURIComponent(KEYCLOAK_CONFIG.clientId);

const redirectUri = encodeURIComponent(getBaseRedirectUri() + "?action=login");
return `${base}/protocol/openid-connect/auth?client_id=${clientId}&redirect_uri=${redirectUri}&response_type=code&scope=openid`;
}

function getKeycloakLogoutUrl() {
const base = `${KEYCLOAK_CONFIG.url}realms/${encodeURIComponent(KEYCLOAK_CONFIG.realm)}`;
const clientId = encodeURIComponent(KEYCLOAK_CONFIG.clientId);
const redirectUri = encodeURIComponent(getBaseRedirectUri() + "?action=logout");
return `${base}/protocol/openid-connect/logout?client_id=${clientId}&post_logout_redirect_uri=${redirectUri}`;
}

const STORAGE_KEYS = {
TOKEN: 'kc_token_data',
REFRESH_TOKEN: 'kc_refresh_token_data',
GUEST_DATA: 'kc_guest_cache'
};

const logger = {
info: (msg, data = '') => console.log(`%c[KC-INFO] ${msg}`, 'color: #007bff; font-weight: bold;', data),
success: (msg, data = '') => console.log(`%c[KC-SUCCESS] ${msg}`, 'color: #28a745; font-weight: bold;', data),
warn: (msg, data = '') => console.warn(`%c[KC-WARN] ${msg}`, 'color: #ffc107; font-weight: bold;', data),
error: (msg, err = '') => console.error(`%c[KC-ERROR] ${msg}`, 'color: #dc3545; font-weight: bold;', err)
};



function truncateString(str, num = 12) {
if (!str) return '';
return str.length > num ? str.slice(0, num) + "..." : str;
}

function pemToArrayBuffer(pem) {
const b64 = pem.replace(/[\r\n]/g, '').replace(/-----BEGIN PUBLIC KEY-----/, '').replace(/-----END PUBLIC KEY-----/, '');
const byteString = window.atob(b64);
const byteArray = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) byteArray[i] = byteString.charCodeAt(i);
return byteArray.buffer;
}

async function verifySignature(token) {
try {
const [headerB64, payloadB64, signatureB64] = token.split('.');
const cryptoKey = await window.crypto.subtle.importKey(
"spki", pemToArrayBuffer(KEYCLOAK_CONFIG.publicKey),
{name: "RSASSA-PKCS1-v1_5", hash: "SHA-256"}, false, ["verify"]
);
const data = new TextEncoder().encode(headerB64 + "." + payloadB64);
const signature = Uint8Array.from(atob(signatureB64.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
const isValid = await window.crypto.subtle.verify("RSASSA-PKCS1-v1_5", cryptoKey, signature, data);

if (isValid) {
logger.success("Token 签名验证通过");
} else {
logger.warn("Token 签名验证失败:签名不匹配");
}
return isValid;
} catch (e) {
logger.error("Token 签名解析过程中出现异常", e);
return false;
}
}



document.addEventListener('DOMContentLoaded', async () => {
setTimeout(() => {
if (!isLoaderEnded) logger.warn("认证超时,强制 Finish ");
finish();
}, CACHE_TIMES.MILLISECONDS_OF_LOADING_TIMEOUT);

const urlParams = new URLSearchParams(window.location.search);
const action = urlParams.get('action');
const localToken = localStorage.getItem(STORAGE_KEYS.TOKEN);
const localRefreshToken = localStorage.getItem(STORAGE_KEYS.REFRESH_TOKEN);
const localGuestData = JSON.parse(localStorage.getItem(STORAGE_KEYS.GUEST_DATA) || '{}');
const isGuestMode = localGuestData.mode === 'logged_out' && localGuestData.expire > Date.now();

try {

if (action === 'login' || urlParams.has('code')) {
logger.info("检测到【登录回调】,启动完整初始化换取 Token");
await performFullInit(()=>{
cleanUrlParams();
finish();
});
return;
}


if (action === 'logout') {
logger.info("A logout callback has been detected. Local cache will be forcibly cleared.");
handleAuthFailure();
cleanUrlParams();
finish();
return;
}

if (localToken) {
logger.info("检测到本地 Token,尝试进入【极速通行通道】...");
const payload = JSON.parse(atob(localToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
const timeLeft = payload.exp - Math.floor(Date.now() / 1000);

if (timeLeft > CACHE_TIMES.SECONDS_OF_TOKEN_MIN_VALID) {
logger.info(`Token 尚未过期 (剩余 ${timeLeft}s),开始本地验签...`);
if (await verifySignature(localToken)) {
logger.success(`极速通行:Token 剩余 ${timeLeft}s`);
updateLoggedInUI(payload);
finish();

startSilentRefresh(localToken, localRefreshToken);
return;
}
}
}

if (isGuestMode) {
logger.info("Visitor mode: Skip detection");
updateLoggedOutUI();
finish();
return;
}


logger.info("无有效本地缓存,进入【服务器同步通道】(Check-SSO)...");
await performFullInit(finish);

} catch (err) {
handleAuthFailure();
finish();
}
});

async function performFullInit(doneCallback) {
keycloakInstance = new Keycloak(KEYCLOAK_CONFIG);
try {
const auth = await keycloakInstance.init({
onLoad: 'check-sso',
checkLoginIframe: false
});

if (auth && keycloakInstance.token) {
logger.success("Server synchronization successful: User has logged in");
handleAuthSuccess(keycloakInstance.token, keycloakInstance.refreshToken, keycloakInstance.tokenParsed);
setupTokenUpdater();
} else {
logger.info("Server synchronization successful: User not logged in");
handleAuthFailure();
}
} catch (e) {
logger.error("SDK 初始化失败");
handleAuthFailure();
} finally {
if (typeof doneCallback === 'function') doneCallback();
}
}

async function startSilentRefresh(existingToken, existingRefreshToken) {
const silentInstance = new Keycloak(KEYCLOAK_CONFIG);
try {
await silentInstance.init({
token: existingToken,
refreshToken: existingRefreshToken,
checkLoginIframe: false,
onLoad: undefined
});
keycloakInstance = silentInstance;
logger.info("The silent renewal instance has been mounted.");
setupTokenUpdater();
} catch (e) {
logger.warn("The silent instance startup failed. It has been reverted to the complete initialization.");
await performFullInit(() => {
});
}
}

function setupTokenUpdater() {

if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}

const checkToken = async () => {
if (!keycloakInstance) return;

try {

const expiresIn = keycloakInstance.tokenParsed.exp - Math.floor(Date.now() / 1000);
const refreshed = await keycloakInstance.updateToken(CACHE_TIMES.SECONDS_OF_REFRESH_THRESHOLD);
logger.info(`定时器检测是否执行续期。当前Token剩余时间:${expiresIn}s`)
if (refreshed) {
logger.success("Renewal successful: Local token has been updated");
localStorage.setItem(STORAGE_KEYS.TOKEN, keycloakInstance.token);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, keycloakInstance.refreshToken);
}


refreshTimer = setTimeout(checkToken, CACHE_TIMES.MILLISECONDS_OF_REFRESH_INTERVAL);
} catch (e) {
logger.error("Token 续期过程彻底失败,会话可能已过期", e);

handleAuthFailure();

}
};

refreshTimer = setTimeout(checkToken, CACHE_TIMES.MILLISECONDS_OF_REFRESH_INTERVAL);
}

function handleAuthSuccess(token, refreshToken, payload) {
localStorage.setItem(STORAGE_KEYS.TOKEN, token);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, refreshToken);
localStorage.removeItem(STORAGE_KEYS.GUEST_DATA);
updateLoggedInUI(payload);
}

function handleAuthFailure() {
const cache = {mode: 'logged_out', expire: Date.now() + CACHE_TIMES.SECONDS_OF_UNAUTH_HINT * 1000};
localStorage.setItem(STORAGE_KEYS.GUEST_DATA, JSON.stringify(cache));
localStorage.removeItem(STORAGE_KEYS.TOKEN);
localStorage.removeItem(STORAGE_KEYS.REFRESH_TOKEN);
sessionStorage.clear();
updateLoggedOutUI();
}





function cleanUrlParams(keys = ['code', 'state', 'action', 'session_state', 'iss']) {
const url = new URL(window.location.href);
let changed = false;

keys.forEach(key => {
if (url.searchParams.has(key)) {
url.searchParams.delete(key);
changed = true;
}
});

if (changed) {

const newUrl = url.pathname + url.search + url.hash;
window.history.replaceState({}, document.title, newUrl);
logger.info("URL 已净化,移除参数: " + keys.join(', '));
}
}

代码的核心功能:就是利用了 Keycloak JavaScript 模块实现用户的登录认证以及登出功能。

核心亮点:代码对于性能的进行了深度优化。特点在于:

  • 分通道验证身份:在页面加载的第一时间,不联网请求 Keycloak,而是直接读取 LocalStorage 获取身份信息。
  • 本地验签:代码是会验证 Token 的签名哦,保证 Token 不可伪造。
  • 非登录用户也有缓存:避免每次刷新界面都要请求 Auth 服务器。
  • 自动续期:后台自动静默续期 Token。
  • 登录和登出回调识别 + 浏览器参数的清洗:保证了代码逻辑能正常运作,实现想要的效果。
  • 防屏幕闪烁处理:联合 Loading 遮丑,提升用户的登录体验。
  • 兜底结束:如果认证服务器挂了,保证全局 Loading 也能直接结束。

虽然代码整体安全性已经够用,但由于本着性能为主,一些安全考量会有所平衡:

  • Token 存储在 LocalStorage,会容易受到 XSS 的威胁。如果博客被植入了恶意脚本,黑客可以轻易读取 kc_token_data
  • 对 Keycloak 服务器的公钥进行了硬编码,这主要是出于性能考量。当然你也可以自己编写公钥的获取函数。
  • 取消了 PKCE。对于这一点,我还在持续研究当中,敬请期待。

怎么使用和定制呢?在上面的代码中,我把配置参数,以及可能会频繁变动的定制内容放在了代码的上半部分。Keycloak 配置就根据 Keycloak 服务器的配置填就行,把公钥直接粘贴进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

const KEYCLOAK_CONFIG = {
url: "https://auth.uuanqin.top/",
realm: "ABA Inc.",
clientId: "uuanqin_blog",
publicKey: "MIIBI.....AB"
};

const CACHE_TIMES = {
SECONDS_OF_UNAUTH_HINT: 86400,
SECONDS_OF_TOKEN_MIN_VALID: 30,
SECONDS_OF_REFRESH_THRESHOLD: 80,
MILLISECONDS_OF_REFRESH_INTERVAL: 30000,
MILLISECONDS_OF_LOADING_TIMEOUT: 5000
};

因为不同的主题有不同的实现,即使是同一个主题也可以有多重放置登录信息的地方。因此,我把和 UI 相关的函数单独拎了出来,供定制。下面这段代码是紧紧配合之前我们在 Hexo-Butterfly 中的按钮的:

1
2
3
4
5
6
7
8
9
10
11
12
13

function finish() {

}

function updateLoggedInUI(payload) {

}


function updateLoggedOutUI() {

}

应用

认证服务部署好后,我们就可以实现我们想要的功能了。比如:

忽然发现一个「副作用」,就是目前 Keycloak 方案也可以防止网站被 站内文章恶意镜像。只要发现重定向的网址不对,Keycloak 会返回错误页面…

后记

个人统一认证系统是自从使用 Hexo 以来就筹划了很久,但是碍于时间和能力没能真正地实现。作者页下的按钮就是为了这个功能而预留的,但是长期以来,它只是测试页面。

image.png

刚好论文初稿、定稿事情弄完了,加上现在的 AI 应该很聪明,所以就用心投入了下。这次的 Keycloak 与博客集成大概花了两周时间,主要花在各种选型和代码调试当中。

老登们忙着指挥别人拥抱 AI,小登们忙着未来可期没空玩 AI,中登们在边玩 AI 边喊「未来已来」。——@差评君前沿

我真正开始逐渐投入 AI Coding 应该是在去年实习的时候,应该算挺晚了吧。作为一个计算机相关专业的学生,其实我还是偏守旧一点,但据说这其实也是个普遍的现象。我最近的时间也开始逐渐探索自己的 AI Coding 方法论,逐渐对一些之前写过的代码进行重构。

在这次的统一认证服务的集成中,我的思路是先能基本跑起来,再抠细节。一开始,AI 生成的代码是完全跑不起来的,或者说功能只完成了一半。不同模型生成的代码质量能直观地看出区别,但这种区别并不是一个好、一个坏,而是各有各的长处和优点。即使是目前最牛的大模型,生成的代码还是会有可维护性、健壮性的问题;胡言乱语、选择性遗忘、前后矛盾、不听从指令的毛病少了,但也不是没有。

提出一个问题往往比解决一个问题更重要。——《物理学的进化》

我真切的感受到了很早之前看到过的一句话:AI 可以快速生成 80 分的代码,但是要达到 90 分会很难。但这次 Coding 体验中,也许项目本身涉及的技术比较小众,加上我的要求也比较特殊,AI 生成的代码只能说 60 分及格,差强人意。后续的每一个 5 分,都要用大量的对话、限制以及肉眼审核才一步步达到最终让人满意的效果。

AI 正把所有人的工程能力拉到同一条起跑线上。大家都在同一片水里,呛着水,学游泳。——@网友

就目前来看,提出优化问题、指出错误等环节是比较依赖人的技能和认知水平的。但这种能力也是可以通过和 AI 交流进行习得的。在 AI Coding 的过程中,认真审阅、理解 AI 给的、能用的代码,是能学到东西的。

欢迎大家进行登录测试,希望大家多提意见! 这次统一登录系统的集成是对博客未来功能的基础。对于目前的博客,我还有一些有意思的想法,就看什么时候有时间实现咯,敬请期待~

本文参考