Android OkHttp,刷新过期令牌

Posted

技术标签:

【中文标题】Android OkHttp,刷新过期令牌【英文标题】:Android OkHttp, refresh expired token 【发布时间】:2015-09-10 08:53:25 【问题描述】:

场景:我正在使用 OkHttp / Retrofit 访问 Web 服务:同时发出多个 HTTP 请求。在某个时候,身份验证令牌会过期,并且多个请求会得到 401 响应。

问题:在我的第一个实现中,我使用了一个拦截器(这里是简化的)并且每个线程都尝试刷新令牌。这会导致混乱。

public class SignedRequestInterceptor implements Interceptor 

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

        // 1. sign this request
        request = request.newBuilder()
                    .header(AUTH_HEADER_KEY, BEARER_HEADER_VALUE + token)
                    .build();


        // 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) 

            // ... try to refresh the token
            newToken = mAuthService.refreshAccessToken(..);


            // sign the request with the new token and proceed
            Request newRequest = request.newBuilder()
                                .removeHeader(AUTH_HEADER_KEY)
                                .addHeader(AUTH_HEADER_KEY, BEARER_HEADER_VALUE + newToken.getAccessToken())
                                .build();

            // return the outcome of the newly signed request
            response = chain.proceed(newRequest);

        

        return response;
    

所需解决方案:所有线程都应等待一个令牌刷新:第一个失败的请求触发刷新,并与其他请求一起等待新令牌。

有什么好的方法可以解决这个问题? OkHttp 的一些内置功能(如 Authenticator)可以提供帮助吗?感谢您的任何提示。

【问题讨论】:

【参考方案1】:

我遇到了同样的问题,我设法使用ReentrantLock 解决了它。

import java.io.IOException;
import java.net.HttpURLConnection;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;
import timber.log.Timber;

public class RefreshTokenInterceptor implements Interceptor 

    private Lock lock = new ReentrantLock();

    @Override
    public Response intercept(Interceptor.Chain chain) throws IOException 

        Request request = chain.request();
        Response response = chain.proceed(request);

        if (response.code() == HttpURLConnection.HTTP_UNAUTHORIZED) 

            // first thread will acquire the lock and start the refresh token
            if (lock.tryLock()) 
                Timber.i("refresh token thread holds the lock");

                try 
                    // this sync call will refresh the token and save it for 
                    // later use (e.g. sharedPreferences)
                    authenticationService.refreshTokenSync();
                    Request newRequest = recreateRequestWithNewAccessToken(chain);
                    return chain.proceed(newRequest);
                 catch (ServiceException exception) 
                    // depending on what you need to do you can logout the user at this 
                    // point or throw an exception and handle it in your onFailure callback
                    return response;
                 finally 
                    Timber.i("refresh token finished. release lock");
                    lock.unlock();
                

             else 
                Timber.i("wait for token to be refreshed");
                lock.lock(); // this will block the thread until the thread that is refreshing 
                             // the token will call .unlock() method
                lock.unlock();
                Timber.i("token refreshed. retry request");
                Request newRequest = recreateRequestWithNewAccessToken(chain);
                return chain.proceed(newRequest);
            
         else 
            return response;
        
    

    private Request recreateRequestWithNewAccessToken(Chain chain) 
        String freshAccessToken = sharedPreferences.getAccessToken();
        Timber.d("[freshAccessToken] %s", freshAccessToken);
        return chain.request().newBuilder()
                .header("access_token", freshAccessToken)
                .build();
    

使用此解决方案的主要优点是您可以使用 mockito 编写单元测试并对其进行测试。您必须启用 Mockito Incubating 功能来模拟最终课程(来自 okhttp 的响应)。阅读有关here 的更多信息。 测试看起来像这样:

@RunWith(MockitoJUnitRunner.class)
public class RefreshTokenInterceptorTest 

    private static final String FRESH_ACCESS_TOKEN = "fresh_access_token";

    @Mock
    AuthenticationService authenticationService;

    @Mock
    RefreshTokenStorage refreshTokenStorage;

    @Mock
    Interceptor.Chain chain;

    @BeforeClass
    public static void setup() 
        Timber.plant(new Timber.DebugTree() 

            @Override
            protected void log(int priority, String tag, String message, Throwable t) 
                System.out.println(Thread.currentThread() + " " + message);
            
        );
    

    @Test
    public void refreshTokenInterceptor_works_as_expected() throws IOException, InterruptedException 

        Response unauthorizedResponse = createUnauthorizedResponse();
        when(chain.proceed((Request) any())).thenReturn(unauthorizedResponse);
        when(authenticationService.refreshTokenSync()).thenAnswer(new Answer<Boolean>() 
            @Override
            public Boolean answer(InvocationOnMock invocation) throws Throwable 
                //refresh token takes some time
                Thread.sleep(10);
                return true;
            
        );
        when(refreshTokenStorage.getAccessToken()).thenReturn(FRESH_ACCESS_TOKEN);
        Request fakeRequest = createFakeRequest();
        when(chain.request()).thenReturn(fakeRequest);

        final Interceptor interceptor = new RefreshTokenInterceptor(authenticationService, refreshTokenStorage);

        Timber.d("5 requests try to refresh token at the same time");
        final CountDownLatch countDownLatch5 = new CountDownLatch(5);
        for (int i = 0; i < 5; i++) 
            new Thread(new Runnable() 
                @Override
                public void run() 
                    try 
                        interceptor.intercept(chain);
                        countDownLatch5.countDown();
                     catch (IOException e) 
                        throw new RuntimeException(e);
                    
                
            ).start();
        
        countDownLatch5.await();

        verify(authenticationService, times(1)).refreshTokenSync();


        Timber.d("next time another 3 threads try to refresh the token at the same time");
        final CountDownLatch countDownLatch3 = new CountDownLatch(3);
        for (int i = 0; i < 3; i++) 
            new Thread(new Runnable() 
                @Override
                public void run() 
                    try 
                        interceptor.intercept(chain);
                        countDownLatch3.countDown();
                     catch (IOException e) 
                        throw new RuntimeException(e);
                    
                
            ).start();
        
        countDownLatch3.await();

        verify(authenticationService, times(2)).refreshTokenSync();


        Timber.d("1 thread tries to refresh the token");
        interceptor.intercept(chain);

        verify(authenticationService, times(3)).refreshTokenSync();
    

    private Response createUnauthorizedResponse() throws IOException 
        Response response = mock(Response.class);
        when(response.code()).thenReturn(401);
        return response;
    

    private Request createFakeRequest() 
        Request request = mock(Request.class);
        Request.Builder fakeBuilder = createFakeBuilder();
        when(request.newBuilder()).thenReturn(fakeBuilder);
        return request;
    

    private Request.Builder createFakeBuilder() 
        Request.Builder mockBuilder = mock(Request.Builder.class);
        when(mockBuilder.header("access_token", FRESH_ACCESS_TOKEN)).thenReturn(mockBuilder);
        return mockBuilder;
    


【讨论】:

谢谢!当“过期令牌”error-code 不是 401 时,此解决方案也很好。就像我的情况一样(不要说,后端团队不听我的) 我同意,有些固执的认为客户可以做任何事情 改用同步锁【参考方案2】:

您不应该使用拦截器或自己实现重试逻辑,因为这会导致递归问题的迷宫。

改为使用 okhttp 的 Authenticator 来解决这个问题:

okHttpClient.setAuthenticator(...);

【讨论】:

除非后端团队支持你... :'( @Greg Ennis 如果在登录凭据错误时收到相同的错误代码(比如说 401)会发生什么?即使不一样,它会尝试刷新令牌吗? @StuartDTO 如果您从服务器收到错误的错误代码,那么客户端框架也无法正常工作【参考方案3】:

感谢您的回答 - 他们让我找到了解决方案。我最终使用了ConditionVariable 锁和 AtomicBoolean。以下是实现此目的的方法:通读 cmets。

/**
 * This class has two tasks:
 * 1) sign requests with the auth token, when available
 * 2) try to refresh a new token
 */
public class SignedRequestInterceptor implements Interceptor 

    // 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.
                    mAccountManager.invalidateAuthToken(AuthConsts.ACCOUNT_TYPE, token);

                    // do we have an access token to refresh?
                    String refreshToken = mAccountManager.getUserData(account, HorshaAuthenticator.KEY_REFRESH_TOKEN);

                    if (!TextUtils.isEmpty(refreshToken)) 
                        .... // refresh token
                    
                    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. The thread in charge of refreshing the token has taken care of
                    // redirecting the user to the login activity.
                    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 user for login again.
        

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

【讨论】:

你能用这个解决方案更新你的代码吗?我需要做类似的事情。 我投了反对票,因为这是一个糟糕的答案,没有任何关于您如何实际解决问题的代码。 @user2641570 很抱歉我之前没有看到这个。代码就在那里(尽管您可能已经解决了这个问题)。 @AustynMahoney 你完全正确。很抱歉我现在才看到这个。添加了带有 cmets 的代码。【参考方案4】:

如果您不想在第一个线程刷新令牌时阻塞线程,您可以使用同步块。

private final static Object lock = new Object();
private static long lastRefresh;

...
synchronized(lock) // lock all thread untill token is refreshed
   // only the first thread does the w refresh
   if(System.currentTimeMillis()-lastRefresh>600000) 
      token = refreshToken();
      lastRefresh=System.currentTimeMillis();
   

这里 600000(10 分钟)是任意的,这个数字应该很大,以防止多次刷新调用,并且小于您的令牌过期时间,以便您在令牌过期时调用刷新。

【讨论】:

【参考方案5】:

为线程安全而编辑

还没有研究过 OkHttp 或改造,但是如何在令牌失败时设置一个静态标志并在请求新令牌之前检查该标志?

private static AtomicBoolean requestingToken = new AtomicBoolean(false);

//..... 
if (requestingToken.get() == false)
 
    requestingToken.set(true);
    //.... request a new token
 

【讨论】:

您的解决方案并未锁定所有线程。这意味着第一个线程将刷新令牌,其他线程不会刷新令牌并使用旧令牌重新调用之前的调用。 @user2641570 会在 else 块中添加一个 while 循环,该循环在令牌有效时终止是一个好方法吗? 没有。您需要使用一些线程同步机制,例如同步块或锁。否则,您的线程将无缘无故地使用 cpu 循环。以我的回答为例。 看看docs.oracle.com/javase/tutorial/essential/concurrency。它详细介绍了有关 Java 并发性的所有信息。

以上是关于Android OkHttp,刷新过期令牌的主要内容,如果未能解决你的问题,请参考以下文章

在 Android 上集成 Google 登录时如何在 ID 令牌过期后刷新?

HttpLoggingInterceptor 干扰刷新令牌机制

OkHttp 和 Retrofit,用并发请求刷新令牌

Android 刷新令牌

Android Retrofit2 刷新 Oauth 2 令牌

在 spotify android sdk 中刷新令牌?