PinLockSreen на основе KeyStore. Kotlin. Jetpack Compose

от автора

Начало

В данной любительской статье разберемся, что такое 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *