Skip to content

Commit

Permalink
Login with client certificates
Browse files Browse the repository at this point in the history
* setup UI: login with URL and client certificate
* account settings UI: show either username/password or client certificate alias
* AccountSettings: serve credentials in generalized Credentials objects
* HttpClient: use Credentials (instead of username/password) for authentication
* HttpClient: always use CustomTlsSocketFactory
* CustomTlsSocketFactory: support client certificates
  • Loading branch information
rfc2822 committed Jan 13, 2018
1 parent dd3b326 commit 3689df1
Show file tree
Hide file tree
Showing 18 changed files with 390 additions and 161 deletions.
Expand Up @@ -16,28 +16,25 @@
import java.net.Socket;

import javax.net.ssl.SSLSocket;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;

import at.bitfire.cert4android.CustomCertManager;
import okhttp3.mockwebserver.MockWebServer;

import static android.support.test.InstrumentationRegistry.getInstrumentation;
import static android.support.test.InstrumentationRegistry.getTargetContext;
import static junit.framework.TestCase.assertFalse;
import static org.apache.commons.lang3.ArrayUtils.contains;
import static org.junit.Assert.assertTrue;

public class SSLSocketFactoryCompatTest {

CustomCertManager certMgr;
SSLSocketFactoryCompat factory;
CustomTlsSocketFactory factory;
MockWebServer server = new MockWebServer();

@Before
public void startServer() throws Exception {
certMgr = new CustomCertManager(getInstrumentation().getContext(), false, true);
factory = new SSLSocketFactoryCompat(certMgr);
factory = new CustomTlsSocketFactory(null, certMgr);
server.start();
}

Expand Down
Expand Up @@ -22,6 +22,7 @@
import at.bitfire.dav4android.property.ResourceType;
import at.bitfire.davdroid.HttpClient;
import at.bitfire.davdroid.log.Logger;
import at.bitfire.davdroid.model.Credentials;
import at.bitfire.davdroid.ui.setup.DavResourceFinder.Configuration.ServiceInfo;
import okhttp3.mockwebserver.Dispatcher;
import okhttp3.mockwebserver.MockResponse;
Expand All @@ -40,7 +41,7 @@ public class DavResourceFinderTest {

DavResourceFinder finder;
HttpClient client;
LoginCredentials credentials;
LoginInfo loginInfo;

private static final String
PATH_NO_DAV = "/nodav",
Expand All @@ -58,11 +59,11 @@ public void initServerAndClient() throws Exception {
server.setDispatcher(new TestDispatcher());
server.start();

credentials = new LoginCredentials(URI.create("/"), "mock", "12345");
finder = new DavResourceFinder(getTargetContext(), credentials);
loginInfo = new LoginInfo(URI.create("/"), new Credentials("mock", "12345"));
finder = new DavResourceFinder(getTargetContext(), loginInfo);

client = new HttpClient.Builder()
.addAuthentication(null, credentials.getUserName(), credentials.getPassword())
.addAuthentication(null, loginInfo.credentials)
.build();
}

Expand Down
27 changes: 21 additions & 6 deletions app/src/main/java/at/bitfire/davdroid/AccountSettings.kt
Expand Up @@ -18,6 +18,7 @@ import android.provider.CalendarContract
import android.provider.ContactsContract
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.CollectionInfo
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.model.ServiceDB
import at.bitfire.davdroid.model.ServiceDB.*
import at.bitfire.davdroid.model.ServiceDB.Collections
Expand Down Expand Up @@ -49,6 +50,7 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
val KEY_SETTINGS_VERSION = "version"

val KEY_USERNAME = "user_name"
val KEY_CERTIFICATE_ALIAS = "certificate_alias"

val KEY_WIFI_ONLY = "wifi_only" // sync on WiFi only (default: false)
val KEY_WIFI_ONLY_SSIDS = "wifi_only_ssids" // restrict sync to specific WiFi SSIDs
Expand Down Expand Up @@ -80,10 +82,17 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(
@JvmField
val SYNC_INTERVAL_MANUALLY = -1L

fun initialUserData(userName: String): Bundle {
fun initialUserData(credentials: Credentials): Bundle {
val bundle = Bundle(2)
bundle.putString(KEY_SETTINGS_VERSION, CURRENT_VERSION.toString())
bundle.putString(KEY_USERNAME, userName)

when (credentials.type) {
Credentials.Type.UsernamePassword ->
bundle.putString(KEY_USERNAME, credentials.userName)
Credentials.Type.ClientCertificate ->
bundle.putString(KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}

return bundle
}

Expand All @@ -110,11 +119,17 @@ class AccountSettings @Throws(InvalidAccountException::class) constructor(

// authentication settings

fun username(): String? = accountManager.getUserData(account, KEY_USERNAME)
fun username(userName: String) = accountManager.setUserData(account, KEY_USERNAME, userName)
fun credentials() = Credentials(
accountManager.getUserData(account, KEY_USERNAME),
accountManager.getPassword(account),
accountManager.getUserData(account, KEY_CERTIFICATE_ALIAS)
)

fun password(): String? = accountManager.getPassword(account)
fun password(password: String) = accountManager.setPassword(account, password)
fun credentials(credentials: Credentials) {
accountManager.setUserData(account, KEY_USERNAME, credentials.userName)
accountManager.setPassword(account, credentials.password)
accountManager.setUserData(account, KEY_CERTIFICATE_ALIAS, credentials.certificateAlias)
}


// sync. settings
Expand Down
Expand Up @@ -15,12 +15,15 @@ import java.net.InetAddress
import java.net.Socket
import java.security.GeneralSecurityException
import java.util.*
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.X509TrustManager
import javax.net.ssl.*

class SSLSocketFactoryCompat(
/**
* Custom TLS socket factory with support for
* - enabling/disabling algorithms depending on the Android version,
* - client certificate authentication
*/
class CustomTlsSocketFactory(
keyManager: KeyManager?,
trustManager: X509TrustManager
): SSLSocketFactory() {

Expand Down Expand Up @@ -105,7 +108,10 @@ class SSLSocketFactoryCompat(
init {
try {
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, arrayOf(trustManager), null)
sslContext.init(
if (keyManager != null) arrayOf(keyManager) else null,
arrayOf(trustManager),
null)
delegate = sslContext.socketFactory
} catch (e: GeneralSecurityException) {
throw IllegalStateException() // system has no TLS
Expand Down
90 changes: 76 additions & 14 deletions app/src/main/java/at/bitfire/davdroid/HttpClient.kt
Expand Up @@ -10,10 +10,12 @@ package at.bitfire.davdroid

import android.content.Context
import android.os.Build
import android.security.KeyChain
import at.bitfire.cert4android.CustomCertManager
import at.bitfire.dav4android.BasicDigestAuthHandler
import at.bitfire.dav4android.UrlUtils
import at.bitfire.davdroid.log.Logger
import at.bitfire.davdroid.model.Credentials
import at.bitfire.davdroid.settings.ISettings
import okhttp3.Cache
import okhttp3.Interceptor
Expand All @@ -25,10 +27,17 @@ import java.io.Closeable
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.Socket
import java.security.KeyStore
import java.security.Principal
import java.text.DateFormat
import java.util.*
import java.util.concurrent.TimeUnit
import java.util.logging.Level
import javax.net.ssl.KeyManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedKeyManager
import javax.net.ssl.X509TrustManager

class HttpClient private constructor(
val okHttpClient: OkHttpClient,
Expand Down Expand Up @@ -60,9 +69,11 @@ class HttpClient private constructor(
val context: Context? = null,
val settings: ISettings? = null,
accountSettings: AccountSettings? = null,
logger: java.util.logging.Logger = Logger.log
val logger: java.util.logging.Logger = Logger.log
) {
private var certManager: CustomCertManager? = null
private var certificateAlias: String? = null

private val orig = sharedClient.newBuilder()

init {
Expand Down Expand Up @@ -101,17 +112,14 @@ class HttpClient private constructor(

// use account settings for authentication
accountSettings?.let {
val userName = accountSettings.username()
val password = accountSettings.password()
if (userName != null && password != null)
addAuthentication(null, userName, password)
addAuthentication(null, it.credentials())
}
}
}
}

constructor(context: Context, host: String?, username: String, password: String): this(context) {
addAuthentication(host, username, password)
constructor(context: Context, host: String?, credentials: Credentials): this(context) {
addAuthentication(host, credentials)
}

fun withDiskCache(): Builder {
Expand All @@ -135,22 +143,76 @@ class HttpClient private constructor(

fun customCertManager(manager: CustomCertManager) {
certManager = manager
orig.sslSocketFactory(SSLSocketFactoryCompat(manager), manager)
orig.hostnameVerifier(manager.hostnameVerifier(OkHostnameVerifier.INSTANCE))
}
fun setForeground(foreground: Boolean): Builder {
certManager?.appInForeground = foreground
return this
}

fun addAuthentication(host: String?, username: String, password: String): Builder {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), username, password)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
fun addAuthentication(host: String?, credentials: Credentials): Builder {
when (credentials.type) {
Credentials.Type.UsernamePassword -> {
val authHandler = BasicDigestAuthHandler(UrlUtils.hostToDomain(host), credentials.userName!!, credentials.password!!)
orig .addNetworkInterceptor(authHandler)
.authenticator(authHandler)
}
Credentials.Type.ClientCertificate -> {
certificateAlias = credentials.certificateAlias
}
}
return this
}

fun build() = HttpClient(orig.build(), certManager)
fun build(): HttpClient {
val trustManager = certManager ?: {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
factory.trustManagers.first() as X509TrustManager
}()

val hostnameVerifier = certManager?.hostnameVerifier(OkHostnameVerifier.INSTANCE)
?: OkHostnameVerifier.INSTANCE

var keyManager: KeyManager? = null
try {
certificateAlias?.let { alias ->
// get client certificate and private key
val certs = KeyChain.getCertificateChain(context, alias) ?: return@let
val key = KeyChain.getPrivateKey(context, alias) ?: return@let
logger.fine("Using client certificate $alias for authentication (chain length: ${certs.size})")

// create Android KeyStore (performs key operations without revealing secret data to DAVdroid)
val keyStore = KeyStore.getInstance("AndroidKeyStore")
keyStore.load(null)

// create KeyManager
keyManager = object: X509ExtendedKeyManager() {
override fun getServerAliases(p0: String?, p1: Array<out Principal>?): Array<String>? = null
override fun chooseServerAlias(p0: String?, p1: Array<out Principal>?, p2: Socket?) = null

override fun getClientAliases(p0: String?, p1: Array<out Principal>?) =
arrayOf(alias)

override fun chooseClientAlias(p0: Array<out String>?, p1: Array<out Principal>?, p2: Socket?) =
alias

override fun getCertificateChain(forAlias: String?) =
certs.takeIf { forAlias == alias }

override fun getPrivateKey(forAlias: String?) =
key.takeIf { forAlias == alias }
}
}
} catch (e: Exception) {
logger.log(Level.SEVERE, "Couldn't set up client certificate authentication", e)
}

orig.sslSocketFactory(CustomTlsSocketFactory(keyManager, trustManager), trustManager)
orig.hostnameVerifier(hostnameVerifier)

return HttpClient(orig.build(), certManager)
}

}


Expand Down
41 changes: 41 additions & 0 deletions app/src/main/java/at/bitfire/davdroid/model/Credentials.kt
@@ -0,0 +1,41 @@
/*
* Copyright © Ricki Hirner (bitfire web engineering).
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the GNU Public License v3.0
* which accompanies this distribution, and is available at
* http://www.gnu.org/licenses/gpl.html
*/

package at.bitfire.davdroid.model

import java.io.Serializable

class Credentials @JvmOverloads constructor(
@JvmField val userName: String? = null,
@JvmField val password: String? = null,
@JvmField val certificateAlias: String? = null
): Serializable {

enum class Type {
UsernamePassword,
ClientCertificate
}

val type: Type

init {
type = when {
!certificateAlias.isNullOrEmpty() ->
Type.ClientCertificate
!userName.isNullOrEmpty() && !password.isNullOrEmpty() ->
Type.UsernamePassword
else ->
throw IllegalArgumentException("Either username/password or certificate alias must be set")
}
}

override fun toString(): String {
return "Credentials(type=$type, userName=$userName, certificateAlias=$certificateAlias)"
}

}
Expand Up @@ -483,12 +483,7 @@ class ContactsSyncManager(
}

// authenticate only against a certain host, and only upon request
val username = accountSettings.username()
val password = accountSettings.password()
val builder = if (username != null && password != null)
HttpClient.Builder(context, baseUrl.host(), username, password)
else
HttpClient.Builder(context)
val builder = HttpClient.Builder(context, baseUrl.host(), accountSettings.credentials())

// allow redirects
builder.followRedirects(true)
Expand Down

0 comments on commit 3689df1

Please sign in to comment.