Open WangShuXian6 opened 4 weeks ago
该 React TypeScript 应用程序的主要功能是处理 SSO(统一登录)逻辑。我们来分析各个部分的流程,并找出其中可以优化的地方。
登录流程
应用启动时检查用户是否已登录。
如果未登录,则跳转到 SSO 登录页面,附带参数origin
,即用户在未登录前的页面地址(去除所有查询参数)。
用户在 SSO 页面登录成功后,跳转回应用的origin
页面,并附带参数ticket
。
应用使用ticket
通过接口获取accessToken
和refreshToken
,并分别存储在全局内存和本地缓存中。
如果 URL 中包含ticket
,应用应立即使用该ticket
获取accessToken
和refreshToken
,确保用户能够顺利登录。
请求处理
accessToken
。refreshToken
换取新的accessToken
,然后继续请求。accessToken
失败,则跳转到 SSO 登录页面重新开始登录流程。页面刷新处理
refreshToken
。refreshToken
,则跳转到 SSO 登录页面重新登录。refreshToken
,则使用它换取新的accessToken
。登出流程
accessToken
和refreshToken
。安全性问题
accessToken
存储在全局内存中,页面刷新会丢失令牌,导致不必要的refreshToken
使用频率过高,增加安全风险。accessToken
也存储在本地缓存中(例如sessionStorage
),并在内存中保留一个副本,这样即使页面刷新也不会丢失,减少对refreshToken
的频繁依赖。Token 刷新逻辑
refreshToken
。如果accessToken
即将过期但尚未过期,可能导致请求失败后才进行刷新,增加延迟。跳转流程优化
请求集中处理
accessToken
,代码冗余,增加维护难度。accessToken
,简化代码逻辑。错误处理与用户提示
refreshToken
失效时,直接跳转到 SSO 登录页面,缺乏用户友好的提示,可能让用户感到困惑。并发请求刷新问题
accessToken
即将过期时有多个并发请求,可能导致多次使用refreshToken
请求新的accessToken
,浪费资源。accessToken
生成,避免重复刷新。活动图(Activity Diagram):用来展示整个登录、令牌刷新、请求处理的流程。可以包括以下步骤:
accessToken
和refreshToken
。accessToken
。refreshToken
换取新accessToken
。refreshToken
是否存在。@startuml
start
:用户访问应用页面;
if (已登录?) then (是)
:发起请求;
else (否)
:跳转到SSO登录页面;
:用户登录;
:返回应用并获取`ticket`;
:使用`ticket`获取`accessToken`和`refreshToken`;
endif
:附带`accessToken`发起请求;
if (请求未授权?) then (是)
:使用`refreshToken`获取新`accessToken`;
if (获取成功?) then (是)
:继续请求;
else (否)
:跳转到SSO登录页面;
endif
endif
:页面刷新;
if (存在`refreshToken`?) then (是)
:使用`refreshToken`获取新`accessToken`;
else (否)
:跳转到SSO登录页面;
endif
:用户登出;
:清除`accessToken`和`refreshToken`;
:跳转到SSO登出页面;
end
@enduml
序列图(Sequence Diagram):展示客户端、SSO 服务器、API 服务器之间的交互过程,特别是登录、获取令牌、刷新令牌和登出步骤。
@startuml
actor 用户
participant 客户端
participant SSO服务器
participant API服务器
用户 -> 客户端 : 访问应用页面
客户端 -> 客户端 : 检查`refreshToken`
alt 未登录
客户端 -> SSO服务器 : 跳转到SSO登录页面
SSO服务器 -> 用户 : 显示登录页面
用户 -> SSO服务器 : 提交登录信息
SSO服务器 -> 客户端 : 返回`ticket`并重定向
客户端 -> API服务器 : 使用`ticket`获取`accessToken`和`refreshToken`
API服务器 -> 客户端 : 返回`accessToken`和`refreshToken`
end
客户端 -> API服务器 : 发起请求 (附带`accessToken`)
API服务器 -> 客户端 : 返回数据
alt `accessToken`过期
API服务器 -> 客户端 : 返回未授权
客户端 -> API服务器 : 使用`refreshToken`获取新`accessToken`
API服务器 -> 客户端 : 返回新的`accessToken`
客户端 -> API服务器 : 重发请求
end
alt `refreshToken`失效
客户端 -> SSO服务器 : 跳转到SSO登录页面
end
用户 -> 客户端 : 登出
客户端 -> SSO服务器 : 跳转到SSO登出页面
客户端 -> 客户端 : 清除`accessToken`和`refreshToken`
@enduml
为了实现上述逻辑的封装和复用性,可以将逻辑拆分为多个独立的 Hooks 和工具函数。以下是实现的结构和 Demo:
工具函数和 Hooks
useAuth()
: 管理登录状态、accessToken
和 refreshToken
的逻辑。useAxiosInterceptor()
: 添加 Axios 拦截器,用于附加accessToken
和处理未授权的请求。useSSORedirect()
: 处理跳转到 SSO 登录页面和登录后的回调逻辑。useLogout()
: 处理登出逻辑,清除令牌并跳转到 SSO 登出页面。实现代码
//src\packages\utils\redirect.ts
export const redirectToSSOLogin = () => {
console.log('redirectToSSOLogin')
const currentUrl = new URL(window.location.href)
currentUrl.searchParams.delete('ticket')
window.location.href = `${import.meta.env.VITE_SSO_LOGIN_URL}?origin=${encodeURIComponent(
currentUrl.toString()
)}`
}
// auth.ts: 处理 token 管理逻辑
import { useState, useEffect, useRef } from "react";
export const useAuth = () => {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(
localStorage.getItem("refreshToken")
);
const tokenExpiryTimeout = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
const ticket = urlParams.get("ticket");
if (ticket) {
getTokensWithTicket(ticket);
} else if (refreshToken) {
refreshAccessToken();
} else {
redirectToSSOLogin();
}
}, []);
const getTokensWithTicket = async (ticket: string) => {
try {
// 调用 API 使用 ticket 获取 accessToken 和 refreshToken
const response = await fetch("/api/getTokens", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ticket }),
});
const data = await response.json();
setAccessToken(data.accessToken);
setRefreshToken(data.refreshToken);
localStorage.setItem("refreshToken", data.refreshToken);
startTokenExpiryMonitor(data.accessToken);
} catch (error) {
redirectToSSOLogin();
}
};
const refreshAccessToken = async () => {
try {
// 调用 API 刷新 accessToken
const response = await fetch("/api/refresh", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ refreshToken }),
});
const data = await response.json();
setAccessToken(data.accessToken);
setRefreshToken(data.refreshToken);
localStorage.setItem("refreshToken", data.refreshToken);
startTokenExpiryMonitor(data.accessToken);
} catch (error) {
alert("Session expired. Please log in again.");
redirectToSSOLogin();
}
};
const startTokenExpiryMonitor = (token: string) => {
cleanTokenExpiryMonitor();
const decodedToken = JSON.parse(atob(token.split(".")[1]));
const expiryTime = decodedToken.exp * 1000;
const currentTime = Date.now();
const timeout = expiryTime - currentTime - 60000; // 提前1分钟刷新
tokenExpiryTimeout.current = setTimeout(() => {
refreshAccessToken();
}, timeout);
};
const cleanTokenExpiryMonitor = () => {
if (tokenExpiryTimeout.current) {
clearTimeout(tokenExpiryTimeout.current);
}
};
return {
accessToken,
refreshToken,
refreshAccessToken,
setAccessToken,
setRefreshToken,
cleanTokenExpiryMonitor,
};
};
// useAxiosInterceptor.ts: 设置 Axios 拦截器
import { useEffect } from "react";
import axios from "axios";
import { useAuth } from "./auth";
export const useAxiosInterceptor = () => {
const { accessToken, refreshAccessToken } = useAuth();
let isRefreshing = false;
let requestQueue: ((newToken: string) => void)[] = []
useEffect(() => {
const requestInterceptor = axios.interceptors.request.use((config) => {
if (accessToken) {
config.headers["Authorization"] = `Bearer ${accessToken}`;
}
return config;
});
const responseInterceptor = axios.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response.status === 401 && !isRefreshing) {
isRefreshing = true;
try {
await refreshAccessToken();
requestQueue.forEach((cb) => cb(accessToken || '' ));
requestQueue = [];
} catch (err) {
requestQueue = [];
throw err;
} finally {
isRefreshing = false;
}
} else if (error.response.status === 401 && isRefreshing) {
return new Promise((resolve) => {
requestQueue.push((newToken: string) => {
error.config.headers["Authorization"] = `Bearer ${newToken}`;
resolve(axios(error.config));
});
});
}
return Promise.reject(error);
}
);
return () => {
axios.interceptors.request.eject(requestInterceptor);
axios.interceptors.response.eject(responseInterceptor);
};
}, [accessToken]);
};
// useSSORedirect.ts: 处理登录重定向逻辑
import { useEffect } from "react";
import { useAuth } from "./auth";
export const useSSORedirect = () => {
const { refreshToken } = useAuth();
useEffect(() => {
if (!refreshToken) {
redirectToSSOLogin()
}
}, [refreshToken]);
};
// useLogout.ts: 处理登出逻辑
import { useAuth } from "./auth";
export const useLogout = () => {
const { setAccessToken, setRefreshToken, cleanTokenExpiryMonitor } =
useAuth();
const logout = () => {
cleanTokenExpiryMonitor();
setAccessToken(null);
setRefreshToken(null);
localStorage.removeItem("refreshToken");
redirectToSSOLogin()
};
return { logout };
};
Demo 组件
import React from "react";
import axios from "axios";
import { useAuth } from "./auth";
import { useAxiosInterceptor } from "./useAxiosInterceptor";
import { useLogout } from "./useLogout";
const App: React.FC = () => {
useAxiosInterceptor();
const { accessToken } = useAuth();
const { logout } = useLogout();
const fetchData = async () => {
try {
const response = await axios.get("/api/data");
console.log(response.data);
} catch (error) {
console.error("Error fetching data", error);
}
};
return (
<div>
<h1>React SSO Demo</h1>
<button onClick={fetchData}>Fetch Data</button>
<button onClick={logout}>Logout</button>
</div>
);
};
export default App;
通过将SSO逻辑封装为通用工具和Hooks,我们可以更好地管理代码的复用性和可维护性。这样可以简化组件中的逻辑,使得各部分更加独立和易扩展。通过useAuth
、useAxiosInterceptor
、useSSORedirect
和 useLogout
等Hooks,整个SSO逻辑可以在应用中轻松复用和管理。
登录/授权系统