Skip to content

Commit 480aa0c

Browse files
committed
Support specifying a client certificate for mTLS auth
1 parent 5c9f6be commit 480aa0c

19 files changed

+176
-23
lines changed

app/src/main/java/com/capyreader/app/CommonModule.kt

+1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ internal val common = module {
1515
single<DatabaseProvider> { AndroidDatabaseProvider(context = get()) }
1616
single {
1717
AccountManager(
18+
context = get(),
1819
rootFolder = androidContext().filesDir.toURI(),
1920
databaseProvider = get(),
2021
cacheDirectory = androidContext().cacheDir.toURI(),

app/src/main/java/com/capyreader/app/ui/accounts/LoginScreen.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.capyreader.app.ui.accounts
22

33
import androidx.compose.runtime.Composable
4+
import androidx.compose.ui.platform.LocalContext
45
import org.koin.androidx.compose.koinViewModel
56

67
@Composable
@@ -9,18 +10,21 @@ fun LoginScreen(
910
onNavigateBack: () -> Unit,
1011
onSuccess: () -> Unit,
1112
) {
13+
val context = LocalContext.current
1214
LoginView(
1315
source = viewModel.source,
1416
onUsernameChange = viewModel::setUsername,
1517
onPasswordChange = viewModel::setPassword,
1618
onUrlChange = viewModel::setURL,
19+
onClientCertAliasChange = viewModel::setClientCertAlias,
1720
onSubmit = {
18-
viewModel.submit {
21+
viewModel.submit(context) {
1922
onSuccess()
2023
}
2124
},
2225
onNavigateBack = onNavigateBack,
2326
url = viewModel.url,
27+
clientCertAlias = viewModel.clientCertAlias,
2428
username = viewModel.username,
2529
password = viewModel.password,
2630
loading = viewModel.loading,

app/src/main/java/com/capyreader/app/ui/accounts/LoginView.kt

+44
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
package com.capyreader.app.ui.accounts
22

3+
import android.app.Activity
4+
import android.security.KeyChain
5+
import androidx.compose.foundation.interaction.MutableInteractionSource
6+
import androidx.compose.foundation.interaction.PressInteraction
37
import androidx.compose.foundation.layout.Arrangement
48
import androidx.compose.foundation.layout.Box
59
import androidx.compose.foundation.layout.Column
@@ -20,6 +24,8 @@ import androidx.compose.material3.Text
2024
import androidx.compose.material3.TextField
2125
import androidx.compose.material3.TopAppBar
2226
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.LaunchedEffect
28+
import androidx.compose.runtime.remember
2329
import androidx.compose.ui.Alignment
2430
import androidx.compose.ui.Modifier
2531
import androidx.compose.ui.platform.LocalContext
@@ -45,9 +51,11 @@ fun LoginView(
4551
onUsernameChange: (username: String) -> Unit = {},
4652
onPasswordChange: (password: String) -> Unit = {},
4753
onUrlChange: (url: String) -> Unit = {},
54+
onClientCertAliasChange: (clientCertAlias: String) -> Unit = {},
4855
onSubmit: () -> Unit = {},
4956
onNavigateBack: () -> Unit = {},
5057
url: String,
58+
clientCertAlias: String,
5159
username: String,
5260
password: String,
5361
loading: Boolean = false,
@@ -104,6 +112,10 @@ fun LoginView(
104112
}
105113
}
106114
)
115+
CertificateField(
116+
onChange = onClientCertAliasChange,
117+
certAlias = clientCertAlias,
118+
)
107119
}
108120
AuthFields(
109121
username = username,
@@ -148,6 +160,37 @@ fun UrlField(
148160
)
149161
}
150162

163+
@Composable
164+
fun CertificateField(
165+
onChange: (certAlias: String) -> Unit,
166+
certAlias: String,
167+
) {
168+
val context = LocalContext.current
169+
TextField(
170+
value = certAlias,
171+
onValueChange = onChange,
172+
singleLine = true,
173+
label = {
174+
Text(stringResource(R.string.auth_fields_client_certificate))
175+
},
176+
modifier = Modifier
177+
.fillMaxWidth(),
178+
readOnly = true,
179+
interactionSource = remember { MutableInteractionSource() }
180+
.also { interactionSource ->
181+
LaunchedEffect(interactionSource) {
182+
interactionSource.interactions.collect {
183+
if (it is PressInteraction.Release) {
184+
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
185+
onChange(alias ?: "")
186+
}, null, null, null, null)
187+
}
188+
}
189+
}
190+
}
191+
)
192+
}
193+
151194
@Preview
152195
@Composable
153196
private fun LoginViewPreview() {
@@ -162,6 +205,7 @@ private fun LoginViewPreview() {
162205
LoginView(
163206
source = Source.FEEDBIN,
164207
url = "",
208+
clientCertAlias = "",
165209
username = "[email protected]",
166210
password = "",
167211
)

app/src/main/java/com/capyreader/app/ui/accounts/LoginViewModel.kt

+14-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.capyreader.app.ui.accounts
22

3+
import android.content.Context
34
import androidx.compose.runtime.getValue
45
import androidx.compose.runtime.mutableStateOf
56
import androidx.compose.runtime.setValue
@@ -27,6 +28,7 @@ class LoginViewModel(
2728
private var _username by mutableStateOf("")
2829
private var _password by mutableStateOf("")
2930
private var _url by mutableStateOf("")
31+
private var _clientCertAlias by mutableStateOf("")
3032
private var _result by mutableStateOf<Async<Unit>>(Async.Uninitialized)
3133
val source = handle.toRoute<Route.Login>().source
3234

@@ -39,6 +41,9 @@ class LoginViewModel(
3941
val url
4042
get() = _url
4143

44+
val clientCertAlias
45+
get() = _clientCertAlias
46+
4247
val loading: Boolean
4348
get() = _result is Async.Loading
4449

@@ -57,7 +62,11 @@ class LoginViewModel(
5762
_url = url
5863
}
5964

60-
fun submit(onSuccess: () -> Unit) {
65+
fun setClientCertAlias(clientCertAlias: String) {
66+
_clientCertAlias = clientCertAlias
67+
}
68+
69+
fun submit(context: Context, onSuccess: () -> Unit) {
6170
if (username.isBlank() || password.isBlank()) {
6271
_result = Async.Failure(loginError())
6372
}
@@ -67,7 +76,7 @@ class LoginViewModel(
6776
viewModelScope.launchIO {
6877
_result = Async.Loading
6978

70-
credentials.verify()
79+
credentials.verify(context)
7180
.onSuccess { result ->
7281
createAccount(result)
7382

@@ -94,14 +103,16 @@ class LoginViewModel(
94103
source = source,
95104
username = username,
96105
password = password,
97-
url = _url
106+
url = url,
107+
clientCertAlias = clientCertAlias,
98108
)
99109

100110
private fun createAccount(credentials: Credentials) {
101111
val accountID = accountManager.createAccount(
102112
username = credentials.username,
103113
password = credentials.secret,
104114
url = credentials.url,
115+
clientCertAlias = credentials.clientCertAlias,
105116
source = credentials.source
106117
)
107118

app/src/main/java/com/capyreader/app/ui/accounts/UpdateLoginViewModel.kt

+6-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.capyreader.app.ui.accounts
22

3+
import android.content.Context
34
import androidx.compose.runtime.getValue
45
import androidx.compose.runtime.mutableStateOf
56
import androidx.compose.runtime.setValue
@@ -18,6 +19,7 @@ class UpdateLoginViewModel(
1819
val username = account.preferences.username.get()
1920
val source = account.source
2021
private val url = account.preferences.url.get()
22+
private val clientCertAlias = account.preferences.clientCertAlias.get()
2123

2224
private var _password by mutableStateOf("")
2325
private var _result by mutableStateOf<Async<Unit>>(Async.Uninitialized)
@@ -35,15 +37,15 @@ class UpdateLoginViewModel(
3537
_password = password
3638
}
3739

38-
fun submit(onSuccess: () -> Unit) {
40+
fun submit(context: Context, onSuccess: () -> Unit) {
3941
if (password.isBlank()) {
4042
_result = Async.Failure(loginError())
4143
}
4244

4345
viewModelScope.launchIO {
4446
_result = Async.Loading
4547

46-
credentials.verify()
48+
credentials.verify(context)
4749
.onSuccess { result ->
4850
updateAccount(result)
4951

@@ -62,7 +64,8 @@ class UpdateLoginViewModel(
6264
source = source,
6365
username = username,
6466
password = password,
65-
url = url
67+
url = url,
68+
clientCertAlias = clientCertAlias,
6669
)
6770

6871
private fun updateAccount(result: Credentials) {

app/src/main/java/com/capyreader/app/ui/articles/UpdateAuthDialog.kt

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.capyreader.app.ui.articles
22

33
import androidx.compose.runtime.Composable
4+
import androidx.compose.ui.platform.LocalContext
45
import androidx.compose.ui.res.stringResource
56
import androidx.compose.ui.window.Dialog
67
import androidx.compose.ui.window.DialogProperties
@@ -15,6 +16,7 @@ fun UpdateAuthDialog(
1516
onSuccess: (message: String) -> Unit,
1617
viewModel: UpdateLoginViewModel = koinViewModel()
1718
) {
19+
val context = LocalContext.current
1820
val successMessage = stringResource(R.string.update_auth_success_message)
1921

2022
Dialog(
@@ -27,7 +29,7 @@ fun UpdateAuthDialog(
2729
onPasswordChange = viewModel::setPassword,
2830
onNavigateBack = onDismissRequest,
2931
onSubmit = {
30-
viewModel.submit {
32+
viewModel.submit(context) {
3133
onSuccess(successMessage)
3234
}
3335
},

app/src/main/res/values/strings.xml

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@
7272
<string name="auth_fields_email">Email</string>
7373
<string name="auth_fields_username">Username</string>
7474
<string name="auth_fields_api_url">Server</string>
75+
<string name="auth_fields_client_certificate">Client certificate (optional)</string>
7576
<string name="auth_fields_api_url_freshrss_placeholder" translatable="false">https://example.com/api/greader.php</string>
7677
<string name="auth_fields_hide_password">Hide password</string>
7778
<string name="auth_fields_show_password">Show password</string>

capy/src/main/java/com/jocmp/capy/Account.kt

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.jocmp.capy
22

3+
import android.content.Context
34
import com.jocmp.capy.accounts.AddFeedResult
45
import com.jocmp.capy.accounts.AutoDelete
56
import com.jocmp.capy.accounts.FaviconFetcher
@@ -30,6 +31,7 @@ import java.net.URI
3031
import java.time.ZonedDateTime
3132

3233
data class Account(
34+
val context: Context,
3335
val id: String,
3436
val path: URI,
3537
val cacheDirectory: URI,
@@ -54,6 +56,7 @@ data class Account(
5456
)
5557

5658
Source.FRESHRSS, Source.READER -> buildReaderDelegate(
59+
context = context,
5760
database = database,
5861
path = cacheDirectory,
5962
preferences = preferences

capy/src/main/java/com/jocmp/capy/AccountManager.kt

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.jocmp.capy
22

3+
import android.content.Context
34
import com.jocmp.capy.accounts.FaviconFetcher
45
import com.jocmp.capy.accounts.Source
56
import com.jocmp.capy.db.Database
@@ -9,6 +10,7 @@ import java.net.URI
910
import java.util.UUID
1011

1112
class AccountManager(
13+
private val context: Context,
1214
val rootFolder: URI,
1315
private val cacheDirectory: URI,
1416
private val databaseProvider: DatabaseProvider,
@@ -21,13 +23,14 @@ class AccountManager(
2123
): Account? {
2224
val existingAccount = findAccountFile(id) ?: return null
2325

24-
return buildAccount(existingAccount, database, faviconFetcher)
26+
return buildAccount(context, existingAccount, database, faviconFetcher)
2527
}
2628

2729
fun createAccount(
2830
username: String,
2931
password: String,
3032
url: String,
33+
clientCertAlias: String,
3134
source: Source
3235
): String {
3336
val accountID = createAccount(source = source)
@@ -36,6 +39,7 @@ class AccountManager(
3639
preferences.username.set(username)
3740
preferences.password.set(password)
3841
preferences.url.set(url)
42+
preferences.clientCertAlias.set(clientCertAlias)
3943
}
4044

4145
return accountID
@@ -72,6 +76,7 @@ class AccountManager(
7276
private fun accountFolder() = File(rootFolder.path, DIRECTORY_NAME)
7377

7478
private fun buildAccount(
79+
context: Context,
7580
path: File,
7681
database: Database,
7782
faviconFetcher: FaviconFetcher,
@@ -82,6 +87,7 @@ class AccountManager(
8287
val cacheDirectory = File(cacheDirectory.path, id).toURI()
8388

8489
return Account(
90+
context = context,
8591
id = id,
8692
path = pathURI,
8793
cacheDirectory = cacheDirectory,

capy/src/main/java/com/jocmp/capy/AccountPreferences.kt

+3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ class AccountPreferences(
1818
val url: Preference<String>
1919
get() = store.getString("api_url", "")
2020

21+
val clientCertAlias: Preference<String>
22+
get() = store.getString("client_cert_alias", "")
23+
2124
val password: Preference<String>
2225
get() = store.getString("password", "")
2326

capy/src/main/java/com/jocmp/capy/accounts/Credentials.kt

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.jocmp.capy.accounts
22

3+
import android.content.Context
34
import com.jocmp.capy.accounts.feedbin.FeedbinCredentials
45
import com.jocmp.capy.accounts.reader.ReaderCredentials
56
import com.jocmp.capy.common.optionalURL
@@ -8,23 +9,26 @@ interface Credentials {
89
val username: String
910
val secret: String
1011
val url: String
12+
val clientCertAlias: String
1113
val source: Source
1214

13-
suspend fun verify(): Result<Credentials>
15+
suspend fun verify(context: Context): Result<Credentials>
1416

1517
companion object {
1618
fun from(
1719
source: Source,
1820
username: String,
1921
password: String,
2022
url: String,
23+
clientCertAlias: String,
2124
): Credentials {
2225
return when (source) {
2326
Source.FEEDBIN -> FeedbinCredentials(username, password)
2427
Source.FRESHRSS, Source.READER -> ReaderCredentials(
2528
username,
2629
password,
2730
url = normalizeURL(url),
31+
clientCertAlias = clientCertAlias,
2832
source = source
2933
)
3034

0 commit comments

Comments
 (0)