Files
ayurishchevandClaude Opus 5.5 8384c2c311 Задачи 034-035: публикация через Caddy, последний вход пользователя
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>
2026-09-27 15:45:30 +03:00

67 lines
5.5 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Последний вход пользователя: дата и 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, «—» у пользователей без входа. В браузере проверяет пользователь, если нужно.