From 07e011092a25e31cbe170e65659d75c7a0befc8a Mon Sep 17 00:00:00 2001 From: Mitchell Syer Date: Mon, 19 Feb 2024 11:06:00 -0500 Subject: [PATCH] Support Token Expiry properly (#878) * Support token expiry properly * Small fix * Lint * Use newer fixes for expiry * Lint --- .../graphql/dataLoaders/TrackDataLoader.kt | 13 +++ .../TachideskDataLoaderRegistryFactory.kt | 2 + .../tachidesk/graphql/types/TrackType.kt | 4 + .../manga/impl/track/tracker/Tracker.kt | 13 +++ .../impl/track/tracker/TrackerPreferences.kt | 17 ++++ .../tracker/anilist/AnilistInterceptor.kt | 10 ++- .../myanimelist/MyAnimeListInterceptor.kt | 82 +++++++++---------- 7 files changed, 93 insertions(+), 48 deletions(-) diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt index dc71d3e86..54604866b 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/dataLoaders/TrackDataLoader.kt @@ -67,6 +67,19 @@ class TrackerScoresDataLoader : KotlinDataLoader> { } } +class TrackerTokenExpiredDataLoader : KotlinDataLoader { + override val dataLoaderName = "TrackerTokenExpiredDataLoader" + + override fun getDataLoader(): DataLoader = + DataLoaderFactory.newDataLoader { ids -> + future { + ids.map { id -> + TrackerManager.getTracker(id)?.getIfAuthExpired() + } + } + } +} + class TrackRecordsForMangaIdDataLoader : KotlinDataLoader { override val dataLoaderName = "TrackRecordsForMangaIdDataLoader" diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt index 5f9ab689c..3807dfa82 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/server/TachideskDataLoaderRegistryFactory.kt @@ -38,6 +38,7 @@ import suwayomi.tachidesk.graphql.dataLoaders.TrackRecordsForTrackerIdDataLoader import suwayomi.tachidesk.graphql.dataLoaders.TrackerDataLoader import suwayomi.tachidesk.graphql.dataLoaders.TrackerScoresDataLoader import suwayomi.tachidesk.graphql.dataLoaders.TrackerStatusesDataLoader +import suwayomi.tachidesk.graphql.dataLoaders.TrackerTokenExpiredDataLoader import suwayomi.tachidesk.graphql.dataLoaders.UnreadChapterCountForMangaDataLoader class TachideskDataLoaderRegistryFactory { @@ -71,6 +72,7 @@ class TachideskDataLoaderRegistryFactory { TrackerDataLoader(), TrackerStatusesDataLoader(), TrackerScoresDataLoader(), + TrackerTokenExpiredDataLoader(), TrackRecordsForMangaIdDataLoader(), DisplayScoreForTrackRecordDataLoader(), TrackRecordsForTrackerIdDataLoader(), diff --git a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt index b00a6695f..bf205d51e 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/graphql/types/TrackType.kt @@ -49,6 +49,10 @@ class TrackerType( fun trackRecords(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { return dataFetchingEnvironment.getValueFromDataLoader("TrackRecordsForTrackerIdDataLoader", id) } + + fun isTokenExpired(dataFetchingEnvironment: DataFetchingEnvironment): CompletableFuture { + return dataFetchingEnvironment.getValueFromDataLoader("TrackerTokenExpiredDataLoader", id) + } } class TrackStatusType( diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt index a78f1a58b..433af5b87 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/Tracker.kt @@ -5,6 +5,7 @@ import okhttp3.OkHttpClient import suwayomi.tachidesk.manga.impl.track.tracker.model.Track import suwayomi.tachidesk.manga.impl.track.tracker.model.TrackSearch import uy.kohesive.injekt.injectLazy +import java.io.IOException abstract class Tracker(val id: Int, val name: String) { val trackPreferences = TrackerPreferences @@ -81,6 +82,14 @@ abstract class Tracker(val id: Int, val name: String) { ) { trackPreferences.setTrackCredentials(this, username, password) } + + fun getIfAuthExpired(): Boolean { + return trackPreferences.trackAuthExpired(this) + } + + fun setAuthExpired() { + trackPreferences.setTrackTokenExpired(this) + } } fun String.extractToken(key: String): String? { @@ -93,3 +102,7 @@ fun String.extractToken(key: String): String? { } return null } + +class TokenExpired : IOException("Token is expired, re-logging required") + +class TokenRefreshFailed : IOException("Token refresh failed") diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt index d9b6183ce..4e008dfd3 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/TrackerPreferences.kt @@ -16,6 +16,12 @@ object TrackerPreferences { fun getTrackPassword(sync: Tracker) = preferenceStore.getString(trackPassword(sync.id), "") + fun trackAuthExpired(tracker: Tracker) = + preferenceStore.getBoolean( + trackTokenExpired(tracker.id), + false, + ) + fun setTrackCredentials( sync: Tracker, username: String, @@ -25,6 +31,7 @@ object TrackerPreferences { preferenceStore.edit() .putString(trackUsername(sync.id), username) .putString(trackPassword(sync.id), password) + .putBoolean(trackTokenExpired(sync.id), false) .apply() } @@ -38,14 +45,22 @@ object TrackerPreferences { if (token == null) { preferenceStore.edit() .remove(trackToken(sync.id)) + .putBoolean(trackTokenExpired(sync.id), false) .apply() } else { preferenceStore.edit() .putString(trackToken(sync.id), token) + .putBoolean(trackTokenExpired(sync.id), false) .apply() } } + fun setTrackTokenExpired(sync: Tracker) { + preferenceStore.edit() + .putBoolean(trackTokenExpired(sync.id), true) + .apply() + } + fun getScoreType(sync: Tracker) = preferenceStore.getString(scoreType(sync.id), Anilist.POINT_10) fun setScoreType( @@ -63,5 +78,7 @@ object TrackerPreferences { private fun trackToken(trackerId: Int) = "track_token_$trackerId" + private fun trackTokenExpired(trackerId: Int) = "track_token_expired_$trackerId" + private fun scoreType(trackerId: Int) = "score_type_$trackerId" } diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt index aa4ff0025..82f8cf392 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/anilist/AnilistInterceptor.kt @@ -2,6 +2,7 @@ package suwayomi.tachidesk.manga.impl.track.tracker.anilist import okhttp3.Interceptor import okhttp3.Response +import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired import java.io.IOException class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Interceptor { @@ -17,6 +18,9 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int } override fun intercept(chain: Interceptor.Chain): Response { + if (anilist.getIfAuthExpired()) { + throw TokenExpired() + } val originalRequest = chain.request() if (token.isNullOrEmpty()) { @@ -26,9 +30,9 @@ class AnilistInterceptor(val anilist: Anilist, private var token: String?) : Int oauth = anilist.loadOAuth() } // Refresh access token if null or expired. - if (oauth!!.isExpired()) { - anilist.logout() - throw IOException("Token expired") + if (oauth?.isExpired() == true) { + anilist.setAuthExpired() + throw TokenExpired() } // Throw on null auth. diff --git a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt index 325626039..abf8c3ea0 100644 --- a/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt +++ b/server/src/main/kotlin/suwayomi/tachidesk/manga/impl/track/tracker/myanimelist/MyAnimeListInterceptor.kt @@ -1,9 +1,12 @@ package suwayomi.tachidesk.manga.impl.track.tracker.myanimelist +import eu.kanade.tachiyomi.AppInfo import eu.kanade.tachiyomi.network.parseAs import kotlinx.serialization.json.Json import okhttp3.Interceptor import okhttp3.Response +import suwayomi.tachidesk.manga.impl.track.tracker.TokenExpired +import suwayomi.tachidesk.manga.impl.track.tracker.TokenRefreshFailed import uy.kohesive.injekt.injectLazy import java.io.IOException @@ -13,51 +16,27 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var t private var oauth: OAuth? = null override fun intercept(chain: Interceptor.Chain): Response { + if (myanimelist.getIfAuthExpired()) { + throw TokenExpired() + } val originalRequest = chain.request() - if (token.isNullOrEmpty()) { - throw IOException("Not authenticated with MyAnimeList") - } - if (oauth == null) { - oauth = myanimelist.loadOAuth() - } - // Refresh access token if expired - if (oauth != null && oauth!!.isExpired()) { - setAuth(refreshToken(chain)) + if (oauth?.isExpired() == true) { + refreshToken(chain) } if (oauth == null) { - throw IOException("No authentication token") + throw IOException("MAL: User is not authenticated") } // Add the authorization header to the original request val authRequest = originalRequest.newBuilder() .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .header("User-Agent", "Suwayomi v${AppInfo.getVersionName()}") .build() - val response = chain.proceed(authRequest) - val tokenIsExpired = - response.headers["www-authenticate"] - ?.contains("The access token expired") ?: false - - // Retry the request once with a new token in case it was not already refreshed - // by the is expired check before. - if (response.code == 401 && tokenIsExpired) { - response.close() - - val newToken = refreshToken(chain) - setAuth(newToken) - - val newRequest = - originalRequest.newBuilder() - .addHeader("Authorization", "Bearer ${newToken.access_token}") - .build() - - return chain.proceed(newRequest) - } - - return response + return chain.proceed(authRequest) } /** @@ -70,23 +49,36 @@ class MyAnimeListInterceptor(private val myanimelist: MyAnimeList, private var t myanimelist.saveOAuth(oauth) } - private fun refreshToken(chain: Interceptor.Chain): OAuth { - val newOauth = - runCatching { - val oauthResponse = chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!)) + private fun refreshToken(chain: Interceptor.Chain): OAuth = + synchronized(this) { + if (myanimelist.getIfAuthExpired()) throw TokenExpired() + oauth?.takeUnless { it.isExpired() }?.let { return@synchronized it } + + val response = + try { + chain.proceed(MyAnimeListApi.refreshTokenRequest(oauth!!)) + } catch (_: Throwable) { + throw TokenRefreshFailed() + } + + if (response.code == 401) { + myanimelist.setAuthExpired() + throw TokenExpired() + } - if (oauthResponse.isSuccessful) { - with(json) { oauthResponse.parseAs() } + return runCatching { + if (response.isSuccessful) { + with(json) { response.parseAs() } } else { - oauthResponse.close() + response.close() null } } - - if (newOauth.getOrNull() == null) { - throw IOException("Failed to refresh the access token") + .getOrNull() + ?.also { + this.oauth = it + myanimelist.saveOAuth(it) + } + ?: throw TokenRefreshFailed() } - - return newOauth.getOrNull()!! - } }