在React中使用刷新令牌旋转的持久性登录

712 阅读6分钟

前端开发者最关心的问题是建立一个安全、快速的授权和认证结构。此外,用户体验也是重中之重,它受认证过程的影响很大。

你还记得你上次在谷歌、Facebook、LinkedIn或其他一些应用或网站上输入登录凭证是什么时候吗?可能不记得了。这是因为现在许多应用程序和网络服务都使用持久性登录来提供流畅的用户体验。

在本教程中,我们将向你展示如何在React中使用刷新令牌来促进无限期的登录会话。我们将涵盖以下内容。

什么是刷新令牌?

简单地说,访问令牌使用户能够从你的应用程序中获得资源。

出于安全原因,访问令牌通常有很短的使用寿命。当访问令牌过期时,可以使用刷新令牌来获取新的访问令牌,而无需再次输入登录凭证。

Access Token Diagram

刷新令牌的寿命很长。如果它们是有效的并且没有过期,客户可以获得新的访问令牌。这种长寿命可能导致受保护资源的漏洞。

什么是刷新令牌轮换?

刷新令牌轮换是一种保护刷新令牌的技术。当用刷新令牌请求一个新的访问令牌时,也会返回一个新的刷新令牌,旧的令牌就会失效。刷新令牌轮换的目的是为了消除长期刷新令牌所带来的漏洞风险。

直到最近,在单页网络应用中不建议使用刷新令牌(与移动应用不同),因为SPA没有安全机制来存储令牌。刷新令牌轮换和刷新令牌重复使用检测(我们将在后面讨论)增加了这种高价值信息的安全性。

下图解释了刷新令牌轮换机制的工作原理。你可以接受Auth0作为一个身份提供者。

Refresh Token Rotation Mechanism

刷新令牌轮换机制(来源:https://auth0.com/docs/tokens/refresh-tokens/refresh-token-rotation)

什么是刷新令牌重复使用检测?

刷新令牌重用检测是一种支持刷新令牌轮换的机制。当访问令牌过期时,客户端使用刷新令牌获得一组新的令牌(访问令牌和刷新令牌)。然后,身份提供者立即使以前的刷新令牌失效。

如果身份提供者检测到使用该无效的刷新令牌,它立即使所有的刷新和访问令牌无效,使客户端再次使用登录凭证进行验证。这种机制可以防止你的应用程序在出现令牌泄漏时受到恶意攻击。

以下来自Auth0文档的两个案例是这些攻击的可能场景以及刷新令牌重用检测如何工作的好例子。

Refresh Token Scenario 1

刷新令牌重用检测机制场景1

Refresh Token Scenario 2

刷新令牌重用检测机制场景2

在哪里存储刷新令牌

在客户端会话中,有几种方法来存储令牌:在内存中,通过沉默认证,以及在浏览器的本地存储。

在内存中存储令牌

你可以将刷新令牌存储在内存中。然而,这种存储不会在页面刷新或新标签中持续存在。因此,用户应该在每次页面刷新或新标签页上输入登录凭证,这对用户体验有负面影响。

无声认证

通过无声认证存储刷新令牌涉及到每当有API请求或在页面刷新时向身份服务器发送请求以获得访问令牌。如果你的会话仍然存在,身份提供者将返回一个有效的令牌。否则,它会将你重定向到登录页面。

然而,这是一个更安全的结构:每当客户端发送一个沉默的认证请求,它就会阻止应用程序。这可能是在页面渲染或API调用期间。

此外,我在隐身模式下遇到过一些不需要的行为,如登录循环。

本地存储令牌

建议持久性登录的做法是将令牌存储在浏览器的本地存储中。本地存储在页面刷新和各种标签之间提供持久的数据。

虽然在本地存储刷新令牌并不能完全消除跨站脚本(XSS)攻击的威胁,但它确实将这种漏洞大大降低到可接受的水平。它还通过使应用程序运行更顺畅来改善用户体验。

使用刷新令牌轮流配置具有持久性登录的React应用程序

为了演示刷新令牌和刷新令牌轮换是如何工作的,我们将用刷新令牌配置一个反应式应用的认证机制。我们将使用Auth0进行刷新令牌轮换和刷新令牌重复使用检测。Auth0是最流行的认证和授权平台之一。

为了将Auth0集成到我们的React应用中,我们将使用Auth0-react将应用与Auth0连接起来,并使用名为useAuth0 的钩子来获取认证状态和方法。然而,要在组件之外达到认证状态和方法是有难度的。

因此,我改造了这个库 [@auth0/auth0-spa-js](https://github.com/auth0/auth0-spa-js)是另一个官方的Auth0客户端库,它有一个认证钩和方法,可以在组件之外访问。

我创建了一个auth0.tsx 文件(当然你也可以用JSX),像这样。

import React, { useState, useEffect, useContext, createContext } from 'react';
import createAuth0Client, {
  getIdTokenClaimsOptions,
  GetTokenSilentlyOptions,
  GetTokenWithPopupOptions,
  IdToken,
  LogoutOptions,
  PopupLoginOptions,
  RedirectLoginOptions,
} from '@auth0/auth0-spa-js';
import Auth0Client from '@auth0/auth0-spa-js/dist/typings/Auth0Client';
import { config } from '../config';
import history from '../history';
import { urls } from '../routers/urls';

interface Auth0Context {
  isAuthenticated: boolean;
  user: any;
  loading: boolean;
  popupOpen: boolean;
  loginWithPopup(options: PopupLoginOptions): Promise<void>;
  handleRedirectCallback(): Promise<any>;
  getIdTokenClaims(o?: getIdTokenClaimsOptions): Promise<IdToken>;
  loginWithRedirect(o: RedirectLoginOptions): Promise<void>;
  getAccessTokenSilently(o?: GetTokenSilentlyOptions): Promise<string | undefined>;
  getTokenWithPopup(o?: GetTokenWithPopupOptions): Promise<string | undefined>;
  logout(o?: LogoutOptions): void;
}

export const Auth0Context = createContext<Auth0Context | null>(null);
export const useAuth0 = () => useContext(Auth0Context)!;

const onRedirectCallback = appState => {
  history.replace(appState && appState.returnTo ? appState.returnTo : urls.orderManagement);
};

let initOptions = config.auth; // Auth0 client credentials

const getAuth0Client: any = () => {
  return new Promise(async (resolve, reject) => {
    let client;
    if (!client) {
      try {
        client = await createAuth0Client({ ...initOptions, scope: 'openid email profile offline_access', cacheLocation: 'localstorage', useRefreshTokens: true });
        resolve(client);
      } catch (e) {
        reject(new Error(`getAuth0Client Error: ${e}`));
      }
    }
  });
};

export const getTokenSilently = async (...p) => {
  const client = await getAuth0Client();
  return await client.getTokenSilently(...p);
};

export const Auth0Provider = ({ children }): any => {
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [user, setUser] = useState<any>();
  const [auth0Client, setAuth0] = useState<Auth0Client>();
  const [loading, setLoading] = useState(true);
  const [popupOpen, setPopupOpen] = useState(false);

  useEffect(() => {
    const initAuth0 = async () => {
      const client = await getAuth0Client();
      setAuth0(client);
      if (window.location.search.includes('code=')) {
        const { appState } = await client.handleRedirectCallback();
        onRedirectCallback(appState);
      }
      const isAuthenticated = await client.isAuthenticated();
      setIsAuthenticated(isAuthenticated);

      if (isAuthenticated) {
        const user = await client.getUser();
        setUser(user);
      }

      setLoading(false);
    };
    initAuth0();
    // eslint-disable-next-line
  }, []);

  const loginWithPopup = async (params = {}) => {
    setPopupOpen(true);
    try {
      await auth0Client!.loginWithPopup(params);
    } catch (error) {
      console.error(error);
    } finally {
      setPopupOpen(false);
    }
    const user = await auth0Client!.getUser();
    setUser(user);
    setIsAuthenticated(true);
  };

  const handleRedirectCallback = async () => {
    setLoading(true);
    await auth0Client!.handleRedirectCallback();
    const user = await auth0Client!.getUser();
    setLoading(false);
    setIsAuthenticated(true);
    setUser(user);
  };

  return (
    <Auth0Context.Provider
      value={{
        isAuthenticated,
        user,
        loading,
        popupOpen,
        loginWithPopup,
        handleRedirectCallback,
        getIdTokenClaims: (o: getIdTokenClaimsOptions | undefined) => auth0Client!.getIdTokenClaims(o),
        loginWithRedirect: (o: RedirectLoginOptions) => auth0Client!.loginWithRedirect(o),
        getAccessTokenSilently: (o: GetTokenSilentlyOptions | undefined) => auth0Client!.getTokenSilently(o),
        getTokenWithPopup: (o: GetTokenWithPopupOptions | undefined) => auth0Client!.getTokenWithPopup(o),
        logout: (o: LogoutOptions | undefined) => auth0Client!.logout(o),
      }}
    >
      {children}
    </Auth0Context.Provider>
  );
};

正如你在第44行看到的,cacheLocation 被设置为localStorageuseRefreshToken 被设置为trueoffline_access 被添加到范围中。

在主App.tsx 文件中,你应该导入Auth0Provider HOC来包装所有的路由。

我还想确定每一个发送的API请求都有一个有效的token。即使API响应说未授权,它也会将客户端重定向到认证页面。

我使用了Axios的拦截器,它可以让你在发送请求或获得响应之前插入逻辑。

// Request interceptor for API calls
axios.interceptors.request.use(
  async config => {
    const token = await getTokenSilently();
    config.headers.authorization = `Bearer ${token}`;
    return config;
  },
  error => {
    Promise.reject(error);
  }
);

// Response interceptor for API calls
axios.interceptors.response.use(
  response => {
    return response.data;
  },
  async function(error) {
    if (error.response?.status === 401 || error?.error === 'login_required') {
      history.push(urls.authentication);
    }
    return Promise.reject(error);
  }
);

认证页面组件只包括loginWithRedirect方法,它将客户端重定向到Auth0登录页面,然后重定向到所需的页面。

import React, { useEffect } from 'react';
import { useAuth0 } from '../../../auth/auth0';
import { urls } from '../../../routers/urls';

const Login: React.FC = () => {
  const { loginWithRedirect, loading } = useAuth0();

  useEffect(() => {
    if (!loading) {
      loginWithRedirect({ appState: urls.orderManagement });
    }
  }, [loading]);
  return null;
};
export default Login; 

在Auth0仪表板上进入你的应用程序。在设置中,你会看到刷新令牌轮换的设置。打开轮换并设置重用间隔,这是刷新令牌重用检测算法将不工作的间隔。

Rotation Enabled

这就是了!现在,我们的应用程序有一个持久和安全的认证系统。这将使你的应用程序更加安全,并改善用户体验。

特别感谢我的同事Turhan Gür,他在这个过程中提供了重要的反馈,支持了我。

The postPersistent Login in React using refresh token rotationappeared first onLogRocket Blog.