mirror of
https://github.com/antebudimir/tempus.git
synced 2026-04-15 16:27:26 +00:00
Support specifying a client certificate for mTLS auth (#458)
* feat: collect and save client certificate * feat: use client certificate for Retrofit, Glide and ExoPlayer --------- Co-authored-by: eddyizm <eddyizm@gmail.com>
This commit is contained in:
parent
3ba2255205
commit
aa5d0f92db
12 changed files with 1335 additions and 10 deletions
1164
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json
Normal file
1164
app/schemas/com.cappielloantonio.tempo.database.AppDatabase/14.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -11,6 +11,7 @@ import com.cappielloantonio.tempo.github.Github;
|
|||
import com.cappielloantonio.tempo.helper.ThemeHelper;
|
||||
import com.cappielloantonio.tempo.subsonic.Subsonic;
|
||||
import com.cappielloantonio.tempo.subsonic.SubsonicPreferences;
|
||||
import com.cappielloantonio.tempo.util.ClientCertManager;
|
||||
import com.cappielloantonio.tempo.util.Preferences;
|
||||
|
||||
public class App extends Application {
|
||||
|
|
@ -31,6 +32,8 @@ public class App extends Application {
|
|||
instance = new App();
|
||||
context = getApplicationContext();
|
||||
preferences = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
|
||||
ClientCertManager.setupSslSocketFactory(context);
|
||||
}
|
||||
|
||||
public static App getInstance() {
|
||||
|
|
|
|||
|
|
@ -30,9 +30,13 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
|
|||
|
||||
@UnstableApi
|
||||
@Database(
|
||||
version = 13,
|
||||
version = 14,
|
||||
entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
|
||||
autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
|
||||
autoMigrations = {
|
||||
@AutoMigration(from = 10, to = 11),
|
||||
@AutoMigration(from = 11, to = 12),
|
||||
@AutoMigration(from = 13, to = 14),
|
||||
}
|
||||
)
|
||||
@TypeConverters({DateConverters.class})
|
||||
public abstract class AppDatabase extends RoomDatabase {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.model
|
|||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.Keep
|
||||
import androidx.annotation.Nullable
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
|
@ -35,5 +34,8 @@ data class Server(
|
|||
val timestamp: Long,
|
||||
|
||||
@ColumnInfo(name = "low_security", defaultValue = "false")
|
||||
val isLowSecurity: Boolean
|
||||
val isLowSecurity: Boolean,
|
||||
|
||||
@ColumnInfo(name = "client_cert")
|
||||
val clientCert: String?,
|
||||
) : Parcelable
|
||||
|
|
@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.subsonic
|
|||
import com.cappielloantonio.tempo.App
|
||||
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
|
||||
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
|
||||
import com.cappielloantonio.tempo.util.ClientCertManager
|
||||
import com.google.gson.GsonBuilder
|
||||
import okhttp3.Cache
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -13,7 +14,7 @@ import java.util.Date
|
|||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class RetrofitClient(subsonic: Subsonic) {
|
||||
var retrofit: Retrofit
|
||||
val retrofit: Retrofit
|
||||
|
||||
init {
|
||||
val gson = GsonBuilder()
|
||||
|
|
@ -50,6 +51,7 @@ class RetrofitClient(subsonic: Subsonic) {
|
|||
.addInterceptor(cacheUtil.offlineInterceptor)
|
||||
// .addNetworkInterceptor(cacheUtil.onlineInterceptor)
|
||||
.cache(getCache())
|
||||
.setupSsl()
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
@ -63,4 +65,11 @@ class RetrofitClient(subsonic: Subsonic) {
|
|||
val cacheSize = 10 * 1024 * 1024
|
||||
return Cache(App.getContext().cacheDir, cacheSize.toLong())
|
||||
}
|
||||
|
||||
private fun OkHttpClient.Builder.setupSsl(): OkHttpClient.Builder {
|
||||
ClientCertManager.sslSocketFactory?.let { sslSocketFactory ->
|
||||
sslSocketFactory(sslSocketFactory, ClientCertManager.trustManager)
|
||||
}
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ package com.cappielloantonio.tempo.ui.activity;
|
|||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.IntentFilter;
|
||||
import android.content.res.Configuration;
|
||||
import android.graphics.Rect;
|
||||
import android.content.IntentFilter;
|
||||
import android.net.ConnectivityManager;
|
||||
import android.net.NetworkInfo;
|
||||
|
|
@ -24,8 +26,8 @@ import androidx.fragment.app.FragmentManager;
|
|||
import androidx.lifecycle.ViewModelProvider;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.MediaMetadata;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.MimeTypes;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.common.util.UnstableApi;
|
||||
import androidx.navigation.NavController;
|
||||
import androidx.navigation.fragment.NavHostFragment;
|
||||
|
|
@ -448,6 +450,7 @@ public class MainActivity extends BaseActivity {
|
|||
Preferences.setServer(null);
|
||||
Preferences.setLocalAddress(null);
|
||||
Preferences.setUser(null);
|
||||
Preferences.setClientCert(null);
|
||||
|
||||
// TODO Enter all settings to be reset
|
||||
Preferences.setOpenSubsonic(false);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package com.cappielloantonio.tempo.ui.dialog;
|
|||
|
||||
import android.app.Dialog;
|
||||
import android.os.Bundle;
|
||||
import android.security.KeyChain;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
|
@ -32,11 +32,21 @@ public class ServerSignupDialog extends DialogFragment {
|
|||
private String server;
|
||||
private String localAddress;
|
||||
private boolean lowSecurity = false;
|
||||
private String clientCertAlias;
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||
bind = DialogServerSignupBinding.inflate(getLayoutInflater());
|
||||
bind.clientCertTextView.setOnClickListener(v -> {
|
||||
if (TextUtils.isEmpty(bind.clientCertTextView.getText())) {
|
||||
KeyChain.choosePrivateKeyAlias(requireActivity(), alias -> {
|
||||
bind.clientCertTextView.setText(alias);
|
||||
}, null, null, null, null);
|
||||
} else {
|
||||
bind.clientCertTextView.setText(null);
|
||||
}
|
||||
});
|
||||
|
||||
loginViewModel = new ViewModelProvider(requireActivity()).get(LoginViewModel.class);
|
||||
|
||||
|
|
@ -74,6 +84,7 @@ public class ServerSignupDialog extends DialogFragment {
|
|||
bind.serverTextView.setText(loginViewModel.getServerToEdit().getAddress());
|
||||
bind.localAddressTextView.setText(loginViewModel.getServerToEdit().getLocalAddress());
|
||||
bind.lowSecurityCheckbox.setChecked(loginViewModel.getServerToEdit().isLowSecurity());
|
||||
bind.clientCertTextView.setText(loginViewModel.getServerToEdit().getClientCert());
|
||||
}
|
||||
} else {
|
||||
loginViewModel.setServerToEdit(null);
|
||||
|
|
@ -106,6 +117,7 @@ public class ServerSignupDialog extends DialogFragment {
|
|||
server = bind.serverTextView.getText() != null && !bind.serverTextView.getText().toString().trim().isBlank() ? bind.serverTextView.getText().toString().trim() : null;
|
||||
localAddress = bind.localAddressTextView.getText() != null && !bind.localAddressTextView.getText().toString().trim().isBlank() ? bind.localAddressTextView.getText().toString().trim() : null;
|
||||
lowSecurity = bind.lowSecurityCheckbox.isChecked();
|
||||
clientCertAlias = bind.clientCertTextView.getText() != null && !bind.clientCertTextView.getText().toString().trim().isBlank() ? bind.clientCertTextView.getText().toString().trim() : null;
|
||||
|
||||
if (TextUtils.isEmpty(serverName)) {
|
||||
bind.serverNameTextView.setError(getString(R.string.error_required));
|
||||
|
|
@ -137,6 +149,6 @@ public class ServerSignupDialog extends DialogFragment {
|
|||
|
||||
private void saveServerPreference() {
|
||||
String serverID = loginViewModel.getServerToEdit() != null ? loginViewModel.getServerToEdit().getServerId() : UUID.randomUUID().toString();
|
||||
loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity));
|
||||
loginViewModel.addServer(new Server(serverID, this.serverName, this.username, this.password, this.server, this.localAddress, System.currentTimeMillis(), this.lowSecurity, this.clientCertAlias));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
|
|||
@Override
|
||||
public void onServerClick(Bundle bundle) {
|
||||
Server server = bundle.getParcelable("server_object");
|
||||
saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity());
|
||||
saveServerPreference(server.getServerId(), server.getAddress(), server.getLocalAddress(), server.getUsername(), server.getPassword(), server.isLowSecurity(), server.getClientCert());
|
||||
|
||||
SystemRepository systemRepository = new SystemRepository();
|
||||
systemRepository.checkUserCredential(new SystemCallback() {
|
||||
|
|
@ -142,13 +142,14 @@ public class LoginFragment extends Fragment implements ClickCallback {
|
|||
dialog.show(activity.getSupportFragmentManager(), null);
|
||||
}
|
||||
|
||||
private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity) {
|
||||
private void saveServerPreference(String serverId, String server, String localAddress, String user, String password, boolean isLowSecurity, String clientCert) {
|
||||
Preferences.setServerId(serverId);
|
||||
Preferences.setServer(server);
|
||||
Preferences.setLocalAddress(localAddress);
|
||||
Preferences.setUser(user);
|
||||
Preferences.setPassword(password);
|
||||
Preferences.setLowSecurity(isLowSecurity);
|
||||
Preferences.setClientCert(clientCert);
|
||||
|
||||
App.getSubsonicClientInstance(true);
|
||||
}
|
||||
|
|
@ -161,6 +162,7 @@ public class LoginFragment extends Fragment implements ClickCallback {
|
|||
Preferences.setToken(null);
|
||||
Preferences.setSalt(null);
|
||||
Preferences.setLowSecurity(false);
|
||||
Preferences.setClientCert(null);
|
||||
|
||||
App.getSubsonicClientInstance(true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
package com.cappielloantonio.tempo.util
|
||||
|
||||
import android.content.Context
|
||||
import android.security.KeyChain
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import okhttp3.internal.platform.Platform
|
||||
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 javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.X509KeyManager
|
||||
|
||||
object ClientCertManager {
|
||||
|
||||
private const val TAG = "ClientCertManager"
|
||||
|
||||
val trustManager = Platform.get().platformTrustManager()
|
||||
var sslSocketFactory: SSLSocketFactory? = null
|
||||
private set
|
||||
|
||||
@JvmStatic
|
||||
fun setupSslSocketFactory(context: Context) {
|
||||
sslSocketFactory = createSslSocketFactory(context)
|
||||
sslSocketFactory?.let {
|
||||
// HttpsURLConnection is used both by:
|
||||
// - Glide: in IPv6StringLoader
|
||||
// - ExoPlayer: in DefaultHttpDataSource
|
||||
HttpsURLConnection.setDefaultSSLSocketFactory(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSslSocketFactory(context: Context): SSLSocketFactory? {
|
||||
return try {
|
||||
val clientKeyManager = object : X509KeyManager {
|
||||
override fun getClientAliases(keyType: String?, issuers: Array<Principal>?) = null
|
||||
|
||||
override fun chooseClientAlias(
|
||||
keyType: Array<String>?,
|
||||
issuers: Array<Principal>?,
|
||||
socket: Socket?
|
||||
): String? {
|
||||
val clientCert = Preferences.getClientCert() ?: return null
|
||||
val server = Preferences.getServer() ?: return null
|
||||
return if (server.toUri().host == socket?.inetAddress?.hostName) {
|
||||
clientCert
|
||||
} else null
|
||||
}
|
||||
|
||||
override fun getServerAliases(keyType: String?, issuers: Array<Principal>?) = null
|
||||
|
||||
override fun chooseServerAlias(
|
||||
keyType: String?,
|
||||
issuers: Array<Principal>?,
|
||||
socket: Socket?
|
||||
) = null
|
||||
|
||||
override fun getCertificateChain(alias: String?): Array<X509Certificate>? {
|
||||
val clientCert = Preferences.getClientCert()
|
||||
return if (alias == clientCert && clientCert != null) {
|
||||
KeyChain.getCertificateChain(
|
||||
context,
|
||||
clientCert
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
override fun getPrivateKey(alias: String?): PrivateKey? {
|
||||
val clientCert = Preferences.getClientCert()
|
||||
return if (alias == clientCert && clientCert != null) {
|
||||
KeyChain.getPrivateKey(
|
||||
context,
|
||||
clientCert
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(arrayOf(clientKeyManager), arrayOf(trustManager), null)
|
||||
sslContext.socketFactory
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
Log.e(TAG, "Failed setting mTLS", e)
|
||||
null
|
||||
} catch (e: KeyManagementException) {
|
||||
Log.e(TAG, "Failed setting mTLS", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ object Preferences {
|
|||
private const val TOKEN = "token"
|
||||
private const val SALT = "salt"
|
||||
private const val LOW_SECURITY = "low_security"
|
||||
private const val CLIENT_CERT = "client_cert"
|
||||
private const val BATTERY_OPTIMIZATION = "battery_optimization"
|
||||
private const val SERVER_ID = "server_id"
|
||||
private const val OPEN_SUBSONIC = "open_subsonic"
|
||||
|
|
@ -173,6 +174,16 @@ object Preferences {
|
|||
App.getInstance().preferences.edit().putBoolean(LOW_SECURITY, isLowSecurity).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getClientCert(): String? {
|
||||
return App.getInstance().preferences.getString(CLIENT_CERT, null)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun setClientCert(clientCert: String?) {
|
||||
App.getInstance().preferences.edit().putString(CLIENT_CERT, clientCert).apply()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun getServerId(): String? {
|
||||
return App.getInstance().preferences.getString(SERVER_ID, null)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,25 @@
|
|||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:text="@string/server_signup_dialog_action_low_security" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
android:id="@+id/client_cert_text_input_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="24dp"
|
||||
android:layout_marginEnd="24dp"
|
||||
android:textColorHint="?android:textColorHint">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/client_cert_text_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:focusableInTouchMode="false"
|
||||
android:hint="@string/server_signup_dialog_hint_client_certificate"
|
||||
android:inputType="textNoSuggestions"
|
||||
android:textCursorDrawable="@null" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
|||
|
|
@ -322,6 +322,7 @@
|
|||
<string name="server_signup_dialog_hint_password">Password</string>
|
||||
<string name="server_signup_dialog_hint_url">Server URL</string>
|
||||
<string name="server_signup_dialog_hint_username">Username</string>
|
||||
<string name="server_signup_dialog_hint_client_certificate">Client certificate (optional)</string>
|
||||
<string name="server_signup_dialog_negative_button">Cancel</string>
|
||||
<string name="server_signup_dialog_neutral_button">Delete</string>
|
||||
<string name="server_signup_dialog_positive_button">Save</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue