Начало
В данной любительской статье разберемся, что такое KeyStore в контексте мобильной разработки, для чего нужен и применим его в крайне легком варианте. Также погрузимся в разработку экрана входа в ваше приложение. Статья будет разделена на 3 так называемых раздела — KeyStore, UI и ViewModel.
Целью данного гайда это вот такой экран с последующим переходом:
P.S. Хочу сразу предупредить, что мой код не является показательным или каким-то супер профессиональным, я просто делал, то, что мог, именно поэтому, если есть желание, то жду комментариев с пояснениями, где я мог лучше.
KeyStore
Инструмент для хранения и управления чувствительными данными, такими, как пароли, ключи, коды и сертификаты, в безопасном хранилище. Данный API используют не только в контексте мобильной разработки под Android, но и на серверах в качестве безопасного места для хранения сертификатов. Я не буду углубляться в понятие KeyStore, так как это достаточно специализированная тема, да и к тому же есть множество гайдов раскрывающие эту тему, в том числе на хабре (Хабр статья, Англоязычная от EditorialTeam in Java security — более подробная).
Мы же возьмем от него только самое необходимое, а точнее модули EncryptedSharedPreferences и MasterKey.
Перед использованием KeyStore необходимо его заиплементить в build.gradle
// Keystore implementation("androidx.security:security-crypto:1.1.0-alpha06")
class KeystoreManager(context: Context) { private val masterKeyAlias = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) .build() private val sharedPreferences = EncryptedSharedPreferences.create( context, "encrypted_prefs", masterKeyAlias, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) fun savePin(pin: String) { sharedPreferences.edit().putString("pin", pin).apply() } fun getPin(): String? { return sharedPreferences.getString("pin", null) } }
Создаем такой класс для последующего использования в качестве хранилища. В начале создаем masterKey. Он используется для защиты как ключей, так и значений внутри SharedPreference
. И выбираем схему шифрования для самого ключа. В данном случае используется AES-256 в режиме GCM (Galois/Counter Mode). Этот режим шифрования обеспечивает конфиденциальность и целостность данных, а также аутентификацию.
Для SharedPreference
мы используем EncryptedSharedPreferences. PrefKeyEncryptionScheme схема шифрования для ключей в SharedPreferences
, а PrefValueEncryptionScheme схема шифрования для значений в SharedPreferences
.
И собственно сами методы по сохранению пина и получению его.
Является ли безопасным метод getPin()? Да. Потому что используется с уже зашифрованным sharedprefs и дешифруется в контексте данного класса. Последующее использование будет гарантировать безопасность, только если вы не решите оставить лог с данным пином.
Разработка UI
Отчасти именно из-за того, что нигде в интернете нет более удобных решений для PinLockScreen с JetpackCompose я решил написать простенький гайд, для сохранения своих знаний и, возможно, помощи другим. К слову, есть библиотека на просторах гитхаба, осуществляющая UI пинлока (без какой-либо логики защиты), однако, использование сторонних библиотек для осуществления легких элементов является крайне нежелательным.
В качестве цветов я использую цвета, которые я прописал в Theme, сделано это для того, чтобы поддержать темную и светлую тему, вы можете использовать любые цвета.
Далее цвета в моем проекте определяются так:
MaterialTheme.colorScheme.tertiary
Самое главное в пинлоке это клавиатура, а она в свою очередь состоит из кнопок, сделаем кнопку, в которую будем подставлять content с аннотацией Composable, тк в качестве контента будет элемент Text().
@Composable private fun KeyButton( onClick: () -> Unit, shape: Shape = RoundedCornerShape(100), content: @Composable () -> Unit ) { Box( modifier = Modifier .padding(8.dp) .clip(shape) .background( color = MaterialTheme.colorScheme.tertiary, shape = RoundedCornerShape(100) ) .clickable(onClick = onClick) .defaultMinSize(minWidth = 95.dp, minHeight = 95.dp) .padding(4.dp), contentAlignment = Alignment.Center ) { CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.secondary) { content() } } }
Далее сама клавиатура, я не додумался ни до чего кроме как представить клавиатуру в виде списка из списков каждого ряда символов. Данный проект является учебным, поэтому я не выношу строки в отдельные ресурсы. Если вы делаете свой проект, который будет развиваться или используете это в коммерции, то следует все это перенести в ресурсы, а сам список кнопок вынести в отдельный объект или же класс.
@Composable private fun Keyboard( ) { val listKeys = listOf( listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"), listOf("del", "0", "OK") ) Column( modifier = Modifier .background(color = MaterialTheme.colorScheme.background) .fillMaxSize() .padding(bottom = 100.dp), verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally, ) { Spacer(modifier = Modifier.height(100.dp)) listKeys.forEach { rows -> Row { rows.forEach { KeyButton( onClick = { } ) { when (it) { "del" -> { Icon( painter = painterResource(id = R.drawable.del_icon), contentDescription = "Clear", modifier = Modifier .size(30.dp), tint = MaterialTheme.colorScheme.secondary ) } "OK" -> { Icon( imageVector = Icons.Default.Check, contentDescription = "Success", modifier = Modifier .size(35.dp), tint = MaterialTheme.colorScheme.secondary ) } else -> Text( text = it, fontSize = 30.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.secondary ) } } } } } } }
Column внутри, которого прохожусь по списку, образуя тем самым rows, они и являются рядами с кнопками. Для того, чтобы определить в столбце спецсимвол пользуюсь выражением when, в случае, если это не кнопка ОК или удалить, то просто вписываю в контент кнопки текст.
Далее требуется создать вот такие точки для ввода и текст
Создаем два элемента — текст и сами точки
@Composable private fun PinDot(isFilled: Boolean) { Box( modifier = Modifier .padding(10.dp) .size(30.dp) .background( if (isFilled) MaterialTheme.colorScheme.tertiary else if (isSystemInDarkTheme()) White else Color.Gray, CircleShape ) ) }
В параметре передаю isFilled для того, чтобы потом использовать, как флаг для заполнения при вводе символа.
ViewModel
Для того, чтобы отображать текст ввода и переходить по навигации нужно сделать вьюмодельку, в ней будет основная логика вызовов действий, в том числе сохранение пароля в KeyStore.
Для самой ViewModel нам понадобится модель state. И интерфейс с events, которые будут использованы как действия в приложении.
data class PinLockState( val isAuthenticated: Boolean = false, val inputPin: String = "", val error: ErrorPin? = null, val confirmPin: String? = null ) sealed interface PinLockEvent { data class AddDigit(val digit: String) : PinLockEvent data object DeleteDigit : PinLockEvent data object ClearPin : PinLockEvent data object CheckPin : PinLockEvent }
В стейте лежит флажок isAuthenticated для того, чтобы понять, есть ли у пользователя уже пинкод или нет, inputPin — сам пинкод, куда будут входить вводимые значения пользователя, error — ошибки, я использую также в качестве флага, для последующего входа, confirmPin — тоже пинкод, только нужен для регистрации и только.
Сама ViewModel представлена ниже.
class PinLockViewModel( private val keystoreManager: KeystoreManager ) : ViewModel() { private val _state = MutableStateFlow(PinLockState()) val state: StateFlow<PinLockState> = _state init { val pinSet = keystoreManager.getPin().isNullOrBlank() _state.update { it.copy( isAuthenticated = !pinSet ) } } fun onEvent(event: PinLockEvent) { when (event) { is PinLockEvent.DeleteDigit -> { _state.update { it.copy( inputPin = it.inputPin.substring(0, it.inputPin.length - 1) ) } } is PinLockEvent.AddDigit -> { _state.update { it.copy( inputPin = it.inputPin.plus(event.digit) ) } } is PinLockEvent.ClearPin -> { _state.update { it.copy( inputPin = "" ) } } is PinLockEvent.CheckPin -> { val currentState = _state.value if (currentState.inputPin.length == 4) { if (currentState.isAuthenticated) { val storedPin = keystoreManager.getPin() if (currentState.inputPin == storedPin) { _state.update { it.copy( error = ErrorPin.SUCCESS ) } } else { _state.update { it.copy( error = ErrorPin.INCORRECT_PASS ) } onEvent(PinLockEvent.ClearPin) } } else { if (currentState.confirmPin.isNullOrBlank()) { _state.update { it.copy( confirmPin = currentState.inputPin, error = ErrorPin.TRY_PIN ) } onEvent(PinLockEvent.ClearPin) } else { if (currentState.inputPin == currentState.confirmPin) { keystoreManager.savePin(currentState.inputPin) _state.value = currentState.copy( isAuthenticated = true, error = ErrorPin.SUCCESS, confirmPin = null ) } else { _state.value = currentState.copy(error = ErrorPin.INCORRECT_PASS, confirmPin = null) onEvent(PinLockEvent.ClearPin) } } } } else { _state.value = currentState.copy(error = ErrorPin.NOT_ENOUGH_DIG) onEvent(PinLockEvent.ClearPin) } } } } } enum class ErrorPin(val error: Int){ SUCCESS(0), INCORRECT_PASS(1), NOT_ENOUGH_DIG(2), TRY_PIN(3) }
В начале оглашаем StateFlow для последующего использования и в качестве init производим идентификацию, есть ли вообще у пользователя пароль или нет. Далее это действия (onEvent). Тут все достаточно просто кроме как CheckPin, но и там тоже все просто, если углубиться, сначала идет проверка на кол‑во символов, если недостаточно символов то выводится ошибка и пин стирается, далее проверка на аутентификацию, если пользователь не аутентифицирован, то идет логика регистрации пина и последующего сохранения в KeyStore, если аутентифицирован, то проходит проверка с соответствующими errors, кстати, про них, создал енум класс для обозначения каждой ошибки .
Далее требуется лишь соединить это с UI, представлено ниже.
@Composable fun PinLockScreen( navController: NavController, viewModel: PinLockViewModel ) { val statePin by viewModel.state.collectAsState() LaunchedEffect(statePin.isAuthenticated, statePin.error) { if (statePin.isAuthenticated && statePin.error == ErrorPin.SUCCESS) { navController.popBackStack() navController.navigate("mainScreen") } } Keyboard( onEvent = viewModel::onEvent, statePin = statePin ) } @Composable private fun Keyboard( onEvent: (PinLockEvent) -> Unit, statePin: PinLockState ) { val listKeys = listOf( listOf("1", "2", "3"), listOf("4", "5", "6"), listOf("7", "8", "9"), listOf("del", "0", "OK") ) Column( modifier = Modifier .background(color = MaterialTheme.colorScheme.background) .fillMaxSize() .padding(bottom = 100.dp), verticalArrangement = Arrangement.Bottom, horizontalAlignment = Alignment.CenterHorizontally, ) { var text = when(statePin.error) { ErrorPin.INCORRECT_PASS -> "Попробуйте ещё раз" ErrorPin.TRY_PIN -> "Введите пин-код ещё раз для подтверждения" ErrorPin.NOT_ENOUGH_DIG -> "Недостаточно цифр, попробуйте еще раз" else -> "Введите пин-код" } if (!statePin.isAuthenticated && statePin.error == null) { text = "Для регистрации введите пин-код" } Text( text = text, fontSize = 18.sp, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.secondary ) Spacer(modifier = Modifier.height(30.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { repeat(4) { index -> val isFilled = index < statePin.inputPin.length PinDot(isFilled = isFilled) } } Spacer(modifier = Modifier.height(100.dp)) listKeys.forEach { rows -> Row { rows.forEach { KeyButton( onClick = { when (it) { "del" -> if (statePin.inputPin.isNotEmpty()) onEvent(PinLockEvent.DeleteDigit) "OK" -> { onEvent(PinLockEvent.CheckPin) } else -> { if (statePin.inputPin.length < 4) onEvent( PinLockEvent.AddDigit(it) ) } } } ) { when (it) { "del" -> { Icon( painter = painterResource(id = R.drawable.del_icon), contentDescription = "Clear", modifier = Modifier .size(30.dp), tint = MaterialTheme.colorScheme.secondary ) } "OK" -> { Icon( imageVector = Icons.Default.Check, contentDescription = "Success", modifier = Modifier .size(35.dp), tint = MaterialTheme.colorScheme.secondary ) } else -> Text( text = it, fontSize = 30.sp, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.secondary ) } } } } } } }
Для перехода на следующую страничку использовал данный код в самом начале, он зависит от флагов, которые я установил в стейте.
LaunchedEffect(statePin.isAuthenticated, statePin.error) { if (statePin.isAuthenticated && statePin.error == ErrorPin.SUCCESS) { navController.popBackStack() navController.navigate("mainScreen") } }
Текст над точками ввода, также зависит напрямую от стейта.
var text = when(statePin.error) { ErrorPin.INCORRECT_PASS -> "Попробуйте ещё раз" ErrorPin.TRY_PIN -> "Введите пин-код ещё раз для подтверждения" ErrorPin.NOT_ENOUGH_DIG -> "Недостаточно цифр, попробуйте еще раз" else -> "Введите пин-код" } if (!statePin.isAuthenticated && statePin.error == null) { text = "Для регистрации введите пин-код" }
В MainActivity это выглядит вот так. Для использования навигации следует имплементировать соответствующий модуль в build.gradle.
class MainActivity : ComponentActivity() { private val keystoreManager: KeystoreManager by lazy { KeystoreManager(applicationContext) } private val viewModelPinLock by viewModels<PinLockViewModel>(factoryProducer = { object : ViewModelProvider.Factory { override fun <T : ViewModel> create(modelClass: Class<T>): T { return PinLockViewModel(keystoreManager) as T } } }) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { PassManagerTheme { val stateItem by viewModelItem.state.collectAsState() val navController = rememberNavController() NavHost(navController = navController, startDestination = "auth") { composable("auth") { PinLockScreen( navController = navController, viewModel = viewModelPinLock ) } composable("mainScreen") { ItemScreen() } } } } } }
Итог
В целом, я доволен итогом, что я приобрел новые знания и сделал свою логику (не без грехов). Данная статья является в первую очередь скопищем моих выводов для самого же себя, хотя, если это станет кому‑то интересно или даже полезно, то буду рад.
У меня есть github, там я опубликовал пет приложение с применением данного пинлока. Также, если вдруг что‑то было непонятно, то можете зайти и сами запустить приложение.
ссылка на оригинал статьи https://habr.com/ru/articles/831914/
Добавить комментарий