Регистрация и вход на сервере задания
Зачем это нужно
Про авторизацию как идею мы уже говорили: пароль меняется на токен, токен живёт в надёжном хранилище, каждый запрос его несёт. Эта глава — про то, как именно это делается на сервере задания, и она почти целиком состоит из подробностей, которых нет ни в спецификации, ни в обычных статьях про 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
}
Поле message наверху одинаково для любой ошибки создания записи — «Failed to create record.». Показывать его пользователю бессмысленно: он не узнает, что чинить. Полезное — в data, где ключ это имя поля.
Четыре случая, снятые со стенда:
| Что сделали | Поле в data | code | message |
|---|---|---|---|
| Пароли не совпали | passwordConfirm | validation_values_mismatch | Values don't match. |
| Почта уже занята | email | validation_not_unique | Value must be unique. |
| Пароль короче восьми | password | validation_min_text_constraint | Must be at least 8 character(s). |
| Почта без собаки | email | validation_is_email | Must 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», а «Пароли не совпадают» под вторым полем пароля.
Вход
Запрос входа — отдельный, со своим путём и своими именами полей:
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 символа
Поле 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
Кто вешает заголовок
Токен передаётся заголовком 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 }
Это сделано специально: сервер не подтверждает, что запись существует, но принадлежит кому-то другому. Для нас практическое следствие такое:
Ветка if (code == 401) { "Войдите" } на этом сервере не выполнится никогда: неверный пароль даёт 400, отсутствие токена — 404. Ветка if (code == 403) — почти никогда: 403 приходит только там, где правило запрещает действие всем, кроме администратора, например при попытке создать товар. В приложении задания таких запросов нет.
Понять, что пользователь просто не вошёл, можно только по своему состоянию: токен на устройстве есть или нет. Не по коду ответа.
То же правило действует и на корзине, которую мы разберём в следующей главе: чужая корзина по идентификатору — 404, а список чужих корзин — 200 с пустым items. Список не ошибается, он фильтруется.
Типичные ошибки
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 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- PocketBase — авторизация записей —
auth-with-password,auth-refreshи форма ответа. - Правила доступа PocketBase — почему запрет выглядит как 404 и как пишутся правила вида
id = @request.auth.id. - OkHttp Interceptors — устройство цепочки и разница между обычным и сетевым перехватчиком.
- DataStore — хранение ключ-значение — куда класть токен вместо переменной.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.