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

想找一个开箱即用、轻量的、开源的认证系统,要求内含用户管理,支持用户自主注册,允许第三方登录。经过近两周的不断的选型和尝试,现决定将 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 报错

错误截图:
image.png

解决方法,在服务器部署 Keycloak 时需补充参数 --proxy-headers=xforwarded--hostname-strict=false

参考:Timeout when waiting for 3rd party check iframe message. · Issue #14414 · 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:
# Keycloak 登录集成
- <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= "统一认证登录"
.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 页面的启停函数

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

修改主题配置文件:

1
2
3
4
5
6
7
8
# Loading Animation  
preloader:
enable: true
# source
# 1. fullpage-loading # 2. pace (progress bar)
source: 1
# pace theme (see https://codebyzach.github.io/pace/)
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 === 'complete') {
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
// ============== Keycloak 配置 ==============
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, // 24h
SECONDS_OF_TOKEN_MIN_VALID: 30,
SECONDS_OF_REFRESH_THRESHOLD: 80,
MILLISECONDS_OF_REFRESH_INTERVAL: 30000, // 30s
MILLISECONDS_OF_LOADING_TIMEOUT: 5000
};

// ============== UI 渲染 ==============

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

// ============== 未登录 UI ==============
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 = "统一认证登录";
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/`;

// 获取当前页面的纯净 URL(去掉之前的 query 参数)
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);
// 加上 action=login 标记
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'); // 获取我们自定义的 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("检测到【登出回调】,强制清理本地缓存");
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();
// 必须传入 refreshToken 进行静默续期
startSilentRefresh(localToken, localRefreshToken);
return;
}
}
}

if (isGuestMode) {
logger.info("访客模式:跳过检测");
updateLoggedOutUI();
finish();
return;
}

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

} catch (err) {
logger.error("主流程崩溃", 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("服务器同步成功:用户已登录");
handleAuthSuccess(keycloakInstance.token, keycloakInstance.refreshToken, keycloakInstance.tokenParsed);
setupTokenUpdater();
} else {
logger.info("服务器同步完成:用户未登录");
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("静默续期实例已挂载");
setupTokenUpdater();
} catch (e) {
logger.warn("静默实例启动失败,回退至完整初始化");
await performFullInit(() => {
});
}
}

function setupTokenUpdater() {
// 1. 清理之前的旧定时器,防止多个定时器冲突
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}

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

try {
// 2. updateToken(CACHE_TIMES.SECONDS_OF_REFRESH_THRESHOLD) 这里的阈值应大于刷新间隔,确保在 Token 过期前完成请求
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("续期成功:已更新本地令牌");
localStorage.setItem(STORAGE_KEYS.TOKEN, keycloakInstance.token);
localStorage.setItem(STORAGE_KEYS.REFRESH_TOKEN, keycloakInstance.refreshToken);
}

// 3. 递归 setTimeout 比 setInterval 更安全,避免请求堆积
refreshTimer = setTimeout(checkToken, CACHE_TIMES.MILLISECONDS_OF_REFRESH_INTERVAL);
} catch (e) {
logger.error("Token 续期过程彻底失败,会话可能已过期", e);
// 4. 如果续期失败,清空本地无效 Token,避免下次加载继续报错
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);
updateLoggedOutUI();
}

/**
* 清理指定的 URL 参数,保持地址栏干净
* @param {string[]} keys - 需要清理的参数名数组,如 ['code', 'state', 'action']
*/
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) {
// 使用 replaceState 修改 URL 且不产生历史记录
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
// ============== Keycloak 配置 ==============
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, // 24h
SECONDS_OF_TOKEN_MIN_VALID: 30,
SECONDS_OF_REFRESH_THRESHOLD: 80,
MILLISECONDS_OF_REFRESH_INTERVAL: 30000, // 30s
MILLISECONDS_OF_LOADING_TIMEOUT: 5000
};

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

1
2
3
4
5
6
7
8
9
10
11
12
13
// ============== UI 渲染  ==============
function finish() {
// ......
}

function updateLoggedInUI(payload) {
// ......
}

// ============== 未登录 UI ==============
function updateLoggedOutUI() {
// ......
}

后记

个人统一认证系统是自从使用 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 给的、能用的代码,是能学到东西的。

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

本文参考