Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

登录/授权系统 #205

Open
WangShuXian6 opened this issue Oct 24, 2024 · 1 comment
Open

登录/授权系统 #205

WangShuXian6 opened this issue Oct 24, 2024 · 1 comment

Comments

@WangShuXian6
Copy link
Owner

登录/授权系统

@WangShuXian6
Copy link
Owner Author

WangShuXian6 commented Oct 24, 2024

SSO(统一登录)逻辑

逻辑分析与优化建议

该 React TypeScript 应用程序的主要功能是处理 SSO(统一登录)逻辑。我们来分析各个部分的流程,并找出其中可以优化的地方。

当前逻辑描述

  1. 登录流程

    • 应用启动时检查用户是否已登录。

    • 如果未登录,则跳转到 SSO 登录页面,附带参数origin,即用户在未登录前的页面地址(去除所有查询参数)。

    • 用户在 SSO 页面登录成功后,跳转回应用的origin页面,并附带参数ticket

    • 应用使用ticket通过接口获取accessTokenrefreshToken,并分别存储在全局内存和本地缓存中。

    • 如果 URL 中包含ticket,应用应立即使用该ticket获取accessTokenrefreshToken,确保用户能够顺利登录。

  2. 请求处理

    • 每次请求接口时,在请求头中附带accessToken
    • 获取用户的菜单数据并展示。
    • 如果请求返回未授权状态,使用refreshToken换取新的accessToken,然后继续请求。
    • 如果换取accessToken失败,则跳转到 SSO 登录页面重新开始登录流程。
  3. 页面刷新处理

    • 当用户刷新页面时,首先从本地缓存中获取refreshToken
    • 如果未取到refreshToken,则跳转到 SSO 登录页面重新登录。
    • 如果取到了refreshToken,则使用它换取新的accessToken
  4. 登出流程

    • 用户主动登出时,清除accessTokenrefreshToken
    • 跳转到 SSO 登出页面,完成登出流程。

不足和可优化的地方

  1. 安全性问题

    • accessToken存储在全局内存中,页面刷新会丢失令牌,导致不必要的refreshToken使用频率过高,增加安全风险。
    • 改进方案:将accessToken也存储在本地缓存中(例如sessionStorage),并在内存中保留一个副本,这样即使页面刷新也不会丢失,减少对refreshToken的频繁依赖。
  2. Token 刷新逻辑

    • 当前逻辑中,只有在请求返回未授权状态时才会使用refreshToken。如果accessToken即将过期但尚未过期,可能导致请求失败后才进行刷新,增加延迟。
    • 改进方案:实现 accessToken 的过期时间监控(例如,使用 JWT 的 exp 字段),提前在令牌即将过期时刷新 accessToken,从而避免请求失败带来的延迟体验。
  3. 跳转流程优化

    • 用户未登录时,跳转到 SSO 登录页面,去除所有查询参数可能导致用户丢失部分有用的信息(如过滤条件等)。
    • 改进方案:保留对用户有用的部分查询参数,增加用户体验。例如,可以保留特定标记,以便在登录后能够返回用户之前所需的状态。
  4. 请求集中处理

    • 每次请求都手动在请求头中附带accessToken,代码冗余,增加维护难度。
    • 改进方案:使用 HTTP 请求拦截器(如 axios 的 interceptor),自动在每次请求中附加accessToken,简化代码逻辑。
  5. 错误处理与用户提示

    • 当用户refreshToken失效时,直接跳转到 SSO 登录页面,缺乏用户友好的提示,可能让用户感到困惑。
    • 改进方案:在令牌失效时,给用户提供明确的提示信息,告知需要重新登录,以提高用户体验。
  6. 并发请求刷新问题

    • 如果在accessToken即将过期时有多个并发请求,可能导致多次使用refreshToken请求新的accessToken,浪费资源。
    • 改进方案:引入请求队列机制,确保在令牌刷新过程中,其他请求等待新的accessToken生成,避免重复刷新。

UML 图建议

  • 活动图(Activity Diagram):用来展示整个登录、令牌刷新、请求处理的流程。可以包括以下步骤:

    1. 用户访问应用页面。
    2. 检查登录状态,未登录则跳转 SSO 登录。
    3. 登录成功后,获取accessTokenrefreshToken
    4. 发起请求时附带accessToken
    5. 如果请求未授权,使用refreshToken换取新accessToken
    6. 页面刷新时,检查refreshToken是否存在。
    7. 用户登出,清除令牌并跳转到 SSO 登出页面。
    @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

登录 活动图(Activity Diagram)

  • 序列图(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

登录 序列图(Sequence Diagram)

通用工具与 Hooks 封装实现

为了实现上述逻辑的封装和复用性,可以将逻辑拆分为多个独立的 Hooks 和工具函数。以下是实现的结构和 Demo:

  1. 工具函数和 Hooks

    • useAuth(): 管理登录状态、accessTokenrefreshToken 的逻辑。
    • useAxiosInterceptor(): 添加 Axios 拦截器,用于附加accessToken和处理未授权的请求。
    • useSSORedirect(): 处理跳转到 SSO 登录页面和登录后的回调逻辑。
    • useLogout(): 处理登出逻辑,清除令牌并跳转到 SSO 登出页面。
  2. 实现代码

//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 };

};


3. **Demo 组件**

```tsx
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,我们可以更好地管理代码的复用性和可维护性。这样可以简化组件中的逻辑,使得各部分更加独立和易扩展。通过useAuthuseAxiosInterceptoruseSSORedirectuseLogout 等Hooks,整个SSO逻辑可以在应用中轻松复用和管理。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant