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"
minSdk = AndroidConfig.minSdk
targetSdk = AndroidConfig.targetSdk
versionCode = 44
versionCode = 45
versionName = "1.8.5"
buildConfigField("String", "COMMIT_COUNT", "\"${getCommitCount()}\"")

View File

@ -381,6 +381,22 @@
<data android:pathPattern="/chapter/..*" />
</intent-filter>
</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>
</manifest>

View File

@ -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<Preference> {
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

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.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")
}

View File

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

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)
// 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.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<MangaDexAuthService>, 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<OAuth>()
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
}
}
}

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

View File

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

View File

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

View File

@ -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<LoginBodyTokenDto>(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<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) =
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()

View File

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