mirror of
https://codeberg.org/timelimit/timelimit-android.git
synced 2025-10-03 17:59:51 +02:00
Add client attestion to the mail authentication
This commit is contained in:
parent
a1151ca4a4
commit
96b7a1c265
7 changed files with 161 additions and 14 deletions
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
|
* TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -61,3 +61,4 @@ interface ServerApi {
|
||||||
|
|
||||||
class MailServerBlacklistedException: RuntimeException()
|
class MailServerBlacklistedException: RuntimeException()
|
||||||
class MailAddressNotWhitelistedException: RuntimeException()
|
class MailAddressNotWhitelistedException: RuntimeException()
|
||||||
|
class MailLoginBlockedForIntegrityReasonsException: RuntimeException()
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
|
* TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -15,9 +15,14 @@
|
||||||
*/
|
*/
|
||||||
package io.timelimit.android.sync.network.api
|
package io.timelimit.android.sync.network.api
|
||||||
|
|
||||||
|
import android.os.Build.VERSION
|
||||||
|
import android.os.Build.VERSION_CODES
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
import android.util.JsonReader
|
import android.util.JsonReader
|
||||||
import android.util.JsonWriter
|
import android.util.JsonWriter
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import de.wivewa.android.network.X509ClientKeyManager
|
||||||
import io.timelimit.android.BuildConfig
|
import io.timelimit.android.BuildConfig
|
||||||
import io.timelimit.android.async.Threads
|
import io.timelimit.android.async.Threads
|
||||||
import io.timelimit.android.coroutines.executeAndWait
|
import io.timelimit.android.coroutines.executeAndWait
|
||||||
|
@ -25,6 +30,7 @@ import io.timelimit.android.coroutines.waitForResponse
|
||||||
import io.timelimit.android.sync.network.*
|
import io.timelimit.android.sync.network.*
|
||||||
import io.timelimit.android.util.okio.LengthSink
|
import io.timelimit.android.util.okio.LengthSink
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
|
@ -34,6 +40,14 @@ import okio.GzipSink
|
||||||
import okio.Sink
|
import okio.Sink
|
||||||
import okio.buffer
|
import okio.buffer
|
||||||
import java.io.OutputStreamWriter
|
import java.io.OutputStreamWriter
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.KeyStore.PrivateKeyEntry
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.security.spec.ECGenParameterSpec
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
|
||||||
class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
companion object {
|
companion object {
|
||||||
|
@ -125,9 +139,10 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendMailLoginCode(mail: String, locale: String, deviceAuthToken: String?): String {
|
override suspend fun sendMailLoginCode(mail: String, locale: String, deviceAuthToken: String?): String = withDeviceVerification { client ->
|
||||||
postJsonRequest(
|
postJsonRequest(
|
||||||
"auth/send-mail-login-code-v2"
|
"auth/send-mail-login-code-v2",
|
||||||
|
client = client
|
||||||
) { writer ->
|
) { writer ->
|
||||||
writer.beginObject()
|
writer.beginObject()
|
||||||
writer.name(MAIL).value(mail)
|
writer.name(MAIL).value(mail)
|
||||||
|
@ -145,7 +160,7 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
Log.d(LOG_TAG, "sendMailLoginCode() try again without deviceAuthToken")
|
Log.d(LOG_TAG, "sendMailLoginCode() try again without deviceAuthToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
return sendMailLoginCode(mail, locale, null)
|
return@use sendMailLoginCode(mail, locale, null)
|
||||||
} else {
|
} else {
|
||||||
throw ex
|
throw ex
|
||||||
}
|
}
|
||||||
|
@ -153,7 +168,7 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
|
|
||||||
val body = it.body!!
|
val body = it.body!!
|
||||||
|
|
||||||
return Threads.network.executeAndWait {
|
return@use Threads.network.executeAndWait {
|
||||||
var response: String? = null
|
var response: String? = null
|
||||||
|
|
||||||
JsonReader(body.charStream()).use { reader ->
|
JsonReader(body.charStream()).use { reader ->
|
||||||
|
@ -172,6 +187,11 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
throw MailAddressNotWhitelistedException()
|
throw MailAddressNotWhitelistedException()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"blockedForIntegrityReasons" -> {
|
||||||
|
if (reader.nextBoolean()) {
|
||||||
|
throw MailLoginBlockedForIntegrityReasonsException()
|
||||||
|
}
|
||||||
|
}
|
||||||
else -> reader.skipValue()
|
else -> reader.skipValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -627,10 +647,11 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
|
|
||||||
private suspend fun postJsonRequest(
|
private suspend fun postJsonRequest(
|
||||||
path: String,
|
path: String,
|
||||||
|
client: OkHttpClient = httpClient,
|
||||||
requestBody: (writer: JsonWriter) -> Unit
|
requestBody: (writer: JsonWriter) -> Unit
|
||||||
): Response {
|
): Response {
|
||||||
if (!sendContentLength) {
|
if (!sendContentLength) {
|
||||||
val response = postJsonRequest(path, requestBody, transmitContentLength = false)
|
val response = postJsonRequest(path, requestBody, transmitContentLength = false, client = client)
|
||||||
|
|
||||||
if (response.code != 411) return response
|
if (response.code != 411) return response
|
||||||
|
|
||||||
|
@ -639,17 +660,18 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
sendContentLength = true
|
sendContentLength = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return postJsonRequest(path, requestBody, transmitContentLength = true)
|
return postJsonRequest(path, requestBody, transmitContentLength = true, client = client)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun postJsonRequest(
|
private suspend fun postJsonRequest(
|
||||||
path: String,
|
path: String,
|
||||||
requestBody: (writer: JsonWriter) -> Unit,
|
requestBody: (writer: JsonWriter) -> Unit,
|
||||||
transmitContentLength: Boolean
|
transmitContentLength: Boolean,
|
||||||
|
client: OkHttpClient = httpClient
|
||||||
): Response {
|
): Response {
|
||||||
val body = createJsonRequestBody(requestBody, transmitContentLength)
|
val body = createJsonRequestBody(requestBody, transmitContentLength)
|
||||||
|
|
||||||
return httpClient.newCall(
|
return client.newCall(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url("$endpointWithoutSlashAtEnd/$path")
|
.url("$endpointWithoutSlashAtEnd/$path")
|
||||||
.post(body)
|
.post(body)
|
||||||
|
@ -657,4 +679,53 @@ class HttpServerApi(private val endpointWithoutSlashAtEnd: String): ServerApi {
|
||||||
.build()
|
.build()
|
||||||
).waitForResponse()
|
).waitForResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun <T> withDeviceVerification(block: suspend (client: OkHttpClient) -> T): T {
|
||||||
|
if (VERSION.SDK_INT >= VERSION_CODES.N) {
|
||||||
|
val keyStoreName = "AndroidKeyStore"
|
||||||
|
val keyStore = KeyStore.getInstance(keyStoreName).also { it.load(null) }
|
||||||
|
val keyId = "temp-" + UUID.randomUUID().toString()
|
||||||
|
val now = getTimeInMillis()
|
||||||
|
|
||||||
|
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, keyStoreName)
|
||||||
|
.also {
|
||||||
|
it.initialize(
|
||||||
|
KeyGenParameterSpec.Builder(
|
||||||
|
keyId,
|
||||||
|
KeyProperties.PURPOSE_SIGN
|
||||||
|
)
|
||||||
|
.setAlgorithmParameterSpec(
|
||||||
|
ECGenParameterSpec("prime256v1")
|
||||||
|
)
|
||||||
|
.setDigests(
|
||||||
|
KeyProperties.DIGEST_NONE,
|
||||||
|
KeyProperties.DIGEST_SHA256,
|
||||||
|
KeyProperties.DIGEST_SHA384,
|
||||||
|
KeyProperties.DIGEST_SHA512
|
||||||
|
)
|
||||||
|
.setCertificateNotBefore(Date(now - 1000 * 60))
|
||||||
|
.setCertificateNotAfter(Date(now + 1000 * 60))
|
||||||
|
.setAttestationChallenge(byteArrayOf())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}.genKeyPair()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val key = keyStore.getEntry(keyId, null) as PrivateKeyEntry
|
||||||
|
val keyManager = X509ClientKeyManager(key.privateKey, key.certificateChain.map { it as X509Certificate })
|
||||||
|
|
||||||
|
val socketFactory = SSLContext.getInstance("TLS").also {
|
||||||
|
it.init(arrayOf(keyManager), null, null)
|
||||||
|
}.socketFactory
|
||||||
|
|
||||||
|
return block(
|
||||||
|
httpClient.newBuilder()
|
||||||
|
.sslSocketFactory(socketFactory, httpClient.x509TrustManager!!)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
keyStore.deleteEntry(keyId)
|
||||||
|
}
|
||||||
|
} else return block(httpClient)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
/*
|
||||||
|
* TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation version 3 of the License.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
package de.wivewa.android.network
|
||||||
|
|
||||||
|
import java.net.Socket
|
||||||
|
import java.security.Principal
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import javax.net.ssl.X509KeyManager
|
||||||
|
|
||||||
|
class X509ClientKeyManager(
|
||||||
|
private val privateKey: PrivateKey,
|
||||||
|
private val certificateChain: List<X509Certificate>
|
||||||
|
): X509KeyManager {
|
||||||
|
companion object {
|
||||||
|
private const val ALIAS_NAME = "key"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun chooseClientAlias(
|
||||||
|
keyType: Array<out String>,
|
||||||
|
principal: Array<out Principal>?,
|
||||||
|
socket: Socket?
|
||||||
|
): String = ALIAS_NAME
|
||||||
|
|
||||||
|
override fun chooseServerAlias(
|
||||||
|
keyType: String,
|
||||||
|
principal: Array<out Principal>?,
|
||||||
|
socket: Socket?
|
||||||
|
): String? = null
|
||||||
|
|
||||||
|
override fun getCertificateChain(alias: String): Array<X509Certificate>? = when (alias) {
|
||||||
|
ALIAS_NAME -> certificateChain.toTypedArray()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getClientAliases(
|
||||||
|
keyType: String,
|
||||||
|
issuers: Array<out Principal>?
|
||||||
|
): Array<String> = arrayOf(ALIAS_NAME)
|
||||||
|
|
||||||
|
override fun getPrivateKey(alias: String): PrivateKey? = when (alias) {
|
||||||
|
ALIAS_NAME -> privateKey
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getServerAliases(
|
||||||
|
keyType: String,
|
||||||
|
issuers: Array<out Principal>?
|
||||||
|
): Array<String> = emptyArray()
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
|
* TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -154,6 +154,11 @@ fun AuthenticateByMailError(error: MailAuthentication.ErrorDialog, close: () ->
|
||||||
stringResource(R.string.authenticate_not_whitelisted_address_text),
|
stringResource(R.string.authenticate_not_whitelisted_address_text),
|
||||||
close
|
close
|
||||||
)
|
)
|
||||||
|
MailAuthentication.ErrorDialog.BlockedForIntegrityReasons -> SimpleErrorDialog(
|
||||||
|
stringResource(R.string.authenticate_error_integrity_title),
|
||||||
|
stringResource(R.string.authenticate_error_integrity_text),
|
||||||
|
close
|
||||||
|
)
|
||||||
is MailAuthentication.ErrorDialog.ExceptionDetails -> DiagnoseExceptionDialog(error.message, close)
|
is MailAuthentication.ErrorDialog.ExceptionDetails -> DiagnoseExceptionDialog(error.message, close)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
/*
|
/*
|
||||||
* TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
|
* TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU General Public License as published by
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
@ -129,6 +129,7 @@ object MailAuthentication {
|
||||||
object RateLimit: ErrorDialog()
|
object RateLimit: ErrorDialog()
|
||||||
object BlockedMailServer: ErrorDialog()
|
object BlockedMailServer: ErrorDialog()
|
||||||
object MailAddressNotAllowed: ErrorDialog()
|
object MailAddressNotAllowed: ErrorDialog()
|
||||||
|
object BlockedForIntegrityReasons: ErrorDialog()
|
||||||
data class ExceptionDetails(val message: String): ErrorDialog()
|
data class ExceptionDetails(val message: String): ErrorDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -188,6 +189,8 @@ object MailAuthentication {
|
||||||
updateState { it.withError(error = ErrorDialog.BlockedMailServer) }
|
updateState { it.withError(error = ErrorDialog.BlockedMailServer) }
|
||||||
} catch (ex: MailAddressNotWhitelistedException) {
|
} catch (ex: MailAddressNotWhitelistedException) {
|
||||||
updateState { it.withError(error = ErrorDialog.MailAddressNotAllowed) }
|
updateState { it.withError(error = ErrorDialog.MailAddressNotAllowed) }
|
||||||
|
} catch (ex: MailLoginBlockedForIntegrityReasonsException) {
|
||||||
|
updateState { it.withError(error = ErrorDialog.BlockedForIntegrityReasons) }
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
showGenericExceptionMessage(ex)
|
showGenericExceptionMessage(ex)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!--
|
<!--
|
||||||
TimeLimit Copyright <C> 2019 - 2023 Jonas Lochmann
|
TimeLimit Copyright <C> 2019 - 2024 Jonas Lochmann
|
||||||
This program is free software: you can redistribute it and/or modify
|
This program is free software: you can redistribute it and/or modify
|
||||||
it under the terms of the GNU General Public License as published by
|
it under the terms of the GNU General Public License as published by
|
||||||
the Free Software Foundation version 3 of the License.
|
the Free Software Foundation version 3 of the License.
|
||||||
|
@ -164,6 +164,8 @@
|
||||||
serverseitig eine Whitelist mit E-Mail-Adressen festgelegt und die angegebene E-Mail-Adresse war nicht
|
serverseitig eine Whitelist mit E-Mail-Adressen festgelegt und die angegebene E-Mail-Adresse war nicht
|
||||||
in dieser Whitelist
|
in dieser Whitelist
|
||||||
</string>
|
</string>
|
||||||
|
<string name="authenticate_error_integrity_title">Anmeldung blockiert</string>
|
||||||
|
<string name="authenticate_error_integrity_text">Wegen einem Problem mit Ihrem Gerät oder Ihrer TimeLimit-Installation ist keine Anmeldung möglich.</string>
|
||||||
<string name="authenticate_too_many_requests_text">Es gab viele Anmeldeversuche, daher gibt es eine vorübergehende Anmeldesperre.
|
<string name="authenticate_too_many_requests_text">Es gab viele Anmeldeversuche, daher gibt es eine vorübergehende Anmeldesperre.
|
||||||
Diese Sperre wird automatisch deaktiviert.
|
Diese Sperre wird automatisch deaktiviert.
|
||||||
</string>
|
</string>
|
||||||
|
|
|
@ -206,6 +206,8 @@
|
||||||
side whitelist of mail addresses and the specified mail address is not
|
side whitelist of mail addresses and the specified mail address is not
|
||||||
in this whitelist
|
in this whitelist
|
||||||
</string>
|
</string>
|
||||||
|
<string name="authenticate_error_integrity_title">Login blocked</string>
|
||||||
|
<string name="authenticate_error_integrity_text">Due to an issue with your TimeLimit installation or your device, you can not sign in.</string>
|
||||||
<string name="authenticate_too_many_requests_text">There were much login attempts.
|
<string name="authenticate_too_many_requests_text">There were much login attempts.
|
||||||
Due to that, signing in is temporarily blocked.
|
Due to that, signing in is temporarily blocked.
|
||||||
This blocking is disabled automatically.
|
This blocking is disabled automatically.
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue