ReactJS - 观察访问令牌过期

IT技术 reactjs
2021-05-16 01:00:02

在我的应用程序中,我有一个必须始终有效的访问令牌(Spotify)。当此访问令牌过期时,应用程序必须每 60 分钟访问一次刷新令牌端点,并获取另一个访问令牌。

授权功能

出于安全原因,这2个来电,来/get_token/refresh_token正在处理的python,服务器端,以及美国目前都已经在我的父母来处理App.jsx,就像这样:

class App extends Component {
  constructor() {
    super();
    this.state = {
      users: [],
      isAuthenticated: false,
      isAuthorizedWithSpotify: false,
      spotifyToken: '',
      isTokenExpired:false,
      isTokenRefreshed:false,
      renewing: false,
      id: '',
    };

 componentDidMount() {
    this.userId(); //<--- this.getSpotifyToken() is called here, inside then(), after async call;
  };

 getSpotifyToken(event) {
    const options = {
      url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get_token/${this.state.id}`,
      method: 'get',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${window.localStorage.authToken}`,
      }
    };
    // needed for sending cookies 
    axios.defaults.withCredentials = true
    return axios(options)
    .then((res) => {
      console.log(res.data)
      this.setState({
        spotifyToken: res.data.access_token,
        isTokenExpired: res.data.token_expired // <--- jwt returns expiration from server
      })
      // if token has expired, refresh it
      if (this.state.isTokenExpired === true){
        console.log('Access token was refreshed')
        this.refreshSpotifyToken();
    }
    })
    .catch((error) => { console.log(error); });

  };

  refreshSpotifyToken(event) {
    const options = {
      url: `${process.env.REACT_APP_WEB_SERVICE_URL}/refresh_token/${this.state.id}`,
      method: 'get',
      headers: {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${window.localStorage.authToken}`,
      }
    };
    axios.defaults.withCredentials = true
    return axios(options)
    .then((res) => {
      console.log(res.data)
      this.setState({
        spotifyToken: res.data.access_token,
        isTokenRefreshed: res.data.token_refreshed,
        isTokenExpired: false,
        isAuthorizedWithSpotify: true
      })
    })
    .catch((error) => { console.log(error); });
  };

然后,我传递this.props.spotifyToken给我的所有子组件,其中使用访问令牌发出请求,并且一切正常。


观察者功能

问题在于,当应用程序在给定页面上闲置超过 60 分钟并且用户发出请求时,这会发现访问令牌已过期,并且其状态不会更新,因此请求将被拒绝。

为了解决这个问题,我想过App.jsx在后台有一个跟踪令牌过期时间的观察者函数,如下所示:

willTokenExpire = () => {
    const accessToken = this.state.spotifyToken;
    console.log('access_token in willTokenExpire', accessToken)
    const expirationTime = 3600
    const token = { accessToken, expirationTime } // { accessToken, expirationTime }
    const threshold = 300 // 300s = 5 minute threshold for token expiration

    const hasToken = token && token.spotifyToken
    const now = (Date.now() / 1000) + threshold
    console.log('NOW', now)
    if(now > token.expirationTime){this.getSpotifyToken();}
    return !hasToken || (now > token.expirationTime)
  }

  handleCheckToken = () => {
    if (this.willTokenExpire()) {
      this.setState({ renewing: true })
    }
  }

和:

shouldComponentUpdate(nextProps, nextState) {
    return this.state.renewing !== nextState.renewing
  }

componentDidMount() {
    this.userId();
    this.timeInterval = setInterval(this.handleCheckToken, 20000)
  };

子组件

然后,从render()Parent App.jsx 中,我将handleCheckToken()作为回调函数以及传递this.props.spotifyToken给可能空闲的子组件,如下所示:

<Route exact path='/tracks' render={() => (
   <Track
    isAuthenticated={this.state.isAuthenticated}
    isAuthorizedWithSpotify={this.state.isAuthorizedWithSpotify}
    spotifyToken={this.state.spotifyToken}
    handleCheckToken={this.handleCheckToken}
    userId={this.state.id}
   />
)} />

在 Child 组件中,我会有:

class Tracks extends Component{
  constructor (props) {
    super(props);
    this.state = { 
        playlist:[],
        youtube_urls:[],
        artists:[],
        titles:[],
        spotifyToken: this.props.spotifyToken
    };
  };

  componentDidMount() {
    if (this.props.isAuthenticated) {
      this.props.handleCheckToken();
    };
  };

以及需要有效的、更新的 spotifyToken 的调用,如下所示:

  getTrack(event) {
    const {userId} = this.props
    const options = {
       url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${this.props.spotifyToken}`,
       method: 'get',
       headers: {
                'Content-Type': 'application/json',
                 Authorization: `Bearer ${window.localStorage.authToken}`
       }
   };
   return axios(options)
    .then((res) => { 
     console.log(res.data.message)
    })
    .catch((error) => { console.log(error); });
    };

但这行不通。

handleCheckToken即使我在 Child 处空闲,也会定期使用 获取新令牌但是,如果我在 60 分钟后提出请求,则在 Child 中,this.props.spotifyToken传递已过期,因此 props 不会正确传递给 Child.jsx。

我错过了什么?

3个回答

你说的是交换refreshTokenaccessToken的机制,我认为你太过复杂了。

背景,我有一个类似的设置,登录生成一个accessToken(有效期为 10 分钟)和一个 refreshToken 作为refreshToken 端点上的cookie(不是必需的)。

然后我所有的组件都使用一个简单的 api 服务(它是一个包装器Axios),以便向服务器发出 ajax 请求。我的所有端点都希望得到一个有效的accessToken,如果它过期,它们会返回 401 并带有过期消息。Axios有一个响应拦截器,它检查响应是否具有状态 401 和特殊消息,如果是,则向refreshToken端点发出请求,如果refreshToken有效(12 小时后到期),则返回accessToken,否则返回 403。拦截器获取新accessToken请求并重试(最多 3 次)先前失败的请求。

很酷的想法是,通过这种方式,accessToken可以保存在内存中(不是localStorage,因为它暴露在XSS 攻击中)。我将它保存在我的 api 服务中,因此,根本没有任何组件处理与令牌相关的任何事情。

另一个很酷的想法是它对整页重新加载也是有效的,因为如果用户有一个带有 的有效 cookie refreshToken,第一个 api 将失败并显示 401,整个机制将工作,否则,它将失败。

// ApiService.js

import Axios from 'axios';

class ApiService {
  constructor() {
    this.axios = Axios.create();
    this.axios.interceptors.response.use(null, this.authInterceptor);

    this.get = this.axios.get.bind(this.axios);
    this.post = this.axios.post.bind(this.axios);
  }

  async login(username, password) {
    const { accessToken } = await this.axios.post('/api/login', {
      username,
      password,
    });
    this.setAccessToken(accessToken);
    return accessToken; // return it to the component that invoked it to store in some state
  }

  async getTrack(userId, spotifyToken) {
    return this.axios.get(
      `${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${spotifyToken}`
    );
  }

  async updateAccessToken() {
    const { accessToken } = await this.axios.post(`/api/auth/refresh-token`, {});
    this.setAccessToken(accessToken);
  }

  async authInterceptor(error) {
    error.config.retries = error.config.retries || {
      count: 0,
    };

    if (this.isUnAuthorizedError(error) && this.shouldRetry(error.config)) {
      await this.updateAccessToken(); // refresh the access token
      error.config.retries.count += 1;

      return this.axios.rawRequest(error.config); // if succeed re-fetch the original request with the updated accessToken
    }
    return Promise.reject(error);
  }

  isUnAuthorizedError(error) {
    return error.config && error.response && error.response.status === 401;
  }

  shouldRetry(config) {
    return config.retries.count < 3;
  }

  setAccessToken(accessToken) {
    this.axios.defaults.headers.common.Authorization = `Bearer ${accessToken}`; // assign all requests to use new accessToken
  }
}

export const apiService = new ApiService(); // this is a single instance of the service, each import of this file will get it

这个机制是基于这篇文章

现在使用此 ApiService,您可以创建一个实例并将其导入到每个组件中,以便进行 api 请求。

import {apiService} from '../ApiService';

class Tracks extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      playlist: [],
      youtube_urls: [],
      artists: [],
      titles: [],
      spotifyToken: this.props.spotifyToken,
    };
  }

  async componentDidMount() {
    if (this.props.isAuthenticated) {
      const {userId, spotifyToken} = this.props;
      const tracks = await apiService.getTracks(userId, spotifyToken);
      this.setState({tracks});
    } else {
      this.setState({tracks: []});
    }
  }

  render() {
    return null;
  }
}

编辑(对评论的回答)

  1. 登录流程的处理也可以使用此服务完成,您可以从登录 api 中提取 accessToken,将其设置为默认标头并将其返回给调用者(这可能会将其保存在其他组件逻辑的状态中,例如条件渲染)(更新了我的片段)。
  2. 它只是一个需要使用api的组件示例。
  3. ApiService在文件的“module”中创建了一个它的实例(最后你可以看到new ApiService),之后你只需将这个导出的实例导入到所有需要进行 api 调用的地方。

如果你想Child在父组件的状态改变时强制重新渲染你的组件,你可以使用keyprop。使用 Spotify 令牌作为密钥。当重新获取和更新 spotify 令牌时,它也会使用新令牌重新安装您的子组件:

<Route exact path='/child' render={() => (
   <Child
    isAuthenticated={this.state.isAuthenticated}
    isAuthorizedWithSpotify={this.state.isAuthorizedWithSpotify}
    spotifyToken={this.state.spotifyToken}
    key={this.state.spotifyToken}
    handleCheckToken={this.handleCheckToken}
    userId={this.state.id}
   />
)} />

尽管这可能会重置您在子组件中的任何内部状态,因为它本质上是卸载和重新安装Child.

编辑:更多细节

keyprops是react的组分使用的特殊props。React 使用键来确定组件是否唯一,从一个组件到另一个组件,或者从一个渲染到另一个。它们通常用于将数组映射到一组组件时,但也可以在此上下文中使用。react的文档有一个很好的解释。基本上,当组件的 key prop 发生变化时,它会告诉 React 现在这是一个组件,因此必须完全重新渲染。因此,当您获取一个 newspotifyToken并将该新令牌分配为键时,React 将Child使用新的 props完全重新挂载希望这能让事情变得更清楚。

关键props将无法从你的内Child-this.props.key内你的孩子不会是有益的尝试接入。但是在您的情况下,您将相同的值传递到spotifyToken内部Child,因此您将在那里获得可用的值。key当子组件中需要该值时,使用具有相同值的另一个 prop 是很常见的

props 不会在子组件上更新,因为对于子组件,它们就像不可变的选项:https : //github.com/uberVU/react-guide/blob/master/props-vs-state.md

所以你需要一些方法来重新渲染 Child 组件。

Child 组件已经构建,因此不会更新和重新渲染。因此,您需要使用“getDerivedStateFromProps()”作为子组件内已弃用的“componentWillReceiveProps”函数的替代品,以便在从父组件接收更新的props时,子组件将重新渲染,如下所示:

class Child extends Component {
    state = {
        spotifyToken: null,
    };

    static getDerivedStateFromProps(props, state) {
        console.log("updated props", props.spotifyToken);
        if (props.spotifyToken !== state.spotifyToken) {
            return {
               spotifyToken: props.spotifyToken,
            };
        }

        // Return null if the state hasn't changed
        return null;
    }

    getTrack(event) {
        const {userId} = this.props
        const options = {
            url: `${process.env.REACT_APP_WEB_SERVICE_URL}/get-tracks/${userId}/${this.state.spotifyToken}`,
        method: 'get',
        headers: {
            'Content-Type': 'application/json',
            Authorization: `Bearer ${window.localStorage.authToken}`
            }
        };
        return axios(options)
        .then((res) => { 
        console.log(res.data.message)
        console.log(options.url) 
        })
        .catch((error) => { console.log(error); });
    }

};

请注意,在 getTrack 函数中,您将使用子状态值而不是props。