【问题标题】:Okhttp refresh expired token when multiple requests are sent to the server向服务器发送多个请求时,Okhttp 刷新过期令牌
【发布时间】:2017-12-15 08:14:30
【问题描述】:

我有一个ViewPager,并且在同时加载ViewPager 时进行了三个Web 服务调用。

当第一个返回 401 时,调用 Authenticator 并刷新 Authenticator 内的令牌,但剩余的 2 个请求已经使用旧的刷新令牌发送到服务器,并且在拦截器和应用程序中捕获的 498 失败登出。

这不是我所期望的理想行为。我想将第二个和第三个请求保留在队列中,并在刷新令牌时重试排队的请求。

目前,我有一个变量来指示Authenticator 中是否正在进行令牌刷新,在这种情况下,我会取消Interceptor 中的所有后续请求,并且用户必须手动刷新页面,否则我可以注销用户并强制用户登录。

对于上述问题,使用 okhttp 3.x for Android 有什么好的解决方案或架构?

编辑:我想解决的问题是一般性的,我不想对我的电话进行排序。即等待一个调用完成并刷新令牌,然后仅在活动和片段级别发送请求的其余部分。

请求了代码。这是Authenticator的标准代码:

public class CustomAuthenticator implements Authenticator {

    @Inject AccountManager accountManager;
    @Inject @AccountType String accountType;
    @Inject @AuthTokenType String authTokenType;

    @Inject
    public ApiAuthenticator(@ForApplication Context context) {
    }

    @Override
    public Request authenticate(Route route, Response response) throws IOException {

        // Invaidate authToken
        String accessToken = accountManager.peekAuthToken(account, authTokenType);
        if (accessToken != null) {
            accountManager.invalidateAuthToken(accountType, accessToken);
        }
        try {
                // Get new refresh token. This invokes custom AccountAuthenticator which makes a call to get new refresh token.
                accessToken = accountManager.blockingGetAuthToken(account, authTokenType, false);
                if (accessToken != null) {
                    Request.Builder requestBuilder = response.request().newBuilder();

                    // Add headers with new refreshToken

                    return requestBuilder.build();
            } catch (Throwable t) {
                Timber.e(t, t.getLocalizedMessage());
            }
        }
        return null;
    }
}

类似这样的一些问题: OkHttp and Retrofit, refresh token with concurrent requests

【问题讨论】:

  • 请贴一些代码
  • 你能发布你的验证码吗?谢谢。还有为什么你用过期的令牌从你的 api 得到 498?
  • @savepopulation 498 表示无效令牌。与第一个请求一起发送的 2 个请求具有旧令牌,请求失败并出现 498 错误代码。
  • 那么当您获得刷新的令牌时,是什么阻止您重复第二和第三次请求?
  • @matrix 我必须在拦截器中对失败的请求进行排队,并使用新的刷新令牌重试。首先,我必须有一个事件来通知新的刷新令牌可用,然后我必须重试所有失败的请求。我还没有找到在拦截器中做到这一点的方法。此外,这似乎不是一个好的解决方案,我想知道使用 okhttp 刷新令牌的常见模式是什么

标签: java android http retrofit okhttp


【解决方案1】:

需要注意的是,accountManager.blockingGetAuthToken(或非阻塞版本)仍然可以在除拦截器之外的其他地方调用。因此,防止此问题发生的正确位置将是 身份验证器内。

我们希望确保需要访问令牌的第一个线程将检索它,并且可能的其他线程应该只注册一个回调,以便在第一个线程完成检索令牌时调用。
好消息是,AbstractAccountAuthenticator 已经有了一种传递异步结果的方法,即AccountAuthenticatorResponse,您可以在上面调用onResultonError


以下示例由 3 个块组成。

第一个是关于确保只有一个线程获取访问令牌,而其他线程仅注册其response 以进行回调。

second 部分只是一个虚拟的空结果包。在这里,您将加载您的令牌,可能刷新它等等。

第三部分是你得到结果(或错误)后所做的事情。您必须确保为可能已注册的每个其他线程调用响应。

boolean fetchingToken;
List<AccountAuthenticatorResponse> queue = null;

@Override
public Bundle getAuthToken(AccountAuthenticatorResponse response, Account account, String authTokenType, Bundle options) throws NetworkErrorException {

  synchronized (this) {
    if (fetchingToken) {
      // another thread is already working on it, register for callback
      List<AccountAuthenticatorResponse> q = queue;
      if (q == null) {
        q = new ArrayList<>();
        queue = q;
      }
      q.add(response);
      // we return null, the result will be sent with the `response`
      return null;
    }
    // we have to fetch the token, and return the result other threads
    fetchingToken = true;
  }

  // load access token, refresh with refresh token, whatever
  // ... todo ...
  Bundle result = Bundle.EMPTY;

  // loop to make sure we don't drop any responses
  for ( ; ; ) {
    List<AccountAuthenticatorResponse> q;
    synchronized (this) {
      // get list with responses waiting for result
      q = queue;
      if (q == null) {
        fetchingToken = false;
        // we're done, nobody is waiting for a response, return
        return null;
      }
      queue = null;
    }

    // inform other threads about the result
    for (AccountAuthenticatorResponse r : q) {
      r.onResult(result); // return result
    }

    // repeat for the case another thread registered for callback
    // while we were busy calling others
  }
}

只要确保在使用response 时在所有路径上都返回null

您显然可以使用其他方式来同步这些代码块,例如 @matrix 在另一个响应中显示的原子。我使用了synchronized,因为我相信这是最容易掌握的实现,因为这是一个很好的问题,每个人都应该这样做;)


上面的示例是emitter loop described here 的改编版本,其中详细介绍了并发性。如果您对 RxJava 的底层工作方式感兴趣,此博客是一个很好的来源。

【讨论】:

  • 玩弄这个,我发现 fetchingToken 永远不是真的。对getAuthToken 的每次调用都会贯穿整个方法。我错过了什么吗?
  • @Jack 除非你在实现它时出错,这只是意味着你没有遇到任何比赛条件。此代码确保 IFF 多个线程需要一个新的 accessToken,只有一个请求它并将结果传递给其他线程。我可以通过在后台线程上一次触发 10 多个 API 调用之前使 accessToken 无效来测试这一点。然后一个线程将进行查找,而其他线程等待结果。
  • 感谢您回复我。这就是我的假设。我在 okhttp Authenticator 中调用 getAuthToken()。我正在使用 RxJava 并在 io 线程上抛出所有原始请求,该线程最终调用 getAuthToken 显然不是以我期望的方法同步的方式。我对同步还很陌生,所以我仍在努力。如果从同一个线程调用同步方法,它还会等待吗?
  • @Jack 你如何观察你的代码?如果您使用调试器,您可能会暂停所有导致您看到的顺序行为的线程。你如何刷新你的令牌?调用是从缓存中返回 accessToken,还是在进行网络调用?如果您只是查找缓存的令牌,它会太快,因此您实际上需要对服务器进行 IO 调用才能充分利用代码。我无法真正帮助您远程调试代码,但我建议您使用Log 并确保您实际上是在使用 IO 来刷新访问令牌(这会切换线程并且需要更长的时间)
  • 啊,是的,我的脑海里有这样的想法,如果我设置一个断点,我基本上是让它同步。我会再玩一些。感谢您的帮助。
【解决方案2】:

你可以这样做:

将它们添加为数据成员:

// these two static variables serve for the pattern to refresh a token
private final static ConditionVariable LOCK = new ConditionVariable(true);
private static final AtomicBoolean mIsRefreshing = new AtomicBoolean(false);

然后上拦截方法:

@Override
    public Response intercept(@NonNull Chain chain) throws IOException {
        Request request = chain.request();

        // 1. sign this request
        ....

        // 2. proceed with the request
        Response response = chain.proceed(request);

        // 3. check the response: have we got a 401?
        if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {

            if (!TextUtils.isEmpty(token)) {
                /*
                *  Because we send out multiple HTTP requests in parallel, they might all list a 401 at the same time.
                *  Only one of them should refresh the token, because otherwise we'd refresh the same token multiple times
                *  and that is bad. Therefore we have these two static objects, a ConditionVariable and a boolean. The
                *  first thread that gets here closes the ConditionVariable and changes the boolean flag.
                */
                if (mIsRefreshing.compareAndSet(false, true)) {
                    LOCK.close();

                    /* we're the first here. let's refresh this token.
                    *  it looks like our token isn't valid anymore.
                    *  REFRESH the actual token here
                    */

                    LOCK.open();
                    mIsRefreshing.set(false);
                } else {
                    // Another thread is refreshing the token for us, let's wait for it.
                    boolean conditionOpened = LOCK.block(REFRESH_WAIT_TIMEOUT);

                    // If the next check is false, it means that the timeout expired, that is - the refresh
                    // stuff has failed.
                    if (conditionOpened) {

                        // another thread has refreshed this for us! thanks!
                        // sign the request with the new token and proceed
                        // return the outcome of the newly signed request
                        response = chain.proceed(newRequest);
                    }
                }
            }
        }

        // check if still unauthorized (i.e. refresh failed)
        if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) {
            ... // clean your access token and prompt for request again.
        }

        // returning the response to the original request
        return response;
    }

通过这种方式,您将只发送 1 个请求来刷新令牌,然后对于其他每个请求,您将拥有刷新的令牌。

【讨论】:

    【解决方案3】:

    你可以试试这个应用级拦截器

     private class HttpInterceptor implements Interceptor {
    
        @Override
        public Response intercept(Chain chain) throws IOException {
            Request request = chain.request();
    
            //Build new request
            Request.Builder builder = request.newBuilder();
            builder.header("Accept", "application/json"); //if necessary, say to consume JSON
    
            String token = settings.getAccessToken(); //save token of this request for future
            setAuthHeader(builder, token); //write current token to request
    
            request = builder.build(); //overwrite old request
            Response response = chain.proceed(request); //perform request, here original request will be executed
    
            if (response.code() == 401) { //if unauthorized
                synchronized (httpClient) { //perform all 401 in sync blocks, to avoid multiply token updates
                    String currentToken = settings.getAccessToken(); //get currently stored token
    
                    if(currentToken != null && currentToken.equals(token)) { //compare current token with token that was stored before, if it was not updated - do update
    
                        int code = refreshToken() / 100; //refresh token
                        if(code != 2) { //if refresh token failed for some reason
                            if(code == 4) //only if response is 400, 500 might mean that token was not updated
                                logout(); //go to login screen
                            return response; //if token refresh failed - show error to user
                        }
                    }
    
                    if(settings.getAccessToken() != null) { //retry requires new auth token,
                        setAuthHeader(builder, settings.getAccessToken()); //set auth token to updated
                        request = builder.build();
                        return chain.proceed(request); //repeat request with new token
                    }
                }
            }
    
            return response;
        }
    
        private void setAuthHeader(Request.Builder builder, String token) {
            if (token != null) //Add Auth token to each request if authorized
                builder.header("Authorization", String.format("Bearer %s", token));
        }
    
        private int refreshToken() {
            //Refresh token, synchronously, save it, and return result code
            //you might use retrofit here
        }
    
        private int logout() {
            //logout your user
        }
    }
    

    你可以像这样将拦截器设置为 okHttp 实例

        Gson gson = new GsonBuilder().create();
    
        OkHttpClient httpClient = new OkHttpClient();
        httpClient.interceptors().add(new HttpInterceptor());
    
        final RestAdapter restAdapter = new RestAdapter.Builder()
                .setEndpoint(BuildConfig.REST_SERVICE_URL)
                .setClient(new OkClient(httpClient))
                .setConverter(new GsonConverter(gson))
                .setLogLevel(RestAdapter.LogLevel.BASIC)
                .build();
    
        remoteService = restAdapter.create(RemoteService.class);
    

    希望这有帮助!!!!

    【讨论】:

      【解决方案4】:

      我用authenticator找到了解决方案,id是请求的编号,仅用于识别。评论是西班牙语

       private final static Lock locks = new ReentrantLock();
      
      httpClient.authenticator(new Authenticator() {
                  @Override
                  public Request authenticate(@NonNull Route route,@NonNull Response response) throws IOException {
      
                      Log.e("Error" , "Se encontro un 401 no autorizado y soy el numero : " + id);
      
                      //Obteniendo token de DB
                      SharedPreferences prefs = mContext.getSharedPreferences(
                              BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE);
      
                      String token_db = prefs.getString("refresh_token","");
      
                      //Comparando tokens
                      if(mToken.getRefreshToken().equals(token_db)){
      
                          locks.lock(); 
      
                          try{
                              //Obteniendo token de DB
                               prefs = mContext.getSharedPreferences(
                                      BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE);
      
                              String token_db2 = prefs.getString("refresh_token","");
                              //Comparando tokens
                              if(mToken.getRefreshToken().equals(token_db2)){
      
                                  //Refresh token
                                  APIClient tokenClient = createService(APIClient.class);
                                  Call<AccessToken> call = tokenClient.getRefreshAccessToken(API_OAUTH_CLIENTID,API_OAUTH_CLIENTSECRET, "refresh_token", mToken.getRefreshToken());
                                  retrofit2.Response<AccessToken> res = call.execute();
                                  AccessToken newToken = res.body();
                                  // do we have an access token to refresh?
                                  if(newToken!=null && res.isSuccessful()){
                                      String refreshToken = newToken.getRefreshToken();
      
                                          Log.e("Entra", "Token actualizado y soy el numero :  " + id + " : " + refreshToken);
      
                                          prefs = mContext.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE);
                                          prefs.edit().putBoolean("log_in", true).apply();
                                          prefs.edit().putString("access_token", newToken.getAccessToken()).apply();
                                          prefs.edit().putString("refresh_token", refreshToken).apply();
                                          prefs.edit().putString("token_type", newToken.getTokenType()).apply();
      
                                          locks.unlock();
      
                                          return response.request().newBuilder()
                                                  .header("Authorization", newToken.getTokenType() + " " + newToken.getAccessToken())
                                                  .build();
      
                                   }else{
                                      //Dirigir a login
                                      Log.e("redirigir", "DIRIGIENDO LOGOUT");
      
                                      locks.unlock();
                                      return null;
                                  }
      
                              }else{
                                  //Ya se actualizo tokens
      
                                  Log.e("Entra", "El token se actualizo anteriormente, y soy el no : " + id );
      
                                  prefs = mContext.getSharedPreferences(BuildConfig.APPLICATION_ID, Context.MODE_PRIVATE);
      
                                  String type = prefs.getString("token_type","");
                                  String access = prefs.getString("access_token","");
      
                                  locks.unlock();
      
                                  return response.request().newBuilder()
                                          .header("Authorization", type + " " + access)
                                          .build();
                              }
      
                          }catch (Exception e){
                              locks.unlock();
                              e.printStackTrace();
                              return null;
                          }
      
      
                      }
                      return null;
                  }
              });
      

      【讨论】:

        猜你喜欢
        • 2018-09-15
        • 2017-11-07
        • 2020-06-30
        • 2017-06-09
        • 2017-05-04
        • 1970-01-01
        • 2015-09-29
        • 1970-01-01
        • 2016-05-23
        相关资源
        最近更新 更多