Keycloak 的部署以及 Hexo-Butterfly 网页应用的接入
|总字数:5.3k|阅读时长:22分钟|浏览量:|
想找一个开箱即用、轻量的、开源的认证系统,要求内含用户管理,支持用户自主注册,允许第三方登录。经过近两周的不断的选型和尝试,现决定将 Keycloak 作为我的 SSO 系统。前期选型如下:
| 软件 |
特点 |
评价 |
| Casdoor |
国产、功能很全 |
第一个测试的认证服务,其实功能挺全的。但是实际体验下来很难用,部分功能的体验不是很好,文档很粗糙,概念多(开箱一坨),配置繁琐。说是以 UI 为重,但我体验下来感觉并不算专业。 |
| Authentik |
功能完善 |
已经很接近我想要的了,但是部分功能反人类,有点吃掉我的耐心了。 |
| Authelia |
轻量 |
没有用户管理。 |
| Ory |
微服务式 |
理想中的认证服务系统,但是学习成本过高,耦合度过低。 |
| Melody Auth |
国内开发者,支持 Cloudflare 部署 |
配置简单。但用户自助注册功能需要自己实现。 |
| Keycloak |
功能完善 |
功能基本符合我的要求,惊喜的是,它提供 JavaScript 适配,简化网页应用的开发。 |
本文主要内容:
- 部署和配置 Keycloak
- 使用 Keycloak JavaScript Adapter,将单点登录的认证集成到博客网页中。
本文以静态博客 Hexo-Butterfly 为例,但核心思路是通用的
毕竟主要集成方式还是 JavaScript,核心思路与博客框架、主题是无关的。而且我在编写相关代码时将一些定制化的内容提前了,以方便各位进行修改。
实现效果:侧边栏登录按钮,实现登录和登出的基本功能。

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 - Keycloak、Configuring Keycloak for production - Keycloak。
直接外网访问 Keycloak 报错
建好 Realm 和用于测试的用户
可参考文档,完成组织和用户的创建:

创建 Client 并配置
创建一个网页应用 Client:

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

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

下面是一些认证配置。要点:
- Client authentication 保持关闭。因为这是个前端应用。
- Authentication flow 默认 Standard flow 即可

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

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_author 的 button 设计:
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
| 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 = "统一认证登录"; 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("检测到【登出回调】,强制清理本地缓存"); 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("访客模式:跳过检测"); updateLoggedOutUI(); finish(); return; }
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() { 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("续期成功:已更新本地令牌"); 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); 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: "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 };
|
因为不同的主题有不同的实现,即使是同一个主题也可以有多重放置登录信息的地方。因此,我把和 UI 相关的函数单独拎了出来,供定制。下面这段代码是紧紧配合之前我们在 Hexo-Butterfly 中的按钮的:
1 2 3 4 5 6 7 8 9 10 11 12 13
| function finish() { }
function updateLoggedInUI(payload) { }
function updateLoggedOutUI() { }
|
后记
个人统一认证系统是自从使用 Hexo 以来就筹划了很久,但是碍于时间和能力没能真正地实现。作者页下的按钮就是为了这个功能而预留的,但是长期以来,它只是测试页面。

刚好论文初稿、定稿事情弄完了,加上现在的 AI 应该很聪明,所以就用心投入了下。这次的 Keycloak 与博客集成大概花了两周时间,主要花在各种选型和代码调试当中。
老登们忙着指挥别人拥抱 AI,小登们忙着未来可期没空玩 AI,中登们在边玩 AI 边喊「未来已来」。——
@差评君前沿
我真正开始逐渐投入 AI Coding 应该是在去年实习的时候,应该算挺晚了吧。作为一个计算机相关专业的学生,其实我还是偏守旧一点,但据说这其实也是个普遍的现象。我最近的时间也开始逐渐探索自己的 AI Coding 方法论,逐渐对一些之前写过的代码进行重构。
在这次的统一认证服务的集成中,我的思路是先能基本跑起来,再抠细节。一开始,AI 生成的代码是完全跑不起来的,或者说功能只完成了一半。不同模型生成的代码质量能直观地看出区别,但这种区别并不是一个好、一个坏,而是各有各的长处和优点。即使是目前最牛的大模型,生成的代码还是会有可维护性、健壮性的问题;胡言乱语、选择性遗忘、前后矛盾、不听从指令的毛病少了,但也不是没有。
提出一个问题往往比解决一个问题更重要。——《物理学的进化》
我真切的感受到了很早之前看到过的一句话:AI 可以快速生成 80 分的代码,但是要达到 90 分会很难。但这次 Coding 体验中,也许项目本身涉及的技术比较小众,加上我的要求也比较特殊,AI 生成的代码只能说 60 分及格,差强人意。后续的每一个 5 分,都要用大量的对话、限制以及肉眼审核才一步步达到最终让人满意的效果。
AI 正把所有人的工程能力拉到同一条起跑线上。大家都在同一片水里,呛着水,学游泳。——@网友
就目前来看,提出优化问题、指出错误等环节是比较依赖人的技能和认知水平的。但这种能力也是可以通过和 AI 交流进行习得的。在 AI Coding 的过程中,认真审阅、理解 AI 给的、能用的代码,是能学到东西的。
欢迎大家进行登录测试,希望大家多提意见! 这次统一登录系统的集成是对博客未来功能的基础。对于目前的博客,我还有一些有意思的想法,就看什么时候有时间实现咯,敬请期待~
本文参考