Перейти к основному содержимому
Мобилка37ГЛАВА 37Регистрация и вход на сервере заданияemailpasswordpasswordConfirmпароли не совпадаютошибка знает поле

Регистрация и вход на сервере задания

Прочитано0%
Квизы0/4
Уровень 1 · Новичок

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

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

Подробности стоят баллов. Например, поле входа называется identity, а не email — и запрос с «очевидным» именем поля не сработает. Или: запрет доступа выглядит как 404, а не 403 — и ветка «нет прав» в коде никогда не выполнится, если писать её по учебнику.

Все ответы ниже сняты со стенда https://style-matule.apis.alspio.com/api, а вывод программ — с настоящего прогона Retrofit 2.11.0 на Kotlin 2.0.21.

Совет

На стенде заведена учебная учётка, чтобы не регистрироваться заново при каждой пробе: student@matule.local с паролем Parol12345. Она нужна только для тренировки — данные в ней ничьи, и правила доступа стенда не дадут ей увидеть чужие корзины и профили. Свою почту и настоящие пароли на учебный сервер не отправляйте.

Регистрация​

Регистрация — это создание записи в коллекции users, тем же POST, каким создаётся что угодно другое:

POST /api/collections/users/records
{"email":"student@matule.local","password":"Parol12345","passwordConfirm":"Parol12345"}

Три поля обязательны, и третье — passwordConfirm — часто забывают: это не проверка на клиенте, а настоящее поле запроса, которое сервер сверяет сам.

Ответ на успешную регистрацию:

{
"avatar": "",
"collectionId": "_pb_users_auth_",
"collectionName": "users",
"created": "2026-09-13 07:03:27.014Z",
"datebirthday": "",
"emailVisibility": false,
"firstname": "",
"gender": "",
"id": "8mi58iaqqgqfm4f",
"lastname": "",
"name": "",
"secondname": "",
"updated": "2026-09-13 07:03:27.014Z",
"verified": false
}
Важно

Обратите внимание, чего в ответе нет: ни токена, ни поля email. Регистрация не входит в аккаунт — после неё нужен отдельный запрос входа. А почта скрыта, потому что emailVisibility равно false: PocketBase не показывает чужие адреса даже владельцу записи, пока тот не авторизован.

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

Описание запроса в коде:

@Serializable
data class RegisterRequest(
val email: String,
val password: String,
val passwordConfirm: String,
)

@POST("collections/users/records")
suspend fun register(@Body body: RegisterRequest): UserDto
Нажми на часть выражения

Ошибки формы приходят по полям​

Вот где спрятан балл, который теряют почти все. Когда форма заполнена неверно, PocketBase отвечает 400 и присылает разбор по каждому полю отдельно:

{
"data": {
"passwordConfirm": {
"code": "validation_values_mismatch",
"message": "Values don't match."
}
},
"message": "Failed to create record.",
"status": 400
}
ОШИБКА ПРИХОДИТ С АДРЕСОМ ПОЛЯ{"status": 400,"message": "Failed to create record.","data": {"passwordConfirm": {"code": "validation_values_mismatch"} }message — для журналаодин и тот же текст на любую ошибку формыdata — для экранаключ = имя поля, под которым рисуем подписьпоказать «Failed to create record.» под формой — потерять балл: пользователь не узнал, что чинитьиз data достаётся, что пароли не совпали — и подпись встаёт под нужным полем
Верхний message одинаков для всех ошибок формы; полезное лежит в data под именем поля

Поле message наверху одинаково для любой ошибки создания записи — «Failed to create record.». Показывать его пользователю бессмысленно: он не узнает, что чинить. Полезное — в data, где ключ это имя поля.

Четыре случая, снятые со стенда:

Что сделалиПоле в datacodemessage
Пароли не совпалиpasswordConfirmvalidation_values_mismatchValues don't match.
Почта уже занятаemailvalidation_not_uniqueValue must be unique.
Пароль короче восьмиpasswordvalidation_min_text_constraintMust be at least 8 character(s).
Почта без собакиemailvalidation_is_emailMust be a valid email address.

Описать это в Kotlin можно одним классом на все случаи:

@Serializable
data class ApiError(
val status: Int = 0,
val message: String = "",
val data: Map<String, FieldError> = emptyMap(),
)

@Serializable
data class FieldError(
val code: String = "",
val message: String = "",
)
Нажми на часть выражения

Достать тело ошибки из Retrofit чуть сложнее, чем успешный ответ: при коде 4xx вылетает HttpException, и тело лежит в нём.

try {
api.register(RegisterRequest(email, password, passwordConfirm))
} catch (e: HttpException) {
val body = e.response()?.errorBody()?.string().orEmpty()
val error = json.decodeFromString<ApiError>(body)
error.data.forEach { (field, detail) ->
println("поле «$field»: ${detail.code}")
}
}
Совет

errorBody()?.string() можно прочитать только один раз — это поток, а не строка. Второй вызов вернёт пустоту. Поэтому результат сразу кладут в переменную, а не читают дважды: один раз для журнала, другой для разбора.

Живой вывод программы на этих самых ошибках:

── пароли не совпали ──
→ POST /api/collections/users/records | Authorization: нет
код 400 · Failed to create record.
поле «passwordConfirm»: validation_values_mismatch — Values don't match.
── короткий пароль ──
→ POST /api/collections/users/records | Authorization: нет
код 400 · Failed to create record.
поле «password»: validation_min_text_constraint — Must be at least 8 character(s).

Это и есть то, что показывают под полями формы: не «Ошибка 400», а «Пароли не совпадают» под вторым полем пароля.

Отвечено вопросов: 0 из 3
Где в ответе PocketBase лежит причина того, что форма не прошла проверку?
Что вернёт второй вызов errorBody()?.string() подряд?
После успешной регистрации приложение может сразу открыть каталог как вошедший пользователь?
Отвечено верно: 0 из 3

Вход​

Запрос входа — отдельный, со своим путём и своими именами полей:

POST /api/collections/users/auth-with-password
{"identity":"student@matule.local","password":"Parol12345"}
Важно

Поле называется identity, а не email. Это не опечатка в документации: PocketBase допускает вход и по имени пользователя, поэтому поле названо нейтрально. Запрос с полем email даёт 400 и совсем другую ошибку:

{
"data": { "identity": { "code": "validation_required", "message": "Cannot be blank." } },
"message": "An error occurred while validating the submitted data.",
"status": 400
}

Заметьте: верхний message здесь тоже другой — «An error occurred while validating the submitted data.». По нему видно, что сервер не дошёл до проверки пароля, а споткнулся о форму запроса.

Успешный ответ устроен иначе, чем всё остальное: это не запись и не конверт со страницами, а пара «токен + запись».

{
"record": {
"id": "ned6nzkcrtqip6s",
"email": "student@matule.local",
"firstname": "Иван",
"lastname": "Петров",
"…": "…"
},
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6…"
}
@Serializable
data class AuthRequest(
val identity: String,
val password: String,
)

@Serializable
data class AuthResponse(
val token: String,
val record: UserDto,
)

@POST("collections/users/auth-with-password")
suspend fun login(@Body body: AuthRequest): AuthResponse

Здесь же — важная деталь про безопасность, которую полезно заметить и повторить в своём приложении:

Интересный факт

Неверный пароль и несуществующая почта дают абсолютно одинаковый ответ: 400 и «Failed to authenticate.», с пустым data. Это сделано намеренно — иначе по разнице ответов можно было бы перебором узнать, какие адреса зарегистрированы. Значит, и на экране нужна одна общая подпись «Неверная почта или пароль», а не две разные.

Что такое этот токен​

Токен — строка из 224 символов, разделённая точками на три части. Первые две — это обычный текст в кодировке base64, который читается кем угодно:

заголовок: {"alg": "HS256", "typ": "JWT"}
нагрузка: {
"collectionId": "_pb_users_auth_",
"exp": 1789715557,
"id": "ned6nzkcrtqip6s",
"refreshable": true,
"type": "auth"
}
подпись: 43 символа
ТОКЕН: ЧТО ВНУТРИ И КТО ЕГО ПОДСТАВЛЯЕТзаголовокid · exp · type: auth · refreshableчитается кем угодно — это не шифрподпись224 символа · живёт 5 суток · подделать нельзя, прочитать — можновходтокен полученхранилищеDataStore, не памятьинтерцепторвешает заголовоклюбой запросуже с токеномзаголовок вешается в одном месте — иначе его забудут ровно в том запросе, который проверяет эксперт
Три части токена и путь заголовка Authorization через интерцептор

Поле exp — время, до которого токен годен, в секундах от начала 1970 года. Для этого токена — пятые сутки после выдачи.

Важно

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

Проверить, что срок жизни читается, можно чистым Kotlin — без сети и без библиотек:

import java.util.Base64 fun decodePart(part: String): String = String(Base64.getUrlDecoder().decode(part.padEnd((part.length + 3) / 4 * 4, '='))) fun main() { // настоящая полезная нагрузка токена со стенда, подпись отброшена val payload = "eyJjb2xsZWN0aW9uSWQiOiJfcGJfdXNlcnNfYXV0aF8iLCJleHAiOjE3ODk3MTU1NTcsImlkIjoibmVkNm56a2NydHFpcDZzIiwicmVmcmVzaGFibGUiOnRydWUsInR5cGUiOiJhdXRoIn0" val text = decodePart(payload) println("нагрузка: ${text}") val exp = Regex("\"exp\":(\\d+)").find(text)!!.groupValues[1].toLong() println("годен до (секунды от 1970): ${exp}") val vydanPrimerno = 1789283557L // момент выдачи этого токена println("осталось часов: " + (exp - vydanPrimerno) / 3600) println("протух? " + (exp <= vydanPrimerno)) }

Настоящий вывод:

нагрузка: {"collectionId":"_pb_users_auth_","exp":1789715557,"id":"ned6nzkcrtqip6s","refreshable":true,"type":"auth"}
годен до (секунды от 1970): 1789715557
осталось часов: 120
протух? false
Отвечено вопросов: 0 из 3
Как называется поле почты в запросе входа PocketBase?
Неверный пароль и несуществующая почта: чем отличаются ответы?
Содержимое токена…
Отвечено верно: 0 из 3

Кто вешает заголовок​

Токен передаётся заголовком Authorization. Писать его руками в каждом методе — верный способ забыть ровно там, где проверяет эксперт. Поэтому заголовок вешает интерцептор OkHttp — один на весь клиент:

private var token: String = ""

private val client = OkHttpClient.Builder()
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.apply { if (token.isNotEmpty()) addHeader("Authorization", token) }
.build()
chain.proceed(request)
}
.build()
Нажми на часть выражения
Интересный факт

В PocketBase заголовок выглядит как Authorization: eyJhbGci… — без слова Bearer. Многие серверы требуют префикс, и привычка добавить его сломает запрос. Впрочем, PocketBase префикс терпит: он умеет отбрасывать Bearer сам. Но полагаться на это не стоит — в спецификации задания префикса нет.

Тот же интерцептор удобно заставить печатать, что уходит, — это экономит время на площадке:

→ POST /api/collections/users/auth-with-password | Authorization: нет
→ GET /api/collections/users/records/bj419xkszg0wcsz | Authorization: eyJhbGciOiJIUz…
→ PATCH /api/collections/users/records/bj419xkszg0wcsz | Authorization: eyJhbGciOiJIUz…

По такому журналу сразу видно, в какой запрос токен не попал.

Профиль и его обновление​

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

@GET("collections/users/records/{id}")
suspend fun profile(@Path("id") id: String): UserDto

@PATCH("collections/users/records/{id}")
suspend fun updateProfile(@Path("id") id: String, @Body body: Map<String, String>): UserDto

Map<String, String> в теле обновления — осознанный выбор: PATCH меняет только те поля, что пришли, и описывать под каждое сочетание свой класс незачем.

api.updateProfile(userId, mapOf(
"firstname" to "Пётр",
"lastname" to "Иванов",
"datebirthday" to "2006-04-17",
))

Живой ответ — вся запись целиком, с обновлённым updated:

── обновление профиля ──
→ PATCH /api/collections/users/records/bj419xkszg0wcsz | Authorization: eyJhbGciOiJIUz…
стало: Пётр Иванов, 2006-04-17

А вот ловушка, которую стоит увидеть заранее:

Важно

PATCH с несуществующим именем поля возвращает 200 и молча ничего не меняет. Отправили {"nickname":"vanya"} — получили 200 и запись без изменений. Никакой ошибки, никакого предупреждения.

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

Запрет выглядит как «не найдено»​

Самая неочевидная повадка PocketBase. Попробуем три разные вещи:

GET /collections/users/records/{свой id} без токена → 404
GET /collections/users/records/{чужой id} со своим токеном → 404
GET /collections/users/records/nesuschestvuyuschiy → 404

Все три — 404 с одним и тем же телом:

{ "data": {}, "message": "The requested resource wasn't found.", "status": 404 }
ТРИ РАЗНЫЕ ПРИЧИНЫ — ОДИН ОТВЕТтокена нетзапрос анонимныйтокен чужойзапись не твоязаписи нетневерный id404 · The requested resource wasn't found.PocketBase не признаётся, что запись есть, но не твояпо коду ответа отличить «не вошёл» от «нет такого» нельзя — решает наличие токена на устройстве
Нет токена, чужая запись, неверный идентификатор — сервер отвечает одинаково

Это сделано специально: сервер не подтверждает, что запись существует, но принадлежит кому-то другому. Для нас практическое следствие такое:

Важно

Ветка if (code == 401) { "Войдите" } на этом сервере не выполнится никогда: неверный пароль даёт 400, отсутствие токена — 404. Ветка if (code == 403) — почти никогда: 403 приходит только там, где правило запрещает действие всем, кроме администратора, например при попытке создать товар. В приложении задания таких запросов нет.

Понять, что пользователь просто не вошёл, можно только по своему состоянию: токен на устройстве есть или нет. Не по коду ответа.

То же правило действует и на корзине, которую мы разберём в следующей главе: чужая корзина по идентификатору — 404, а список чужих корзин — 200 с пустым items. Список не ошибается, он фильтруется.

Отвечено вопросов: 0 из 3
Запрос профиля без токена возвращает…
PATCH профиля с полем "nickname", которого в коллекции нет. Что придёт?
Где правильно вешать заголовок Authorization?
Отвечено верно: 0 из 3

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

1. Поле входа названо email. Самая частая: код выглядит правильным, сервер отвечает 400 с validation_required на поле identity. Верхний message при этом другой, чем при неверном пароле, — по нему и различают.

2. passwordConfirm проверили только на клиенте. Поле обязательно в запросе. Без него сервер ответит validation_required на passwordConfirm, даже если пароли в форме совпали.

3. Токен хранится в переменной. Работает до первого перезапуска приложения, а эксперт приложение перезапускает. Хранилище — DataStore, и читать его надо до показа первого экрана, иначе на секунду мелькнёт экран входа у вошедшего пользователя.

4. Под формой показывают message. «Failed to create record.» — не сообщение для человека. Разбирать надо data.

5. Ветка 401 (и почти всегда 403). Их не будет: 400 при неверном пароле, 404 при отсутствии прав. Код, написанный по учебнику, тихо не сработает.

6. Слово Bearer в заголовке. В спецификации задания его нет. PocketBase этот префикс прощает, но другой сервер — не обязательно.

Что засчитывает эксперт​

В этой главе закрыты четыре запроса из четырнадцати: регистрация, вход, получение профиля и его обновление. Проверяется обычно так:

  • регистрация с занятой почтой показывает понятную подпись под полем почты, а не общую ошибку;
  • пароли, которые не совпали, подписаны под вторым полем пароля;
  • вход с неверным паролем не пускает и объясняет это одной общей фразой;
  • после перезапуска приложения пользователь остаётся вошедшим;
  • профиль показывает данные сервера, изменения сохраняются и видны после перезахода.
Совет

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

Куда углубиться​

  • POST /collections/users/auth-refresh — обновить токен до истечения срока, не спрашивая пароль заново. В нагрузке токена для этого есть refreshable: true.
  • POST /collections/users/request-password-reset — сброс пароля по почте.
  • emailVisibility — как сделать почту видимой и зачем это иногда нужно.
  • EncryptedSharedPreferences и DataStore с шифрованием — где на самом деле хранят токен в боевых приложениях.
  • Проверка срока годности токена на клиенте: разобрать exp и не отправлять заведомо протухший запрос.

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

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

Видео по теме

ВидеоRetrofit - авторизация, практика. Часть 1 | Android Studio (Kotlin)Neco Ru
ВидеоLet's Build: Authenticated API's with PocketBaseSam Wyndham
ВидеоPreferences DataStore - MAD SkillsAndroid Developers
ВидеоBuilding Secure APIs with JWT and Token Refresh Mechanism in Android using OkHttp InterceptorsRahul Ray
ВидеоHow To Generate Types And Use API Rules In PocketbaseProgramonaut

Комментарии

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