WangShuXian6 / blog

FE-BLOG
https://wangshuxian6.github.io/blog/
MIT License
46 stars 10 forks source link

登录/授权系统 #205

Open WangShuXian6 opened 4 weeks ago

WangShuXian6 commented 4 weeks ago

登录/授权系统

WangShuXian6 commented 4 weeks ago

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 图建议

通用工具与 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 组件

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逻辑可以在应用中轻松复用和管理。