From 908a931d4ae47daeda26a399ace2a7254eb3e646 Mon Sep 17 00:00:00 2001 From: Maciej Procyk Date: Mon, 4 Mar 2024 19:05:54 +0100 Subject: [PATCH] add OneDrive support --- .../kotlin/openotp/backup/DropboxService.kt | 4 +- .../kotlin/openotp/backup/OneDriveService.kt | 194 ++++++++++++++++++ .../component/UserLinkedAccountsModel.kt | 24 ++- .../dev/kotlin/openotp/ui/icons/OneDrive.kt | 109 ++++++++++ .../dev/kotlin/openotp/util/HttpClientUtil.kt | 2 +- .../commonMain/resources/MR/base/strings.xml | 1 + .../commonMain/resources/MR/pl/strings.xml | 1 + 7 files changed, 329 insertions(+), 6 deletions(-) create mode 100644 shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/OneDriveService.kt create mode 100644 shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OneDrive.kt diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/DropboxService.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/DropboxService.kt index 1009c98..a03cada 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/DropboxService.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/DropboxService.kt @@ -15,7 +15,7 @@ import kotlinx.serialization.json.Json import ml.dev.kotlin.openotp.component.UserLinkedAccountsModel import ml.dev.kotlin.openotp.util.createJsonHttpClient import ml.dev.kotlin.openotp.util.randomBytesChallenge -import ml.dev.kotlin.openotp.util.safHttpRequest +import ml.dev.kotlin.openotp.util.safeHttpRequest import ml.dev.kotlin.openotp.util.safeRequest import org.kotlincrypto.hash.sha2.SHA256 import kotlin.math.min @@ -99,7 +99,7 @@ sealed class DropboxService : OAuth2AccountService { override suspend fun downloadBackupData(): ByteArray? { val apiArg = DropboxJson.encodeToString(DropboxDownloadArg()) - return client.safHttpRequest { + return client.safeHttpRequest { method = HttpMethod.Post url("https://content.dropboxapi.com/2/files/download") header(HttpHeaders.Authorization, "Bearer ${refreshableAccessData.accessToken}") diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/OneDriveService.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/OneDriveService.kt new file mode 100644 index 0000000..ec67e7b --- /dev/null +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/backup/OneDriveService.kt @@ -0,0 +1,194 @@ +package ml.dev.kotlin.openotp.backup + +import io.ktor.client.* +import io.ktor.client.request.* +import io.ktor.client.request.forms.* +import io.ktor.client.statement.* +import io.ktor.http.* +import io.ktor.http.content.* +import io.ktor.util.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import ml.dev.kotlin.openotp.component.UserLinkedAccountsModel +import ml.dev.kotlin.openotp.util.createJsonHttpClient +import ml.dev.kotlin.openotp.util.randomBytesChallenge +import ml.dev.kotlin.openotp.util.safeHttpRequest +import ml.dev.kotlin.openotp.util.safeRequest +import org.kotlincrypto.hash.sha2.SHA256 +import kotlin.time.Duration.Companion.seconds + +sealed class OneDriveService : OAuth2AccountService { + + protected val client: HttpClient by lazy(::createJsonHttpClient) + + data object Initialized : OneDriveService(), OAuth2AccountService.Initialized { + override fun requestPermissions(): RequestedPermissions? { + val bytes = randomBytesChallenge(count = 32) ?: return null + val codeVerifier = bytes.oneDriveEncodeBase64() + val codeChallenge = SHA256().digest(codeVerifier.encodeToByteArray()).oneDriveEncodeBase64() + val accessData = OneDriveAccessData(codeVerifier, codeChallenge) + return RequestedPermissions(accessData) + } + } + + class RequestedPermissions( + private val accessData: OneDriveAccessData, + ) : OneDriveService(), OAuth2AccountService.RequestedPermissions { + + override fun generateVerifyUri(): String = + "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" + + "?client_id=$CLIENT_ID" + + "&response_type=code" + + "&redirect_uri=https%3A%2F%2Fopen-otp.procyk.in" + + "&response_mode=fragment" + + "&scope=offline_access+files.readwrite.all" + + "&code_challenge=${accessData.codeChallenge}" + + "&code_challenge_method=S256" + + override suspend fun authenticateUser(userCode: String): Result = + client.safeRequest { + method = HttpMethod.Post + url("https://login.microsoftonline.com/common/oauth2/v2.0/token") + setBody(FormDataContent(parameters { + append("code", userCode) + append("grant_type", "authorization_code") + append("code_verifier", accessData.codeVerifier) + append("client_id", CLIENT_ID) + append("redirect_uri", "https://open-otp.procyk.in") + append("scope", "files.readwrite.all") + })) + }.map { response -> + val refreshableAccessData = response.toOneDriveRefreshableAccessData() + Authenticated(refreshableAccessData) + } + + } + + class Authenticated( + private val refreshableAccessData: OneDriveRefreshableAccessData, + ) : OneDriveService(), OAuth2AccountService.Authenticated { + + override val isExpired: Boolean + get() = Clock.System.now() >= refreshableAccessData.expiresAt + + override suspend fun refreshUserAccessToken(): Result = + client.safeRequest { + method = HttpMethod.Post + url("https://login.microsoftonline.com/common/oauth2/v2.0/token") + setBody(FormDataContent(parameters { + append("grant_type", "refresh_token") + append("refresh_token", refreshableAccessData.refreshToken) + append("client_id", CLIENT_ID) + append("scope", "files.readwrite.all") + })) + }.map { response -> + val refreshableAccessData = response.toOneDriveRefreshableAccessData(refreshableAccessData.refreshToken) + Authenticated(refreshableAccessData) + } + + override suspend fun uploadBackupData(data: ByteArray): Result = + client.safeRequest { + method = HttpMethod.Put + url("https://graph.microsoft.com/v1.0/me/drive/root:/$BACKUP_PATH:/content") + header(HttpHeaders.ContentType, ContentType.Application.OctetStream) + header(HttpHeaders.Authorization, "Bearer ${refreshableAccessData.accessToken}") + setBody(ByteArrayContent(data)) + }.map { response -> + response.file.hashes.sha256Hash == data.oneDriveContentHash() + } + + override suspend fun downloadBackupData(): ByteArray? = + client.safeHttpRequest { + method = HttpMethod.Get + url("https://graph.microsoft.com/v1.0/me/drive/root:/$BACKUP_PATH:/content") + header(HttpHeaders.Authorization, "Bearer ${refreshableAccessData.accessToken}") + }.map { response -> + val downloadLocation = response.headers[HttpHeaders.ContentLocation] ?: return@map null + client.safeHttpRequest { + method = HttpMethod.Get + url(downloadLocation) + } + .map { it.readBytes() } + .getOrNull() + }.getOrNull() + + override fun updateUserLinkedAccounts(linkedAccounts: UserLinkedAccountsModel): UserLinkedAccountsModel = + linkedAccounts.copy(onedrive = refreshableAccessData) + } +} + +data class OneDriveAccessData( + val codeVerifier: String, + val codeChallenge: String, +) + +@Serializable +data class OneDriveRefreshableAccessData( + val expiresAt: Instant, + val accessToken: String, + val refreshToken: String, +) + +@Serializable +private data class OneDriveOAuth2RefreshableTokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresInSeconds: Int, + @SerialName("token_type") val tokenType: TokenType, + @SerialName("refresh_token") val refreshToken: String, +) { + @Serializable + enum class TokenType { Bearer } +} + +@Serializable +private data class OneDriveOAuth2TokenResponse( + @SerialName("access_token") val accessToken: String, + @SerialName("expires_in") val expiresInSeconds: Int, + @SerialName("token_type") val tokenType: TokenType, +) { + @Serializable + enum class TokenType { Bearer } +} + +@Serializable +private data class OneDriveUploadResponse( + @SerialName("file") val file: File, +) { + @Serializable + data class File( + @SerialName("hashes") val hashes: Hashes, + ) { + @Serializable + data class Hashes( + @SerialName("sha256Hash") val sha256Hash: String, + ) + } +} + +private fun OneDriveOAuth2RefreshableTokenResponse.toOneDriveRefreshableAccessData(): OneDriveRefreshableAccessData = + OneDriveRefreshableAccessData( + expiresAt = Clock.System.now() + expiresInSeconds.seconds / 2, + accessToken = accessToken, + refreshToken = refreshToken, + ) + +private fun OneDriveOAuth2TokenResponse.toOneDriveRefreshableAccessData(refreshToken: String): OneDriveRefreshableAccessData = + OneDriveRefreshableAccessData( + expiresAt = Clock.System.now() + expiresInSeconds.seconds / 2, + accessToken = accessToken, + refreshToken = refreshToken, + ) + +private const val CLIENT_ID: String = "8612f175-11d3-4dea-960a-d2cb89867a33" + +private const val BACKUP_PATH: String = "OpenOTP/OpenOTP.backup" + +private fun ByteArray.oneDriveEncodeBase64(): String = encodeBase64() + .replace('+', '-') + .replace('/', '_') + .replace("=", "") + +private fun ByteArray.oneDriveContentHash(): String = + SHA256().digest(this).toHexString(format = HexFormat.UpperCase) diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserLinkedAccountsModel.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserLinkedAccountsModel.kt index dd05b1e..93f4d40 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserLinkedAccountsModel.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/component/UserLinkedAccountsModel.kt @@ -3,17 +3,17 @@ package ml.dev.kotlin.openotp.component import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.vector.ImageVector import kotlinx.serialization.Serializable -import ml.dev.kotlin.openotp.backup.DropboxRefreshableAccessData -import ml.dev.kotlin.openotp.backup.DropboxService -import ml.dev.kotlin.openotp.backup.OAuth2AccountService +import ml.dev.kotlin.openotp.backup.* import ml.dev.kotlin.openotp.shared.OpenOtpResources import ml.dev.kotlin.openotp.ui.OtpIcons import ml.dev.kotlin.openotp.ui.icons.Dropbox +import ml.dev.kotlin.openotp.ui.icons.OneDrive import org.koin.compose.koinInject @Serializable data class UserLinkedAccountsModel( val dropbox: DropboxRefreshableAccessData? = null, + val onedrive: OneDriveRefreshableAccessData? = null, ) enum class UserLinkedAccountType { @@ -34,6 +34,24 @@ enum class UserLinkedAccountType { override fun createAuthenticatedService(linkedAccounts: UserLinkedAccountsModel): DropboxService.Authenticated? = linkedAccounts.dropbox?.let(DropboxService::Authenticated) + }, + OneDrive { + override val icon: ImageVector = OtpIcons.OneDrive + + override val OpenOtpAppComponentContext.iconContentDescription: String + get() = stringResource(OpenOtpResources.strings.onedrive_name) + + override fun reset(model: UserLinkedAccountsModel) = + model.copy(onedrive = null) + + override fun isLinked(model: UserLinkedAccountsModel) = + model.onedrive != null + + override fun createService() = + OneDriveService.Initialized + + override fun createAuthenticatedService(linkedAccounts: UserLinkedAccountsModel): OneDriveService.Authenticated? = + linkedAccounts.onedrive?.let(OneDriveService::Authenticated) } ; diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OneDrive.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OneDrive.kt new file mode 100644 index 0000000..a333e9c --- /dev/null +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/ui/icons/OneDrive.kt @@ -0,0 +1,109 @@ +package ml.dev.kotlin.openotp.ui.icons + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType.Companion.NonZero +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap.Companion.Butt +import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.ImageVector.Builder +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp +import ml.dev.kotlin.openotp.ui.OtpIcons + +val OtpIcons.OneDrive: ImageVector + get() { + if (_onedrive != null) { + return _onedrive!! + } + _onedrive = Builder( + name = "OneDrive", defaultWidth = 24.0.dp, + defaultHeight = 24.0.dp, viewportWidth = 24.0f, viewportHeight = 24.0f + ).apply { + path( + fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f, + strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f, + pathFillType = NonZero + ) { + moveTo(19.453f, 9.95f) + quadToRelative(0.961f, 0.058f, 1.787f, 0.468f) + quadToRelative(0.826f, 0.41f, 1.442f, 1.066f) + quadToRelative(0.615f, 0.657f, 0.966f, 1.512f) + quadToRelative(0.352f, 0.856f, 0.352f, 1.816f) + quadToRelative(0.0f, 1.008f, -0.387f, 1.893f) + quadToRelative(-0.386f, 0.885f, -1.049f, 1.547f) + quadToRelative(-0.662f, 0.662f, -1.546f, 1.049f) + quadToRelative(-0.885f, 0.387f, -1.893f, 0.387f) + lineTo(6.0f, 19.688f) + quadToRelative(-1.242f, 0.0f, -2.332f, -0.475f) + quadToRelative(-1.09f, -0.475f, -1.904f, -1.29f) + quadToRelative(-0.815f, -0.814f, -1.29f, -1.903f) + quadTo(0.0f, 14.93f, 0.0f, 13.688f) + quadToRelative(0.0f, -0.985f, 0.31f, -1.887f) + quadToRelative(0.311f, -0.903f, 0.862f, -1.658f) + quadToRelative(0.55f, -0.756f, 1.324f, -1.325f) + quadToRelative(0.774f, -0.568f, 1.711f, -0.861f) + quadToRelative(0.434f, -0.129f, 0.85f, -0.187f) + quadToRelative(0.416f, -0.06f, 0.861f, -0.082f) + horizontalLineToRelative(0.012f) + quadToRelative(0.515f, -0.786f, 1.207f, -1.413f) + quadToRelative(0.691f, -0.627f, 1.5f, -1.066f) + quadToRelative(0.808f, -0.44f, 1.705f, -0.668f) + quadToRelative(0.896f, -0.229f, 1.845f, -0.229f) + quadToRelative(1.278f, 0.0f, 2.456f, 0.417f) + quadToRelative(1.177f, 0.416f, 2.144f, 1.16f) + quadToRelative(0.967f, 0.744f, 1.658f, 1.78f) + quadToRelative(0.692f, 1.038f, 1.008f, 2.28f) + close() + moveTo(12.188f, 5.813f) + quadToRelative(-1.325f, 0.0f, -2.52f, 0.544f) + quadToRelative(-1.195f, 0.545f, -2.04f, 1.565f) + quadToRelative(0.446f, 0.117f, 0.85f, 0.299f) + quadToRelative(0.405f, 0.181f, 0.792f, 0.416f) + lineToRelative(4.78f, 2.86f) + lineToRelative(2.731f, -1.15f) + quadToRelative(0.27f, -0.117f, 0.545f, -0.204f) + quadToRelative(0.276f, -0.088f, 0.58f, -0.147f) + quadToRelative(-0.293f, -0.937f, -0.855f, -1.705f) + quadToRelative(-0.563f, -0.768f, -1.319f, -1.318f) + quadToRelative(-0.755f, -0.551f, -1.658f, -0.856f) + quadToRelative(-0.902f, -0.304f, -1.886f, -0.304f) + close() + moveTo(2.414f, 16.395f) + lineToRelative(9.914f, -4.184f) + lineToRelative(-3.832f, -2.297f) + quadToRelative(-0.586f, -0.351f, -1.23f, -0.539f) + quadToRelative(-0.645f, -0.188f, -1.325f, -0.188f) + quadToRelative(-0.914f, 0.0f, -1.722f, 0.364f) + quadToRelative(-0.809f, 0.363f, -1.412f, 0.978f) + quadToRelative(-0.604f, 0.616f, -0.955f, 1.436f) + quadToRelative(-0.352f, 0.82f, -0.352f, 1.723f) + quadToRelative(0.0f, 0.703f, 0.234f, 1.423f) + quadToRelative(0.235f, 0.721f, 0.68f, 1.284f) + close() + moveTo(19.125f, 18.188f) + quadToRelative(0.563f, 0.0f, 1.078f, -0.176f) + quadToRelative(0.516f, -0.176f, 0.961f, -0.516f) + lineToRelative(-7.23f, -4.324f) + lineToRelative(-10.301f, 4.336f) + quadToRelative(0.527f, 0.328f, 1.13f, 0.504f) + quadToRelative(0.604f, 0.175f, 1.237f, 0.175f) + close() + moveTo(22.137f, 16.336f) + quadToRelative(0.363f, -0.727f, 0.363f, -1.523f) + quadToRelative(0.0f, -0.774f, -0.293f, -1.407f) + reflectiveQuadToRelative(-0.791f, -1.072f) + quadToRelative(-0.498f, -0.44f, -1.166f, -0.68f) + quadToRelative(-0.668f, -0.24f, -1.406f, -0.24f) + quadToRelative(-0.422f, 0.0f, -0.838f, 0.1f) + reflectiveQuadToRelative(-0.815f, 0.252f) + quadToRelative(-0.398f, 0.152f, -0.785f, 0.334f) + quadToRelative(-0.386f, 0.181f, -0.761f, 0.345f) + close() + } + } + .build() + return _onedrive!! + } + +private var _onedrive: ImageVector? = null diff --git a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/HttpClientUtil.kt b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/HttpClientUtil.kt index 1e03f18..b1787c1 100644 --- a/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/HttpClientUtil.kt +++ b/shared/src/commonMain/kotlin/ml/dev/kotlin/openotp/util/HttpClientUtil.kt @@ -29,7 +29,7 @@ suspend inline fun HttpClient.safeRequest( Result.failure(e) } -suspend inline fun HttpClient.safHttpRequest( +suspend inline fun HttpClient.safeHttpRequest( block: HttpRequestBuilder.() -> Unit, ): Result = try { val response = request { block() } diff --git a/shared/src/commonMain/resources/MR/base/strings.xml b/shared/src/commonMain/resources/MR/base/strings.xml index bf83bb4..1a1d6d7 100644 --- a/shared/src/commonMain/resources/MR/base/strings.xml +++ b/shared/src/commonMain/resources/MR/base/strings.xml @@ -33,6 +33,7 @@ Authenticate to get access to application codes. App icon Dropbox + OneDrive Synced successfully all backups destinations Some backups destinations have failed All backups destinations have failed diff --git a/shared/src/commonMain/resources/MR/pl/strings.xml b/shared/src/commonMain/resources/MR/pl/strings.xml index c5f5830..3dce5ca 100644 --- a/shared/src/commonMain/resources/MR/pl/strings.xml +++ b/shared/src/commonMain/resources/MR/pl/strings.xml @@ -35,6 +35,7 @@ Uwierzytelnij, aby uzyskać dostęp do kodów aplikacji. Ikona aplikacji Dropbox + OneDrive Pomyślnie stworzono kopię zapasową Niektóre kopie zapasowe nie mogły zostać ukończone poprawnie Wszystkie kopie zapasowe nie mogły zostać ukończone poprawnie