Android OkHttp,刷新过期令牌

22

方案:我正在使用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)是否有帮助?感谢任何提示。


请在这里查看我的解决方案:https://stackoverflow.com/a/77206302/11671900 - undefined
5个回答

13

我曾经遇到过同样的问题,但是我使用了可重入锁解决了它。

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的响应)进行模拟。了解更多 这里。 测试看起来像这样:
@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;
    }

}

1
谢谢!这个解决方案也适用于“过期令牌”error-code不是401的情况。就像我的情况一样(别说后端团队不听我的话)。 - borichellow
1
我同意,有些固执的人认为客户端可以做任何事情。 - silentsudo
1
请使用同步锁代替。 - Mubarak Tahir

11

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

相反,应该实现okhttp提供的Authenticator来解决这个问题:

okHttpClient.setAuthenticator(...);

3
除非后端团队支持你... :'( - silentsudo
@Greg Ennis如果登录凭据错误时收到相同的错误代码(例如401),会发生什么?即使不是相同的错误,它是否会尝试刷新令牌? - StuartDTO
@StuartDTO 如果您从服务器获取错误的错误代码,则客户端框架也无法正常工作。 - Greg Ennis

9

感谢您的回答-它们引导我找到了解决方案。最终我使用了一个 ConditionVariable 锁和一个AtomicBoolean。这是如何实现的:请阅读评论。

/**
 * 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;
    }
}

1
请问您能否使用这个解决方案更新您的代码?我需要做类似的事情。 - user2641570
6
我给你的回答投了反对票,因为它没有展示你如何实际解决问题的代码,所以这是一个质量不高的回答。 - Austyn Mahoney
@user2641570 对不起,我之前没有看到这个消息。代码已经在那里了(虽然你可能已经解决了这个问题)。 - ticofab
@AustynMahoney 你是完全正确的。很抱歉我现在才看到这个。已添加带注释的代码。 - ticofab

2

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

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分钟)是任意的,该数字应足够大以防止多次刷新调用,并小于您的令牌过期时间,以便在令牌过期时调用刷新。


0

为线程安全而编辑

还没有看过OkHttp或retrofit,但是考虑一下是否可以设置一个静态标志,一旦令牌失败就将其设置,并在请求新令牌之前检查该标志?

private static AtomicBoolean requestingToken = new AtomicBoolean(false);

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

1
你的解决方案没有锁定所有线程。这意味着第一个线程将刷新令牌,而其他线程将不会刷新令牌,并使用旧令牌重新进行其先前的调用。 - user2641570
@user2641570 在 else 块中添加一个 while 循环,当令牌有效时终止,这样做是一个好方法吗? - Antwan Kakki
不行,你需要使用一些线程同步机制,比如synchronized块或锁。否则,你的线程将无缘无故地循环使用CPU。请看我的答案中的示例。 - user2641570
请查看http://docs.oracle.com/javase/tutorial/essential/concurrency/。它详细介绍了有关Java并发的所有知识。 - user2641570

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接