大型銀行應(yīng)用中如何使用TypeScript實現(xiàn)狀態(tài)同步
在开发一个大型银行软件时,我遇到了与令牌刷新周期相关的问题。
该应用程序使用了Keycloak来实现OpenID Connect (OIDC)认证,其中访问令牌被设置为每5分钟过期,以此来增强安全性。前端应用程序是用React.js、Vite和TypeScript开发的。
流注意:本文中的所有图片和代码示例仅供示意,与提及的项目无关。这些场景被设计为示意用途,以保护项目的隐私和代码。
如何在多标签浏览器中安全存储认证信息
我们优先考虑安全性,将经过身份验证的用户数据存储在会话存储中,而不是放在本地存储或cookies中。这种架构选择通过会话隔离技术增强了安全性。
使用会话存储的一个关键好处是它的每个标签的特定范围。与本地存储或 cookies 在所有浏览器标签中保持持久性不同,会话存储为每个标签创建独立的认证环境。
这到底是什么意思?
与本地存储(即使浏览器关闭后数据仍然被保存)或cookies(可能被某些类型的攻击利用)不同,session storage 确保认证数据仅在会话期间存在。一旦用户关闭浏览器标签,数据就会自动被删除。
提取自定义的声明
在前端界面上,我们有一个页面显示已登录用户的权限信息(这些权限信息是在我们的身份提供程序——Keycloak 中预设的)。但等等!我是如何获取这些权限信息的呢?
页面草稿
当用户登录时,我会收到一个包含各种声明的响应,这些声明存储在令牌内。声明是存储在令牌内的信息片段。它们告诉系统你是谁以及你能做什么事情。
可以这么想,就像一张身份证明。
想象你有一张学生证,上面写着:
- 你的名字和姓
- 你的学号
- 你的身份(例如,学生)
我从token中提取这些声明并将用户信息存储在会话存储中。最重要的是,我检索一个包含profile对象,该对象包含一个“authorities”数组,列出了已认证用户的全部权限。示例如下。
可以在这个GitHub Gist里找到代码。
就在这里我碰到了头一个问题。
我使用了 [**react-oidc-context**](https://www.npmjs.com/package/react-oidc-context)
这个库来处理应用的身份验证。在后台,这个库会把上述响应转换成他们的 UserManager 对象。
很遗憾地,其_UserManager_对象中的“profile”对象里没有包含“authorities”数组。在这种情况下,因此,“authorities”是一个自定义属性。
所以我在这个“profile”对象里实现了添加自定义声明的逻辑。我所需要做的就是从响应中的“authorities”数组提取权限,并在初始化用户数据时添加这些权限。
你可以在这个GitHub Gist中找到代码。
我在他们的GitHub仓库中为此创建了一个问题。然而,在这个库能够支持自定义声明之前,我们需要手动在系统中添加这些自定义声明。这是一种两面性的做法,后面你会明白其中的原因。
之后,我用这个库里的 useAuth() 钩子来获取已认证的用户对象。
代码:
// userPermissions.tsx
const { user } = useAuth();
const [authorities, setAuthorities] = useState<string[]>([]);
useEffect(() => {
if (user?.profile?.authorities) {
setAuthorities(user.profile.authorities as string[]);
}
}, [user]);
// 这用于首次渲染以初始化数据
useEffect(() => {
const updateAuthorities = async () => {
const oidcKey = `${KEYCLOAK_URL}:${KEYCLOAK_REALM_CLIENT_ID}`;
const storedUserData = JSON.parse(
sessionStorage.getItem(oidcKey) || "{}",
);
const storedAuthorities =
storedUserData?.profile?.["authorities"] || [];
setAuthorities(storedAuthorities);
};
updateAuthorities();
userManager.events.addUserLoaded(handleUserLoaded);
return () => {
userManager.events.removeUserLoaded(handleUserLoaded);
};
}, []);
// 其他内容
return (
<tbody>
{authorities.map((permission) => (
<tr key={permission}>
<td>
{formatPermissionName(permission)}
</td>
</tr>
))}
</tbody>
)
现在每次用户打开“权限设置”页面时,组件就会从存储中获取权限列表并显示出来。
token过期了会咋办?
当令牌过期时,应用程序会自动请求一个新的令牌。整个过程是全自动的,并且会在当前令牌过期前一分钟请求新的访问令牌。一旦应用程序收到新令牌,它会把更新的用户数据存储在会话中。
而且一切看起来都很棒,但其实不然。就像我在之前的帖子中经常提到的一样,你知道总有一个方面需要我们去关注。那么,这种方法到底哪里不对劲呢?
明确的问题:有一种特殊情况。所以如果用户打开“权限设置”页面(当页面重新加载时),页面会不断从存储中获取最新的信息,这会额外产生一些开销。
但用户停留在页面上超过30分钟的持续时间而没有进行互动操作或刷新会怎样呢?你知道发生了什么吗……这可就麻烦了。
是的,我知道这是一个特殊情况,但客观来说,这种情况确实可能发生。让我给你举个例子:你在一个标签页中打开了权限页面,然后喝杯咖啡。当你回来后,在另一个标签页中参加了一个45分钟的会议。会议结束后,你回到第一个标签页时,突然看到权限页面还在那里,然后就出现了错误!
为什么呢?为什么最后保存的权限没有显示呢?
我们有一个 useEffect
,它监听用户状态的变化。一旦用户状态发生变化,它就会更新 权限 列表,这个列表保存在 useState
里。
在我们的情况下,当 token 更新时,它会检测用户的变化,然后更新会话存储。然而,因为我的自定义方法 getCustomClaims 还没有被执行,这个过程中不会包含 ‘authorities’ 数组。为解决这个问题,我需要触发它的执行,以更新状态并正确反映权限。
那就是事件插手的时候了。
活动为了解决这个问题,我们可以引入一个自定义窗口事件处理程序,当新的声明(claims)被填充到user
对象中时触发该事件。当事件被触发时,我们可以手动更新authorities
,确保新的权限被显示,而无需重新加载页面。
在TypeScript中操作窗口事件的步骤:
- 创建一个自定义事件: 首先,例如,我们需要定义一个自定义事件,当新的声明(claims)添加到会话存储时触发。我们可以使用 JavaScript/TypeScript 中的
CustomEvent
构造函数来创建一个新的事件,该事件可以在全局范围内被监听。 - 在令牌刷新后触发事件: 当新的声明(如
authorities
)在令牌刷新后成功添加到会话存储中时,我们将触发这个自定义事件。 - 在
userPermissions.tsx
文件中,我们可以监听该事件: 在userPermissions.tsx
文件中,我们可以监听该事件并相应地更新状态信息。
修改了 getCustomClaims
方法,来触发一个事件:
修改了 userPermissions.tsx
文件来识别事件,这样就能检测到事件。
const { user } = useAuth();
const [authorities, setAuthorities] = useState<string[]>([]);
// 新的方法
const updateAuthorities = () => {
const oidcKey = `${OIDC_USER_PREFIX}:${KEYCLOAK_URL}:${KEYCLOAK_REALM_CLIENT_ID}`;
const storedUserData = JSON.parse(sessionStorage.getItem(oidcKey) || "{}");
const storedAuthorities =
storedUserData?.profile?.[AUTHORITIES_KEY_NAME] || [];
setAuthorities(storedAuthorities);
};
useEffect(() => {
if (user?.profile?.authorities) {
setAuthorities(user.profile.authorities as string[]);
}
}, [user]);
useEffect(() => {
updateAuthorities();
const handleAuthoritiesUpdated = () => {
updateAuthorities();
};
const handleUserLoaded = () => {
updateAuthorities();
};
// 添加事件监听器
window.addEventListener(
AUTHORITIES_UPDATED_EVENT,
handleAuthoritiesUpdated as EventListener,
);
userManager.events.addUserLoaded(handleUserLoaded);
return () => {
// 清理事件监听器
window.removeEventListener(
AUTHORITIES_UPDATED_EVENT,
handleAuthoritiesUpdated as EventListener,
);
userManager.events.removeUserLoaded(handleUserLoaded);
};
}, []);
// 其他相关代码
清理活动
别忘了哦!
当组件卸载或不再需要监听事件时,移除事件监听器非常重要。这可以防止内存泄漏并确保正确释放资源。
还有一些小故事 ✨
加我一下
GitHub, LinkedIn, dev.to (GitHub: 一个面向开源及私人软件项目的版本控制系统,LinkedIn: 一个职场社交网站,dev.to: 一个面向开发者的博客平台)
希望你今天学到了新知识!✌️
共同學(xué)習(xí),寫下你的評論
評論加載中...
作者其他優(yōu)質(zhì)文章