66 lines
5.5 KiB
Markdown
66 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, «—» у пользователей без входа. В браузере проверяет пользователь, если нужно.
|