Skip to content

Support specifying a client certificate for mTLS auth #688

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/com/capyreader/app/CommonModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ internal val common = module {
single<DatabaseProvider> { AndroidDatabaseProvider(context = get()) }
single {
AccountManager(
context = get(),
rootFolder = androidContext().filesDir.toURI(),
databaseProvider = get(),
cacheDirectory = androidContext().cacheDir.toURI(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.capyreader.app.ui.accounts

import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import org.koin.androidx.compose.koinViewModel

@Composable
Expand All @@ -9,18 +10,21 @@ fun LoginScreen(
onNavigateBack: () -> Unit,
onSuccess: () -> Unit,
) {
val context = LocalContext.current
LoginView(
source = viewModel.source,
onUsernameChange = viewModel::setUsername,
onPasswordChange = viewModel::setPassword,
onUrlChange = viewModel::setURL,
onClientCertAliasChange = viewModel::setClientCertAlias,
onSubmit = {
viewModel.submit {
viewModel.submit(context) {
onSuccess()
}
},
onNavigateBack = onNavigateBack,
url = viewModel.url,
clientCertAlias = viewModel.clientCertAlias,
username = viewModel.username,
password = viewModel.password,
loading = viewModel.loading,
Expand Down
44 changes: 44 additions & 0 deletions app/src/main/java/com/capyreader/app/ui/accounts/LoginView.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.capyreader.app.ui.accounts

import android.app.Activity
import android.security.KeyChain
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
Expand All @@ -20,6 +24,8 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
Expand All @@ -45,9 +51,11 @@ fun LoginView(
onUsernameChange: (username: String) -> Unit = {},
onPasswordChange: (password: String) -> Unit = {},
onUrlChange: (url: String) -> Unit = {},
onClientCertAliasChange: (clientCertAlias: String) -> Unit = {},
onSubmit: () -> Unit = {},
onNavigateBack: () -> Unit = {},
url: String,
clientCertAlias: String,
username: String,
password: String,
loading: Boolean = false,
Expand Down Expand Up @@ -104,6 +112,10 @@ fun LoginView(
}
}
)
CertificateField(
onChange = onClientCertAliasChange,
certAlias = clientCertAlias,
)
}
AuthFields(
username = username,
Expand Down Expand Up @@ -148,6 +160,37 @@ fun UrlField(
)
}

@Composable
fun CertificateField(
onChange: (certAlias: String) -> Unit,
certAlias: String,
) {
val context = LocalContext.current
TextField(
value = certAlias,
onValueChange = onChange,
singleLine = true,
label = {
Text(stringResource(R.string.auth_fields_client_certificate))
},
modifier = Modifier
.fillMaxWidth(),
readOnly = true,
interactionSource = remember { MutableInteractionSource() }
.also { interactionSource ->
LaunchedEffect(interactionSource) {
interactionSource.interactions.collect {
if (it is PressInteraction.Release) {
KeyChain.choosePrivateKeyAlias(context as Activity, { alias ->
onChange(alias ?: "")
}, null, null, null, null)
}
}
}
}
)
}

@Preview
@Composable
private fun LoginViewPreview() {
Expand All @@ -162,6 +205,7 @@ private fun LoginViewPreview() {
LoginView(
source = Source.FEEDBIN,
url = "",
clientCertAlias = "",
username = "[email protected]",
password = "",
)
Expand Down
17 changes: 14 additions & 3 deletions app/src/main/java/com/capyreader/app/ui/accounts/LoginViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.capyreader.app.ui.accounts

import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
Expand Down Expand Up @@ -28,6 +29,7 @@ class LoginViewModel(
private var _username by mutableStateOf("")
private var _password by mutableStateOf("")
private var _url by mutableStateOf("")
private var _clientCertAlias by mutableStateOf("")
private var _result by mutableStateOf<Async<Unit>>(Async.Uninitialized)
val source = handle.toRoute<Route.Login>().source

Expand All @@ -40,6 +42,9 @@ class LoginViewModel(
val url
get() = _url

val clientCertAlias
get() = _clientCertAlias

val loading: Boolean
get() = _result is Async.Loading

Expand All @@ -58,7 +63,11 @@ class LoginViewModel(
_url = url
}

fun submit(onSuccess: () -> Unit) {
fun setClientCertAlias(clientCertAlias: String) {
_clientCertAlias = clientCertAlias
}

fun submit(context: Context, onSuccess: () -> Unit) {
if (username.isBlank() || password.isBlank()) {
_result = Async.Failure(loginError())
}
Expand All @@ -68,7 +77,7 @@ class LoginViewModel(
viewModelScope.launchIO {
_result = Async.Loading

credentials.verify()
credentials.verify(context)
.onSuccess { result ->
createAccount(result)

Expand Down Expand Up @@ -98,14 +107,16 @@ class LoginViewModel(
source = source,
username = username,
password = password,
url = _url
url = url,
clientCertAlias = clientCertAlias,
)

private fun createAccount(credentials: Credentials) {
val accountID = accountManager.createAccount(
username = credentials.username,
password = credentials.secret,
url = credentials.url,
clientCertAlias = credentials.clientCertAlias,
source = credentials.source
)

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.capyreader.app.ui.accounts

import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
Expand All @@ -18,6 +19,7 @@ class UpdateLoginViewModel(
val username = account.preferences.username.get()
val source = account.source
private val url = account.preferences.url.get()
private val clientCertAlias = account.preferences.clientCertAlias.get()

private var _password by mutableStateOf("")
private var _result by mutableStateOf<Async<Unit>>(Async.Uninitialized)
Expand All @@ -35,15 +37,15 @@ class UpdateLoginViewModel(
_password = password
}

fun submit(onSuccess: () -> Unit) {
fun submit(context: Context, onSuccess: () -> Unit) {
if (password.isBlank()) {
_result = Async.Failure(loginError())
}

viewModelScope.launchIO {
_result = Async.Loading

credentials.verify()
credentials.verify(context)
.onSuccess { result ->
updateAccount(result)

Expand All @@ -62,7 +64,8 @@ class UpdateLoginViewModel(
source = source,
username = username,
password = password,
url = url
url = url,
clientCertAlias = clientCertAlias,
)

private fun updateAccount(result: Credentials) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.capyreader.app.ui.articles

import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
Expand All @@ -15,6 +16,7 @@ fun UpdateAuthDialog(
onSuccess: (message: String) -> Unit,
viewModel: UpdateLoginViewModel = koinViewModel()
) {
val context = LocalContext.current
val successMessage = stringResource(R.string.update_auth_success_message)

Dialog(
Expand All @@ -27,7 +29,7 @@ fun UpdateAuthDialog(
onPasswordChange = viewModel::setPassword,
onNavigateBack = onDismissRequest,
onSubmit = {
viewModel.submit {
viewModel.submit(context) {
onSuccess(successMessage)
}
},
Expand Down
1 change: 1 addition & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<string name="auth_fields_email">Email</string>
<string name="auth_fields_username">Username</string>
<string name="auth_fields_api_url">Server</string>
<string name="auth_fields_client_certificate">Client certificate (optional)</string>
<string name="auth_fields_api_url_freshrss_placeholder" translatable="false">https://example.com/</string>
<string name="auth_fields_hide_password">Hide password</string>
<string name="auth_fields_show_password">Show password</string>
Expand Down
3 changes: 3 additions & 0 deletions capy/src/main/java/com/jocmp/capy/Account.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jocmp.capy

import android.content.Context
import com.jocmp.capy.accounts.AddFeedResult
import com.jocmp.capy.accounts.AutoDelete
import com.jocmp.capy.accounts.FaviconFetcher
Expand Down Expand Up @@ -31,6 +32,7 @@ import java.net.URI
import java.time.ZonedDateTime

data class Account(
val context: Context,
val id: String,
val path: URI,
val cacheDirectory: URI,
Expand All @@ -55,6 +57,7 @@ data class Account(
)

Source.FRESHRSS, Source.READER -> buildReaderDelegate(
context = context,
source = source,
database = database,
path = cacheDirectory,
Expand Down
8 changes: 7 additions & 1 deletion capy/src/main/java/com/jocmp/capy/AccountManager.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jocmp.capy

import android.content.Context
import com.jocmp.capy.accounts.FaviconFetcher
import com.jocmp.capy.accounts.Source
import com.jocmp.capy.db.Database
Expand All @@ -9,6 +10,7 @@ import java.net.URI
import java.util.UUID

class AccountManager(
private val context: Context,
val rootFolder: URI,
private val cacheDirectory: URI,
private val databaseProvider: DatabaseProvider,
Expand All @@ -21,13 +23,14 @@ class AccountManager(
): Account? {
val existingAccount = findAccountFile(id) ?: return null

return buildAccount(existingAccount, database, faviconFetcher)
return buildAccount(context, existingAccount, database, faviconFetcher)
}

fun createAccount(
username: String,
password: String,
url: String,
clientCertAlias: String,
source: Source
): String {
val accountID = createAccount(source = source)
Expand All @@ -36,6 +39,7 @@ class AccountManager(
preferences.username.set(username)
preferences.password.set(password)
preferences.url.set(url)
preferences.clientCertAlias.set(clientCertAlias)
}

return accountID
Expand Down Expand Up @@ -72,6 +76,7 @@ class AccountManager(
private fun accountFolder() = File(rootFolder.path, DIRECTORY_NAME)

private fun buildAccount(
context: Context,
path: File,
database: Database,
faviconFetcher: FaviconFetcher,
Expand All @@ -82,6 +87,7 @@ class AccountManager(
val cacheDirectory = File(cacheDirectory.path, id).toURI()

return Account(
context = context,
id = id,
path = pathURI,
cacheDirectory = cacheDirectory,
Expand Down
3 changes: 3 additions & 0 deletions capy/src/main/java/com/jocmp/capy/AccountPreferences.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ class AccountPreferences(
val url: Preference<String>
get() = store.getString("api_url", "")

val clientCertAlias: Preference<String>
get() = store.getString("client_cert_alias", "")

val password: Preference<String>
get() = store.getString("password", "")

Expand Down
6 changes: 5 additions & 1 deletion capy/src/main/java/com/jocmp/capy/accounts/Credentials.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.jocmp.capy.accounts

import android.content.Context
import com.jocmp.capy.accounts.feedbin.FeedbinCredentials
import com.jocmp.capy.accounts.reader.ReaderCredentials
import com.jocmp.capy.common.optionalURL
Expand All @@ -8,23 +9,26 @@ interface Credentials {
val username: String
val secret: String
val url: String
val clientCertAlias: String
val source: Source

suspend fun verify(): Result<Credentials>
suspend fun verify(context: Context): Result<Credentials>

companion object {
fun from(
source: Source,
username: String,
password: String,
url: String,
clientCertAlias: String,
): Credentials {
return when (source) {
Source.FEEDBIN -> FeedbinCredentials(username, password)
Source.FRESHRSS, Source.READER -> ReaderCredentials(
username,
password,
url = normalizeURL(url),
clientCertAlias = clientCertAlias,
source = source
)

Expand Down
Loading