Android OkHttp,刷新过期令牌
Android OkHttp, refresh expired token
场景:我正在使用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)有帮助吗?感谢您的任何提示。
针对线程安全进行了编辑
还没有看过 OkHttp 或改造,但是在令牌失败后立即设置一个静态标志并在请求新令牌之前检查该标志怎么样?
private static AtomicBoolean requestingToken = new AtomicBoolean(false);
//.....
if (requestingToken.get() == false)
{
requestingToken.set(true);
//.... request a new token
}
如果您不希望线程在第一个线程刷新令牌时阻塞,您可以使用同步块。
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 分钟)是任意的,这个数字应该足够大以防止多次刷新调用并且小于您的令牌过期时间以便您在令牌过期时调用刷新。
感谢您的回答 - 他们让我找到了解决方案。我最终使用了 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;
}
}
你不应该使用拦截器或自己实现重试逻辑,因为这会导致递归问题的迷宫。
而是执行专门为解决这个问题而提供的okhttp的Authenticator
:
okHttpClient.setAuthenticator(...);
我遇到了同样的问题,我设法使用 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 孵化功能以模拟最终 类(来自 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;
}
}
场景:我正在使用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)有帮助吗?感谢您的任何提示。
针对线程安全进行了编辑
还没有看过 OkHttp 或改造,但是在令牌失败后立即设置一个静态标志并在请求新令牌之前检查该标志怎么样?
private static AtomicBoolean requestingToken = new AtomicBoolean(false);
//.....
if (requestingToken.get() == false)
{
requestingToken.set(true);
//.... request a new token
}
如果您不希望线程在第一个线程刷新令牌时阻塞,您可以使用同步块。
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 分钟)是任意的,这个数字应该足够大以防止多次刷新调用并且小于您的令牌过期时间以便您在令牌过期时调用刷新。
感谢您的回答 - 他们让我找到了解决方案。我最终使用了 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;
}
}
你不应该使用拦截器或自己实现重试逻辑,因为这会导致递归问题的迷宫。
而是执行专门为解决这个问题而提供的okhttp的Authenticator
:
okHttpClient.setAuthenticator(...);
我遇到了同样的问题,我设法使用 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 孵化功能以模拟最终 类(来自 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;
}
}