From 708b868e7b759cc5e2bf7ecf04735817640e6dbe Mon Sep 17 00:00:00 2001 From: Jobobby04 Date: Tue, 20 Dec 2022 13:34:01 -0500 Subject: [PATCH] MangaDex OAuth Co-authored-by: Carlos <2092019+CarlosEsco@users.noreply.github.com> --- app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 16 ++ .../settings/screen/SettingsMangadexScreen.kt | 162 ++---------------- .../tachiyomi/data/track/mdlist/MdList.kt | 6 + .../tachiyomi/source/online/all/MangaDex.kt | 31 +--- app/src/main/java/exh/EXHMigrations.kt | 5 + .../main/java/exh/md/MangaDexLoginActivity.kt | 27 +++ app/src/main/java/exh/md/dto/AuthDto.kt | 39 ----- .../exh/md/network/MangaDexAuthInterceptor.kt | 96 +++++++++++ .../exh/md/network/MangaDexLoginHelper.kt | 140 +++++++-------- .../java/exh/md/network/TokenAuthenticator.kt | 54 ------ .../exh/md/service/MangaDexAuthService.kt | 86 ++-------- app/src/main/java/exh/md/utils/MdApi.kt | 11 +- app/src/main/java/exh/md/utils/MdConstants.kt | 27 +++ app/src/main/java/exh/md/utils/MdUtil.kt | 68 +++++--- .../tachiyomi/source/online/LoginSource.kt | 4 +- 16 files changed, 339 insertions(+), 435 deletions(-) create mode 100644 app/src/main/java/exh/md/MangaDexLoginActivity.kt delete mode 100644 app/src/main/java/exh/md/dto/AuthDto.kt create mode 100644 app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt delete mode 100644 app/src/main/java/exh/md/network/TokenAuthenticator.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d497c2b7f..c1600907c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -31,7 +31,7 @@ android { applicationId = "eu.kanade.tachiyomi.sy" minSdk = AndroidConfig.minSdk targetSdk = AndroidConfig.targetSdk - versionCode = 44 + versionCode = 45 versionName = "1.8.5" buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e087f9843..0faef9846 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -381,6 +381,22 @@ + + + + + + + + + + + diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt index 168763008..3709a2dfe 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsMangadexScreen.kt @@ -2,25 +2,15 @@ package eu.kanade.presentation.more.settings.screen import androidx.annotation.StringRes import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Close -import androidx.compose.material.icons.outlined.Visibility -import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Checkbox -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -35,15 +25,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.input.VisualTransformation -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import eu.kanade.domain.UnsortedPreferences import eu.kanade.domain.source.service.SourcePreferences +import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.presentation.more.settings.Preference +import eu.kanade.presentation.util.collectAsState import eu.kanade.presentation.util.padding import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.library.LibraryUpdateService @@ -51,8 +37,9 @@ import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.util.lang.launchIO import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.system.logcat +import eu.kanade.tachiyomi.util.system.openInBrowser import eu.kanade.tachiyomi.util.system.toast -import exh.log.xLogW +import exh.md.utils.MdConstants import exh.md.utils.MdUtil import logcat.LogPriority import uy.kohesive.injekt.Injekt @@ -71,134 +58,17 @@ object SettingsMangadexScreen : SearchableSettings { override fun getPreferences(): List { val sourcePreferences: SourcePreferences = remember { Injekt.get() } val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() } + val trackPreferences: TrackPreferences = remember { Injekt.get() } val mdex = remember { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences) } ?: return emptyList() return listOf( - loginPreference(mdex), + loginPreference(mdex, trackPreferences), preferredMangaDexId(unsortedPreferences, sourcePreferences), syncMangaDexIntoThis(unsortedPreferences), syncLibraryToMangaDex(), ) } - @Composable - fun LoginDialog( - mdex: MangaDex, - onDismissRequest: () -> Unit, - onLoginSuccess: () -> Unit, - ) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var username by remember { mutableStateOf(TextFieldValue(mdex.getUsername())) } - var password by remember { mutableStateOf(TextFieldValue(mdex.getPassword())) } - var processing by remember { mutableStateOf(false) } - var inputError by remember { mutableStateOf(false) } - - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = stringResource(R.string.login_title, mdex.name), - modifier = Modifier.weight(1f), - ) - IconButton(onClick = onDismissRequest) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = stringResource(R.string.action_close), - ) - } - } - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = username, - onValueChange = { username = it }, - label = { Text(text = stringResource(R.string.username)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), - singleLine = true, - isError = inputError && username.text.isEmpty(), - ) - - var hidePassword by remember { mutableStateOf(true) } - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = password, - onValueChange = { password = it }, - label = { Text(text = stringResource(R.string.password)) }, - trailingIcon = { - IconButton(onClick = { hidePassword = !hidePassword }) { - Icon( - imageVector = if (hidePassword) { - Icons.Outlined.Visibility - } else { - Icons.Outlined.VisibilityOff - }, - contentDescription = null, - ) - } - }, - visualTransformation = if (hidePassword) { - PasswordVisualTransformation() - } else { - VisualTransformation.None - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - singleLine = true, - isError = inputError && password.text.isEmpty(), - ) - } - }, - confirmButton = { - Button( - modifier = Modifier.fillMaxWidth(), - enabled = !processing, - onClick = { - if (username.text.isEmpty() || password.text.isEmpty()) { - inputError = true - return@Button - } - scope.launchIO { - try { - inputError = false - processing = true - val result = mdex.login( - username = username.text, - password = password.text, - twoFactorCode = null, - ) - if (result) { - onDismissRequest() - onLoginSuccess() - withUIContext { - context.toast(R.string.login_success) - } - } - } catch (e: Exception) { - xLogW("Login to Mangadex error", e) - withUIContext { - e.message?.let { context.toast(it) } - } - } finally { - processing = false - } - } - }, - ) { - val id = if (processing) R.string.loading else R.string.login - Text(text = stringResource(id)) - } - }, - properties = DialogProperties( - dismissOnBackPress = !processing, - dismissOnClickOutside = !processing, - ), - ) - } - @Composable fun LogoutDialog( onDismissRequest: () -> Unit, @@ -223,18 +93,10 @@ object SettingsMangadexScreen : SearchableSettings { } @Composable - fun loginPreference(mdex: MangaDex): Preference.PreferenceItem.MangaDexPreference { + fun loginPreference(mdex: MangaDex, trackPreferences: TrackPreferences): Preference.PreferenceItem.MangaDexPreference { val context = LocalContext.current val scope = rememberCoroutineScope() - var loggedIn by remember { mutableStateOf(mdex.isLogged()) } - var loginDialogOpen by remember { mutableStateOf(false) } - if (loginDialogOpen) { - LoginDialog( - mdex = mdex, - onDismissRequest = { loginDialogOpen = false }, - onLoginSuccess = { loggedIn = true }, - ) - } + val loggedIn by remember { trackPreferences.trackToken(mdex.mdList) }.collectAsState() var logoutDialogOpen by remember { mutableStateOf(false) } if (logoutDialogOpen) { LogoutDialog( @@ -244,7 +106,6 @@ object SettingsMangadexScreen : SearchableSettings { scope.launchIO { try { if (mdex.logout()) { - loggedIn = false withUIContext { context.toast(R.string.logout_success) } @@ -265,9 +126,12 @@ object SettingsMangadexScreen : SearchableSettings { } return Preference.PreferenceItem.MangaDexPreference( title = mdex.name + " Login", - loggedIn = loggedIn, + loggedIn = loggedIn.isNotEmpty(), login = { - loginDialogOpen = true + context.openInBrowser( + MdConstants.Login.authUrl(MdUtil.getPkceChallengeCode()), + forceDefaultBrowser = true, + ) }, logout = { logoutDialogOpen = true diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt index ed3e46d48..199984fba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mdlist/MdList.kt @@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.util.lang.awaitSingle import eu.kanade.tachiyomi.util.lang.runAsObservable import eu.kanade.tachiyomi.util.lang.withIOContext +import exh.md.network.MangaDexAuthInterceptor import exh.md.utils.FollowStatus import exh.md.utils.MdUtil import uy.kohesive.injekt.Injekt @@ -23,6 +24,8 @@ class MdList(private val context: Context, id: Long) : TrackService(id) { private val mdex by lazy { MdUtil.getEnabledMangaDex(Injekt.get()) } + val interceptor = MangaDexAuthInterceptor(trackPreferences, this) + @StringRes override fun nameRes(): Int = R.string.mdlist @@ -157,5 +160,8 @@ class MdList(private val context: Context, id: Long) : TrackService(id) { trackPreferences.trackToken(this).delete() } + override val isLogged: Boolean + get() = trackPreferences.trackToken(this).get().isNotEmpty() + class MangaDexNotFoundException : Exception("Mangadex not enabled") } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt index 8d6605399..f2e454208 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt @@ -34,7 +34,6 @@ import exh.md.handlers.MangaPlusHandler import exh.md.handlers.PageHandler import exh.md.handlers.SimilarHandler import exh.md.network.MangaDexLoginHelper -import exh.md.network.TokenAuthenticator import exh.md.service.MangaDexAuthService import exh.md.service.MangaDexService import exh.md.service.SimilarService @@ -76,14 +75,10 @@ class MangaDex(delegate: HttpSource, val context: Context) : context.getSharedPreferences("source_$id", 0x0000) } - private val mangadexAuthServiceLazy = lazy { MangaDexAuthService(baseHttpClient, headers, trackPreferences, mdList) } - - private val loginHelper = MangaDexLoginHelper(mangadexAuthServiceLazy, trackPreferences, mdList) + private val loginHelper = MangaDexLoginHelper(network.client, trackPreferences, mdList, mdList.interceptor) override val baseHttpClient: OkHttpClient = delegate.client.newBuilder() - .authenticator( - TokenAuthenticator(loginHelper), - ) + .addInterceptor(mdList.interceptor) .build() private fun dataSaver() = sourcePreferences.getBoolean(getDataSaverPreferenceKey(mdLang.lang), false) @@ -94,7 +89,9 @@ class MangaDex(delegate: HttpSource, val context: Context) : private val mangadexService by lazy { MangaDexService(client) } - private val mangadexAuthService by mangadexAuthServiceLazy + private val mangadexAuthService by lazy { + MangaDexAuthService(baseHttpClient, headers) + } private val similarService by lazy { SimilarService(client) } @@ -232,24 +229,12 @@ class MangaDex(delegate: HttpSource, val context: Context) : return mdList.getPassword() } - override suspend fun login( - username: String, - password: String, - twoFactorCode: String?, - ): Boolean { - val result = loginHelper.login(username, password) - return if (result) { - mdList.saveCredentials(username, password) - true - } else { - false - } + override suspend fun login(authCode: String): Boolean { + return loginHelper.login(authCode) } override suspend fun logout(): Boolean { - loginHelper.logout() - mdList.logout() - return true + return loginHelper.logout() } // FollowsSource methods diff --git a/app/src/main/java/exh/EXHMigrations.kt b/app/src/main/java/exh/EXHMigrations.kt index d7e1cae40..cf81a6dcc 100644 --- a/app/src/main/java/exh/EXHMigrations.kt +++ b/app/src/main/java/exh/EXHMigrations.kt @@ -510,6 +510,11 @@ object EXHMigrations { } } } + if (oldVersion under 45) { + // Force MangaDex log out due to login flow change + val trackManager = Injekt.get() + trackManager.mdList.logout() + } // if (oldVersion under 1) { } (1 is current release version) // do stuff here when releasing changed crap diff --git a/app/src/main/java/exh/md/MangaDexLoginActivity.kt b/app/src/main/java/exh/md/MangaDexLoginActivity.kt new file mode 100644 index 000000000..28f540209 --- /dev/null +++ b/app/src/main/java/exh/md/MangaDexLoginActivity.kt @@ -0,0 +1,27 @@ +package exh.md + +import android.net.Uri +import androidx.lifecycle.lifecycleScope +import eu.kanade.tachiyomi.ui.setting.track.BaseOAuthLoginActivity +import eu.kanade.tachiyomi.util.lang.launchIO +import exh.md.utils.MdUtil +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class MangaDexLoginActivity : BaseOAuthLoginActivity() { + + override fun handleResult(data: Uri?) { + val code = data?.getQueryParameter("code") + if (code != null) { + lifecycleScope.launchIO { + MdUtil.getEnabledMangaDex(Injekt.get())?.login(code) + returnToSettings() + } + } else { + lifecycleScope.launchIO { + MdUtil.getEnabledMangaDex(Injekt.get())?.logout() + returnToSettings() + } + } + } +} diff --git a/app/src/main/java/exh/md/dto/AuthDto.kt b/app/src/main/java/exh/md/dto/AuthDto.kt deleted file mode 100644 index 85789b371..000000000 --- a/app/src/main/java/exh/md/dto/AuthDto.kt +++ /dev/null @@ -1,39 +0,0 @@ -package exh.md.dto - -import kotlinx.serialization.Serializable - -/** - * Login Request object for Dex Api - */ -@Serializable -data class LoginRequestDto(val username: String, val password: String) - -/** - * Response after login - */ -@Serializable -data class LoginResponseDto(val result: String, val token: LoginBodyTokenDto) - -/** - * Tokens for the logins - */ -@Serializable -data class LoginBodyTokenDto(val session: String, val refresh: String) - -/** - * Response after logout - */ -@Serializable -data class LogoutDto(val result: String) - -/** - * Check if session token is valid - */ -@Serializable -data class CheckTokenDto(val isAuthenticated: Boolean) - -/** - * Request to refresh token - */ -@Serializable -data class RefreshTokenDto(val token: String) diff --git a/app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt b/app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt new file mode 100644 index 000000000..4e6d87a99 --- /dev/null +++ b/app/src/main/java/exh/md/network/MangaDexAuthInterceptor.kt @@ -0,0 +1,96 @@ +package exh.md.network + +import eu.kanade.domain.track.service.TrackPreferences +import eu.kanade.tachiyomi.data.track.mdlist.MdList +import eu.kanade.tachiyomi.data.track.myanimelist.OAuth +import eu.kanade.tachiyomi.data.track.myanimelist.isExpired +import eu.kanade.tachiyomi.network.parseAs +import exh.md.utils.MdUtil +import exh.util.nullIfBlank +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + +class MangaDexAuthInterceptor( + private val trackPreferences: TrackPreferences, + private val mdList: MdList, +) : Interceptor { + + var token = trackPreferences.trackToken(mdList).get().nullIfBlank() + + private var oauth: OAuth? = null + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + if (token.isNullOrEmpty()) { + return chain.proceed(originalRequest) + } + if (oauth == null) { + oauth = MdUtil.loadOAuth(trackPreferences, mdList) + } + // Refresh access token if expired + if (oauth != null && oauth!!.isExpired()) { + setAuth(refreshToken(chain)) + } + + if (oauth == null) { + throw IOException("No authentication token") + } + + // Add the authorization header to the original request + val authRequest = originalRequest.newBuilder() + .addHeader("Authorization", "Bearer ${oauth!!.access_token}") + .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 + } + + /** + * Called when the user authenticates with MangaDex for the first time. Sets the refresh token + * and the oauth object. + */ + fun setAuth(oauth: OAuth?) { + token = oauth?.access_token + this.oauth = oauth + MdUtil.saveOAuth(trackPreferences, mdList, oauth) + } + + private fun refreshToken(chain: Interceptor.Chain): OAuth { + val newOauth = runCatching { + val oauthResponse = chain.proceed(MdUtil.refreshTokenRequest(oauth!!)) + + if (oauthResponse.isSuccessful) { + oauthResponse.parseAs() + } else { + oauthResponse.close() + null + } + } + + if (newOauth.getOrNull() == null) { + throw IOException("Failed to refresh the access token", newOauth.exceptionOrNull()) + } + + return newOauth.getOrNull()!! + } +} diff --git a/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt b/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt index ff9e3e524..3404cd1a2 100644 --- a/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt +++ b/app/src/main/java/exh/md/network/MangaDexLoginHelper.kt @@ -2,88 +2,88 @@ package exh.md.network import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.tachiyomi.data.track.mdlist.MdList -import eu.kanade.tachiyomi.util.lang.withIOContext -import exh.log.xLogE -import exh.log.xLogI -import exh.md.dto.LoginRequestDto -import exh.md.dto.RefreshTokenDto -import exh.md.service.MangaDexAuthService +import eu.kanade.tachiyomi.data.track.myanimelist.OAuth +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.util.system.logcat +import exh.md.utils.MdApi +import exh.md.utils.MdConstants import exh.md.utils.MdUtil -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.time.Duration.Companion.seconds +import logcat.LogPriority +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.OkHttpClient -class MangaDexLoginHelper(authServiceLazy: Lazy, val preferences: TrackPreferences, val mdList: MdList) { - private val authService by authServiceLazy - suspend fun isAuthenticated(): Boolean { - return runCatching { authService.checkToken().isAuthenticated } - .getOrElse { e -> - xLogE("error authenticating", e) +class MangaDexLoginHelper( + private val client: OkHttpClient, + private val preferences: TrackPreferences, + private val mdList: MdList, + private val mangaDexAuthInterceptor: MangaDexAuthInterceptor, +) { + + /** + * Login given the generated authorization code + */ + suspend fun login(authorizationCode: String): Boolean { + val loginFormBody = FormBody.Builder() + .add("client_id", MdConstants.Login.clientId) + .add("grant_type", MdConstants.Login.authorizationCode) + .add("code", authorizationCode) + .add("code_verifier", MdUtil.getPkceChallengeCode()) + .add("redirect_uri", MdConstants.Login.redirectUri) + .build() + + val error = kotlin.runCatching { + val data = client.newCall(POST(MdApi.baseAuthUrl + MdApi.token, body = loginFormBody)).await().parseAs() + mangaDexAuthInterceptor.setAuth(data) + }.exceptionOrNull() + + return when (error == null) { + true -> true + false -> { + logcat(LogPriority.ERROR, error) { "Error logging in" } + mdList.logout() false } + } } - suspend fun refreshToken(): Boolean { - val refreshToken = MdUtil.refreshToken(preferences, mdList) - if (refreshToken.isNullOrEmpty()) { - return false - } - val refresh = runCatching { - val jsonResponse = authService.refreshToken(RefreshTokenDto(refreshToken)) - MdUtil.updateLoginToken(jsonResponse.token, preferences, mdList) + suspend fun logout(): Boolean { + val oauth = MdUtil.loadOAuth(preferences, mdList) + val sessionToken = oauth?.access_token + val refreshToken = oauth?.refresh_token + if (refreshToken.isNullOrEmpty() || sessionToken.isNullOrEmpty()) { + mdList.logout() + return true } - val e = refresh.exceptionOrNull() - if (e is CancellationException) throw e + val formBody = FormBody.Builder() + .add("client_id", MdConstants.Login.clientId) + .add("refresh_token", refreshToken) + .add("redirect_uri", MdConstants.Login.redirectUri) + .build() - return refresh.isSuccess - } + val error = kotlin.runCatching { + client.newCall( + POST( + url = MdApi.baseAuthUrl + MdApi.logout, + headers = Headers.Builder().add("Authorization", "Bearer $sessionToken") + .build(), + body = formBody, + ), + ).await() + mdList.logout() + }.exceptionOrNull() - suspend fun login( - username: String, - password: String, - ): Boolean { - return withIOContext { - val loginRequest = LoginRequestDto(username, password) - val loginResult = runCatching { authService.login(loginRequest) } - .onFailure { this@MangaDexLoginHelper.xLogE("Error logging in", it) } - - val e = loginResult.exceptionOrNull() - if (e is CancellationException) throw e - - val loginResponseDto = loginResult.getOrNull() - if (loginResponseDto != null) { - MdUtil.updateLoginToken( - loginResponseDto.token, - preferences, - mdList, - ) + return when (error == null) { + true -> { + mangaDexAuthInterceptor.setAuth(null) true - } else { - false } - } - } - - suspend fun login(): Boolean { - val username = preferences.trackUsername(mdList).get() - val password = preferences.trackPassword(mdList).get() - if (username.isBlank() || password.isBlank()) { - xLogI("No username or password stored, can't login") - return false - } - return login(username, password) - } - - suspend fun logout() { - return withIOContext { - withTimeoutOrNull(10.seconds) { - runCatching { - authService.logout() - }.onFailure { - if (it is CancellationException) throw it - this@MangaDexLoginHelper.xLogE("Error logging out", it) - } + false -> { + logcat(LogPriority.ERROR, error) { "Error logging out" } + false } } } diff --git a/app/src/main/java/exh/md/network/TokenAuthenticator.kt b/app/src/main/java/exh/md/network/TokenAuthenticator.kt deleted file mode 100644 index 69d6619c0..000000000 --- a/app/src/main/java/exh/md/network/TokenAuthenticator.kt +++ /dev/null @@ -1,54 +0,0 @@ -package exh.md.network - -import exh.log.xLogI -import exh.md.utils.MdUtil -import kotlinx.coroutines.runBlocking -import okhttp3.Authenticator -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import java.io.IOException - -class TokenAuthenticator(private val loginHelper: MangaDexLoginHelper) : Authenticator { - override fun authenticate(route: Route?, response: Response): Request? { - xLogI("Detected Auth error ${response.code} on ${response.request.url}") - - val token = try { - refreshToken(loginHelper) - } catch (e: Exception) { - throw IOException(e) - } - return if (token != null) { - response.request.newBuilder().header("Authorization", token).build() - } else { - null - } - } - - @Synchronized - fun refreshToken(loginHelper: MangaDexLoginHelper): String? { - var validated = false - - runBlocking { - val checkToken = loginHelper.isAuthenticated() - - if (checkToken) { - this@TokenAuthenticator.xLogI("Token is valid, other thread must have refreshed it") - validated = true - } - if (validated.not()) { - this@TokenAuthenticator.xLogI("Token is invalid trying to refresh") - validated = loginHelper.refreshToken() - } - - if (validated.not()) { - this@TokenAuthenticator.xLogI("Did not refresh token, trying to login") - validated = loginHelper.login() - } - } - return when { - validated -> "Bearer ${MdUtil.sessionToken(loginHelper.preferences, loginHelper.mdList)!!}" - else -> null - } - } -} diff --git a/app/src/main/java/exh/md/service/MangaDexAuthService.kt b/app/src/main/java/exh/md/service/MangaDexAuthService.kt index 155486bcf..d95d2001c 100644 --- a/app/src/main/java/exh/md/service/MangaDexAuthService.kt +++ b/app/src/main/java/exh/md/service/MangaDexAuthService.kt @@ -1,27 +1,19 @@ package exh.md.service -import eu.kanade.domain.track.service.TrackPreferences -import eu.kanade.tachiyomi.data.track.mdlist.MdList import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.parseAs -import exh.md.dto.CheckTokenDto -import exh.md.dto.LoginRequestDto -import exh.md.dto.LoginResponseDto -import exh.md.dto.LogoutDto import exh.md.dto.MangaListDto import exh.md.dto.RatingDto import exh.md.dto.RatingResponseDto import exh.md.dto.ReadChapterDto import exh.md.dto.ReadingStatusDto import exh.md.dto.ReadingStatusMapDto -import exh.md.dto.RefreshTokenDto import exh.md.dto.ResultDto import exh.md.utils.MdApi import exh.md.utils.MdConstants import exh.md.utils.MdUtil -import okhttp3.Authenticator import okhttp3.CacheControl import okhttp3.Headers import okhttp3.HttpUrl.Companion.toHttpUrl @@ -31,65 +23,13 @@ import okhttp3.Request class MangaDexAuthService( private val client: OkHttpClient, private val headers: Headers, - private val preferences: TrackPreferences, - private val mdList: MdList, ) { - private val noAuthenticatorClient = client.newBuilder() - .authenticator(Authenticator.NONE) - .build() - - fun getHeaders() = MdUtil.getAuthHeaders( - headers, - preferences, - mdList, - ) - - suspend fun login(request: LoginRequestDto): LoginResponseDto { - return noAuthenticatorClient.newCall( - POST( - MdApi.login, - body = MdUtil.encodeToBody(request), - cache = CacheControl.FORCE_NETWORK, - ), - ).await().parseAs(MdUtil.jsonParser) - } - - suspend fun logout(): LogoutDto { - return client.newCall( - POST( - MdApi.logout, - getHeaders(), - cache = CacheControl.FORCE_NETWORK, - ), - ).await().parseAs(MdUtil.jsonParser) - } - - suspend fun checkToken(): CheckTokenDto { - return noAuthenticatorClient.newCall( - GET( - MdApi.checkToken, - getHeaders(), - CacheControl.FORCE_NETWORK, - ), - ).await().parseAs(MdUtil.jsonParser) - } - - suspend fun refreshToken(request: RefreshTokenDto): LoginResponseDto { - return noAuthenticatorClient.newCall( - POST( - MdApi.refreshToken, - getHeaders(), - body = MdUtil.encodeToBody(request), - cache = CacheControl.FORCE_NETWORK, - ), - ).await().parseAs(MdUtil.jsonParser) - } suspend fun userFollowList(offset: Int): MangaListDto { return client.newCall( GET( "${MdApi.userFollows}?limit=100&offset=$offset&includes[]=${MdConstants.Types.coverArt}", - getHeaders(), + headers, CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) @@ -99,7 +39,7 @@ class MangaDexAuthService( return client.newCall( GET( "${MdApi.manga}/$mangaId/status", - getHeaders(), + headers, CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) @@ -109,7 +49,7 @@ class MangaDexAuthService( return client.newCall( GET( "${MdApi.manga}/$mangaId/read", - getHeaders(), + headers, CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) @@ -122,7 +62,7 @@ class MangaDexAuthService( return client.newCall( POST( "${MdApi.manga}/$mangaId/status", - getHeaders(), + headers, body = MdUtil.encodeToBody(readingStatusDto), cache = CacheControl.FORCE_NETWORK, ), @@ -133,7 +73,7 @@ class MangaDexAuthService( return client.newCall( GET( MdApi.readingStatusForAllManga, - getHeaders(), + headers, cache = CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) @@ -143,7 +83,7 @@ class MangaDexAuthService( return client.newCall( GET( "${MdApi.readingStatusForAllManga}?status=$status", - getHeaders(), + headers, cache = CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) @@ -153,7 +93,7 @@ class MangaDexAuthService( return client.newCall( POST( "${MdApi.chapter}/$chapterId/read", - getHeaders(), + headers, cache = CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) @@ -164,7 +104,7 @@ class MangaDexAuthService( Request.Builder() .url("${MdApi.chapter}/$chapterId/read") .delete() - .headers(getHeaders()) + .headers(headers) .cacheControl(CacheControl.FORCE_NETWORK) .build(), ).await().parseAs(MdUtil.jsonParser) @@ -174,7 +114,7 @@ class MangaDexAuthService( return client.newCall( POST( "${MdApi.manga}/$mangaId/follow", - getHeaders(), + headers, cache = CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) @@ -185,7 +125,7 @@ class MangaDexAuthService( Request.Builder() .url("${MdApi.manga}/$mangaId/follow") .delete() - .headers(getHeaders()) + .headers(headers) .cacheControl(CacheControl.FORCE_NETWORK) .build(), ).await().parseAs(MdUtil.jsonParser) @@ -195,7 +135,7 @@ class MangaDexAuthService( return client.newCall( POST( "${MdApi.rating}/$mangaId", - getHeaders(), + headers, body = MdUtil.encodeToBody(RatingDto(rating)), cache = CacheControl.FORCE_NETWORK, ), @@ -207,7 +147,7 @@ class MangaDexAuthService( Request.Builder() .delete() .url("${MdApi.rating}/$mangaId") - .headers(getHeaders()) + .headers(headers) .cacheControl(CacheControl.FORCE_NETWORK) .build(), ).await().parseAs(MdUtil.jsonParser) @@ -224,7 +164,7 @@ class MangaDexAuthService( } } .build(), - getHeaders(), + headers, cache = CacheControl.FORCE_NETWORK, ), ).await().parseAs(MdUtil.jsonParser) diff --git a/app/src/main/java/exh/md/utils/MdApi.kt b/app/src/main/java/exh/md/utils/MdApi.kt index e7f1e94d4..1e441d7e0 100644 --- a/app/src/main/java/exh/md/utils/MdApi.kt +++ b/app/src/main/java/exh/md/utils/MdApi.kt @@ -2,10 +2,6 @@ package exh.md.utils object MdApi { const val baseUrl = "https://api.mangadex.org" - const val login = "$baseUrl/auth/login" - const val checkToken = "$baseUrl/auth/check" - const val refreshToken = "$baseUrl/auth/refresh" - const val logout = "$baseUrl/auth/logout" const val manga = "$baseUrl/manga" const val chapter = "$baseUrl/chapter" const val group = "$baseUrl/group" @@ -18,4 +14,11 @@ object MdApi { const val atHomeServer = "$baseUrl/at-home/server" const val legacyMapping = "$baseUrl/legacy/mapping" + + const val baseAuthUrl = "https://auth.mangadex.org" + private const val auth = "/realms/mangadex/protocol/openid-connect" + const val login = "$auth/auth" + const val logout = "$auth/logout" + const val token = "$auth/token" + const val userInfo = "$auth/userinfo" } diff --git a/app/src/main/java/exh/md/utils/MdConstants.kt b/app/src/main/java/exh/md/utils/MdConstants.kt index eabacdd72..8a84284dd 100644 --- a/app/src/main/java/exh/md/utils/MdConstants.kt +++ b/app/src/main/java/exh/md/utils/MdConstants.kt @@ -1,5 +1,8 @@ package exh.md.utils +import android.util.Base64 +import androidx.core.net.toUri +import java.security.MessageDigest import kotlin.time.Duration.Companion.minutes object MdConstants { @@ -16,4 +19,28 @@ object MdConstants { } val mdAtHomeTokenLifespan = 5.minutes.inWholeMilliseconds + + object Login { + const val redirectUri = "tachiyomisy://mangadex-auth" + const val clientId = "tachiyomisy" + const val authorizationCode = "authorization_code" + const val refreshToken = "refresh_token" + + fun authUrl(codeVerifier: String): String { + val bytes = codeVerifier.toByteArray() + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(bytes) + val digest = messageDigest.digest() + val encoding = Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP + val codeChallenge = Base64.encodeToString(digest, encoding) + + return (MdApi.baseAuthUrl + MdApi.login).toUri().buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("response_type", "code") + .appendQueryParameter("redirect_uri", redirectUri) + .appendQueryParameter("code_challenge", codeChallenge) + .appendQueryParameter("code_challenge_method", "S256") + .build().toString() + } + } } diff --git a/app/src/main/java/exh/md/utils/MdUtil.kt b/app/src/main/java/exh/md/utils/MdUtil.kt index 2fda6c309..1b71d27be 100644 --- a/app/src/main/java/exh/md/utils/MdUtil.kt +++ b/app/src/main/java/exh/md/utils/MdUtil.kt @@ -4,26 +4,27 @@ import eu.kanade.domain.UnsortedPreferences import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.tachiyomi.data.track.mdlist.MdList +import eu.kanade.tachiyomi.data.track.myanimelist.OAuth +import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.all.MangaDex -import exh.log.xLogD -import exh.md.dto.LoginBodyTokenDto +import eu.kanade.tachiyomi.util.PkceUtil import exh.md.dto.MangaAttributesDto import exh.md.dto.MangaDataDto import exh.md.network.NoSessionException import exh.source.getMainSource import exh.util.dropBlank import exh.util.floor -import exh.util.nullIfBlank import exh.util.nullIfZero -import kotlinx.serialization.SerializationException import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import okhttp3.FormBody import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Request import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody import org.jsoup.parser.Parser @@ -190,32 +191,57 @@ class MdUtil { return "$cdnUrl/covers/$dexId/$fileName" } - fun getLoginBody(preferences: TrackPreferences, mdList: MdList) = preferences.trackToken(mdList) - .get() - .nullIfBlank() - ?.let { - try { - jsonParser.decodeFromString(it) - } catch (e: SerializationException) { - xLogD("Unable to load login body") - null - } + fun saveOAuth(preferences: TrackPreferences, mdList: MdList, oAuth: OAuth?) { + if (oAuth == null) { + preferences.trackToken(mdList).delete() + } else { + preferences.trackToken(mdList).set(jsonParser.encodeToString(oAuth)) } - - fun sessionToken(preferences: TrackPreferences, mdList: MdList) = getLoginBody(preferences, mdList)?.session - - fun refreshToken(preferences: TrackPreferences, mdList: MdList) = getLoginBody(preferences, mdList)?.refresh - - fun updateLoginToken(token: LoginBodyTokenDto, preferences: TrackPreferences, mdList: MdList) { - preferences.trackToken(mdList).set(jsonParser.encodeToString(token)) } + fun loadOAuth(preferences: TrackPreferences, mdList: MdList): OAuth? { + return try { + jsonParser.decodeFromString(preferences.trackToken(mdList).get()) + } catch (e: Exception) { + null + } + } + + fun sessionToken(preferences: TrackPreferences, mdList: MdList) = loadOAuth(preferences, mdList)?.access_token + + fun refreshToken(preferences: TrackPreferences, mdList: MdList) = loadOAuth(preferences, mdList)?.refresh_token + fun getAuthHeaders(headers: Headers, preferences: TrackPreferences, mdList: MdList) = headers.newBuilder().add( "Authorization", "Bearer " + (sessionToken(preferences, mdList) ?: throw NoSessionException()), ).build() + private var codeVerifier: String? = null + + fun refreshTokenRequest(oauth: OAuth): Request { + val formBody = FormBody.Builder() + .add("client_id", MdConstants.Login.clientId) + .add("grant_type", MdConstants.Login.refreshToken) + .add("refresh_token", oauth.refresh_token) + .add("code_verifier", getPkceChallengeCode()) + .add("redirect_uri", MdConstants.Login.redirectUri) + .build() + + // Add the Authorization header manually as this particular + // request is called by the interceptor itself so it doesn't reach + // the part where the token is added automatically. + val headers = Headers.Builder() + .add("Authorization", "Bearer ${oauth.access_token}") + .build() + + return POST(MdApi.baseAuthUrl + MdApi.token, body = formBody, headers = headers) + } + + fun getPkceChallengeCode(): String { + return codeVerifier ?: PkceUtil.generateCodeVerifier().also { codeVerifier = it } + } + fun getEnabledMangaDex(preferences: UnsortedPreferences, sourcePreferences: SourcePreferences = Injekt.get(), sourceManager: SourceManager = Injekt.get()): MangaDex? { return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs -> preferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero() diff --git a/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt b/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt index 363fa67dd..f5c7e0c10 100644 --- a/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt +++ b/source-api/src/main/java/eu/kanade/tachiyomi/source/online/LoginSource.kt @@ -13,7 +13,9 @@ interface LoginSource : Source { fun getPassword(): String - suspend fun login(username: String, password: String, twoFactorCode: String?): Boolean + suspend fun login(username: String, password: String, twoFactorCode: String?): Boolean = false + + suspend fun login(authCode: String): Boolean = false suspend fun logout(): Boolean