Логин без бэкенда: member, bcrypt и sealed cookie

Логин без бэкенда: member, bcrypt и sealed cookie

Дневнику питания нужен аккаунт: нормы и дневник — приватные данные, они обязаны оставаться доступными при смене устройства. Свой бэкенд ради этого мы отмели ещё на этапе архитектуры. В DynapiCMS есть пресеты member-авторизации — готовые флоу регистрации, входа и восстановления пароля. Мы взяли их в готовом виде и не стали ничего переписывать с нуля.

Тип member и тип bcrypt

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

{ "name": "email", "type": "string", "required": true, "unique": true },
{ "name": "password", "type": "bcrypt", "required": true },
{ "name": "verified", "type": "boolean" },
{ "name": "norm_kcal", "type": "int" }

Всё интересное сосредоточено в типе bcrypt. При записи платформа хеширует значение сама и примешивает перец проекта — секрет, который хранится только на сервере. При чтении поле отвечает "***" любому API-клиенту. Сам хеш лежит в записи member, как и положено. Bcrypt необратим, так что пароли не восстановить даже из дампа базы. По API хеш тоже недоступен: звёздочки получают все, а сравнить пароль при входе умеет только серверное исполнение флоу.

С паролем напрямую нам работать так ни разу и не пришлось, и это лучшая рекомендация пресетов.

Регистрация: готовый флоу плюс 15 строк валидации

Пресет register — граф из восьми узлов. Мы применили его командой flowctl apply, подставив свой домен и SMTP-секрет, и обнаружилось, что пресет не проверяет ни формат email, ни длину пароля. Валидация выполняется в condition-узле и ветке отказа перед созданием записи, и граф вырос до девяти узлов:

{ "field": "form.email",
  "operator": "matches",
  "value": "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$" },
{ "field": "form.password",
  "operator": "matches",
  "value": ".{8,}" }

Если проверка не прошла, клиент получает редирект на /register?failed=1, и запись не создаётся. После успешной проверки флоу ищет дубликат запросом по email, создаёт member и отправляет письмо со ссылкой подтверждения. Ссылку строит фильтр signed_token прямо в шаблоне письма:

{{ results.m.members[0].password | signed_token:
   results.m.members[0].id, "verify", 86400
   | prepend: "https://calories.dynapi.ru/verify?token=" }}

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

На узле создания записи стоит secret_vars: ["password"] — платформа вычищает пароль из журналов прогонов флоу.

Сессионная кука

Вход — это флоу из шести узлов: находим member по email, сравниваем пароль оператором bcrypt_matches, ставим куку и редиректим на /app:

{ "action_type": "set_cookie",
  "config": {
    "name": "member",
    "value": "{\"id\":\"{{results.login.members[0].id}}\"}",
    "ttl_days": 30, "path": "/", "session": true
  } }

Куку клиент не видит и не читает. Внутри — JWT, зашифрованный AES-GCM, и платформа отдаёт её с флагами HttpOnly; Secure; SameSite=Lax; Max-Age=2592000. Тридцать дней без всякого «запомнить меня»: PWA просто открывается уже залогиненной. Из куки шаблоны и флоу достают member.id и member.name. Распаковывать её умеет только сервер.

Форма регистрации: светлая и тёмная темы

Дневник пользователя: записи видны только владельцу

Изоляция данных: главная глава

Каждая запись дневника несёт поле owner. Фильтр «только моё» выглядит так:

entries(where: {client_uuid: {eq: $uuid}, owner: {eq: $member}}, limit: 1)

Переменная $member в graphql-узле флоу заполнена значением {{ member.id }} — платформа достаёт его из подписанной куки на сервере. Тот же паттерн используют платформенные пресеты авторизации. Получается, id пользователя всегда берётся с сервера, и подделывать нечего.

Изоляцию мы проверяли двумя тестовыми аккаунтами. B не видит ни одной записи A. Сохранение с чужим client_uuid создаёт для B отдельную строку, чужая при этом остаётся нетронутой. Такая проверка входит в цикл релиза: при каждом выпуске мы проверяем оба аккаунта заново, «по коду видно» аргументом не считается.

Грабли

Login-CSRF. Кука ставится только на same-origin запрос: cross-site POST на /login платформа отклоняет с 403 REQUEST_ORIGIN_DENIED, и кука не ставится. Для наших форм это защита. Если встроить форму входа на чужой домен, то же поведение станет сюрпризом.

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

Валидация — наша работа. Криптографией и сессиями занимается платформа. Политика паролей остаётся нашей работой, минимальный набор — те два condition-узла, что мы добавили выше.

Что дальше

Следующая статья — «JSON API из флоу»: девять эндпоинтов дневника, идемпотентный апсет по client_uuid, на котором держится офлайн-синхронизация, и грабли сборки JSON в Liquid-шаблонах.


Все записи