web-dev-qa-db-ja.com

Android OkHttp、期限切れのトークンを更新

シナリオ: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のいくつかの組み込み機能(オーセンティケーターなど)は役に立ちますか?ヒントありがとうございます。

17
ticofab

あなたの答えをありがとう-彼らは私を解決策に導きました。結局、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;
    }
}
8
ticofab

再帰的な問題の迷路につながるため、インターセプターを使用したり、再試行ロジックを自分で実装したりしないでください。

代わりに、この問題を解決するために特別に提供されているokhttpのAuthenticatorを実装します。

okHttpClient.setAuthenticator(...);
11
Greg Ennis

私は同じ問題を抱えていて、 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からの応答)。 ここ についてもっと読む。テストは次のようになります。

@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;
    }

}
8
raducoti

最初のスレッドがトークンを更新している間にスレッドをボックさせたくない場合は、同期ブロックを使用できます。

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分)は任意です。この番号は、複数の更新呼び出しを防ぐために大きく、トークンの有効期限よりも小さくして、トークンの有効期限が切れたときに更新を呼び出す必要があります。

1
user2641570

スレッドセーフのために編集

HaventはOkHttpまたはレトロフィットを検討しましたが、トークンが失敗するとすぐに静的フラグを設定し、新しいトークンを要求する前にそのフラグを確認するのはどうでしょうか。

private static AtomicBoolean requestingToken = new AtomicBoolean(false);

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