034 Публикация стенда как https://rxipam.rxmsk.ru через общий Caddy хоста (Caddyfile вне репозитория): UI и /api/v1 доступны из интернета, /docs, /redoc, /openapi.json — только из частных сетей (RFC 1918/4193, loopback). Caddy проксирует на опубликованный порт приложения — общая Docker-сеть отклонена из-за коллизии имён app/db с Nextcloud. TRUSTED_PROXIES=172.16.0.0/12: реальный IP клиента из X-Forwarded-For. Интеграционный тест журнала больше не проверяет подделку XFF с хоста (хост за Caddy доверенный), логику покрывает unit-тест. 035 Дата и IP последнего входа в разделе «Пользователи»: users.last_login_at / last_login_ip (миграция 0014 с заполнением из журнала), запись при успешном входе, UserOut, колонка в UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
67 lines
5.5 KiB
Markdown
67 lines
5.5 KiB
Markdown
# Последний вход пользователя: дата и IP на странице «Пользователи» (изменение 035)
|
||
|
||
## Context
|
||
Суперадминистратору нужно видеть в разделе «Пользователи», когда каждый пользователь последний раз входил в систему и с какого IP.
|
||
Сейчас это можно найти только поиском по журналу. `known_logins` для этого не подходит: там IP за 30 дней, ротация удаляет старые записи,
|
||
и он хранит набор IP, а не последний вход.
|
||
|
||
**Решение:** два поля в `users` — время и IP последнего успешного входа. Поля обновляются в `POST /auth/login`,
|
||
отдаются в `UserOut` и выводятся колонкой в таблице пользователей.
|
||
IP берётся тот же, что уже вычисляет `request_context` для журнала: реальный адрес клиента за Caddy (`TRUSTED_PROXIES`, изменение 034).
|
||
|
||
## Изменения
|
||
|
||
### БД и модель
|
||
- `app/models.py::User`:
|
||
- `last_login_at: Mapped[datetime | None]` (`DateTime(timezone=True)`);
|
||
- `last_login_ip: Mapped[str | None]` (`INET`);
|
||
- комментарий «изменение 035».
|
||
- Миграция `alembic/versions/0014_users_last_login.py` (down_revision `0013`):
|
||
- две nullable-колонки;
|
||
- backfill из журнала: для каждого пользователя берётся последнее событие `session.login` (`DISTINCT ON (username) … ORDER BY username, ts DESC`)
|
||
по `audit_log.username = users.username`, из него — `ts` и `client_ip`;
|
||
- если журнал ротирован или очищен, значения остаются `NULL` («—» в UI).
|
||
- `downgrade` — удалить колонки.
|
||
|
||
### API
|
||
- `app/api/v1/auth.py::login`, ветка успешного входа (рядом с `_remember_login`):
|
||
`user.last_login_at = func.now()`, `user.last_login_ip = ip`, в той же транзакции, что и запись `session.login`.
|
||
Неудачные попытки поля не трогают.
|
||
- `app/schemas.py::UserOut`: `last_login_at: datetime | None = None`, `last_login_ip: str | None = None`.
|
||
INET приводить к строке тем же способом, что уже используется для `client_ip` в `AuditOut` и адресов (`ip_text`),
|
||
чтобы в JSON был `"192.168.5.9"`, а не объект. `UserOut` собирается через `model_validate` из ORM, поэтому нужен
|
||
`@field_validator("last_login_ip", mode="before")` с вызовом `ip_text()`.
|
||
`UserOut` отдают и `/users` (только `superadmin`), и `/auth/me` (свои данные) — утечки чужих IP нет.
|
||
|
||
### UI (`web/app.js::screens.users`)
|
||
- Новая колонка «Последний вход» после «Статус»:
|
||
- дата — `fmtDate(u.last_login_at)` (как в журнале, UTC);
|
||
- под ней или через разделитель — IP моноширинным шрифтом, `muted`;
|
||
- «—», если входа не было.
|
||
- Расширить `cols` (`selCols(...)`) и заголовок `.th` на одну колонку. Ширину подобрать так, чтобы таблица не переполнялась,
|
||
по образцу соседних колонок (≈170–190px).
|
||
|
||
### Тесты (минимально)
|
||
- `tests/test_users.py::test_users_management`: после входа созданного пользователя (`_client(name, ...)`)
|
||
`GET /users?q=<name>` → у записи заполнены `last_login_at` и `last_login_ip`. Новых тестов не добавлять.
|
||
|
||
### Документация (правила проекта)
|
||
- `docs/changes/035-user-last-login/PLAN.md` — этот план; по завершении — `SUMMARY.md`.
|
||
- `README.md`:
|
||
- раздел «Интерфейс»: в «Пользователях» видны дата и IP последнего входа;
|
||
- миграции `0001`–`0014`;
|
||
- строка 035 в истории изменений.
|
||
|
||
## Исполнение
|
||
По принятой схеме: код и тест пишет агент на Sonnet (без запуска тестов и стенда), затем ревью, пересборка стенда,
|
||
проверки и SUMMARY — моя часть.
|
||
|
||
## Проверка
|
||
- `docker compose -p ipam_control_006 up -d --build app` → `alembic current` = `0014`, `alembic check` — чисто;
|
||
откат до 0013 и повторный upgrade — без ошибок.
|
||
- Backfill: у суперадминистратора из `.env`, который входил, `last_login_at`/`last_login_ip` совпадают с последним `session.login` в журнале.
|
||
- Вход через Caddy с LAN-адреса хоста (`--resolve rxipam.rxmsk.ru:443:192.168.5.9`) → `last_login_ip = 192.168.5.9`;
|
||
неудачная попытка не меняет поля.
|
||
- `GET /users` — поля в JSON строками; `pytest -q` — все зелёные.
|
||
- UI: колонка «Последний вход» с датой и IP, «—» у пользователей без входа. В браузере проверяет пользователь, если нужно.
|