Перейти к основному содержимому
Мобилка35ГЛАВА 35Авторизация: вход, живая сессия и биометрияivan@mail.ru••••••••Bearerтокен, не пароль

Авторизация: вход, живая сессия и биометрия

Прочитано0%
Квизы0/6
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Экран входа — первое, что увидит эксперт. И единственная тема, где одна ошибка стоит не балл, а всю проверку: если вход не работает, дальше смотреть нечего.

При этом в конкурсных заданиях авторизация расписана подробнее любой другой темы. Валидация почты — по дословному паттерну. Пароль — не короче восьми символов. Сессия — «должна быть сохранена после перезапуска приложения». Ошибки — под полем, а не всплывающим сообщением. Всё это проверяется руками: эксперт вводит Ivan@Mail.RU, вводит семь символов, убивает приложение и открывает заново.

В этой главе сойдётся почти всё из блока данных: сеть даст эндпоинты, хранилище примет токен, обработка ошибок научит различать «неверный пароль» и «нет соединения». А в конце — биометрия, которая работает не так, как кажется.

Что значит «пользователь вошёл»​

С точки зрения приложения «вошёл» — это не открытый главный экран. Это наличие действующего токена.

«ВОШЁЛ» — ЭТО НЕ ЭКРАН, А ТОКЕНPOST /loginemail + парольtokenживёт ограниченное времяDataStoreпереживёт перезапускпароль на устройстве не хранят никогда — только токен, который протухает и отзываетсякаждый следующий запрос несёт токен в заголовке — так сервер узнаёт, кто пришёл
Вход обменивает пароль на токен; дальше пароль не нужен вовсе — каждый запрос несёт токен

Механика простая. Приложение один раз отправляет почту и пароль на сервер. Сервер отвечает токеном — длинной строкой, которая живёт ограниченное время. Все дальнейшие запросы несут этот токен в заголовке, и по нему сервер понимает, кто пришёл.

Отсюда два следствия, которые часто путают.

Пароль на устройстве не хранят никогда. Нужен только токен. Он протухает, его можно отозвать — утечка токена ограничена его сроком жизни. Утечка пароля — нет.

Токен переживает перезапуск — иначе пользователю пришлось бы входить при каждом открытии. Место для него — DataStore из главы про хранение.

Два эндпоинта​

В заданиях они называются одинаково из года в год: Registration и Authorization.

interface AuthApi {
@POST("auth/register")
suspend fun register(@Body body: RegisterDto): TokenDto

@POST("auth/login")
suspend fun login(@Body body: LoginDto): TokenDto
}

@Serializable data class LoginDto(val email: String, val password: String)
@Serializable data class TokenDto(val token: String, val userId: String)

Регистрация создаёт учётную запись и обычно сразу возвращает токен. Вход обменивает пароль на токен. Оба — POST, потому что несут тело и меняют состояние на сервере.

Проверять форму до запроса​

Пустое поле и короткий пароль видно на телефоне за ноль миллисекунд. Отправлять их на сервер и ждать ответа — тратить секунду пользователя на то, что можно было сказать сразу.

Поэтому валидация делится на два уровня. Локальная — формат почты, длина пароля, совпадение подтверждения. Серверная — существует ли пользователь, верен ли пароль. Первую делаем сами до запроса, вторую получаем в ответе.

Валидация почты по паттерну задания​

Требование формулируется дословно и повторяется во всех заданиях: почта должна соответствовать паттерну name@domenname.ru, где имя и доменное имя могут состоять только из маленьких букв и цифр.

Это строже привычного «правильного email», и разница видна на конкретных адресах:

// Валидатор email по требованию конкурсного задания: «name@domenname.ru», // только строчные латинские буквы и цифры. // Создан 05-09-2026, участник №00. val looseRule = Regex("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$") val kzRule = Regex("^[a-z0-9]+@[a-z0-9]+\\.ru$") val kzNoAnchors = Regex("[a-z0-9]+@[a-z0-9]+\\.ru") val cases = listOf( "ivan@mail.ru", "user123@shop2024.ru", "Ivan@mail.ru", "ivan@Mail.ru", "ivan@mail.RU", "иван@почта.ru", "ivan.petrov@mail.ru", "ivan_petrov@mail.ru", "ivan-petrov@mail.ru", "ivan@mail.com", "ivan@ru", "@mail.ru", "ivan@sub.mail.ru", " ivan@mail.ru " ) fun mark(b: Boolean) = if (b) "принял" else "ОТВЕРГ" fun main() { println("── один и тот же список через два правила ──") println("адрес свободное правило КЗ") var looseOk = 0 var kzOk = 0 for (c in cases) { val l = looseRule.matches(c) val k = kzRule.matches(c) if (l) looseOk++ if (k) kzOk++ println("«" + c + "»" + " ".repeat(maxOf(1, 24 - c.length)) + mark(l) + " " + mark(k)) } println() println("свободное правило пропустило: " + looseOk + " из " + cases.size) println("правило задания пропустило: " + kzOk + " из " + cases.size) println() println("── четыре способа спросить одно и то же ──") val dirty = "мой адрес ivan@mail.ru, пишите" println("строка: «" + dirty + "»") println(" с якорями ^$, matches() → " + kzRule.matches(dirty)) println(" БЕЗ якорей, matches() → " + kzNoAnchors.matches(dirty) + " ← в Kotlin matches() всегда по всей строке") println(" с якорями ^$, containsMatchIn() → " + kzRule.containsMatchIn(dirty)) println(" БЕЗ якорей, containsMatchIn() → " + kzNoAnchors.containsMatchIn(dirty) + " ← вот она, дыра") println() println("── пробелы по краям ──") val spaced = " ivan@mail.ru " println("«" + spaced + "» → " + kzRule.matches(spaced) + ", после trim() → " + kzRule.matches(spaced.trim())) println() println("── длина пароля ──") for (p in listOf("qwerty", "qwerty12", "пароль12", "pass🔒🔒")) { println("«" + p + "» length=" + p.length + " символов=" + p.codePointCount(0, p.length) + " проходит length>=8: " + (p.length >= 8)) } }

Живой вывод:

── один и тот же список через два правила ──
адрес свободное правило КЗ
«ivan@mail.ru» принял принял
«Ivan@mail.ru» принял ОТВЕРГ
«ivan.petrov@mail.ru» принял ОТВЕРГ
«ivan@mail.com» принял ОТВЕРГ
«ivan@sub.mail.ru» принял ОТВЕРГ
« ivan@mail.ru » ОТВЕРГ ОТВЕРГ

свободное правило пропустило: 10 из 14
правило задания пропустило: 2 из 14

── четыре способа спросить одно и то же ──
строка: «мой адрес ivan@mail.ru, пишите»
с якорями ^$, matches() → false
БЕЗ якорей, matches() → false ← в Kotlin matches() всегда по всей строке
с якорями ^$, containsMatchIn() → false
БЕЗ якорей, containsMatchIn() → true ← вот она, дыра

── длина пароля ──
«qwerty12» length=8 символов=8 проходит length>=8: true
«pass🔒🔒» length=8 символов=6 проходит length>=8: true

Три урока в одном выводе.

Свободное правило пропускает Ivan@mail.ru, точку, дефис, .com и поддомен — всё, что задание запрещает. Из четырнадцати адресов оно приняло десять, а правило задания — два. Значит, привычный «валидатор email» здесь не годится: нужен свой, под формулировку.

Дыра в containsMatchIn без якорей. Строка «мой адрес ivan@mail.ru, пишите» проходит проверку, потому что где-то внутри неё есть похожий на адрес кусок. В Kotlin matches() всегда сравнивает всю строку, а вот containsMatchIn() ищет вхождение — и это разница между «поле принимает адрес» и «поле принимает что угодно, где есть адрес».

Эмодзи считаются за два. «pass🔒🔒».length даёт восемь, хотя символов шесть. Для чемпионата достаточно length, но знать про это стоит.

Сам валидатор:

private val EMAIL_RULE = Regex("^[a-z0-9]+@[a-z0-9]+\\.ru$")

fun isEmailValid(raw: String): Boolean = EMAIL_RULE.matches(raw.trim())
Нажми на часть выражения
Важно

Соблазн взять готовый android.util.Patterns.EMAIL_ADDRESS понятен, но он подводит дважды. Во-первых, он принимает всё, что задание запрещает, — заглавные, точки, .com. Во-вторых, Patterns — часть Android-фреймворка, и в обычном JVM-тесте он даёт NullPointerException. А тесты на валидатор — одно из требований модуля тестирования. Свой Regex решает обе проблемы.

Отвечено вопросов: 0 из 2
Адрес Ivan@mail.ru должен пройти валидацию по заданию?
Чем опасен containsMatchIn() без якорей для проверки почты?
Отвечено верно: 0 из 2

Пароль: восемь символов и невидимость​

Требование к паролю в большинстве заданий сводится к одному: не менее восьми символов. Проверяется вводом семи.

fun isPasswordValid(p: String): Boolean = p.length >= 8

Второе требование — не про проверку, а про поле: пароль скрывают точками, а рядом стоит кнопка «показать». Оба готовы в Material:

OutlinedTextField(
value = password,
onValueChange = onPasswordChange,
visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
IconButton(onClick = { visible = !visible }) {
Icon(if (visible) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription = "Показать пароль")
}
},
)

KeyboardType.Password не только меняет раскладку — он выключает автоподстановку и обучение клавиатуры, чтобы пароль не всплывал потом в подсказках.

Ошибка под полем, а не тостом​

Всплывающее сообщение исчезает через две секунды и не привязано к полю: пользователь не понимает, какое из двух он заполнил неверно. В заданиях формулировка прямая — «при некорректном заполнении необходимо отобразить ошибку», и по макету она стоит под полем.

OutlinedTextField(
value = email,
onValueChange = onEmailChange,
isError = emailError != null,
supportingText = { emailError?.let { Text(it) } },
)

isError красит рамку, supportingText показывает текст под полем. Он остаётся на экране, пока пользователь не исправит ввод.

Отдельный вопрос — когда показывать. Красить поле красным на первой введённой букве — отталкивать пользователя, который ещё не закончил. Практичный компромисс: проверять при потере фокуса или после первого нажатия «Войти», а дальше — при каждом изменении, пока ошибка не исчезнет.

Куда класть токен​

Место токена — DataStore, и хранилище для него мы уже писали в главе про данные:

class SessionStorage(private val context: Context) {
private val tokenKey = stringPreferencesKey("auth_token")

val token: Flow<String?> = context.settings.data.map { it[tokenKey] }

suspend fun save(token: String) = context.settings.edit { it[tokenKey] = token }
suspend fun clear() = context.settings.edit { it.remove(tokenKey) }
}

Что не класть: пароль — никогда, ни в каком виде. Если пользователь поставил галочку «запомнить меня», запоминают не пароль, а сам факт, что токен надо хранить дольше сеанса.

Заголовок через перехватчик​

Токен нужен почти каждому запросу. В главе про сеть мы уже решили, что писать @Header в каждом методе — путь к «забыл в одном из двадцати». Правильное место — перехватчик OkHttp:

class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider() ?: return chain.proceed(chain.request())
val authed = chain.request().newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
return chain.proceed(authed)
}
}

Здесь есть тонкость, о которой узнают по первому 401 после запуска. Перехватчик синхронный, а токен в DataStore читается через Flow. Если передать в tokenProvider функцию, которая пытается прочитать Flow, первый запрос уйдёт без заголовка. Решение — держать текущий токен в памяти: при старте прочитать его один раз из DataStore в переменную, и tokenProvider возвращает её.

Отвечено вопросов: 0 из 2
Что хранят на устройстве после входа?
Первый запрос после запуска приходит с 401, хотя токен сохранён. В чём причина?
Отвечено верно: 0 из 2

Стартовый экран: куда пускать при запуске​

Кажется, исходов два: токен есть — главный экран, токена нет — вход. На самом деле их три, и модель показывает, почему двух мало:

// Машина состояний сессии: чем «токен лежит на диске» отличается от «пользователь вошёл». // Создан 05-09-2026, участник №00. sealed interface Session { data object None : Session data class Active(val token: String, val expiresAt: Long) : Session } enum class Screen { LOGIN, MAIN } fun log(level: String, tag: String, event: String, details: String) = println(" $level [$tag]: $event — $details") // Наивно: токен есть — значит вошёл. fun naive(s: Session): Screen = if (s is Session.Active) Screen.MAIN else Screen.LOGIN // Аккуратно: токен есть И он ещё живой (с запасом 60 с на дорогу запроса). fun careful(s: Session, now: Long): Screen = when { s is Session.Active && s.expiresAt > now + 60 -> Screen.MAIN else -> Screen.LOGIN } fun main() { var now = 1_000_000L var session: Session = Session.None var wrongScreens = 0 fun step(title: String) { val n = naive(session) val c = careful(session, now) val flag = if (n != c) { wrongScreens++; " ← РАСХОЖДЕНИЕ" } else "" println(title) println(" состояние: " + session) println(" наивно → " + n + " аккуратно → " + c + flag) } println("── 1. первый запуск, диск пуст ──") log("INFO", "SplashScreen", "onCreate", "проверяем сохранённую сессию") step(" решение о стартовом экране:") println() println("── 2. успешный вход ──") log("INFO", "AuthScreen", "нажата кнопка Войти", "email=ivan@mail.ru") log("INFO", "AuthRepository", "POST /login", "начало") session = Session.Active("eyJhbGciOi...", expiresAt = now + 3600) log("DEBUG", "AuthRepository", "POST /login", "успех, токен живёт 3600 с") step(" решение о стартовом экране:") println() println("── 3. перезапуск приложения через 10 минут ──") now += 600 log("INFO", "SplashScreen", "onCreate", "токен прочитан из DataStore") step(" решение о стартовом экране:") println() println("── 4. перезапуск на следующий день: токен протух ──") now += 86_400 log("INFO", "SplashScreen", "onCreate", "токен прочитан из DataStore") step(" решение о стартовом экране:") println(" наивный путь дальше: MAIN → первый запрос → 401 → выкидывает на LOGIN") log("ERROR", "AuthInterceptor", "ответ 401", "GET /profile, токен недействителен") println() println("── 5. обновление по refresh-токену ──") log("INFO", "AuthRepository", "POST /refresh", "начало") session = Session.Active("eyJhbGciOi...НОВЫЙ", expiresAt = now + 3600) log("DEBUG", "AuthRepository", "POST /refresh", "успех, выдан новый access-токен") step(" решение о стартовом экране:") println() println("── 6. выход ──") log("INFO", "ProfileScreen", "нажата кнопка Выйти", "очищаем DataStore и кэш Room") session = Session.None step(" решение о стартовом экране:") println() println("расхождений между двумя стратегиями: " + wrongScreens) println("каждое расхождение = мигание чужого экрана перед глазами эксперта") }

Живой вывод (сокращён до расхождения):

── 3. перезапуск приложения через 10 минут ──
состояние: Active(token=eyJhbGciOi..., expiresAt=1003600)
наивно → MAIN аккуратно → MAIN

── 4. перезапуск на следующий день: токен протух ──
состояние: Active(token=eyJhbGciOi..., expiresAt=1003600)
наивно → MAIN аккуратно → LOGIN ← РАСХОЖДЕНИЕ
наивный путь дальше: MAIN → первый запрос → 401 → выкидывает на LOGIN
ERROR [AuthInterceptor]: ответ 401 — GET /profile, токен недействителен
ТРИ ИСХОДА ПРИ ЗАПУСКЕ, А НЕ ДВАчитаем токен из DataStoreещё не прочитализаставкане мигать формой входатокена нетэкран входапервый запуск или вышлитокен есть и не протухглавный экрансессия живатокен есть, но протух — это «токена нет»: иначе главный экран откроется и первый же запрос вернёт 401третий исход нужен, потому что чтение с диска асинхронно — доля секунды, но её видно
Токен есть, токен протух, токен ещё не прочитан — три состояния, а не два

На четвёртом шаге наивное решение открывает главный экран, потому что токен в хранилище есть. Пользователь видит главный экран, приложение делает первый запрос, получает 401 и выкидывает его на вход. Мигание экранов и ощущение сломанности.

Аккуратное решение проверяет не факт наличия, а срок: если сервер прислал время жизни токена, его сохраняют рядом и сравнивают при старте.

Третий исход — «ещё не прочитали». Чтение из DataStore асинхронно: на долю секунды состояние неизвестно. Если в этот момент показать экран входа, а потом переключить на главный — пользователь увидит мигание. Заставка на это время решает вопрос.

sealed interface SessionState {
data object Loading : SessionState
data object LoggedOut : SessionState
data class Active(val token: String) : SessionState
}

Протухший токен и обновление​

Токен живёт ограниченное время, и на длинной сессии он протухнет посреди работы. Сервер ответит 401. Есть два пути.

Простой: на 401 стереть токен и отправить на вход. Честно, но пользователь теряет контекст.

Правильный: сервер вместе с токеном выдаёт второй, долгоживущий — refresh-токен. По нему без пароля получают новый токен доступа и повторяют упавший запрос. Пользователь ничего не замечает.

В OkHttp для этого есть отдельный механизм — Authenticator, который вызывается сам при каждом 401:

class TokenAuthenticator(private val session: SessionManager) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
val newToken = session.refreshBlocking() ?: return null // null — сдаёмся, пользователь выйдет
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build() // запрос повторится с новым токеном
}
}

И здесь спрятан самый неочевидный баг всей темы.

Один полёт за обновлением, а не три​

Читая код одного запроса, этот баг увидеть невозможно. Он проявляется, когда запросов несколько:

// Три экрана обновляются одновременно, токен только что протух. // Что делает интерсептор, который обновляет токен «в лоб». // Создан 05-09-2026, участник №00. import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock class Server { var validAccess = "нет" // access-токен клиента уже протух var validRefresh = "R1" var refreshCalls = 0 suspend fun get(path: String, access: String): Int { delay(20) return if (access == validAccess) 200 else 401 } // Сервер ротирует refresh: использованный больше не действует. suspend fun refresh(rt: String): String? { delay(50) refreshCalls++ if (rt != validRefresh) return null validAccess = "A" + refreshCalls validRefresh = "R" + (refreshCalls + 1) return validAccess } } class Client(val server: Server) { var access = "A0" var refresh = "R1" var loggedOut = false val lock = Mutex() suspend fun naive(path: String): String { if (server.get(path, access) != 401) return "$path → 200" val new = server.refresh(refresh) ?: run { loggedOut = true; return "$path → 401, ВЫХОД" } access = new refresh = server.validRefresh return "$path → повтор → " + server.get(path, access) } suspend fun guarded(path: String): String { if (server.get(path, access) != 401) return "$path → 200" val seen = access lock.withLock { if (access == seen) { // никто не успел обновить val new = server.refresh(refresh) ?: run { loggedOut = true; return "$path → 401, ВЫХОД" } access = new refresh = server.validRefresh } } return "$path → повтор → " + server.get(path, access) } } fun main() = runBlocking { val paths = listOf("/profile", "/cart", "/orders") println("── интерсептор без защиты ──") val s1 = Server() val c1 = Client(s1) coroutineScope { paths.map { async { c1.naive(it) } }.awaitAll() }.forEach { println(" $it") } println(" обращений к /refresh: " + s1.refreshCalls + ", пользователь выкинут: " + c1.loggedOut) println() println("── тот же сценарий, обновление под замком ──") val s2 = Server() val c2 = Client(s2) coroutineScope { paths.map { async { c2.guarded(it) } }.awaitAll() }.forEach { println(" $it") } println(" обращений к /refresh: " + s2.refreshCalls + ", пользователь выкинут: " + c2.loggedOut) println() println("разница в коде — шесть строк, разница для эксперта — вход слетает на ровном месте") }

Живой вывод:

── интерсептор без защиты ──
/profile → повтор → 200
/cart → 401, ВЫХОД
/orders → 401, ВЫХОД
обращений к /refresh: 3, пользователь выкинут: true

── тот же сценарий, обновление под замком ──
/profile → повтор → 200
/cart → повтор → 200
/orders → повтор → 200
обращений к /refresh: 1, пользователь выкинут: false

разница в коде — шесть строк, разница для эксперта — вход слетает на ровном месте
ТРИ ЭКРАНА, ОДИН ПРОТУХШИЙ ТОКЕНбез защиты: каждый запрос обновляет сам/profile/refresh → 200/cart/refresh → 401 · ВЫХОД/orders/refresh → 401 · ВЫХОДпод замком: первый обновляет, остальные ждут его результата/profile → /refreshодин полёт/cartждёт · повтор → 200/ordersждёт · повтор → 200старый refresh-токен одноразовый: второй вызов с ним — уже отказ, и пользователя выкидывает на ровном месте
Три экрана получают 401 одновременно; без замка каждый идёт за новым токеном сам, и второй уже опаздывает

Главный экран открывается и запускает три запроса — профиль, корзина, заказы. Токен протух, все три получают 401, и каждый сам идёт обновлять токен. Первый успевает. Но refresh-токен одноразовый: второй и третий вызовы с ним получают отказ, и приложение решает, что сессия мертва. Пользователя выкидывает на вход — при живом сервере и правильном пароле.

Решение — обновлять под замком: первый запрос, получивший 401, идёт за новым токеном, остальные ждут его результата и повторяются уже с ним. В Kotlin для этого есть Mutex:

private val refreshMutex = Mutex()

suspend fun refreshIfNeeded(failedToken: String): String? = refreshMutex.withLock {
val current = currentToken()
if (current != failedToken) return current // кто-то уже обновил, пока мы ждали
api.refresh(refreshToken)?.also { save(it) }
}

Проверка current != failedToken — ключевая: второй и третий запросы, дождавшись замка, обнаруживают, что токен уже новый, и не ходят на сервер вовсе.

Отвечено вопросов: 0 из 2
Токен в хранилище есть. Значит, показываем главный экран?
Три запроса одновременно получили 401. Что произойдёт без замка?
Отвечено верно: 0 из 2

Выход: что именно стереть​

Стереть токен — очевидно. Но в главе про кэш мы уже видели, что этого мало:

suspend fun logout() {
session.clear() // токен и refresh-токен
userDao.clear() // корзина, заказы, профиль — всё, что принадлежит пользователю
// общий каталог можно оставить
}

И ещё одно, о чём забывают: состояние экранов. ViewModel главного экрана может пережить выход и при следующем входе показать прежние данные, пока не обновится. Если экраны переживают смену пользователя, их состояние нужно сбросить вместе с остальным.

Биометрия работает не так, как кажется​

В части заданий встречается требование: «вход по паролю или биометрии». Важно понять, что делает отпечаток на самом деле.

Он не заменяет токен и не ходит на сервер. Сервер про отпечаток не знает и знать не может. Биометрия — это локальная защёлка на уже сохранённой сессии: пользователь однажды вошёл паролем, токен лежит в хранилище, и при следующем открытии вместо пароля приложение просит палец, чтобы разблокировать доступ к этому токену.

Отсюда: биометрия доступна только когда токен уже есть. При первом входе она бессмысленна.

val prompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
openMainScreen() // токен уже в хранилище — просто пускаем
}
})

val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Вход")
.setSubtitle("Подтвердите отпечатком")
.setNegativeButtonText("Ввести пароль")
.build()

prompt.authenticate(info)

Перед показом проверяют, доступна ли биометрия вообще — BiometricManager.canAuthenticate(). На устройстве без датчика или без записанного отпечатка показывают обычный вход.

Важно

Библиотека androidx.biometric живёт в двух версиях, и это ловушка для руководств. Стабильная — 1.1.0, она работает через Activity и обратные вызовы, как в примере выше. Compose-обёртка появилась только в 1.4.0-alpha, и на чемпионате её брать не стоит. Вторая ловушка — setNegativeButtonText конфликтует с разрешением DEVICE_CREDENTIAL: если разрешить вход по PIN устройства, кнопку отмены задавать нельзя, иначе build() упадёт с IllegalArgumentException.

Комментарии и логирование здесь​

Множитель из главы про чистый код действует и на этот модуль. Что именно логировать в авторизации — из обязательных точек задания:

Log.i(TAG, "[AuthScreen]: Нажата кнопка — Войти, email=${email.take(3)}***")
Log.i(TAG, "[AuthRepository]: POST /login — начало")
Log.d(TAG, "[AuthRepository]: POST /login — успех, токен получен")
Log.e(TAG, "[AuthRepository]: POST /login — ошибка: ${e.message}", e)

Одно правило сверху: ни пароль, ни токен целиком в лог не попадают. Почту можно обрезать до первых символов, токен — не писать вовсе. Логи попадают в отчёты и на экраны экспертов.

Типичные ошибки​

Регекс без якорей и containsMatchIn. Поле принимает любую строку, где встречается адрес.

Patterns.EMAIL_ADDRESS. Принимает всё, что задание запрещает, и падает с NullPointerException в JVM-тесте.

Стартовый экран смотрит на факт наличия токена. Протухший токен открывает главный экран на секунду, потом 401 выкидывает на вход.

Токен читается из DataStore внутри синхронного перехватчика. Первый запрос после запуска уходит без заголовка.

Обновление токена без замка. Несколько экранов идут за новым токеном одновременно; refresh-токен одноразовый — пользователя выкидывает.

Выход стирает токен, но не кэш и не состояние. Следующий пользователь видит чужие данные.

Ошибка тостом. Исчезает через две секунды и не привязана к полю.

setNegativeButtonText вместе с DEVICE_CREDENTIAL. Падение на build().

Слова главы​

  • Токен — строка, подтверждающая вход; живёт ограниченное время, несётся в каждом запросе.
  • Refresh-токен — долгоживущий; по нему получают новый токен доступа без пароля. Одноразовый.
  • Authorization: Bearer — заголовок с токеном.
  • Authenticator — механизм OkHttp, вызываемый на 401.
  • Mutex — замок: обновление токена делает один, остальные ждут.
  • matches() / containsMatchIn() — вся строка против поиска вхождения.
  • isError / supportingText — рамка и текст ошибки под полем.
  • PasswordVisualTransformation — точки вместо символов.
  • BiometricPrompt — системный диалог отпечатка; защёлка на сохранённой сессии.

Тренажёр: набрать вслепую​

Цель: скорость ≥ 100 зн/мин, точность ≥ 88%

private val EMAIL_RULE = Regex("^[a-z0-9]+@[a-z0-9]+\\.ru$")

На чемпионате​

Что эксперт делает руками — и что должно произойти:

  • Вводит Ivan@Mail.RU — поле показывает ошибку под собой. Не тост.
  • Вводит семь символов пароля — ошибка под полем.
  • Входит, убивает приложение из недавних, открывает — главный экран без повторного входа. Это «данные входа и активная сессия должны быть сохранены после перезапуска приложения».
  • Выходит и входит другим пользователем — никаких следов предыдущего.
  • Отключает сеть и нажимает «Войти» — понятная ошибка про соединение, не «неверный пароль».

И два требования из соседних модулей: unit-тесты сетевого слоя на каждый запрос — /register и /login входят в это «каждый»; шапка класса и логи с датой и номером участника.

Отвечено вопросов: 0 из 2
Что делает отпечаток пальца при входе?
Эксперт отключил сеть и нажал «Войти». Что показать?
Отвечено верно: 0 из 2

Что дальше​

Пользователь входит, сессия живёт, выход чистит следы. Дальше — то, что приложение делает с железом устройства: камера и галерея для фото профиля, локальные уведомления и виджет на домашний экран. Это модуль аппаратных возможностей, и в нём три требования повторяются из года в год почти дословно.

Отвечено вопросов: 0 из 1
Почему валидацию делят на локальную и серверную?
Отвечено верно: 0 из 1

Углубиться​

  • OAuth и вход через сторонние сервисы: чем отличается от собственной авторизации и когда его требуют.
  • JWT изнутри: что зашито в токене, почему его можно прочитать без сервера и почему нельзя доверять прочитанному.
  • Android Keystore: аппаратное хранение ключей для по-настоящему чувствительных данных.
  • CredentialManager — системный диалог с сохранёнными паролями и ключами доступа.
  • Ограничение попыток входа и что делать с подбором пароля на стороне клиента.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоУрок 9: TextField, OutlinedTextField, Валидация почтыKotlin & Android с нуля
ВидеоHow to Implement Biometric Auth in Your Android AppPhilipp Lackner
ВидеоБиометрическая аутентификация / сканер отпечатка пальцаАндрей Яшев
ВидеоBuilding Secure APIs with JWT and Token Refresh MechanismRahul Ray
ВидеоPreferences DataStore - MAD SkillsAndroid Developers

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.