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
+5
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
@@ -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()
}
}
}
}
-39
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)
@@ -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()!!
}
}
@@ -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
}
}
}
@@ -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
}
}
}
@@ -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)
+7 -4
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"
}
@@ -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()
}
}
}
+47 -21
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()