Merge pull request #344 from cyb3rko/client-certificate-auth

Client certificate authentication (mTLS)
This commit is contained in:
Jannis Mattheis
2024-06-13 21:43:18 +02:00
committed by GitHub
15 changed files with 354 additions and 142 deletions

View File

@@ -4,9 +4,13 @@ import android.app.Application
import android.app.NotificationManager import android.app.NotificationManager
import android.os.Build import android.os.Build
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.github.gotify.api.CertUtils
import com.github.gotify.log.LoggerHelper import com.github.gotify.log.LoggerHelper
import com.github.gotify.log.UncaughtExceptionHandler import com.github.gotify.log.UncaughtExceptionHandler
import com.github.gotify.settings.ThemeHelper import com.github.gotify.settings.ThemeHelper
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import org.tinylog.kotlin.Logger import org.tinylog.kotlin.Logger
class GotifyApplication : Application() { class GotifyApplication : Application() {
@@ -26,6 +30,22 @@ class GotifyApplication : Application() {
) )
} }
val settings = Settings(this)
if (settings.legacyCert != null) {
Logger.info("Migrating legacy CA cert to new location")
try {
val legacyCert = settings.legacyCert
settings.legacyCert = null
val caCertFile = File(settings.filesDir, CertUtils.CA_CERT_NAME)
FileOutputStream(caCertFile).use {
it.write(legacyCert?.encodeToByteArray())
}
settings.caCertPath = caCertFile.absolutePath
Logger.info("Migration of legacy CA cert succeeded")
} catch (e: IOException) {
Logger.error(e, "Migration of legacy CA cert failed")
}
}
super.onCreate() super.onCreate()
} }
} }

View File

@@ -1,3 +1,8 @@
package com.github.gotify package com.github.gotify
internal class SSLSettings(val validateSSL: Boolean, val cert: String?) internal class SSLSettings(
val validateSSL: Boolean,
val caCertPath: String?,
val clientCertPath: String?,
val clientCertPassword: String?
)

View File

@@ -6,6 +6,7 @@ import com.github.gotify.client.model.User
internal class Settings(context: Context) { internal class Settings(context: Context) {
private val sharedPreferences: SharedPreferences private val sharedPreferences: SharedPreferences
val filesDir: String
var url: String var url: String
get() = sharedPreferences.getString("url", "")!! get() = sharedPreferences.getString("url", "")!!
set(value) = sharedPreferences.edit().putString("url", value).apply() set(value) = sharedPreferences.edit().putString("url", value).apply()
@@ -26,15 +27,25 @@ internal class Settings(context: Context) {
var serverVersion: String var serverVersion: String
get() = sharedPreferences.getString("version", "UNKNOWN")!! get() = sharedPreferences.getString("version", "UNKNOWN")!!
set(value) = sharedPreferences.edit().putString("version", value).apply() set(value) = sharedPreferences.edit().putString("version", value).apply()
var cert: String? var legacyCert: String?
get() = sharedPreferences.getString("cert", null) get() = sharedPreferences.getString("cert", null)
set(value) = sharedPreferences.edit().putString("cert", value).apply() set(value) = sharedPreferences.edit().putString("cert", value).commit().toUnit()
var caCertPath: String?
get() = sharedPreferences.getString("caCertPath", null)
set(value) = sharedPreferences.edit().putString("caCertPath", value).commit().toUnit()
var validateSSL: Boolean var validateSSL: Boolean
get() = sharedPreferences.getBoolean("validateSSL", true) get() = sharedPreferences.getBoolean("validateSSL", true)
set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply() set(value) = sharedPreferences.edit().putBoolean("validateSSL", value).apply()
var clientCertPath: String?
get() = sharedPreferences.getString("clientCertPath", null)
set(value) = sharedPreferences.edit().putString("clientCertPath", value).apply()
var clientCertPassword: String?
get() = sharedPreferences.getString("clientCertPass", null)
set(value) = sharedPreferences.edit().putString("clientCertPass", value).apply()
init { init {
sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE) sharedPreferences = context.getSharedPreferences("gotify", Context.MODE_PRIVATE)
filesDir = context.filesDir.absolutePath
} }
fun tokenExists(): Boolean = !token.isNullOrEmpty() fun tokenExists(): Boolean = !token.isNullOrEmpty()
@@ -43,7 +54,10 @@ internal class Settings(context: Context) {
url = "" url = ""
token = null token = null
validateSSL = true validateSSL = true
cert = null legacyCert = null
caCertPath = null
clientCertPath = null
clientCertPassword = null
} }
fun setUser(name: String?, admin: Boolean) { fun setUser(name: String?, admin: Boolean) {
@@ -51,6 +65,14 @@ internal class Settings(context: Context) {
} }
fun sslSettings(): SSLSettings { fun sslSettings(): SSLSettings {
return SSLSettings(validateSSL, cert) return SSLSettings(
validateSSL,
caCertPath,
clientCertPath,
clientCertPassword
)
} }
@Suppress("UnusedReceiverParameter")
private fun Any?.toUnit() = Unit
} }

View File

@@ -12,10 +12,6 @@ import coil.target.Target
import com.github.gotify.client.JSON import com.github.gotify.client.JSON
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import com.google.gson.Gson import com.google.gson.Gson
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStream
import java.io.InputStreamReader
import java.net.MalformedURLException import java.net.MalformedURLException
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
@@ -24,7 +20,6 @@ import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okio.Buffer
import org.threeten.bp.OffsetDateTime import org.threeten.bp.OffsetDateTime
import org.tinylog.kotlin.Logger import org.tinylog.kotlin.Logger
@@ -80,25 +75,6 @@ internal object Utils {
} }
} }
fun readFileFromStream(inputStream: InputStream): String {
val sb = StringBuilder()
var currentLine: String?
try {
BufferedReader(InputStreamReader(inputStream)).use { reader ->
while (reader.readLine().also { currentLine = it } != null) {
sb.append(currentLine).append("\n")
}
}
} catch (e: IOException) {
throw IllegalArgumentException("failed to read input")
}
return sb.toString()
}
fun stringToInputStream(str: String?): InputStream? {
return if (str == null) null else Buffer().writeUtf8(str).inputStream()
}
fun AppCompatActivity.launchCoroutine( fun AppCompatActivity.launchCoroutine(
dispatcher: CoroutineDispatcher = Dispatchers.IO, dispatcher: CoroutineDispatcher = Dispatchers.IO,
action: suspend (coroutineScope: CoroutineScope) -> Unit action: suspend (coroutineScope: CoroutineScope) -> Unit

View File

@@ -2,14 +2,17 @@ package com.github.gotify.api
import android.annotation.SuppressLint import android.annotation.SuppressLint
import com.github.gotify.SSLSettings import com.github.gotify.SSLSettings
import com.github.gotify.Utils import java.io.File
import java.io.IOException import java.io.FileInputStream
import java.io.InputStream
import java.security.GeneralSecurityException import java.security.GeneralSecurityException
import java.security.KeyStore import java.security.KeyStore
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.Certificate import java.security.cert.Certificate
import java.security.cert.CertificateFactory import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import javax.net.ssl.KeyManager
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory import javax.net.ssl.TrustManagerFactory
@@ -18,6 +21,9 @@ import okhttp3.OkHttpClient
import org.tinylog.kotlin.Logger import org.tinylog.kotlin.Logger
internal object CertUtils { internal object CertUtils {
const val CA_CERT_NAME = "ca-cert.crt"
const val CLIENT_CERT_NAME = "client-cert.p12"
@SuppressLint("CustomX509TrustManager") @SuppressLint("CustomX509TrustManager")
private val trustAll = object : X509TrustManager { private val trustAll = object : X509TrustManager {
@SuppressLint("TrustAllX509TrustManager") @SuppressLint("TrustAllX509TrustManager")
@@ -31,10 +37,10 @@ internal object CertUtils {
override fun getAcceptedIssuers() = arrayOf<X509Certificate>() override fun getAcceptedIssuers() = arrayOf<X509Certificate>()
} }
fun parseCertificate(cert: String): Certificate { fun parseCertificate(inputStream: InputStream): Certificate {
try { try {
val certificateFactory = CertificateFactory.getInstance("X509") val certificateFactory = CertificateFactory.getInstance("X509")
return certificateFactory.generateCertificate(Utils.stringToInputStream(cert)) return certificateFactory.generateCertificate(inputStream)
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalArgumentException("certificate is invalid") throw IllegalArgumentException("certificate is invalid")
} }
@@ -43,25 +49,35 @@ internal object CertUtils {
fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) { fun applySslSettings(builder: OkHttpClient.Builder, settings: SSLSettings) {
// Modified from ApiClient.applySslSettings in the client package. // Modified from ApiClient.applySslSettings in the client package.
try { try {
if (!settings.validateSSL) { val trustManagers = mutableSetOf<TrustManager>()
val context = SSLContext.getInstance("TLS") val keyManagers = mutableSetOf<KeyManager>()
context.init(arrayOf(), arrayOf<TrustManager>(trustAll), SecureRandom()) if (settings.validateSSL) {
builder.sslSocketFactory(context.socketFactory, trustAll) // Custom SSL validation
settings.caCertPath?.let { trustManagers.addAll(certToTrustManager(it)) }
} else {
// Disable SSL validation
trustManagers.add(trustAll)
builder.hostnameVerifier { _, _ -> true } builder.hostnameVerifier { _, _ -> true }
return
} }
val cert = settings.cert settings.clientCertPath?.let {
if (cert != null) { keyManagers.addAll(certToKeyManager(it, settings.clientCertPassword))
val trustManagers = certToTrustManager(cert) }
if (trustManagers.isNotEmpty()) { if (trustManagers.isNotEmpty() || keyManagers.isNotEmpty()) {
if (trustManagers.isEmpty()) {
// Fall back to system trust managers
trustManagers.addAll(defaultSystemTrustManager())
}
val context = SSLContext.getInstance("TLS") val context = SSLContext.getInstance("TLS")
context.init(arrayOf(), trustManagers, SecureRandom()) context.init(
keyManagers.toTypedArray(),
trustManagers.toTypedArray(),
SecureRandom()
)
builder.sslSocketFactory( builder.sslSocketFactory(
context.socketFactory, context.socketFactory,
trustManagers[0] as X509TrustManager trustManagers.elementAt(0) as X509TrustManager
) )
} }
}
} catch (e: Exception) { } catch (e: Exception) {
// We shouldn't have issues since the cert is verified on login. // We shouldn't have issues since the cert is verified on login.
Logger.error(e, "Failed to apply SSL settings") Logger.error(e, "Failed to apply SSL settings")
@@ -69,12 +85,14 @@ internal object CertUtils {
} }
@Throws(GeneralSecurityException::class) @Throws(GeneralSecurityException::class)
private fun certToTrustManager(cert: String): Array<TrustManager> { private fun certToTrustManager(certPath: String): Array<TrustManager> {
val certificateFactory = CertificateFactory.getInstance("X.509") val certificateFactory = CertificateFactory.getInstance("X.509")
val certificates = certificateFactory.generateCertificates(Utils.stringToInputStream(cert)) val certificates = FileInputStream(File(certPath)).use(
certificateFactory::generateCertificates
)
require(certificates.isNotEmpty()) { "expected non-empty set of trusted certificates" } require(certificates.isNotEmpty()) { "expected non-empty set of trusted certificates" }
val caKeyStore = newEmptyKeyStore() val caKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) }
certificates.forEachIndexed { index, certificate -> certificates.forEachIndexed { index, certificate ->
val certificateAlias = "ca$index" val certificateAlias = "ca$index"
caKeyStore.setCertificateEntry(certificateAlias, certificate) caKeyStore.setCertificateEntry(certificateAlias, certificate)
@@ -86,13 +104,24 @@ internal object CertUtils {
} }
@Throws(GeneralSecurityException::class) @Throws(GeneralSecurityException::class)
private fun newEmptyKeyStore(): KeyStore { private fun certToKeyManager(certPath: String, certPassword: String?): Array<KeyManager> {
return try { require(certPassword != null) { "empty client certificate password" }
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType())
keyStore.load(null, null) val keyStore = KeyStore.getInstance("PKCS12")
keyStore FileInputStream(File(certPath)).use {
} catch (e: IOException) { keyStore.load(it, certPassword.toCharArray())
throw AssertionError(e) }
} val keyManagerFactory =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
keyManagerFactory.init(keyStore, certPassword.toCharArray())
return keyManagerFactory.keyManagers
}
private fun defaultSystemTrustManager(): Array<TrustManager> {
val trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm()
)
trustManagerFactory.init(null as KeyStore?)
return trustManagerFactory.trustManagers
} }
} }

View File

@@ -9,55 +9,55 @@ import com.github.gotify.client.auth.ApiKeyAuth
import com.github.gotify.client.auth.HttpBasicAuth import com.github.gotify.client.auth.HttpBasicAuth
internal object ClientFactory { internal object ClientFactory {
private fun unauthorized(baseUrl: String, sslSettings: SSLSettings): ApiClient { private fun unauthorized(
return defaultClient(arrayOf(), "$baseUrl/", sslSettings) settings: Settings,
sslSettings: SSLSettings,
baseUrl: String
): ApiClient {
return defaultClient(arrayOf(), settings, sslSettings, baseUrl)
} }
fun basicAuth( fun basicAuth(
baseUrl: String, settings: Settings,
sslSettings: SSLSettings, sslSettings: SSLSettings,
username: String, username: String,
password: String password: String
): ApiClient { ): ApiClient {
val client = defaultClient( val client = defaultClient(arrayOf("basicAuth"), settings, sslSettings)
arrayOf("basicAuth"),
"$baseUrl/",
sslSettings
)
val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth val auth = client.apiAuthorizations["basicAuth"] as HttpBasicAuth
auth.username = username auth.username = username
auth.password = password auth.password = password
return client return client
} }
fun clientToken(baseUrl: String, sslSettings: SSLSettings, token: String?): ApiClient { fun clientToken(settings: Settings, token: String? = settings.token): ApiClient {
val client = defaultClient( val client = defaultClient(arrayOf("clientTokenHeader"), settings)
arrayOf("clientTokenHeader"),
"$baseUrl/",
sslSettings
)
val tokenAuth = client.apiAuthorizations["clientTokenHeader"] as ApiKeyAuth val tokenAuth = client.apiAuthorizations["clientTokenHeader"] as ApiKeyAuth
tokenAuth.apiKey = token tokenAuth.apiKey = token
return client return client
} }
fun versionApi(baseUrl: String, sslSettings: SSLSettings): VersionApi { fun versionApi(
return unauthorized(baseUrl, sslSettings).createService(VersionApi::class.java) settings: Settings,
sslSettings: SSLSettings = settings.sslSettings(),
baseUrl: String = settings.url
): VersionApi {
return unauthorized(settings, sslSettings, baseUrl).createService(VersionApi::class.java)
} }
fun userApiWithToken(settings: Settings): UserApi { fun userApiWithToken(settings: Settings): UserApi {
return clientToken(settings.url, settings.sslSettings(), settings.token) return clientToken(settings).createService(UserApi::class.java)
.createService(UserApi::class.java)
} }
private fun defaultClient( private fun defaultClient(
authentications: Array<String>, authentications: Array<String>,
baseUrl: String, settings: Settings,
sslSettings: SSLSettings sslSettings: SSLSettings = settings.sslSettings(),
baseUrl: String = settings.url
): ApiClient { ): ApiClient {
val client = ApiClient(authentications) val client = ApiClient(authentications)
CertUtils.applySslSettings(client.okBuilder, sslSettings) CertUtils.applySslSettings(client.okBuilder, sslSettings)
client.adapterBuilder.baseUrl(baseUrl) client.adapterBuilder.baseUrl("$baseUrl/")
return client return client
} }
} }

View File

@@ -167,7 +167,7 @@ internal class InitializationActivity : AppCompatActivity() {
callback: SuccessCallback<VersionInfo>, callback: SuccessCallback<VersionInfo>,
errorCallback: Callback.ErrorCallback errorCallback: Callback.ErrorCallback
) { ) {
ClientFactory.versionApi(settings.url, settings.sslSettings()) ClientFactory.versionApi(settings)
.version .version
.enqueue(Callback.callInUI(this, callback, errorCallback)) .enqueue(Callback.callInUI(this, callback, errorCallback))
} }

View File

@@ -3,6 +3,7 @@ package com.github.gotify.login
import android.content.Context import android.content.Context
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.CompoundButton import android.widget.CompoundButton
import androidx.core.widget.doOnTextChanged
import com.github.gotify.R import com.github.gotify.R
import com.github.gotify.databinding.AdvancedSettingsDialogBinding import com.github.gotify.databinding.AdvancedSettingsDialogBinding
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -15,6 +16,9 @@ internal class AdvancedDialog(
private var onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? = null private var onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? = null
private lateinit var onClickSelectCaCertificate: Runnable private lateinit var onClickSelectCaCertificate: Runnable
private lateinit var onClickRemoveCaCertificate: Runnable private lateinit var onClickRemoveCaCertificate: Runnable
private lateinit var onClickSelectClientCertificate: Runnable
private lateinit var onClickRemoveClientCertificate: Runnable
private lateinit var onClose: (password: String) -> Unit
fun onDisableSSLChanged( fun onDisableSSLChanged(
onCheckedChangeListener: CompoundButton.OnCheckedChangeListener? onCheckedChangeListener: CompoundButton.OnCheckedChangeListener?
@@ -33,35 +37,101 @@ internal class AdvancedDialog(
return this return this
} }
fun show(disableSSL: Boolean, selectedCertificate: String?): AdvancedDialog { fun onClickSelectClientCertificate(onClickSelectClientCertificate: Runnable): AdvancedDialog {
this.onClickSelectClientCertificate = onClickSelectClientCertificate
return this
}
fun onClickRemoveClientCertificate(onClickRemoveClientCertificate: Runnable): AdvancedDialog {
this.onClickRemoveClientCertificate = onClickRemoveClientCertificate
return this
}
fun onClose(onClose: (password: String) -> Unit): AdvancedDialog {
this.onClose = onClose
return this
}
fun show(
disableSSL: Boolean,
caCertPath: String? = null,
caCertCN: String?,
clientCertPath: String? = null,
clientCertPassword: String?
): AdvancedDialog {
binding = AdvancedSettingsDialogBinding.inflate(layoutInflater) binding = AdvancedSettingsDialogBinding.inflate(layoutInflater)
binding.disableSSL.isChecked = disableSSL binding.disableSSL.isChecked = disableSSL
binding.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener) binding.disableSSL.setOnCheckedChangeListener(onCheckedChangeListener)
if (selectedCertificate == null) { if (!clientCertPassword.isNullOrEmpty()) {
showSelectCACertificate() binding.clientCertPasswordEdittext.setText(clientCertPassword)
}
binding.clientCertPasswordEdittext.doOnTextChanged { _, _, _, _ ->
if (binding.selectedClientCert.text.toString() ==
context.getString(R.string.certificate_found)
) {
showPasswordMissing(binding.clientCertPasswordEdittext.text.toString().isEmpty())
}
}
if (caCertPath == null) {
showSelectCaCertificate()
} else { } else {
showRemoveCACertificate(selectedCertificate) showRemoveCaCertificate(caCertCN!!)
}
if (clientCertPath == null) {
showSelectClientCertificate()
} else {
showRemoveClientCertificate()
} }
MaterialAlertDialogBuilder(context) MaterialAlertDialogBuilder(context)
.setView(binding.root) .setView(binding.root)
.setTitle(R.string.advanced_settings) .setTitle(R.string.advanced_settings)
.setPositiveButton(context.getString(R.string.done), null) .setPositiveButton(context.getString(R.string.done), null)
.setOnDismissListener {
onClose(binding.clientCertPasswordEdittext.text.toString())
}
.show() .show()
return this return this
} }
private fun showSelectCACertificate() { private fun showSelectCaCertificate() {
binding.toggleCaCert.setText(R.string.select_ca_certificate) binding.toggleCaCert.setText(R.string.select_ca_certificate)
binding.toggleCaCert.setOnClickListener { onClickSelectCaCertificate.run() } binding.toggleCaCert.setOnClickListener { onClickSelectCaCertificate.run() }
binding.selecetedCaCert.setText(R.string.no_certificate_selected) binding.selectedCaCert.setText(R.string.no_certificate_selected)
} }
fun showRemoveCACertificate(certificate: String) { fun showRemoveCaCertificate(certificateCN: String) {
binding.toggleCaCert.setText(R.string.remove_ca_certificate) binding.toggleCaCert.setText(R.string.remove_ca_certificate)
binding.toggleCaCert.setOnClickListener { binding.toggleCaCert.setOnClickListener {
showSelectCACertificate() showSelectCaCertificate()
onClickRemoveCaCertificate.run() onClickRemoveCaCertificate.run()
} }
binding.selecetedCaCert.text = certificate binding.selectedCaCert.text = certificateCN
}
private fun showSelectClientCertificate() {
binding.toggleClientCert.setText(R.string.select_client_certificate)
binding.toggleClientCert.setOnClickListener { onClickSelectClientCertificate.run() }
binding.selectedClientCert.setText(R.string.no_certificate_selected)
showPasswordMissing(false)
binding.clientCertPasswordEdittext.text = null
}
fun showRemoveClientCertificate() {
binding.toggleClientCert.setText(R.string.remove_client_certificate)
binding.toggleClientCert.setOnClickListener {
showSelectClientCertificate()
onClickRemoveClientCertificate.run()
}
binding.selectedClientCert.setText(R.string.certificate_found)
showPasswordMissing(binding.clientCertPasswordEdittext.text.toString().isEmpty())
}
private fun showPasswordMissing(toggled: Boolean) {
val error = if (toggled) {
context.getString(R.string.client_cert_password_missing)
} else {
null
}
binding.clientCertPassword.error = error
} }
} }

View File

@@ -8,7 +8,9 @@ import android.os.Bundle
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.view.View import android.view.View
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import com.github.gotify.R import com.github.gotify.R
import com.github.gotify.SSLSettings import com.github.gotify.SSLSettings
@@ -31,6 +33,10 @@ import com.github.gotify.log.LogsActivity
import com.github.gotify.log.UncaughtExceptionHandler import com.github.gotify.log.UncaughtExceptionHandler
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.tinylog.kotlin.Logger import org.tinylog.kotlin.Logger
@@ -40,10 +46,13 @@ internal class LoginActivity : AppCompatActivity() {
private lateinit var settings: Settings private lateinit var settings: Settings
private var disableSslValidation = false private var disableSslValidation = false
private var caCertContents: String? = null private var caCertCN: String? = null
private var caCertPath: String? = null
private var clientCertPath: String? = null
private var clientCertPassword: String? = null
private lateinit var advancedDialog: AdvancedDialog private lateinit var advancedDialog: AdvancedDialog
private val certificateDialogResultLauncher = private val caDialogResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
try { try {
require(result.resultCode == RESULT_OK) { "result was ${result.resultCode}" } require(result.resultCode == RESULT_OK) { "result was ${result.resultCode}" }
@@ -52,18 +61,38 @@ internal class LoginActivity : AppCompatActivity() {
val uri = result.data!!.data ?: throw IllegalArgumentException("file path was null") val uri = result.data!!.data ?: throw IllegalArgumentException("file path was null")
val fileStream = contentResolver.openInputStream(uri) val fileStream = contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("file path was invalid") ?: throw IllegalArgumentException("file path was invalid")
val destinationFile = File(filesDir, CertUtils.CA_CERT_NAME)
copyStreamToFile(fileStream, destinationFile)
val content = Utils.readFileFromStream(fileStream) // temporarily store it (don't store to settings until they decide to login)
val name = getNameOfCertContent(content) caCertCN = getNameOfCertContent(destinationFile) ?: "unknown"
caCertPath = destinationFile.absolutePath
// temporarily set the contents (don't store to settings until they decide to login) advancedDialog.showRemoveCaCertificate(caCertCN!!)
caCertContents = content
advancedDialog.showRemoveCACertificate(name)
} catch (e: Exception) { } catch (e: Exception) {
Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.message)) Utils.showSnackBar(this, getString(R.string.select_ca_failed, e.message))
} }
} }
private val clientCertDialogResultLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
try {
require(result.resultCode == RESULT_OK) { "result was ${result.resultCode}" }
requireNotNull(result.data) { "file path was null" }
val uri = result.data!!.data ?: throw IllegalArgumentException("file path was null")
val fileStream = contentResolver.openInputStream(uri)
?: throw IllegalArgumentException("file path was invalid")
val destinationFile = File(filesDir, CertUtils.CLIENT_CERT_NAME)
copyStreamToFile(fileStream, destinationFile)
// temporarily store it (don't store to settings until they decide to login)
clientCertPath = destinationFile.absolutePath
advancedDialog.showRemoveClientCertificate()
} catch (e: Exception) {
Utils.showSnackBar(this, getString(R.string.select_client_failed, e.message))
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
UncaughtExceptionHandler.registerCurrentThread() UncaughtExceptionHandler.registerCurrentThread()
@@ -115,7 +144,7 @@ internal class LoginActivity : AppCompatActivity() {
binding.checkurl.visibility = View.GONE binding.checkurl.visibility = View.GONE
try { try {
ClientFactory.versionApi(url, tempSslSettings()) ClientFactory.versionApi(settings, tempSslSettings(), url)
.version .version
.enqueue(Callback.callInUI(this, onValidUrl(url), onInvalidUrl(url))) .enqueue(Callback.callInUI(this, onValidUrl(url), onInvalidUrl(url)))
} catch (e: Exception) { } catch (e: Exception) {
@@ -140,12 +169,6 @@ internal class LoginActivity : AppCompatActivity() {
} }
private fun toggleShowAdvanced() { private fun toggleShowAdvanced() {
val selectedCertName = if (caCertContents != null) {
getNameOfCertContent(caCertContents!!)
} else {
null
}
advancedDialog = AdvancedDialog(this, layoutInflater) advancedDialog = AdvancedDialog(this, layoutInflater)
.onDisableSSLChanged { _, disable -> .onDisableSSLChanged { _, disable ->
invalidateUrl() invalidateUrl()
@@ -153,34 +176,53 @@ internal class LoginActivity : AppCompatActivity() {
} }
.onClickSelectCaCertificate { .onClickSelectCaCertificate {
invalidateUrl() invalidateUrl()
doSelectCACertificate() doSelectCertificate(caDialogResultLauncher, R.string.select_ca_file)
} }
.onClickRemoveCaCertificate { .onClickRemoveCaCertificate {
invalidateUrl() invalidateUrl()
caCertContents = null caCertPath = null
clientCertPassword = null
} }
.show(disableSslValidation, selectedCertName) .onClickSelectClientCertificate {
invalidateUrl()
doSelectCertificate(clientCertDialogResultLauncher, R.string.select_client_file)
}
.onClickRemoveClientCertificate {
invalidateUrl()
clientCertPath = null
}
.onClose { newPassword ->
clientCertPassword = newPassword
}
.show(
disableSslValidation,
caCertPath,
caCertCN,
clientCertPath,
clientCertPassword
)
} }
private fun doSelectCACertificate() { private fun doSelectCertificate(
resultLauncher: ActivityResultLauncher<Intent>,
@StringRes descriptionId: Int
) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
// we don't really care what kind of file it is as long as we can parse it // we don't really care what kind of file it is as long as we can parse it
intent.type = "*/*" intent.type = "*/*"
intent.addCategory(Intent.CATEGORY_OPENABLE) intent.addCategory(Intent.CATEGORY_OPENABLE)
try { try {
certificateDialogResultLauncher.launch( resultLauncher.launch(Intent.createChooser(intent, getString(descriptionId)))
Intent.createChooser(intent, getString(R.string.select_ca_file))
)
} catch (e: ActivityNotFoundException) { } catch (e: ActivityNotFoundException) {
// case for user not having a file browser installed // case for user not having a file browser installed
Utils.showSnackBar(this, getString(R.string.please_install_file_browser)) Utils.showSnackBar(this, getString(R.string.please_install_file_browser))
} }
} }
private fun getNameOfCertContent(content: String): String { private fun getNameOfCertContent(file: File): String? {
val ca = CertUtils.parseCertificate(content) val ca = FileInputStream(file).use { CertUtils.parseCertificate(it) }
return (ca as X509Certificate).subjectDN.name return (ca as X509Certificate).subjectX500Principal.name
} }
private fun onValidUrl(url: String): SuccessCallback<VersionInfo> { private fun onValidUrl(url: String): SuccessCallback<VersionInfo> {
@@ -211,7 +253,7 @@ internal class LoginActivity : AppCompatActivity() {
binding.login.visibility = View.GONE binding.login.visibility = View.GONE
binding.loginProgress.visibility = View.VISIBLE binding.loginProgress.visibility = View.VISIBLE
val client = ClientFactory.basicAuth(settings.url, tempSslSettings(), username, password) val client = ClientFactory.basicAuth(settings, tempSslSettings(), username, password)
client.createService(UserApi::class.java) client.createService(UserApi::class.java)
.currentUser() .currentUser()
.enqueue( .enqueue(
@@ -265,7 +307,9 @@ internal class LoginActivity : AppCompatActivity() {
private fun onCreatedClient(client: Client) { private fun onCreatedClient(client: Client) {
settings.token = client.token settings.token = client.token
settings.validateSSL = !disableSslValidation settings.validateSSL = !disableSslValidation
settings.cert = caCertContents settings.caCertPath = caCertPath
settings.clientCertPath = clientCertPath
settings.clientCertPassword = clientCertPassword
Utils.showSnackBar(this, getString(R.string.created_client)) Utils.showSnackBar(this, getString(R.string.created_client))
startActivity(Intent(this, InitializationActivity::class.java)) startActivity(Intent(this, InitializationActivity::class.java))
@@ -288,6 +332,17 @@ internal class LoginActivity : AppCompatActivity() {
} }
private fun tempSslSettings(): SSLSettings { private fun tempSslSettings(): SSLSettings {
return SSLSettings(!disableSslValidation, caCertContents) return SSLSettings(
!disableSslValidation,
caCertPath,
clientCertPath,
clientCertPassword
)
}
private fun copyStreamToFile(inputStream: InputStream, file: File) {
FileOutputStream(file).use {
inputStream.copyTo(it)
}
} }
} }

View File

@@ -544,7 +544,7 @@ internal class MessagesActivity :
private fun deleteApp(appId: Long) { private fun deleteApp(appId: Long) {
val settings = viewModel.settings val settings = viewModel.settings
val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) val client = ClientFactory.clientToken(settings)
client.createService(ApplicationApi::class.java) client.createService(ApplicationApi::class.java)
.deleteApp(appId) .deleteApp(appId)
.enqueue( .enqueue(
@@ -602,8 +602,7 @@ internal class MessagesActivity :
private fun deleteClientAndNavigateToLogin() { private fun deleteClientAndNavigateToLogin() {
val settings = viewModel.settings val settings = viewModel.settings
val api = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) val api = ClientFactory.clientToken(settings).createService(ClientApi::class.java)
.createService(ClientApi::class.java)
stopService(Intent(this@MessagesActivity, WebSocketService::class.java)) stopService(Intent(this@MessagesActivity, WebSocketService::class.java))
try { try {
val clients = Api.execute(api.clients) val clients = Api.execute(api.clients)

View File

@@ -14,7 +14,7 @@ import com.github.gotify.messages.provider.MessageState
internal class MessagesModel(parentView: Activity) : ViewModel() { internal class MessagesModel(parentView: Activity) : ViewModel() {
val settings = Settings(parentView) val settings = Settings(parentView)
val coilHandler = CoilHandler(parentView, settings) val coilHandler = CoilHandler(parentView, settings)
val client = ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) val client = ClientFactory.clientToken(settings)
val appsHolder = ApplicationHolder(parentView, client) val appsHolder = ApplicationHolder(parentView, client)
val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder) val messages = MessageFacade(client.createService(MessageApi::class.java), appsHolder)

View File

@@ -68,11 +68,7 @@ internal class WebSocketService : Service() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
settings = Settings(this) settings = Settings(this)
val client = ClientFactory.clientToken( val client = ClientFactory.clientToken(settings)
settings.url,
settings.sslSettings(),
settings.token
)
missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java)) missingMessageUtil = MissedMessageUtil(client.createService(MessageApi::class.java))
Logger.info("Create ${javaClass.simpleName}") Logger.info("Create ${javaClass.simpleName}")
coilHandler = CoilHandler(this, settings) coilHandler = CoilHandler(this, settings)
@@ -129,7 +125,7 @@ internal class WebSocketService : Service() {
} }
private fun fetchApps() { private fun fetchApps() {
ClientFactory.clientToken(settings.url, settings.sslSettings(), settings.token) ClientFactory.clientToken(settings)
.createService(ApplicationApi::class.java) .createService(ApplicationApi::class.java)
.apps .apps
.enqueue( .enqueue(

View File

@@ -61,11 +61,7 @@ internal class ShareActivity : AppCompatActivity() {
return return
} }
val client = ClientFactory.clientToken( val client = ClientFactory.clientToken(settings)
settings.url,
settings.sslSettings(),
settings.token
)
appsHolder = ApplicationHolder(this, client) appsHolder = ApplicationHolder(this, client)
appsHolder.onUpdate { appsHolder.onUpdate {
val apps = appsHolder.get() val apps = appsHolder.get()
@@ -136,11 +132,7 @@ internal class ShareActivity : AppCompatActivity() {
} }
private fun executeMessageCall(appIndex: Int, message: Message): Boolean { private fun executeMessageCall(appIndex: Int, message: Message): Boolean {
val pushClient = ClientFactory.clientToken( val pushClient = ClientFactory.clientToken(settings, appsHolder.get()[appIndex].token)
settings.url,
settings.sslSettings(),
appsHolder.get()[appIndex].token
)
return try { return try {
val messageApi = pushClient.createService(MessageApi::class.java) val messageApi = pushClient.createService(MessageApi::class.java)
Api.execute(messageApi.createMessage(message)) Api.execute(messageApi.createMessage(message))

View File

@@ -1,6 +1,8 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout
android:orientation="vertical" android:layout_width="match_parent" xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:padding="20dp"> android:padding="20dp">
@@ -18,8 +20,47 @@
android:text="@string/select_ca_certificate" /> android:text="@string/select_ca_certificate" />
<com.google.android.material.textview.MaterialTextView <com.google.android.material.textview.MaterialTextView
android:id="@+id/seleceted_ca_cert" android:id="@+id/selected_ca_cert"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/no_certificate_selected" /> android:text="@string/no_certificate_selected" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/toggle_client_cert"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:textColor="@android:color/white"
android:text="@string/select_client_certificate" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/selected_client_cert"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/no_certificate_selected" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/client_cert_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:maxWidth="280dp"
android:ems="10">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/client_cert_password_edittext"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
android:inputType="textPassword"
android:importantForAutofill="no"
android:hint="@string/client_cert_password" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout> </LinearLayout>

View File

@@ -40,9 +40,15 @@
<string name="password">Password</string> <string name="password">Password</string>
<string name="disabled_validate_ssl">Disable SSL Validation</string> <string name="disabled_validate_ssl">Disable SSL Validation</string>
<string name="select_ca_certificate">Select CA Certificate</string> <string name="select_ca_certificate">Select CA Certificate</string>
<string name="select_ca_file">Select a Certificate File</string> <string name="select_client_certificate">Select Client Certificate (PKCS#12)</string>
<string name="select_ca_file">Select a CA Certificate File</string>
<string name="select_client_file">Select a Client Certificate File</string>
<string name="client_cert_password">Certificate Password</string>
<string name="client_cert_password_missing">Password required</string>
<string name="please_install_file_browser">Please install a file browser</string> <string name="please_install_file_browser">Please install a file browser</string>
<string name="select_ca_failed">Failed to read CA: %s</string> <string name="select_ca_failed">Failed to read CA cert: %s</string>
<string name="select_client_failed">Failed to read client cert: %s</string>
<string name="certificate_found">Certificate found</string>
<string name="login">Login</string> <string name="login">Login</string>
<string name="check_url">Check URL</string> <string name="check_url">Check URL</string>
<string name="permissions_dialog_grant">Grant</string> <string name="permissions_dialog_grant">Grant</string>
@@ -65,6 +71,7 @@
<string name="done">Done</string> <string name="done">Done</string>
<string name="no_certificate_selected">No certificate selected</string> <string name="no_certificate_selected">No certificate selected</string>
<string name="remove_ca_certificate">Remove CA Certificate</string> <string name="remove_ca_certificate">Remove CA Certificate</string>
<string name="remove_client_certificate">Remove Client Certificate</string>
<string name="warning">Warning</string> <string name="warning">Warning</string>
<string name="http_warning">Using HTTP is insecure and it\'s recommend to use HTTPS instead. Use your favorite search engine to get more information about this topic.</string> <string name="http_warning">Using HTTP is insecure and it\'s recommend to use HTTPS instead. Use your favorite search engine to get more information about this topic.</string>
<string name="i_understand">I Understand</string> <string name="i_understand">I Understand</string>