Support specifying a client certificate for mTLS auth (#940)

This commit is contained in:
Angelo Suzuki 2025-01-24 03:47:39 +01:00 committed by GitHub
parent ec05bddba8
commit 39dc3bceee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 243 additions and 28 deletions

View File

@ -5,11 +5,13 @@ class FeverSecurityKey private constructor() : SecurityKey() {
var serverUrl: String? = null
var username: String? = null
var password: String? = null
var clientCertificateAlias: String? = null
constructor(serverUrl: String?, username: String?, password: String?) : this() {
constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() {
this.serverUrl = serverUrl
this.username = username
this.password = password
this.clientCertificateAlias = clientCertificateAlias
}
constructor(value: String? = DESUtils.empty) : this() {
@ -17,6 +19,7 @@ class FeverSecurityKey private constructor() : SecurityKey() {
serverUrl = it.serverUrl
username = it.username
password = it.password
clientCertificateAlias = it.clientCertificateAlias
}
}
}

View File

@ -5,11 +5,13 @@ class FreshRSSSecurityKey private constructor() : SecurityKey() {
var serverUrl: String? = null
var username: String? = null
var password: String? = null
var clientCertificateAlias: String? = null
constructor(serverUrl: String?, username: String?, password: String?) : this() {
constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() {
this.serverUrl = serverUrl
this.username = username
this.password = password
this.clientCertificateAlias = clientCertificateAlias
}
constructor(value: String? = DESUtils.empty) : this() {
@ -17,6 +19,7 @@ class FreshRSSSecurityKey private constructor() : SecurityKey() {
serverUrl = it.serverUrl
username = it.username
password = it.password
clientCertificateAlias = it.clientCertificateAlias
}
}
}

View File

@ -5,11 +5,13 @@ class GoogleReaderSecurityKey private constructor() : SecurityKey() {
var serverUrl: String? = null
var username: String? = null
var password: String? = null
var clientCertificateAlias: String? = null
constructor(serverUrl: String?, username: String?, password: String?) : this() {
constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() {
this.serverUrl = serverUrl
this.username = username
this.password = password
this.clientCertificateAlias = clientCertificateAlias
}
constructor(value: String? = DESUtils.empty) : this() {
@ -17,6 +19,7 @@ class GoogleReaderSecurityKey private constructor() : SecurityKey() {
serverUrl = it.serverUrl
username = it.username
password = it.password
clientCertificateAlias = it.clientCertificateAlias
}
}
}

View File

@ -70,11 +70,13 @@ class FeverRssService @Inject constructor(
private suspend fun getFeverAPI() =
FeverSecurityKey(accountDao.queryById(context.currentAccountId)!!.securityKey).run {
FeverAPI.getInstance(
context = context,
serverUrl = serverUrl!!,
username = username!!,
password = password!!,
httpUsername = null,
httpPassword = null,
clientCertificateAlias = clientCertificateAlias,
)
}

View File

@ -72,11 +72,13 @@ class GoogleReaderRssService @Inject constructor(
private suspend fun getGoogleReaderAPI() =
GoogleReaderSecurityKey(accountDao.queryById(context.currentAccountId)!!.securityKey).run {
GoogleReaderAPI.getInstance(
context = context,
serverUrl = serverUrl!!,
username = username!!,
password = password!!,
httpUsername = null,
httpPassword = null,
clientCertificateAlias = clientCertificateAlias,
)
}

View File

@ -20,7 +20,9 @@
package me.ash.reader.infrastructure.di
import android.annotation.SuppressLint
import android.content.Context
import android.security.KeyChain
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@ -31,15 +33,18 @@ import okhttp3.Cache
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.Response
import okhttp3.internal.platform.Platform
import java.io.File
import java.net.Socket
import java.security.KeyManagementException
import java.security.NoSuchAlgorithmException
import java.security.Principal
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.inject.Singleton
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager
/**
@ -54,6 +59,7 @@ object OkHttpClientModule {
fun provideOkHttpClient(
@ApplicationContext context: Context,
): OkHttpClient = cachingHttpClient(
context = context,
cacheDirectory = context.cacheDir.resolve("http")
).newBuilder()
.addNetworkInterceptor(UserAgentInterceptor)
@ -61,11 +67,13 @@ object OkHttpClientModule {
}
fun cachingHttpClient(
context: Context,
cacheDirectory: File? = null,
cacheSize: Long = 10L * 1024L * 1024L,
trustAllCerts: Boolean = true,
connectTimeoutSecs: Long = 30L,
readTimeoutSecs: Long = 30L,
clientCertificateAlias: String? = null,
): OkHttpClient {
val builder: OkHttpClient.Builder = OkHttpClient.Builder()
@ -78,31 +86,75 @@ fun cachingHttpClient(
.readTimeout(readTimeoutSecs, TimeUnit.SECONDS)
.followRedirects(true)
if (trustAllCerts) {
builder.trustAllCerts()
if (!clientCertificateAlias.isNullOrBlank() || trustAllCerts) {
builder.setupSsl(context, clientCertificateAlias, trustAllCerts)
}
return builder.build()
}
fun OkHttpClient.Builder.trustAllCerts() {
fun OkHttpClient.Builder.setupSsl(
context: Context,
clientCertificateAlias: String?,
trustAllCerts: Boolean
) {
try {
val trustManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
val clientKeyManager = clientCertificateAlias?.let { clientAlias ->
object : X509KeyManager {
override fun getClientAliases(keyType: String?, issuers: Array<Principal>?) =
throw UnsupportedOperationException("getClientAliases")
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
}
override fun chooseClientAlias(
keyType: Array<String>?,
issuers: Array<Principal>?,
socket: Socket?
) = clientCertificateAlias
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
override fun getServerAliases(keyType: String?, issuers: Array<Principal>?) =
throw UnsupportedOperationException("getServerAliases")
override fun chooseServerAlias(
keyType: String?,
issuers: Array<Principal>?,
socket: Socket?
) = throw UnsupportedOperationException("chooseServerAlias")
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
return if (alias == clientAlias) KeyChain.getCertificateChain(context, clientAlias) else null
}
override fun getPrivateKey(alias: String?): PrivateKey? {
return if (alias == clientAlias) KeyChain.getPrivateKey(context, clientAlias) else null
}
}
}
val trustManager = if (trustAllCerts) {
hostnameVerifier { _, _ -> true }
@SuppressLint("CustomX509TrustManager")
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<out X509Certificate>?,
authType: String?
) = Unit
override fun checkServerTrusted(
chain: Array<out X509Certificate>?,
authType: String?
) = Unit
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
} else {
Platform.get().platformTrustManager()
}
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf<TrustManager>(trustManager), null)
sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null)
val sslSocketFactory = sslContext.socketFactory
sslSocketFactory(sslSocketFactory, trustManager)
.hostnameVerifier(HostnameVerifier { _, _ -> true })
} catch (e: NoSuchAlgorithmException) {
// ignore
} catch (e: KeyManagementException) {

View File

@ -1,14 +1,18 @@
package me.ash.reader.infrastructure.rss.provider
import android.content.Context
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import me.ash.reader.infrastructure.di.UserAgentInterceptor
import me.ash.reader.infrastructure.di.cachingHttpClient
import okhttp3.OkHttpClient
abstract class ProviderAPI {
abstract class ProviderAPI(context: Context, clientCertificateAlias: String?) {
protected val client: OkHttpClient = cachingHttpClient()
protected val client: OkHttpClient = cachingHttpClient(
context = context,
clientCertificateAlias = clientCertificateAlias,
)
.newBuilder()
.addNetworkInterceptor(UserAgentInterceptor)
.build()

View File

@ -1,5 +1,6 @@
package me.ash.reader.infrastructure.rss.provider.fever
import android.content.Context
import me.ash.reader.infrastructure.exception.FeverAPIException
import me.ash.reader.infrastructure.rss.provider.ProviderAPI
import me.ash.reader.ui.ext.encodeBase64
@ -10,11 +11,13 @@ import okhttp3.executeAsync
import java.util.concurrent.ConcurrentHashMap
class FeverAPI private constructor(
context: Context,
private val serverUrl: String,
private val apiKey: String,
private val httpUsername: String? = null,
private val httpPassword: String? = null,
) : ProviderAPI() {
clientCertificateAlias: String? = null,
) : ProviderAPI(context, clientCertificateAlias) {
private suspend inline fun <reified T> postRequest(query: String?): T {
val response = client.newCall(
@ -104,14 +107,16 @@ class FeverAPI private constructor(
private val instances: ConcurrentHashMap<String, FeverAPI> = ConcurrentHashMap()
fun getInstance(
context: Context,
serverUrl: String,
username: String,
password: String,
httpUsername: String? = null,
httpPassword: String? = null,
clientCertificateAlias: String? = null,
): FeverAPI = "$username:$password".md5().run {
instances.getOrPut("$serverUrl$this$httpUsername$httpPassword") {
FeverAPI(serverUrl, this, httpUsername, httpPassword)
instances.getOrPut("$serverUrl$this$httpUsername$httpPassword$clientCertificateAlias") {
FeverAPI(context, serverUrl, this, httpUsername, httpPassword, clientCertificateAlias)
}
}

View File

@ -1,5 +1,6 @@
package me.ash.reader.infrastructure.rss.provider.greader
import android.content.Context
import me.ash.reader.infrastructure.di.USER_AGENT_STRING
import me.ash.reader.infrastructure.exception.GoogleReaderAPIException
import me.ash.reader.infrastructure.exception.RetryException
@ -10,12 +11,14 @@ import okhttp3.executeAsync
import java.util.concurrent.ConcurrentHashMap
class GoogleReaderAPI private constructor(
context: Context,
private val serverUrl: String,
private val username: String,
private val password: String,
private val httpUsername: String? = null,
private val httpPassword: String? = null,
) : ProviderAPI() {
clientCertificateAlias: String? = null,
) : ProviderAPI(context, clientCertificateAlias) {
enum class Stream(val tag: String) {
ALL_ITEMS("user/-/state/com.google/reading-list"),
@ -350,13 +353,15 @@ class GoogleReaderAPI private constructor(
private val instances: ConcurrentHashMap<String, GoogleReaderAPI> = ConcurrentHashMap()
fun getInstance(
context: Context,
serverUrl: String,
username: String,
password: String,
httpUsername: String? = null,
httpPassword: String? = null,
): GoogleReaderAPI = instances.getOrPut("$serverUrl$username$password$httpUsername$httpPassword") {
GoogleReaderAPI(serverUrl, username, password, httpUsername, httpPassword)
clientCertificateAlias: String? = null
): GoogleReaderAPI = instances.getOrPut("$serverUrl$username$password$httpUsername$httpPassword$clientCertificateAlias") {
GoogleReaderAPI(context, serverUrl, username, password, httpUsername, httpPassword, clientCertificateAlias)
}
fun clearInstance() {

View File

@ -1,5 +1,7 @@
package me.ash.reader.ui.component.base
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
@ -22,6 +24,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
@ -46,6 +49,7 @@ fun RYOutlineTextField(
errorMessage: String = "",
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
keyboardActions: KeyboardActions = KeyboardActions(),
onClick: (() -> Unit)? = null,
) {
val clipboardManager = LocalClipboardManager.current
val focusRequester = remember { FocusRequester() }
@ -59,7 +63,11 @@ fun RYOutlineTextField(
}
OutlinedTextField(
modifier = Modifier.focusRequester(focusRequester),
modifier = if (onClick != null) {
Modifier.focusProperties { canFocus = false }
} else {
Modifier.focusRequester(focusRequester)
},
colors = TextFieldDefaults.colors(
unfocusedContainerColor = Color.Transparent,
focusedContainerColor = Color.Transparent
@ -115,5 +123,18 @@ fun RYOutlineTextField(
},
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
readOnly = onClick != null,
interactionSource = onClick?.let {
remember { MutableInteractionSource() }
.also { interactionSource ->
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect {
if (it is PressInteraction.Release) {
onClick.invoke()
}
}
}
}
}
)
}

View File

@ -1,5 +1,7 @@
package me.ash.reader.ui.page.settings.accounts.addition
import android.app.Activity
import android.security.KeyChain
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@ -55,6 +57,7 @@ fun AddFeverAccountDialog(
var feverServerUrl by rememberSaveable { mutableStateOf("") }
var feverUsername by rememberSaveable { mutableStateOf("") }
var feverPassword by rememberSaveable { mutableStateOf("") }
var feverClientCertificateAlias by rememberSaveable { mutableStateOf("") }
RYDialog(
modifier = Modifier.padding(horizontal = 44.dp),
@ -121,6 +124,19 @@ fun AddFeverAccountDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
requestFocus = false,
readOnly = accountUiState.isLoading,
value = feverClientCertificateAlias,
onValueChange = { feverClientCertificateAlias = it },
label = stringResource(R.string.client_certificate),
onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
feverClientCertificateAlias = alias ?: ""
}, null, null, null, null)
}
)
Spacer(modifier = Modifier.height(10.dp))
}
},
confirmButton = {
@ -138,6 +154,7 @@ fun AddFeverAccountDialog(
serverUrl = feverServerUrl,
username = feverUsername,
password = feverPassword,
clientCertificateAlias = feverClientCertificateAlias,
).toString(),
)) { account, exception ->
if (account == null) {

View File

@ -1,5 +1,7 @@
package me.ash.reader.ui.page.settings.accounts.addition
import android.app.Activity
import android.security.KeyChain
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@ -55,6 +57,7 @@ fun AddFreshRSSAccountDialog(
var freshRSSServerUrl by rememberSaveable { mutableStateOf("") }
var freshRSSUsername by rememberSaveable { mutableStateOf("") }
var freshRSSPassword by rememberSaveable { mutableStateOf("") }
var freshRSSClientCertificateAlias by rememberSaveable { mutableStateOf("") }
RYDialog(
modifier = Modifier.padding(horizontal = 44.dp),
@ -122,6 +125,19 @@ fun AddFreshRSSAccountDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
requestFocus = false,
readOnly = accountUiState.isLoading,
value = freshRSSClientCertificateAlias,
onValueChange = { freshRSSClientCertificateAlias = it },
label = stringResource(R.string.client_certificate),
onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
freshRSSClientCertificateAlias = alias ?: ""
}, null, null, null, null)
}
)
Spacer(modifier = Modifier.height(10.dp))
}
},
confirmButton = {
@ -142,6 +158,7 @@ fun AddFreshRSSAccountDialog(
serverUrl = freshRSSServerUrl,
username = freshRSSUsername,
password = freshRSSPassword,
clientCertificateAlias = freshRSSClientCertificateAlias,
).toString(),
)) { account, exception ->
if (account == null) {

View File

@ -1,5 +1,7 @@
package me.ash.reader.ui.page.settings.accounts.addition
import android.app.Activity
import android.security.KeyChain
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
@ -56,6 +58,7 @@ fun AddGoogleReaderAccountDialog(
var googleReaderServerUrl by rememberSaveable { mutableStateOf("") }
var googleReaderUsername by rememberSaveable { mutableStateOf("") }
var googleReaderPassword by rememberSaveable { mutableStateOf("") }
var googleReaderClientCertificateAlias by rememberSaveable { mutableStateOf("") }
RYDialog(
modifier = Modifier.padding(horizontal = 44.dp),
@ -123,6 +126,19 @@ fun AddGoogleReaderAccountDialog(
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
requestFocus = false,
readOnly = accountUiState.isLoading,
value = googleReaderClientCertificateAlias,
onValueChange = { googleReaderClientCertificateAlias = it },
label = stringResource(R.string.client_certificate),
onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
googleReaderClientCertificateAlias = alias ?: ""
}, null, null, null, null)
}
)
Spacer(modifier = Modifier.height(10.dp))
}
},
confirmButton = {
@ -143,6 +159,7 @@ fun AddGoogleReaderAccountDialog(
serverUrl = googleReaderServerUrl,
username = googleReaderUsername,
password = googleReaderPassword,
clientCertificateAlias = googleReaderClientCertificateAlias.takeIf { it.isNotEmpty() },
).toString(),
)) { account, exception ->
if (account == null) {

View File

@ -1,7 +1,16 @@
package me.ash.reader.ui.page.settings.accounts.connection
import android.app.Activity
import android.security.KeyChain
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.R
@ -17,6 +26,8 @@ fun LazyItemScope.FeverConnection(
account: Account,
viewModel: AccountViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val securityKey by remember {
derivedStateOf { FeverSecurityKey(account.securityKey) }
}
@ -56,6 +67,16 @@ fun LazyItemScope.FeverConnection(
passwordDialogVisible = true
},
) {}
SettingItem(
title = stringResource(R.string.client_certificate),
desc = securityKey.clientCertificateAlias,
onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
securityKey.clientCertificateAlias = alias
save(account, viewModel, securityKey)
}, null, null, null, null)
},
) {}
TextFieldDialog(
visible = serverUrlDialogVisible,

View File

@ -1,7 +1,16 @@
package me.ash.reader.ui.page.settings.accounts.connection
import android.app.Activity
import android.security.KeyChain
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.R
@ -17,6 +26,8 @@ fun LazyItemScope.FreshRSSConnection(
account: Account,
viewModel: AccountViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val securityKey by remember {
derivedStateOf { FreshRSSSecurityKey(account.securityKey) }
}
@ -56,6 +67,16 @@ fun LazyItemScope.FreshRSSConnection(
passwordDialogVisible = true
},
) {}
SettingItem(
title = stringResource(R.string.client_certificate),
desc = securityKey.clientCertificateAlias,
onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
securityKey.clientCertificateAlias = alias
save(account, viewModel, securityKey)
}, null, null, null, null)
},
) {}
TextFieldDialog(
visible = serverUrlDialogVisible,

View File

@ -1,7 +1,16 @@
package me.ash.reader.ui.page.settings.accounts.connection
import android.app.Activity
import android.security.KeyChain
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.R
@ -17,6 +26,8 @@ fun LazyItemScope.GoogleReaderConnection(
account: Account,
viewModel: AccountViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val securityKey by remember {
derivedStateOf { GoogleReaderSecurityKey(account.securityKey) }
}
@ -56,6 +67,16 @@ fun LazyItemScope.GoogleReaderConnection(
passwordDialogVisible = true
},
) {}
SettingItem(
title = stringResource(R.string.client_certificate),
desc = securityKey.clientCertificateAlias,
onClick = {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
securityKey.clientCertificateAlias = alias
save(account, viewModel, securityKey)
}, null, null, null, null)
},
) {}
TextFieldDialog(
visible = serverUrlDialogVisible,

View File

@ -392,6 +392,7 @@
<string name="server_url">Server URL</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="client_certificate">Client certificate (optional)</string>
<string name="connection">Connection</string>
<string name="system_default">System</string>
<string name="initial_open_app">App when link is clicked</string>