Skip to content

Commit 1669fac

Browse files
committed
Support specifying a client certificate for mTLS auth
1 parent e0cd9ac commit 1669fac

17 files changed

+242
-27
lines changed

app/src/main/java/me/ash/reader/domain/model/account/security/FeverSecurityKey.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@ class FeverSecurityKey private constructor() : SecurityKey() {
55
var serverUrl: String? = null
66
var username: String? = null
77
var password: String? = null
8+
var clientCertificateAlias: String? = null
89

9-
constructor(serverUrl: String?, username: String?, password: String?) : this() {
10+
constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() {
1011
this.serverUrl = serverUrl
1112
this.username = username
1213
this.password = password
14+
this.clientCertificateAlias = clientCertificateAlias
1315
}
1416

1517
constructor(value: String? = DESUtils.empty) : this() {
1618
decode(value, FeverSecurityKey::class.java).let {
1719
serverUrl = it.serverUrl
1820
username = it.username
1921
password = it.password
22+
clientCertificateAlias = it.clientCertificateAlias
2023
}
2124
}
2225
}

app/src/main/java/me/ash/reader/domain/model/account/security/FreshRSSSecurityKey.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@ class FreshRSSSecurityKey private constructor() : SecurityKey() {
55
var serverUrl: String? = null
66
var username: String? = null
77
var password: String? = null
8+
var clientCertificateAlias: String? = null
89

9-
constructor(serverUrl: String?, username: String?, password: String?) : this() {
10+
constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() {
1011
this.serverUrl = serverUrl
1112
this.username = username
1213
this.password = password
14+
this.clientCertificateAlias = clientCertificateAlias
1315
}
1416

1517
constructor(value: String? = DESUtils.empty) : this() {
1618
decode(value, FreshRSSSecurityKey::class.java).let {
1719
serverUrl = it.serverUrl
1820
username = it.username
1921
password = it.password
22+
clientCertificateAlias = it.clientCertificateAlias
2023
}
2124
}
2225
}

app/src/main/java/me/ash/reader/domain/model/account/security/GoogleReaderSecurityKey.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,21 @@ class GoogleReaderSecurityKey private constructor() : SecurityKey() {
55
var serverUrl: String? = null
66
var username: String? = null
77
var password: String? = null
8+
var clientCertificateAlias: String? = null
89

9-
constructor(serverUrl: String?, username: String?, password: String?) : this() {
10+
constructor(serverUrl: String?, username: String?, password: String?, clientCertificateAlias: String?) : this() {
1011
this.serverUrl = serverUrl
1112
this.username = username
1213
this.password = password
14+
this.clientCertificateAlias = clientCertificateAlias
1315
}
1416

1517
constructor(value: String? = DESUtils.empty) : this() {
1618
decode(value, GoogleReaderSecurityKey::class.java).let {
1719
serverUrl = it.serverUrl
1820
username = it.username
1921
password = it.password
22+
clientCertificateAlias = it.clientCertificateAlias
2023
}
2124
}
2225
}

app/src/main/java/me/ash/reader/domain/service/FeverRssService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,13 @@ class FeverRssService @Inject constructor(
7070
private suspend fun getFeverAPI() =
7171
FeverSecurityKey(accountDao.queryById(context.currentAccountId)!!.securityKey).run {
7272
FeverAPI.getInstance(
73+
context = context,
7374
serverUrl = serverUrl!!,
7475
username = username!!,
7576
password = password!!,
7677
httpUsername = null,
7778
httpPassword = null,
79+
clientCertificateAlias = clientCertificateAlias,
7880
)
7981
}
8082

app/src/main/java/me/ash/reader/domain/service/GoogleReaderRssService.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,13 @@ class GoogleReaderRssService @Inject constructor(
7272
private suspend fun getGoogleReaderAPI() =
7373
GoogleReaderSecurityKey(accountDao.queryById(context.currentAccountId)!!.securityKey).run {
7474
GoogleReaderAPI.getInstance(
75+
context = context,
7576
serverUrl = serverUrl!!,
7677
username = username!!,
7778
password = password!!,
7879
httpUsername = null,
7980
httpPassword = null,
81+
clientCertificateAlias = clientCertificateAlias,
8082
)
8183
}
8284

app/src/main/java/me/ash/reader/infrastructure/di/OkHttpClientModule.kt

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020

2121
package me.ash.reader.infrastructure.di
2222

23+
import android.annotation.SuppressLint
2324
import android.content.Context
25+
import android.security.KeyChain
2426
import dagger.Module
2527
import dagger.Provides
2628
import dagger.hilt.InstallIn
@@ -31,15 +33,18 @@ import okhttp3.Cache
3133
import okhttp3.Interceptor
3234
import okhttp3.OkHttpClient
3335
import okhttp3.Response
36+
import okhttp3.internal.platform.Platform
3437
import java.io.File
38+
import java.net.Socket
3539
import java.security.KeyManagementException
3640
import java.security.NoSuchAlgorithmException
41+
import java.security.Principal
42+
import java.security.PrivateKey
3743
import java.security.cert.X509Certificate
3844
import java.util.concurrent.TimeUnit
3945
import javax.inject.Singleton
40-
import javax.net.ssl.HostnameVerifier
4146
import javax.net.ssl.SSLContext
42-
import javax.net.ssl.TrustManager
47+
import javax.net.ssl.X509KeyManager
4348
import javax.net.ssl.X509TrustManager
4449

4550
/**
@@ -54,18 +59,21 @@ object OkHttpClientModule {
5459
fun provideOkHttpClient(
5560
@ApplicationContext context: Context,
5661
): OkHttpClient = cachingHttpClient(
62+
context = context,
5763
cacheDirectory = context.cacheDir.resolve("http")
5864
).newBuilder()
5965
.addNetworkInterceptor(UserAgentInterceptor)
6066
.build()
6167
}
6268

6369
fun cachingHttpClient(
70+
context: Context,
6471
cacheDirectory: File? = null,
6572
cacheSize: Long = 10L * 1024L * 1024L,
6673
trustAllCerts: Boolean = true,
6774
connectTimeoutSecs: Long = 30L,
6875
readTimeoutSecs: Long = 30L,
76+
clientCertificateAlias: String? = null,
6977
): OkHttpClient {
7078
val builder: OkHttpClient.Builder = OkHttpClient.Builder()
7179

@@ -78,31 +86,75 @@ fun cachingHttpClient(
7886
.readTimeout(readTimeoutSecs, TimeUnit.SECONDS)
7987
.followRedirects(true)
8088

81-
if (trustAllCerts) {
82-
builder.trustAllCerts()
89+
if (!clientCertificateAlias.isNullOrBlank() || trustAllCerts) {
90+
builder.setupSsl(context, clientCertificateAlias, trustAllCerts)
8391
}
8492

8593
return builder.build()
8694
}
8795

88-
fun OkHttpClient.Builder.trustAllCerts() {
96+
fun OkHttpClient.Builder.setupSsl(
97+
context: Context,
98+
clientCertificateAlias: String?,
99+
trustAllCerts: Boolean
100+
) {
89101
try {
90-
val trustManager = object : X509TrustManager {
91-
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
102+
val clientKeyManager = clientCertificateAlias?.let { clientAlias ->
103+
object : X509KeyManager {
104+
override fun getClientAliases(keyType: String?, issuers: Array<Principal>?) =
105+
throw UnsupportedOperationException("getClientAliases")
106+
107+
override fun chooseClientAlias(
108+
keyType: Array<String>?,
109+
issuers: Array<Principal>?,
110+
socket: Socket?
111+
) = clientCertificateAlias
112+
113+
override fun getServerAliases(keyType: String?, issuers: Array<Principal>?) =
114+
throw UnsupportedOperationException("getServerAliases")
115+
116+
override fun chooseServerAlias(
117+
keyType: String?,
118+
issuers: Array<Principal>?,
119+
socket: Socket?
120+
) = throw UnsupportedOperationException("chooseServerAlias")
121+
122+
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
123+
return if (alias == clientAlias) KeyChain.getCertificateChain(context, clientAlias) else null
124+
}
125+
126+
override fun getPrivateKey(alias: String?): PrivateKey? {
127+
return if (alias == clientAlias) KeyChain.getPrivateKey(context, clientAlias) else null
128+
}
92129
}
130+
}
93131

94-
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
95-
}
132+
val trustManager = if (trustAllCerts) {
133+
hostnameVerifier { _, _ -> true }
134+
135+
@SuppressLint("CustomX509TrustManager")
136+
object : X509TrustManager {
137+
override fun checkClientTrusted(
138+
chain: Array<out X509Certificate>?,
139+
authType: String?
140+
) = Unit
96141

97-
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
142+
override fun checkServerTrusted(
143+
chain: Array<out X509Certificate>?,
144+
authType: String?
145+
) = Unit
146+
147+
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
148+
}
149+
} else {
150+
Platform.get().platformTrustManager()
98151
}
99152

100153
val sslContext = SSLContext.getInstance("TLS")
101-
sslContext.init(null, arrayOf<TrustManager>(trustManager), null)
154+
sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null)
102155
val sslSocketFactory = sslContext.socketFactory
103156

104157
sslSocketFactory(sslSocketFactory, trustManager)
105-
.hostnameVerifier(HostnameVerifier { _, _ -> true })
106158
} catch (e: NoSuchAlgorithmException) {
107159
// ignore
108160
} catch (e: KeyManagementException) {

app/src/main/java/me/ash/reader/infrastructure/rss/provider/ProviderAPI.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package me.ash.reader.infrastructure.rss.provider
22

3+
import android.content.Context
34
import com.google.gson.Gson
45
import com.google.gson.GsonBuilder
56
import me.ash.reader.infrastructure.di.UserAgentInterceptor
67
import me.ash.reader.infrastructure.di.cachingHttpClient
78
import okhttp3.OkHttpClient
89

9-
abstract class ProviderAPI {
10+
abstract class ProviderAPI(context: Context, clientCertificateAlias: String?) {
1011

11-
protected val client: OkHttpClient = cachingHttpClient()
12+
protected val client: OkHttpClient = cachingHttpClient(
13+
context = context,
14+
clientCertificateAlias = clientCertificateAlias,
15+
)
1216
.newBuilder()
1317
.addNetworkInterceptor(UserAgentInterceptor)
1418
.build()

app/src/main/java/me/ash/reader/infrastructure/rss/provider/fever/FeverAPI.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package me.ash.reader.infrastructure.rss.provider.fever
22

3+
import android.content.Context
34
import me.ash.reader.infrastructure.exception.FeverAPIException
45
import me.ash.reader.infrastructure.rss.provider.ProviderAPI
56
import me.ash.reader.ui.ext.encodeBase64
@@ -10,11 +11,13 @@ import okhttp3.executeAsync
1011
import java.util.concurrent.ConcurrentHashMap
1112

1213
class FeverAPI private constructor(
14+
context: Context,
1315
private val serverUrl: String,
1416
private val apiKey: String,
1517
private val httpUsername: String? = null,
1618
private val httpPassword: String? = null,
17-
) : ProviderAPI() {
19+
clientCertificateAlias: String? = null,
20+
) : ProviderAPI(context, clientCertificateAlias) {
1821

1922
private suspend inline fun <reified T> postRequest(query: String?): T {
2023
val response = client.newCall(
@@ -104,14 +107,16 @@ class FeverAPI private constructor(
104107
private val instances: ConcurrentHashMap<String, FeverAPI> = ConcurrentHashMap()
105108

106109
fun getInstance(
110+
context: Context,
107111
serverUrl: String,
108112
username: String,
109113
password: String,
110114
httpUsername: String? = null,
111115
httpPassword: String? = null,
116+
clientCertificateAlias: String? = null,
112117
): FeverAPI = "$username:$password".md5().run {
113-
instances.getOrPut("$serverUrl$this$httpUsername$httpPassword") {
114-
FeverAPI(serverUrl, this, httpUsername, httpPassword)
118+
instances.getOrPut("$serverUrl$this$httpUsername$httpPassword$clientCertificateAlias") {
119+
FeverAPI(context, serverUrl, this, httpUsername, httpPassword, clientCertificateAlias)
115120
}
116121
}
117122

app/src/main/java/me/ash/reader/infrastructure/rss/provider/greader/GoogleReaderAPI.kt

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package me.ash.reader.infrastructure.rss.provider.greader
22

3+
import android.content.Context
34
import me.ash.reader.infrastructure.di.USER_AGENT_STRING
45
import me.ash.reader.infrastructure.exception.GoogleReaderAPIException
56
import me.ash.reader.infrastructure.exception.RetryException
@@ -10,12 +11,14 @@ import okhttp3.executeAsync
1011
import java.util.concurrent.ConcurrentHashMap
1112

1213
class GoogleReaderAPI private constructor(
14+
context: Context,
1315
private val serverUrl: String,
1416
private val username: String,
1517
private val password: String,
1618
private val httpUsername: String? = null,
1719
private val httpPassword: String? = null,
18-
) : ProviderAPI() {
20+
clientCertificateAlias: String? = null,
21+
) : ProviderAPI(context, clientCertificateAlias) {
1922

2023
enum class Stream(val tag: String) {
2124
ALL_ITEMS("user/-/state/com.google/reading-list"),
@@ -350,13 +353,15 @@ class GoogleReaderAPI private constructor(
350353
private val instances: ConcurrentHashMap<String, GoogleReaderAPI> = ConcurrentHashMap()
351354

352355
fun getInstance(
356+
context: Context,
353357
serverUrl: String,
354358
username: String,
355359
password: String,
356360
httpUsername: String? = null,
357361
httpPassword: String? = null,
358-
): GoogleReaderAPI = instances.getOrPut("$serverUrl$username$password$httpUsername$httpPassword") {
359-
GoogleReaderAPI(serverUrl, username, password, httpUsername, httpPassword)
362+
clientCertificateAlias: String? = null
363+
): GoogleReaderAPI = instances.getOrPut("$serverUrl$username$password$httpUsername$httpPassword$clientCertificateAlias") {
364+
GoogleReaderAPI(context, serverUrl, username, password, httpUsername, httpPassword, clientCertificateAlias)
360365
}
361366

362367
fun clearInstance() {

app/src/main/java/me/ash/reader/ui/component/base/RYOutlineTextField.kt

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package me.ash.reader.ui.component.base
22

3+
import androidx.compose.foundation.interaction.MutableInteractionSource
4+
import androidx.compose.foundation.interaction.PressInteraction
35
import androidx.compose.foundation.text.KeyboardActions
46
import androidx.compose.foundation.text.KeyboardOptions
57
import androidx.compose.material.icons.Icons
@@ -22,6 +24,7 @@ import androidx.compose.runtime.remember
2224
import androidx.compose.runtime.setValue
2325
import androidx.compose.ui.Modifier
2426
import androidx.compose.ui.focus.FocusRequester
27+
import androidx.compose.ui.focus.focusProperties
2528
import androidx.compose.ui.focus.focusRequester
2629
import androidx.compose.ui.graphics.Color
2730
import androidx.compose.ui.platform.LocalClipboardManager
@@ -46,6 +49,7 @@ fun RYOutlineTextField(
4649
errorMessage: String = "",
4750
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
4851
keyboardActions: KeyboardActions = KeyboardActions(),
52+
onClick: (() -> Unit)? = null,
4953
) {
5054
val clipboardManager = LocalClipboardManager.current
5155
val focusRequester = remember { FocusRequester() }
@@ -59,7 +63,11 @@ fun RYOutlineTextField(
5963
}
6064

6165
OutlinedTextField(
62-
modifier = Modifier.focusRequester(focusRequester),
66+
modifier = if (onClick != null) {
67+
Modifier.focusProperties { canFocus = false }
68+
} else {
69+
Modifier.focusRequester(focusRequester)
70+
},
6371
colors = TextFieldDefaults.colors(
6472
unfocusedContainerColor = Color.Transparent,
6573
focusedContainerColor = Color.Transparent
@@ -115,5 +123,18 @@ fun RYOutlineTextField(
115123
},
116124
keyboardOptions = keyboardOptions,
117125
keyboardActions = keyboardActions,
126+
readOnly = onClick != null,
127+
interactionSource = onClick?.let {
128+
remember { MutableInteractionSource() }
129+
.also { interactionSource ->
130+
LaunchedEffect(interactionSource) {
131+
interactionSource.interactions.collect {
132+
if (it is PressInteraction.Release) {
133+
onClick.invoke()
134+
}
135+
}
136+
}
137+
}
138+
}
118139
)
119140
}

0 commit comments

Comments
 (0)