feat: аккаунты по почте с подтверждением, сбросом пароля и синхронизацией прогресса

API аккаунтов на чистой стандартной библиотеке Python (server/splitapi):
- регистрация по почте: пароль задаётся по ссылке из письма, аккаунт создаётся только после подтверждения
- вход, выход, сброс пароля по почте (токены одноразовые, в базе только SHA-256), удаление аккаунта
- пароли scrypt, лимиты попыток по паре «адрес + IP», IP и аккаунту, защита от CSRF
- хранение прогресса пользователя с оптимистичной блокировкой версии
- SMTP через переменные окружения; без SMTP на публичном адресе API не стартует

Клиент: public/auth.js (вход, регистрация, сброс, синхронизация), public/sync.js
(чистое слияние прогресса). Без API сайт работает как раньше.

Развёртывание: Dockerfile.api, сервис splitapi в docker-compose, проксирование /api/
в nginx с ограничением частоты, deploy/install-api-debian.sh (systemd), .env.example.

Тесты: 216 для бэкенда (unittest, покрытие около 97%), 16 для клиентского слияния.
Документация: раздел «Аккаунты и почта» в docs/DEPLOY.md, раздел 9 в docs/ИНСТРУКЦИЯ.md.
This commit is contained in:
darko
2026-10-16 11:25:09 +03:00
parent e897a6c5eb
commit fcafe1ae15
36 changed files with 4520 additions and 22 deletions
+131 -10
View File
@@ -1,6 +1,6 @@
# Развёртывание на своём сервере (Debian 12 / 13)
Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и ничего не хранит на сервере. Нет бэкенда, базы данных, куки и обращений к внешним сайтам. Сервер только отдаёт файлы, поэтому требования минимальные.
Сплит-тренер состоит из статических файлов (HTML, CSS, JS) и необязательного API аккаунтов (вход по почте и хранение прогресса на сервере). Без API сервер только отдаёт файлы, ничего не хранит и не ставит куки; с API появляются вход, подтверждение почты, сброс пароля и синхронизация прогресса между устройствами (раздел 4, «Аккаунты и почта»). Обращений к внешним сайтам нет в обоих режимах, кроме отправки писем через ваш SMTP.
## 1. Минимальные требования
@@ -16,20 +16,24 @@
| ПО (вариант A) | Docker Engine 24+ с плагином Compose v2 | рекомендуется официальный репозиторий: docs.docker.com/engine/install/debian |
| ПО (вариант B) | nginx 1.22+ из пакетов Debian (в Debian 12 это 1.22, в 13 это 1.26) | ставится скриптом |
| Права | root или sudo на время установки | сайт работает без root-прав |
| Аккаунты: ПО | вариант A: ничего дополнительного (API в отдельном контейнере); вариант B: python3 3.11+ (в Debian 12 это 3.11, в 13 это 3.13) | только стандартная библиотека Python, `pip` не нужен |
| Аккаунты: память | ещё до 256 МБ свободной памяти | каждый вход хеширует пароль (scrypt, около 32 МБ на вход); для контейнера задан лимит 384 МБ |
| Аккаунты: почта | доступ к SMTP-серверу (порт 587 или 465) | без SMTP письма печатаются в лог: годится только для проверки |
Нужно выбрать один вариант: A или B.
Нужно выбрать один вариант: A или B. Аккаунты необязательны: без API сайт работает как раньше, просто без входа.
### Домен и HTTPS
- Для работы сайта HTTPS **не обязателен**: приложение не использует API, требующие защищённого контекста.
- Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 4).
- Но на публичном адресе HTTPS нужен: его даёт ваш обратный прокси (раздел 5). С аккаунтами это особенно важно: пароли и cookie входа не должны ходить по открытому каналу. Если `PUBLIC_URL` начинается с `https://`, cookie входа получает флаг `Secure`.
### Браузеры пользователей
Современные: Chrome и Edge 111+, Firefox 113+, Safari 16.2+ (примерно 2023 год и новее). Для работы нужен включённый JavaScript и `localStorage`.
## 2. Что важно знать про данные
- Прогресс и статистика хранятся **в браузере пользователя** (`localStorage`), на сервер ничего не отправляется. Резервировать на сервере нечего.
- Данные привязаны к адресу сайта (схема + домен + порт). Если сменить домен, пользователи начнут «с нуля»; смена пути (`/trainer/`) прогресс не затрагивает.
- **Без входа (гость)** прогресс и статистика хранятся в браузере пользователя (`localStorage`), на сервер ничего не отправляется. Данные привязаны к адресу сайта (схема + домен + порт): при смене домена гость начинает «с нуля»; смена пути (`/trainer/`) прогресс не затрагивает.
- **После входа** прогресс, история упражнений и статистика клавиш копируются на сервер и подтягиваются на других устройствах. Настройки и раскладка Vial остаются на устройстве.
- **Что хранит сервер** (файл SQLite, в Docker это том `splitapi-data`): адрес почты, хеш пароля (scrypt, пароль в открытом виде не хранится), хеши токенов входа, подтверждения и сброса (сами токены не хранятся), JSON с прогрессом. Резервное копирование и удаление данных: раздел 4.
- Файл раскладки Vial (.vil) читается локально в браузере и никуда не загружается.
## 3. Установка
@@ -89,9 +93,120 @@ sudo ./deploy/install-debian.sh # вариант B
Браузеры подхватят новую версию сами: сайт отдаётся с `Cache-Control: no-cache`, то есть при каждом открытии версия сверяется по ETag.
## 4. За обратным прокси
## 4. Аккаунты и почта
От прокси ничего особенного не требуется: сайт статический, без WebSocket, без нестандартных заголовков, все ссылки внутри относительные. Достаточно передать запрос на `127.0.0.1:8080`.
Аккаунты необязательны: без API сайт работает как раньше, просто без пункта «Войти». С API появляются регистрация с подтверждением почты, вход, сброс пароля по почте, удаление аккаунта и хранение прогресса на сервере.
### Как включить
**Docker (вариант A).** `docker compose up -d --build` поднимает сайт и API вместе. Скопируйте `.env.example` в `.env` и задайте:
- `PUBLIC_URL`: внешний адрес сайта, как его видят пользователи (из него строятся ссылки в письмах и проверяется `Origin`);
- настройки почты (`SMTP_*`, см. ниже). Если сайт публичный, а SMTP не задан, API **не запустится**: в `docker compose logs splitapi` будет подсказка. Это сделано намеренно: без почты ссылки подтверждения оказались бы в логах. Для проверки можно явно указать `MAIL_BACKEND=console`: письма тогда печатаются в лог (`docker compose logs splitapi`).
Если аккаунты не нужны, удалите сервис `splitapi` из `docker-compose.yml`: сайт сам поймёт, что API нет, и не покажет вход.
**Без Docker (вариант B).** После `install-debian.sh`:
```bash
sudo PUBLIC_URL=https://trainer.example.com \
SMTP_HOST=smtp.example.com SMTP_USER=noreply@example.com SMTP_PASSWORD='...' \
./deploy/install-api-debian.sh
```
Скрипт ставит python3 (нужен 3.11 или новее: это Debian 12 и 13), копирует код в `/opt/split-trainer/api`, создаёт `/etc/split-trainer/api.env` (права `0600`) и службу `split-trainer-api` с изоляцией systemd (`DynamicUser`, `ProtectSystem=strict`, лимит памяти). База лежит в `/var/lib/split-trainer`. Повторный запуск обновляет код и перезапускает службу, а `api.env` не трогает: его правят вручную и затем `sudo systemctl restart split-trainer-api`. Логи: `journalctl -u split-trainer-api`.
### Как это выглядит для пользователя
1. **Регистрация.** Человек вводит только почту и получает письмо со ссылкой (действует 24 часа, работает один раз). По ссылке он подтверждает адрес **и сам задаёт пароль**: аккаунт создаётся только в этот момент. Поэтому чужая регистрация на ваш адрес ничего не подменит.
2. **Вход.** Почта и пароль. После входа прогресс подтягивается с сервера и объединяется с тем, что уже есть на устройстве.
3. **Сброс пароля.** «Забыли пароль?» → письмо со ссылкой (1 час, один раз). После смены пароля все прежние входы завершаются и приходит уведомление.
4. **Синхронизация.** После каждого упражнения прогресс уходит на сервер. Слияние простое: лучший результат по урокам, объединение истории, для статистики клавиш берётся запись с большим числом нажатий (если два устройства долго работали офлайн, часть нажатий одного из них может не учесться).
5. **Удаление.** В разделе «Аккаунт» есть «Удалить аккаунт» (нужен пароль): удаляются аккаунт, сессии и прогресс на сервере. «Сбросить все данные» в «Статистике» при входе очищает и серверную копию (на других устройствах локальные данные при следующей синхронизации вернутся: очистите и их).
### Переменные окружения
| Переменная | По умолчанию | Назначение |
|---|---|---|
| `PUBLIC_URL` | в compose `http://127.0.0.1:8080` | **обязательна**: внешний адрес сайта (если сайт на подпути, с ним: `https://example.com/trainer`) |
| `SMTP_HOST`, `SMTP_PORT` | порт зависит от `SMTP_SECURITY` | почтовый сервер |
| `SMTP_SECURITY` | `starttls` | `starttls` (порт 587), `ssl` (465) или `none` (только для локального релея) |
| `SMTP_USER`, `SMTP_PASSWORD` | пусто | логин и пароль SMTP; вместо пароля можно `SMTP_PASSWORD_FILE=/путь/к/файлу` (Docker secrets) |
| `MAIL_FROM` | `Сплит-тренер <noreply@домен-сайта>` | от чьего имени уходят письма |
| `MAIL_BACKEND` | `smtp`, если задан `SMTP_HOST`; `console` только для localhost | `console` печатает письма в лог: только для проверки |
| `ALLOW_REGISTRATION` | `true` | `false` закроет регистрацию (прежние пользователи продолжат входить) |
| `SESSION_DAYS` | `30` | сколько дней действует вход на устройстве |
| `DB_PATH`, `BIND`, `PORT`, `TRUST_PROXY` | заданы compose и скриптом установки | служебные: менять не нужно |
После правки `.env`: `docker compose up -d`. Если сайт открывают не по тому адресу, что указан в `PUBLIC_URL`, запросы получат «Запрос отклонён», а в логе API будет строка с полученным и ожидаемым `Origin`.
### Почта (SMTP)
Примеры (подставьте данные вашего провайдера):
```
# STARTTLS, порт 587
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USER=noreply@example.com
SMTP_PASSWORD=секрет
# SSL, порт 465
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_SECURITY=ssl
```
Чтобы письма не попадали в «Спам», настройте для домена отправителя SPF, DKIM и DMARC. Письма отправляются в фоне: если SMTP недоступен, в логе API будет строка «не удалось отправить письмо» (с типом ошибки, без адреса и текста письма). Очередь рассчитана на 100 писем; при переполнении пользователь получает «сервер занят», а не ложное «проверьте почту». Очередь хранится в памяти: при перезапуске API неотправленные письма теряются.
### Резервное копирование и обслуживание
Данные лежат в SQLite (Docker: том `splitapi-data`; без Docker: `/var/lib/split-trainer/splitapi.sqlite3`). Копировать файл базы «на лету» нельзя, нужна встроенная резервная копия.
Docker:
```bash
docker compose exec -T splitapi python -c "import sqlite3; s=sqlite3.connect('/data/splitapi.sqlite3'); d=sqlite3.connect('/data/backup.sqlite3'); s.backup(d); d.close(); s.close()"
docker cp splitapi:/data/backup.sqlite3 ./backup-$(date +%F).sqlite3
docker compose exec -T splitapi rm /data/backup.sqlite3
```
Без Docker:
```bash
sudo python3 -c "import sqlite3; s=sqlite3.connect('/var/lib/split-trainer/splitapi.sqlite3'); d=sqlite3.connect('/root/backup.sqlite3'); s.backup(d)"
```
Удалить пользователя вручную (по просьбе, когда нет доступа к его паролю): в базе включите внешние ключи, иначе его сессии и прогресс останутся:
```bash
docker compose exec -T splitapi python -c "import sqlite3; c=sqlite3.connect('/data/splitapi.sqlite3'); c.execute('pragma foreign_keys=on'); c.execute('delete from users where email=?', ('user@example.com',)); c.commit()"
```
### Безопасность в двух словах
- Пароли хранятся только как хеш scrypt; токены входа, подтверждения и сброса хранятся только как SHA-256 и работают один раз.
- Ссылки в письмах строятся из `PUBLIC_URL`, а не из заголовка `Host`. Токен лежит во фрагменте адреса (`#…`): он не попадает в логи сервера и заголовок `Referer`.
- Ограничения частоты: вход считает только неудачи: по паре «адрес + IP» (5 за 10 минут), по IP (20 за 10 минут) и по всему аккаунту (100 за час, против распределённого подбора). Регистрация и сброс пароля ограничены по IP и по адресу. IPv6-клиенты считаются по сети /64. Успешные входы лимиты не расходуют: класс или семья за одним NAT входят без помех.
- Порт API **не публикуется наружу**: к нему ходит только nginx из контейнера сайта. Не публикуйте порт 8000 и не ставьте API за другой прокси: API верит заголовку `X-Real-IP` (`TRUST_PROXY=1`) и только от приватных адресов.
- Ваш внешний обратный прокси обязан **добавлять** настоящий адрес клиента в `X-Forwarded-For` (пример в разделе 5: `$proxy_add_x_forwarded_for`). Если он передаёт заголовок клиента без изменений, ограничения частоты можно обойти подменой адреса.
- Режим `console` выключен по умолчанию на публичных адресах: ссылки из писем нельзя оставлять в логах.
- Две вкладки с разными аккаунтами не смешивают прогресс: сервер сверяет, от чьего имени сохраняются данные, а устаревшая вкладка обновляется.
### Что сознательно не решено (принятые риски)
- **Счётчики ограничений хранятся в памяти одного процесса.** После перезапуска API они обнуляются, а запускать несколько процессов нельзя.
- **Чужой «шум» мешает владельцу.** Кто-то может засыпать чужой адрес письмами (3 в час на адрес, 5 в час с IP) и этим же лимитом на время лишить владельца письма для сброса пароля. Это цена защиты ящика от рассылки.
- **Пароли.** Минимум 8 символов и короткий список плохих паролей; проверки по базам утечек и двухфакторной защиты нет. Поэтому действуют лимиты на подбор.
- **Доверие к `X-Real-IP`** от любых приватных адресов, а не только от nginx: на хосте любой локальный процесс может подделать IP (лимиты по почте при этом остаются). Закрыто тем, что API слушает только `127.0.0.1`.
- **Данные последнего пользователя остаются на устройстве после выхода**, включая его адрес почты в локальном хранилище браузера. На общем компьютере используйте отдельный профиль браузера.
- **Нет выхода со всех устройств и смены пароля внутри сессии:** сброс через почту завершает все входы.
- **Пароль SMTP** передаётся переменной окружения и виден через `docker inspect`; вместо этого можно задать `SMTP_PASSWORD_FILE` (Docker secrets).
- **Очередь писем в памяти:** при перезапуске API неотправленные письма теряются.
## 5. За обратным прокси
От прокси ничего особенного не требуется: без WebSocket, без нестандартных заголовков, все ссылки внутри относительные. Достаточно передать все запросы (в том числе `/api/`) на `127.0.0.1:8080`.
### nginx
@@ -167,16 +282,22 @@ example.com {
Конфиг доверяет заголовку `X-Forwarded-For` только от прокси из частных сетей и localhost (`127.0.0.0/8`, `10/8`, `172.16/12`, `192.168/16`). Если ваш прокси стоит на другом сервере с публичным адресом, добавьте его в `set_real_ip_from` в `deploy/split-trainer.conf` и пересоберите или переустановите.
## 5. Что отдаёт сервер
## 6. Что отдаёт сервер
- `GET /healthz` отвечает `200 ok` (для мониторинга и healthcheck).
- `GET /healthz` отвечает `200 ok` (для мониторинга и healthcheck сайта), `GET /api/health` отвечает `{"ok": true}` (то же для API).
- Запросы `/api/*` nginx передаёт API аккаунтов; ответы API не кешируются (`Cache-Control: no-store`).
- Заголовки безопасности: строгий `Content-Security-Policy` (только свои скрипты и стили, никаких внешних адресов), `X-Content-Type-Options`, `Referrer-Policy: no-referrer`, `X-Frame-Options`, `Permissions-Policy`.
- Файлы сжимаются gzip, скрытые файлы (`.git`, `.env`) не отдаются, версия nginx в ответах скрыта.
## 6. Если что-то не работает
## 7. Если что-то не работает
| Симптом | Причина и решение |
|---|---|
| На сайте нет пункта «Войти» | API не запущен или недоступен: `docker compose ps`, `curl http://127.0.0.1:8080/api/health` (ожидается `{"ok": true}`) |
| Письмо не приходит | Проверьте «Спам» и `docker compose logs splitapi`: при ошибке SMTP там строка «не удалось отправить письмо». Без `SMTP_HOST` письма только в логе |
| «Ссылка недействительна или устарела» | Ссылка одноразовая и живёт 24 часа (подтверждение) или 1 час (сброс); повторная регистрация или запрос сброса присылает новую, а старая перестаёт работать |
| «Запрос отклонён» при входе | `PUBLIC_URL` не совпадает с адресом, по которому открыт сайт (схема, домен, порт): исправьте в `.env` и перезапустите `splitapi` |
| «Слишком много попыток» | Сработало ограничение частоты: подождите несколько минут |
| 502 / 504 от прокси | Сайт не запущен или слушает другой порт: `docker compose ps`, `curl http://127.0.0.1:8080/healthz` |
| Белая страница под `/trainer/` | Нет завершающего `/` в `proxy_pass` или нет редиректа `/trainer` на `/trainer/` (см. выше) |
| После обновления видна старая версия | Обновите страницу с Ctrl+F5; если стоит CDN или кеш на прокси, исключите сайт из кеширования |