MangaDex OAuth

Co-authored-by: Carlos <2092019+CarlosEsco@users.noreply.github.com>
This commit is contained in:
Jobobby04 2022-12-20 13:34:01 -05:00
parent 54c9ef51a6
commit 708b868e7b
16 changed files with 339 additions and 435 deletions

View File

@ -31,7 +31,7 @@ android {
applicationId = "eu.kanade.tachiyomi.sy" applicationId = "eu.kanade.tachiyomi.sy"
minSdk = AndroidConfig.minSdk minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk targetSdk = AndroidConfig.targetSdk
versionCode = 44 versionCode = 45
versionName = "1.8.5" versionName = "1.8.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"") buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View File

@ -381,6 +381,22 @@
<data android:pathPattern="/chapter/..*" /> <data android:pathPattern="/chapter/..*" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name="exh.md.MangaDexLoginActivity"
android:label="MangaDexLogin"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="mangadex-auth"
android:scheme="tachiyomisy" />
</intent-filter>
</activity>
</application> </application>
</manifest> </manifest>

View File

@ -2,25 +2,15 @@ package eu.kanade.presentation.more.settings.screen
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll 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.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox import androidx.compose.material3.Checkbox
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -35,15 +25,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.UnsortedPreferences
import eu.kanade.domain.source.service.SourcePreferences 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.more.settings.Preference
import eu.kanade.presentation.util.collectAsState
import eu.kanade.presentation.util.padding import eu.kanade.presentation.util.padding
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.library.LibraryUpdateService 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.launchIO
import eu.kanade.tachiyomi.util.lang.withUIContext import eu.kanade.tachiyomi.util.lang.withUIContext
import eu.kanade.tachiyomi.util.system.logcat import eu.kanade.tachiyomi.util.system.logcat
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast import eu.kanade.tachiyomi.util.system.toast
import exh.log.xLogW import exh.md.utils.MdConstants
import exh.md.utils.MdUtil import exh.md.utils.MdUtil
import logcat.LogPriority import logcat.LogPriority
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -71,134 +58,17 @@ object SettingsMangadexScreen : SearchableSettings {
override fun getPreferences(): List<Preference> { override fun getPreferences(): List<Preference> {
val sourcePreferences: SourcePreferences = remember { Injekt.get() } val sourcePreferences: SourcePreferences = remember { Injekt.get() }
val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() } val unsortedPreferences: UnsortedPreferences = remember { Injekt.get() }
val trackPreferences: TrackPreferences = remember { Injekt.get() }
val mdex = remember { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences) } ?: return emptyList() val mdex = remember { MdUtil.getEnabledMangaDex(unsortedPreferences, sourcePreferences) } ?: return emptyList()
return listOf( return listOf(
loginPreference(mdex), loginPreference(mdex, trackPreferences),
preferredMangaDexId(unsortedPreferences, sourcePreferences), preferredMangaDexId(unsortedPreferences, sourcePreferences),
syncMangaDexIntoThis(unsortedPreferences), syncMangaDexIntoThis(unsortedPreferences),
syncLibraryToMangaDex(), 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 @Composable
fun LogoutDialog( fun LogoutDialog(
onDismissRequest: () -> Unit, onDismissRequest: () -> Unit,
@ -223,18 +93,10 @@ object SettingsMangadexScreen : SearchableSettings {
} }
@Composable @Composable
fun loginPreference(mdex: MangaDex): Preference.PreferenceItem.MangaDexPreference { fun loginPreference(mdex: MangaDex, trackPreferences: TrackPreferences): Preference.PreferenceItem.MangaDexPreference {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var loggedIn by remember { mutableStateOf(mdex.isLogged()) } val loggedIn by remember { trackPreferences.trackToken(mdex.mdList) }.collectAsState()
var loginDialogOpen by remember { mutableStateOf(false) }
if (loginDialogOpen) {
LoginDialog(
mdex = mdex,
onDismissRequest = { loginDialogOpen = false },
onLoginSuccess = { loggedIn = true },
)
}
var logoutDialogOpen by remember { mutableStateOf(false) } var logoutDialogOpen by remember { mutableStateOf(false) }
if (logoutDialogOpen) { if (logoutDialogOpen) {
LogoutDialog( LogoutDialog(
@ -244,7 +106,6 @@ object SettingsMangadexScreen : SearchableSettings {
scope.launchIO { scope.launchIO {
try { try {
if (mdex.logout()) { if (mdex.logout()) {
loggedIn = false
withUIContext { withUIContext {
context.toast(R.string.logout_success) context.toast(R.string.logout_success)
} }
@ -265,9 +126,12 @@ object SettingsMangadexScreen : SearchableSettings {
} }
return Preference.PreferenceItem.MangaDexPreference( return Preference.PreferenceItem.MangaDexPreference(
title = mdex.name + " Login", title = mdex.name + " Login",
loggedIn = loggedIn, loggedIn = loggedIn.isNotEmpty(),
login = { login = {
loginDialogOpen = true context.openInBrowser(
MdConstants.Login.authUrl(MdUtil.getPkceChallengeCode()),
forceDefaultBrowser = true,
)
}, },
logout = { logout = {
logoutDialogOpen = true logoutDialogOpen = true

View File

@ -14,6 +14,7 @@ import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.util.lang.awaitSingle import eu.kanade.tachiyomi.util.lang.awaitSingle
import eu.kanade.tachiyomi.util.lang.runAsObservable import eu.kanade.tachiyomi.util.lang.runAsObservable
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.util.lang.withIOContext
import exh.md.network.MangaDexAuthInterceptor
import exh.md.utils.FollowStatus import exh.md.utils.FollowStatus
import exh.md.utils.MdUtil import exh.md.utils.MdUtil
import uy.kohesive.injekt.Injekt 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()) } private val mdex by lazy { MdUtil.getEnabledMangaDex(Injekt.get()) }
val interceptor = MangaDexAuthInterceptor(trackPreferences, this)
@StringRes @StringRes
override fun nameRes(): Int = R.string.mdlist 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() trackPreferences.trackToken(this).delete()
} }
override val isLogged: Boolean
get() = trackPreferences.trackToken(this).get().isNotEmpty()
class MangaDexNotFoundException : Exception("Mangadex not enabled") class MangaDexNotFoundException : Exception("Mangadex not enabled")
} }

View File

@ -34,7 +34,6 @@ import exh.md.handlers.MangaPlusHandler
import exh.md.handlers.PageHandler import exh.md.handlers.PageHandler
import exh.md.handlers.SimilarHandler import exh.md.handlers.SimilarHandler
import exh.md.network.MangaDexLoginHelper import exh.md.network.MangaDexLoginHelper
import exh.md.network.TokenAuthenticator
import exh.md.service.MangaDexAuthService import exh.md.service.MangaDexAuthService
import exh.md.service.MangaDexService import exh.md.service.MangaDexService
import exh.md.service.SimilarService import exh.md.service.SimilarService
@ -76,14 +75,10 @@ class MangaDex(delegate: HttpSource, val context: Context) :
context.getSharedPreferences("source_$id", 0x0000) context.getSharedPreferences("source_$id", 0x0000)
} }
private val mangadexAuthServiceLazy = lazy { MangaDexAuthService(baseHttpClient, headers, trackPreferences, mdList) } private val loginHelper = MangaDexLoginHelper(network.client, trackPreferences, mdList, mdList.interceptor)
private val loginHelper = MangaDexLoginHelper(mangadexAuthServiceLazy, trackPreferences, mdList)
override val baseHttpClient: OkHttpClient = delegate.client.newBuilder() override val baseHttpClient: OkHttpClient = delegate.client.newBuilder()
.authenticator( .addInterceptor(mdList.interceptor)
TokenAuthenticator(loginHelper),
)
.build() .build()
private fun dataSaver() = sourcePreferences.getBoolean(getDataSaverPreferenceKey(mdLang.lang), false) 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 { private val mangadexService by lazy {
MangaDexService(client) MangaDexService(client)
} }
private val mangadexAuthService by mangadexAuthServiceLazy private val mangadexAuthService by lazy {
MangaDexAuthService(baseHttpClient, headers)
}
private val similarService by lazy { private val similarService by lazy {
SimilarService(client) SimilarService(client)
} }
@ -232,24 +229,12 @@ class MangaDex(delegate: HttpSource, val context: Context) :
return mdList.getPassword() return mdList.getPassword()
} }
override suspend fun login( override suspend fun login(authCode: String): Boolean {
username: String, return loginHelper.login(authCode)
password: String,
twoFactorCode: String?,
): Boolean {
val result = loginHelper.login(username, password)
return if (result) {
mdList.saveCredentials(username, password)
true
} else {
false
}
} }
override suspend fun logout(): Boolean { override suspend fun logout(): Boolean {
loginHelper.logout() return loginHelper.logout()
mdList.logout()
return true
} }
// FollowsSource methods // FollowsSource methods

View File

@ -510,6 +510,11 @@ object EXHMigrations {
} }
} }
} }
if (oldVersion under 45) {
// Force MangaDex log out due to login flow change
val trackManager = Injekt.get<TrackManager>()
trackManager.mdList.logout()
}
// if (oldVersion under 1) { } (1 is current release version) // if (oldVersion under 1) { } (1 is current release version)
// do stuff here when releasing changed crap // do stuff here when releasing changed crap

View File

@ -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()
}
}
}
}

View File

@ -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)

View File

@ -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<OAuth>()
} else {
oauthResponse.close()
null
}
}
if (newOauth.getOrNull() == null) {
throw IOException("Failed to refresh the access token", newOauth.exceptionOrNull())
}
return newOauth.getOrNull()!!
}
}

View File

@ -2,88 +2,88 @@ package exh.md.network
import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.track.mdlist.MdList import eu.kanade.tachiyomi.data.track.mdlist.MdList
import eu.kanade.tachiyomi.util.lang.withIOContext import eu.kanade.tachiyomi.data.track.myanimelist.OAuth
import exh.log.xLogE import eu.kanade.tachiyomi.network.POST
import exh.log.xLogI import eu.kanade.tachiyomi.network.await
import exh.md.dto.LoginRequestDto import eu.kanade.tachiyomi.network.parseAs
import exh.md.dto.RefreshTokenDto import eu.kanade.tachiyomi.util.system.logcat
import exh.md.service.MangaDexAuthService import exh.md.utils.MdApi
import exh.md.utils.MdConstants
import exh.md.utils.MdUtil import exh.md.utils.MdUtil
import kotlinx.coroutines.CancellationException import logcat.LogPriority
import kotlinx.coroutines.withTimeoutOrNull import okhttp3.FormBody
import kotlin.time.Duration.Companion.seconds import okhttp3.Headers
import okhttp3.OkHttpClient
class MangaDexLoginHelper(authServiceLazy: Lazy<MangaDexAuthService>, val preferences: TrackPreferences, val mdList: MdList) { class MangaDexLoginHelper(
private val authService by authServiceLazy private val client: OkHttpClient,
suspend fun isAuthenticated(): Boolean { private val preferences: TrackPreferences,
return runCatching { authService.checkToken().isAuthenticated } private val mdList: MdList,
.getOrElse { e -> private val mangaDexAuthInterceptor: MangaDexAuthInterceptor,
xLogE("error authenticating", e) ) {
/**
* 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<OAuth>()
mangaDexAuthInterceptor.setAuth(data)
}.exceptionOrNull()
return when (error == null) {
true -> true
false -> {
logcat(LogPriority.ERROR, error) { "Error logging in" }
mdList.logout()
false false
} }
}
} }
suspend fun refreshToken(): Boolean { suspend fun logout(): Boolean {
val refreshToken = MdUtil.refreshToken(preferences, mdList) val oauth = MdUtil.loadOAuth(preferences, mdList)
if (refreshToken.isNullOrEmpty()) { val sessionToken = oauth?.access_token
return false val refreshToken = oauth?.refresh_token
} if (refreshToken.isNullOrEmpty() || sessionToken.isNullOrEmpty()) {
val refresh = runCatching { mdList.logout()
val jsonResponse = authService.refreshToken(RefreshTokenDto(refreshToken)) return true
MdUtil.updateLoginToken(jsonResponse.token, preferences, mdList)
} }
val e = refresh.exceptionOrNull() val formBody = FormBody.Builder()
if (e is CancellationException) throw e .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( return when (error == null) {
username: String, true -> {
password: String, mangaDexAuthInterceptor.setAuth(null)
): 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,
)
true true
} else {
false
} }
} false -> {
} logcat(LogPriority.ERROR, error) { "Error logging out" }
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)
}
} }
} }
} }

View File

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

View File

@ -1,27 +1,19 @@
package exh.md.service 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.GET
import eu.kanade.tachiyomi.network.POST import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.parseAs 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.MangaListDto
import exh.md.dto.RatingDto import exh.md.dto.RatingDto
import exh.md.dto.RatingResponseDto import exh.md.dto.RatingResponseDto
import exh.md.dto.ReadChapterDto import exh.md.dto.ReadChapterDto
import exh.md.dto.ReadingStatusDto import exh.md.dto.ReadingStatusDto
import exh.md.dto.ReadingStatusMapDto import exh.md.dto.ReadingStatusMapDto
import exh.md.dto.RefreshTokenDto
import exh.md.dto.ResultDto import exh.md.dto.ResultDto
import exh.md.utils.MdApi import exh.md.utils.MdApi
import exh.md.utils.MdConstants import exh.md.utils.MdConstants
import exh.md.utils.MdUtil import exh.md.utils.MdUtil
import okhttp3.Authenticator
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
@ -31,65 +23,13 @@ import okhttp3.Request
class MangaDexAuthService( class MangaDexAuthService(
private val client: OkHttpClient, private val client: OkHttpClient,
private val headers: Headers, 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 { suspend fun userFollowList(offset: Int): MangaListDto {
return client.newCall( return client.newCall(
GET( GET(
"${MdApi.userFollows}?limit=100&offset=$offset&includes[]=${MdConstants.Types.coverArt}", "${MdApi.userFollows}?limit=100&offset=$offset&includes[]=${MdConstants.Types.coverArt}",
getHeaders(), headers,
CacheControl.FORCE_NETWORK, CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -99,7 +39,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
GET( GET(
"${MdApi.manga}/$mangaId/status", "${MdApi.manga}/$mangaId/status",
getHeaders(), headers,
CacheControl.FORCE_NETWORK, CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -109,7 +49,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
GET( GET(
"${MdApi.manga}/$mangaId/read", "${MdApi.manga}/$mangaId/read",
getHeaders(), headers,
CacheControl.FORCE_NETWORK, CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -122,7 +62,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
POST( POST(
"${MdApi.manga}/$mangaId/status", "${MdApi.manga}/$mangaId/status",
getHeaders(), headers,
body = MdUtil.encodeToBody(readingStatusDto), body = MdUtil.encodeToBody(readingStatusDto),
cache = CacheControl.FORCE_NETWORK, cache = CacheControl.FORCE_NETWORK,
), ),
@ -133,7 +73,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
GET( GET(
MdApi.readingStatusForAllManga, MdApi.readingStatusForAllManga,
getHeaders(), headers,
cache = CacheControl.FORCE_NETWORK, cache = CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -143,7 +83,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
GET( GET(
"${MdApi.readingStatusForAllManga}?status=$status", "${MdApi.readingStatusForAllManga}?status=$status",
getHeaders(), headers,
cache = CacheControl.FORCE_NETWORK, cache = CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -153,7 +93,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
POST( POST(
"${MdApi.chapter}/$chapterId/read", "${MdApi.chapter}/$chapterId/read",
getHeaders(), headers,
cache = CacheControl.FORCE_NETWORK, cache = CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -164,7 +104,7 @@ class MangaDexAuthService(
Request.Builder() Request.Builder()
.url("${MdApi.chapter}/$chapterId/read") .url("${MdApi.chapter}/$chapterId/read")
.delete() .delete()
.headers(getHeaders()) .headers(headers)
.cacheControl(CacheControl.FORCE_NETWORK) .cacheControl(CacheControl.FORCE_NETWORK)
.build(), .build(),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -174,7 +114,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
POST( POST(
"${MdApi.manga}/$mangaId/follow", "${MdApi.manga}/$mangaId/follow",
getHeaders(), headers,
cache = CacheControl.FORCE_NETWORK, cache = CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -185,7 +125,7 @@ class MangaDexAuthService(
Request.Builder() Request.Builder()
.url("${MdApi.manga}/$mangaId/follow") .url("${MdApi.manga}/$mangaId/follow")
.delete() .delete()
.headers(getHeaders()) .headers(headers)
.cacheControl(CacheControl.FORCE_NETWORK) .cacheControl(CacheControl.FORCE_NETWORK)
.build(), .build(),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -195,7 +135,7 @@ class MangaDexAuthService(
return client.newCall( return client.newCall(
POST( POST(
"${MdApi.rating}/$mangaId", "${MdApi.rating}/$mangaId",
getHeaders(), headers,
body = MdUtil.encodeToBody(RatingDto(rating)), body = MdUtil.encodeToBody(RatingDto(rating)),
cache = CacheControl.FORCE_NETWORK, cache = CacheControl.FORCE_NETWORK,
), ),
@ -207,7 +147,7 @@ class MangaDexAuthService(
Request.Builder() Request.Builder()
.delete() .delete()
.url("${MdApi.rating}/$mangaId") .url("${MdApi.rating}/$mangaId")
.headers(getHeaders()) .headers(headers)
.cacheControl(CacheControl.FORCE_NETWORK) .cacheControl(CacheControl.FORCE_NETWORK)
.build(), .build(),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)
@ -224,7 +164,7 @@ class MangaDexAuthService(
} }
} }
.build(), .build(),
getHeaders(), headers,
cache = CacheControl.FORCE_NETWORK, cache = CacheControl.FORCE_NETWORK,
), ),
).await().parseAs(MdUtil.jsonParser) ).await().parseAs(MdUtil.jsonParser)

View File

@ -2,10 +2,6 @@ package exh.md.utils
object MdApi { object MdApi {
const val baseUrl = "https://api.mangadex.org" 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 manga = "$baseUrl/manga"
const val chapter = "$baseUrl/chapter" const val chapter = "$baseUrl/chapter"
const val group = "$baseUrl/group" const val group = "$baseUrl/group"
@ -18,4 +14,11 @@ object MdApi {
const val atHomeServer = "$baseUrl/at-home/server" const val atHomeServer = "$baseUrl/at-home/server"
const val legacyMapping = "$baseUrl/legacy/mapping" 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"
} }

View File

@ -1,5 +1,8 @@
package exh.md.utils package exh.md.utils
import android.util.Base64
import androidx.core.net.toUri
import java.security.MessageDigest
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
object MdConstants { object MdConstants {
@ -16,4 +19,28 @@ object MdConstants {
} }
val mdAtHomeTokenLifespan = 5.minutes.inWholeMilliseconds 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()
}
}
} }

View File

@ -4,26 +4,27 @@ import eu.kanade.domain.UnsortedPreferences
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.track.mdlist.MdList 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.SourceManager
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.all.MangaDex import eu.kanade.tachiyomi.source.online.all.MangaDex
import exh.log.xLogD import eu.kanade.tachiyomi.util.PkceUtil
import exh.md.dto.LoginBodyTokenDto
import exh.md.dto.MangaAttributesDto import exh.md.dto.MangaAttributesDto
import exh.md.dto.MangaDataDto import exh.md.dto.MangaDataDto
import exh.md.network.NoSessionException import exh.md.network.NoSessionException
import exh.source.getMainSource import exh.source.getMainSource
import exh.util.dropBlank import exh.util.dropBlank
import exh.util.floor import exh.util.floor
import exh.util.nullIfBlank
import exh.util.nullIfZero import exh.util.nullIfZero
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.FormBody
import okhttp3.Headers import okhttp3.Headers
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.jsoup.parser.Parser import org.jsoup.parser.Parser
@ -190,32 +191,57 @@ class MdUtil {
return "$cdnUrl/covers/$dexId/$fileName" return "$cdnUrl/covers/$dexId/$fileName"
} }
fun getLoginBody(preferences: TrackPreferences, mdList: MdList) = preferences.trackToken(mdList) fun saveOAuth(preferences: TrackPreferences, mdList: MdList, oAuth: OAuth?) {
.get() if (oAuth == null) {
.nullIfBlank() preferences.trackToken(mdList).delete()
?.let { } else {
try { preferences.trackToken(mdList).set(jsonParser.encodeToString(oAuth))
jsonParser.decodeFromString<LoginBodyTokenDto>(it)
} catch (e: SerializationException) {
xLogD("Unable to load login body")
null
}
} }
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<OAuth>(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) = fun getAuthHeaders(headers: Headers, preferences: TrackPreferences, mdList: MdList) =
headers.newBuilder().add( headers.newBuilder().add(
"Authorization", "Authorization",
"Bearer " + (sessionToken(preferences, mdList) ?: throw NoSessionException()), "Bearer " + (sessionToken(preferences, mdList) ?: throw NoSessionException()),
).build() ).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? { fun getEnabledMangaDex(preferences: UnsortedPreferences, sourcePreferences: SourcePreferences = Injekt.get(), sourceManager: SourceManager = Injekt.get()): MangaDex? {
return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs -> return getEnabledMangaDexs(sourcePreferences, sourceManager).let { mangadexs ->
preferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero() preferences.preferredMangaDexId().get().toLongOrNull()?.nullIfZero()

View File

@ -13,7 +13,9 @@ interface LoginSource : Source {
fun getPassword(): String 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 suspend fun logout(): Boolean