Пользователи системы: роли, временные пароли, личный кабинет и история правок #69
No files matched your search
@@ -29,6 +29,7 @@ jobs:
|
||||
- run: python -m pytest -q
|
||||
- run: node --check app/static/admin.js
|
||||
- run: node --check app/static/council.js
|
||||
- run: node --check app/static/users.js
|
||||
- run: sh -n infra/postgres-backup.sh
|
||||
- run: sh -n infra/install-postgres-backup-cron.sh
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ cp .env.example .env
|
||||
- `SOURCE_URL`: список сотрудников МИЭМ.
|
||||
- `CRAWL_CRON`: расписание в формате crontab, по умолчанию `0 3 * * 1`.
|
||||
- `CRAWL_LIMIT`: опциональный лимит профилей для тестового запуска.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: логин и пароль админки.
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD`: начальные данные администратора для создания учётной записи в БД. После смены пароля используется пароль из БД; значения окружения его не перезаписывают.
|
||||
- `SESSION_SECRET`: секрет подписи cookie.
|
||||
- `PARSER_USE_PLAYWRIGHT`: включение Playwright-рендера динамических вкладок.
|
||||
- `DISMISSAL_CONFIRMATION_RUNS`: сколько последовательных проверок недоступности нужно для увольнения, по умолчанию `3`.
|
||||
@@ -53,6 +53,24 @@ uvicorn app.main:app --reload
|
||||
|
||||
Каталог формируется сервером: отдельная клиентская загрузка списка не нужна, переход показывает браузер. Пустой результат предлагает сбросить фильтры, пустая база — запустить парсинг. При ошибке базы каталог возвращает HTTP 503 и предлагает повторить загрузку с теми же фильтрами. При обновлении прогресса показывается состояние загрузки; при ошибке сохраняются последние значения с предупреждением об их актуальности и кнопкой «Повторить». Автоматические попытки продолжаются каждые 4 секунды, запрос ограничен 15 секундами; параллельные запросы не запускаются.
|
||||
|
||||
## Пользователи и личный кабинет
|
||||
|
||||
`/admin/users` доступен только администратору: создание учётных записей с ролями «Администратор»/«Пользователь», включение и отключение доступа, сброс пароля, первый и последний вход, дата смены пароля пользователем и выдачи временного пароля. Пользователь может читать служебные страницы и редактировать карточки, но не запускает парсинг (включая обновление одного сотрудника) и не управляет учётными записями. Проверки выполняются на сервере для HTML и API. Публичный просмотр диссовета и XLSX сохранён.
|
||||
|
||||
Временный пароль создаётся криптографическим генератором, содержит 8 символов и все четыре группы: строчные/заглавные латинские буквы, цифры и символы. Показывается администратору только в ответе на создание или сброс; повторно прочитать его нельзя. В БД — PBKDF2-HMAC-SHA256 с 600 000 итерациями и отдельной случайной солью. Пароли и их хеши не включаются в журнал действий или страницы просмотра истории.
|
||||
|
||||
При первом успешном входе и после сброса требуется задать свой пароль (15–128 символов, можно фразу), указав текущий пароль. Попап нельзя закрыть Escape или нажатием на фон; можно сменить пароль либо выйти. Без JavaScript доступна обычная форма, а серверные операции до смены пароля заблокированы. Начальный администратор также меняет пароль при первом входе после обновления.
|
||||
|
||||
`/admin/account` показывает первый/последний вход, смену пароля и историю действий. Каждый видит только свои действия и события, относящиеся к его учётной записи; администратор на странице пользователей видит общую историю или фильтр по учётной записи. Журнал фиксирует успешные входы, смену/сброс пароля, изменения ролей/доступа и реальные правки карточек: сотрудник, статья, год, прежние/новые значения названия, журнала, квартиля и тематической связи. Сохранение правки и запись истории — одна транзакция; отклонённые и неизменённые сохранения не создают запись о редактировании. История разбита на страницы по 30 записей.
|
||||
|
||||
Сессия ограничена 12 часами. Смена/сброс пароля, изменение роли/доступа и выход отзывают прежние сессии. Собственную роль/доступ и пароль через административный сброс менять нельзя: свой пароль меняется в личном кабинете. Изменения доступа сериализуются, чтобы параллельные администраторы не лишили систему всех администраторов. После пяти неверных входов учётная запись блокирует новые входы на 15 минут; административный сброс снимает это ограничение.
|
||||
|
||||
Все POST-формы требуют CSRF-токен; JSON-редактор сохраняет обязательный специальный заголовок при отключённом CORS. Проверяется Origin. Cookie — HttpOnly/SameSite, при HTTPS или HTTPS за обратным прокси — Secure; приватные страницы и одноразовые пароли возвращаются с Cache-Control: no-store. Для программного запуска парсинга передавайте CSRF-токен действующей административной сессии в X-CSRF-Token.
|
||||
|
||||
При штатном запуске создаются таблицы `system_users` и `user_actions` и начальная административная запись, если её нет. Для ручного добавления схемы доступна `migrations/013_system_users.sql`. Перед обновлением рабочей БД сделайте штатную резервную копию. После обновления потребуется повторный вход; первый вход и история отсчитываются от введения этой функции, прежние события не восстанавливаются. Изменение переменных окружения не сбрасывает существующий пароль и не включает отключённый аккаунт.
|
||||
|
||||
Проверки: `python -m pytest -q`, `python tests/browser_users.py`. Браузерный сценарий использует только синтетическую SQLite-БД и временный локальный HTTP-сервер без запуска миграций рабочего приложения; временные пароли на скриншотах скрыты. Для native PostgreSQL-проверок тестов пользователей задайте USERS_TEST_DATABASE_URL на отдельную одноразовую БД **miem_users_test**: эти тесты создают и удаляют в ней схему и не должны запускаться на рабочей БД.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```bash
|
||||
@@ -100,7 +118,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
|
||||
Предупреждения публикации показаны красным значком рядом с типом, годом, журналом, квартилем или специальностью. Подсказка открывается при наведении, фокусе или нажатии, закрывается Escape. При отсутствии/неоднозначности типа подпись «Статья» — только отображение: красный значок объясняет отсутствие/расхождение исходного типа, и правило зачёта не ослабляется. Конфликт названия журнала поясняет, для какого журнала сохранён квартиль и к какому отнесена статья; проверки DOI/ISSN сохранены.
|
||||
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход администратора.
|
||||
Кнопка «Статьи, журналы и квартили» открывает `/admin/council/publications` в отдельной вкладке. Здесь доступны совместные фильтры по ФИО, названию статьи и журнала. Нажатие на карточку статьи (или Enter/пробел с клавиатуры) показывает иконку редактирования; она открывает форму названия статьи, журнала и квартиля. Та же форма доступна в карточках основного списка. Для сохранения требуется вход пользователя или администратора.
|
||||
|
||||
Ручные изменения хранятся в отдельной таблице `council_corrections`, создаваемой при запуске приложения (`migrations/012_council_corrections.sql` для ручного обновления схемы). Исходные публикации и файлы выгрузки не перезаписываются. Квартиль применяется к выбранному журналу за год статьи, в том числе к другим статьям и сотрудникам; при неизвестном или конфликтующем годе можно исправить только название и журнал. Правки учитываются в фильтрах, предварительных счётчиках и XLSX. Ручные изменения журнала или квартиля требуют подтверждения источниками; изменение тематической связи само по себе не отменяет проверенный квартиль. Одновременное сохранение из устаревшей вкладки отклоняется: нужно обновить страницу. Привязка статьи использует исходный DOI или название и год; при изменении этих идентификаторов в источнике ручную привязку нужно проверить заново.
|
||||
|
||||
@@ -127,7 +145,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
|
||||
Worker запускает обход по `CRAWL_CRON`. Ручной запуск также доступен в админке на `Dashboard` и странице `Runs` или через REST:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..."
|
||||
curl -X POST http://localhost:8000/api/crawl-runs --cookie "miem_admin_session=..." -H "X-CSRF-Token: ..."
|
||||
```
|
||||
|
||||
Алгоритм обновления:
|
||||
|
||||
+77
-28
@@ -9,8 +9,10 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections
|
||||
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin, read_session
|
||||
from app.models import CrawlError, CrawlRun, Employee, CouncilCorrections, UserAction
|
||||
from app.security import (SESSION_COOKIE, LOGIN_CSRF_COOKIE, SESSION_SECONDS, require_admin, require_user,
|
||||
optional_user, sign_session, authenticate, check_csrf)
|
||||
import secrets
|
||||
from app.services.admin_data import (
|
||||
employee_detail_payload,
|
||||
format_admin_datetime,
|
||||
@@ -25,13 +27,14 @@ from app.services.council import CouncilInputError, council_candidates, _journal
|
||||
from app.services.council_export import council_xlsx
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
router = APIRouter(prefix="/admin", dependencies=[Depends(check_csrf)])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
templates.env.filters["admin_datetime"] = format_admin_datetime
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def dashboard(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
counts = stats_payload(db)
|
||||
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
||||
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
||||
@@ -42,25 +45,48 @@ def dashboard(request: Request, db: Session = Depends(get_db), settings: Setting
|
||||
|
||||
@router.get("/login", response_class=HTMLResponse)
|
||||
def login_form(request: Request):
|
||||
return _render(request, "login.html", {"error": None})
|
||||
csrf = secrets.token_urlsafe(24)
|
||||
response = _render(request, "login.html", {"error": None, "csrf_token": csrf})
|
||||
response.set_cookie(LOGIN_CSRF_COOKIE, csrf, httponly=True, samesite="strict", max_age=600, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(
|
||||
request: Request,
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
username: str = Form(..., max_length=64),
|
||||
password: str = Form(..., max_length=1024),
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
if not verify_admin(username, password, settings):
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль"}, status_code=401)
|
||||
redirect = RedirectResponse("/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(username, settings), httponly=True, samesite="lax")
|
||||
try:
|
||||
user = authenticate(db, username, password, settings)
|
||||
if user is None:
|
||||
return _render(request, "login.html", {"error": "Неверный логин или пароль, либо вход временно недоступен",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=401)
|
||||
db.add(UserAction(user_id=user.id, action="login"))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _render(request, "login.html", {"error": "Вход временно недоступен. Попробуйте ещё раз",
|
||||
"csrf_token": request.cookies.get(LOGIN_CSRF_COOKIE, '')}, status_code=503)
|
||||
redirect = RedirectResponse("/admin/account" if user.must_change_password else "/admin", status_code=303)
|
||||
redirect.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
|
||||
httponly=True, samesite="lax", max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
redirect.delete_cookie(LOGIN_CSRF_COOKIE)
|
||||
return redirect
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
def logout():
|
||||
def logout(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = optional_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if user is not None:
|
||||
try:
|
||||
user.session_version += 1
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось завершить сессии. Попробуйте ещё раз")
|
||||
redirect = RedirectResponse("/admin/login", status_code=303)
|
||||
redirect.delete_cookie(SESSION_COOKIE)
|
||||
return redirect
|
||||
@@ -71,9 +97,10 @@ def employees(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
|
||||
|
||||
@@ -93,7 +120,7 @@ def directory(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
parsed_started_from = _parse_date(started_from)
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
@@ -139,6 +166,7 @@ def directory(
|
||||
@router.get("/council", response_class=HTMLResponse)
|
||||
def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_count", direction: str = "desc", specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction, specialty=specialty,
|
||||
branch=branch)
|
||||
@@ -147,12 +175,13 @@ def council(request: Request, q: str = "", offset: int = 0, sort: str = "total_c
|
||||
except (SQLAlchemyError, OSError, ValueError, KeyError):
|
||||
db.rollback()
|
||||
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result})
|
||||
return _render(request, "council.html", {"error": False, "public_page": True, "can_edit": authenticated is not None, **result})
|
||||
|
||||
|
||||
@router.get("/council/employees/{employee_id}/articles", response_class=HTMLResponse)
|
||||
def council_employee_articles(employee_id: int, request: Request, specialty: str = "", branch: str = "технические",
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
authenticated = optional_user(request, settings, db)
|
||||
try:
|
||||
result = council_candidates(db, employee_id=employee_id, paginate=False, specialty=specialty, branch=branch)
|
||||
if not result["candidates"]:
|
||||
@@ -163,7 +192,7 @@ def council_employee_articles(employee_id: int, request: Request, specialty: str
|
||||
db.rollback()
|
||||
raise HTTPException(503, "Не удалось загрузить публикации. Попробуйте ещё раз")
|
||||
return _render(request, "council_candidate_articles.html", {"candidate": result["candidates"][0],
|
||||
"can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username, **result})
|
||||
"can_edit": authenticated is not None, **result})
|
||||
|
||||
|
||||
class CouncilArticleEdit(BaseModel):
|
||||
@@ -181,7 +210,7 @@ class CouncilArticleEdit(BaseModel):
|
||||
def council_publications(request: Request, employee: str = "", title: str = "", journal: str = "", offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
context = {"public_page": True, "employee": employee, "title": title, "journal": journal,
|
||||
"can_edit": read_session(request.cookies.get(SESSION_COOKIE), settings) == settings.admin_username}
|
||||
"can_edit": optional_user(request, settings, db) is not None}
|
||||
try:
|
||||
result = council_candidates(db, q=employee, paginate=False, sort="full_name", direction="asc")
|
||||
articles = [{**article, "employee_id": candidate["employee_id"], "employee_name": candidate["name"]}
|
||||
@@ -202,9 +231,8 @@ def council_publications(request: Request, employee: str = "", title: str = "",
|
||||
@router.post("/council/publications")
|
||||
def edit_council_publication(request: Request, change: CouncilArticleEdit,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
username = read_session(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if username != settings.admin_username:
|
||||
raise HTTPException(401, "Для сохранения войдите в админку")
|
||||
actor = require_user(request, settings, db, lock=True)
|
||||
username = actor.username
|
||||
# A custom header plus JSON prevents cross-site form submissions; CORS is disabled.
|
||||
if request.headers.get("X-Council-Edit") != "1":
|
||||
raise HTTPException(403, "Недопустимый запрос на изменение")
|
||||
@@ -244,6 +272,22 @@ def edit_council_publication(request: Request, change: CouncilArticleEdit,
|
||||
raise HTTPException(409, "Данные уже изменены. Обновите страницу перед сохранением")
|
||||
else:
|
||||
db.add(CouncilCorrections(id=1, data=data, revision=1, updated_by=username))
|
||||
changes = {}
|
||||
for field, before, after in (("title", article["title"], change.title),
|
||||
("journal", " / ".join(article["journals"]), change.journal),
|
||||
("quartile", article["edit_quartile"], change.quartile)):
|
||||
if before != after:
|
||||
changes[field] = {"before": before, "after": after}
|
||||
if "specialty_codes" in change.model_fields_set:
|
||||
before = {"manual": article["manual_specialties"], "codes": article["edit_specialty_codes"] if article["manual_specialties"] else None}
|
||||
after = {"manual": change.specialty_codes is not None,
|
||||
"codes": sorted(set(change.specialty_codes or [])) if change.specialty_codes is not None else None}
|
||||
if before != after:
|
||||
changes["specialty_codes"] = {"before": before, "after": after}
|
||||
if changes:
|
||||
candidate = next(c for c in result["candidates"] if c["employee_id"] == change.employee_id)
|
||||
db.add(UserAction(user_id=actor.id, action="card_edit", employee_id=change.employee_id,
|
||||
employee_name=candidate["name"], publication_year=article["year"], publication_key=change.edit_key, article_title=change.title, changes=changes))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
@@ -277,7 +321,7 @@ def employee_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/employees", status_code=303)
|
||||
@@ -308,7 +352,7 @@ def refresh_employee_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return RedirectResponse("/admin/directory", status_code=303)
|
||||
@@ -319,7 +363,7 @@ def refresh_employee_detail(
|
||||
|
||||
@router.get("/runs", response_class=HTMLResponse)
|
||||
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
||||
items = [run_payload(run) for run in run_models]
|
||||
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
||||
@@ -333,7 +377,7 @@ def run_detail(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
@@ -347,7 +391,7 @@ def trigger_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin/runs", status_code=303)
|
||||
|
||||
@@ -366,7 +410,7 @@ def crawl_now(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -385,7 +429,7 @@ def refresh_dismissed(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
):
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
if get_running_run(db):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
|
||||
@@ -400,11 +444,16 @@ def refresh_dismissed(
|
||||
def _render(request: Request, template: str, context: dict, status_code: int = 200) -> HTMLResponse:
|
||||
payload = {
|
||||
"request": request,
|
||||
"current_user": getattr(request.state, "user", None),
|
||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||
"backend_version": BACKEND_VERSION,
|
||||
"frontend_version": FRONTEND_VERSION,
|
||||
**context,
|
||||
}
|
||||
return templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
response = templates.TemplateResponse(request, template, payload, status_code=status_code)
|
||||
if getattr(request.state, "user", None) is not None or template == "login.html":
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def _parse_date(value: str | None):
|
||||
|
||||
+9
-9
@@ -7,12 +7,12 @@ from sqlalchemy.orm import Session
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import SessionLocal, get_db
|
||||
from app.models import CrawlRun, Employee
|
||||
from app.security import require_admin
|
||||
from app.security import require_admin, require_user, check_csrf
|
||||
from app.services.admin_data import employee_display_payload, list_employees_page, run_detail_payload, run_payload, stats_payload
|
||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||
|
||||
router = APIRouter(prefix="/api")
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
@@ -36,7 +36,7 @@ def list_employees(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
@@ -59,7 +59,7 @@ def get_employee(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
employee = db.get(Employee, employee_id)
|
||||
if not employee:
|
||||
return {"error": "not_found"}
|
||||
@@ -73,7 +73,7 @@ def list_crawl_runs(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(limit)).all()
|
||||
return {"items": [run_payload(run) for run in runs]}
|
||||
|
||||
@@ -84,7 +84,7 @@ def latest_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
running = get_running_run(db)
|
||||
latest = db.scalar(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(1))
|
||||
return {"running": run_payload(running), "latest": run_payload(latest)}
|
||||
@@ -97,7 +97,7 @@ def get_crawl_run(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
run = db.get(CrawlRun, run_id)
|
||||
if not run:
|
||||
return {"error": "not_found"}
|
||||
@@ -111,7 +111,7 @@ def trigger_crawl(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_admin(request, settings, db)
|
||||
running = get_running_run(db)
|
||||
if running:
|
||||
return {"status": "already_running", "run": run_payload(running)}
|
||||
@@ -130,7 +130,7 @@ def stats(
|
||||
db: Session = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> dict:
|
||||
require_admin(request, settings)
|
||||
require_user(request, settings, db)
|
||||
return stats_payload(db)
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ def init_db() -> None:
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_runtime_schema()
|
||||
from app.security import ensure_bootstrap_admin
|
||||
with SessionLocal() as db:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
|
||||
|
||||
def _ensure_runtime_schema() -> None:
|
||||
|
||||
@@ -3,12 +3,14 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from app.admin import router as admin_router
|
||||
from app.api import router as api_router
|
||||
from app.users import router as users_router
|
||||
from app.db import init_db
|
||||
from app.version import BACKEND_VERSION
|
||||
|
||||
app = FastAPI(title="MIEM Employees", version=BACKEND_VERSION)
|
||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
||||
app.include_router(api_router)
|
||||
app.include_router(users_router)
|
||||
app.include_router(admin_router)
|
||||
|
||||
|
||||
|
||||
+40
-1
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy import CheckConstraint, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, String, Text, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.types import JSON
|
||||
@@ -254,3 +254,42 @@ class CouncilCorrections(Base):
|
||||
data: Mapped[dict] = mapped_column(json_type, nullable=False)
|
||||
updated_by: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
|
||||
class SystemUser(Base):
|
||||
__tablename__ = "system_users"
|
||||
__table_args__ = (CheckConstraint("role IN ('admin', 'user')", name="ck_system_users_role"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
must_change_password: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
session_version: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
first_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
password_reset_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
failed_login_attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
|
||||
class UserAction(Base):
|
||||
__tablename__ = "user_actions"
|
||||
__table_args__ = (Index("ix_user_actions_user_created", "user_id", "created_at"),
|
||||
Index("ix_user_actions_target_created", "target_user_id", "created_at"))
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("system_users.id"), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
target_user_id: Mapped[int | None] = mapped_column(ForeignKey("system_users.id"))
|
||||
employee_id: Mapped[int | None] = mapped_column(Integer)
|
||||
employee_name: Mapped[str | None] = mapped_column(Text)
|
||||
publication_year: Mapped[int | None] = mapped_column(Integer)
|
||||
publication_key: Mapped[str | None] = mapped_column(String(64))
|
||||
article_title: Mapped[str | None] = mapped_column(Text)
|
||||
changes: Mapped[dict] = mapped_column(json_type, default=dict, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
+177
-25
@@ -2,45 +2,197 @@ import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
from datetime import timedelta, timezone
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import Settings
|
||||
from app.config import Settings, get_settings
|
||||
from app.models import SystemUser, utcnow
|
||||
|
||||
SESSION_COOKIE = "miem_admin_session"
|
||||
LOGIN_CSRF_COOKIE = "miem_login_csrf"
|
||||
SESSION_SECONDS = 12 * 60 * 60
|
||||
PASSWORD_ITERATIONS = 600_000
|
||||
|
||||
|
||||
def verify_admin(username: str, password: str, settings: Settings) -> bool:
|
||||
return hmac.compare_digest(username, settings.admin_username) and hmac.compare_digest(
|
||||
password, settings.admin_password
|
||||
)
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, PASSWORD_ITERATIONS)
|
||||
return f"pbkdf2_sha256${PASSWORD_ITERATIONS}${salt.hex()}${digest.hex()}"
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings) -> str:
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"sub": username, "iat": int(time.time())}, separators=(",", ":")).encode("utf-8")
|
||||
).decode("ascii")
|
||||
signature = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
return f"{payload}.{signature}"
|
||||
def verify_password(password: str, encoded: str) -> bool:
|
||||
try:
|
||||
scheme, iterations, salt, expected = encoded.split('$')
|
||||
if scheme != 'pbkdf2_sha256' or int(iterations) != PASSWORD_ITERATIONS:
|
||||
return False
|
||||
actual = hashlib.pbkdf2_hmac('sha256', password.encode(), bytes.fromhex(salt), PASSWORD_ITERATIONS)
|
||||
return hmac.compare_digest(actual, bytes.fromhex(expected))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
_DUMMY_HASH = hash_password('dummy password for unknown account timing')
|
||||
|
||||
|
||||
def temporary_password() -> str:
|
||||
groups = ('ABCDEFGHJKLMNPQRSTUVWXYZ', 'abcdefghijkmnpqrstuvwxyz', '23456789', '!@#$%+-_')
|
||||
characters = [secrets.choice(group) for group in groups]
|
||||
characters.extend(secrets.choice(''.join(groups)) for _ in range(4))
|
||||
secrets.SystemRandom().shuffle(characters)
|
||||
return ''.join(characters)
|
||||
|
||||
|
||||
def ensure_bootstrap_admin(db: Session, settings: Settings) -> SystemUser:
|
||||
username = settings.admin_username.strip().casefold()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
if user is not None:
|
||||
return user
|
||||
user = SystemUser(username=username, role='admin', password_hash=hash_password(settings.admin_password),
|
||||
must_change_password=False)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username))
|
||||
return user
|
||||
|
||||
|
||||
def sign_session(username: str, settings: Settings, version: int = 0) -> str:
|
||||
payload = base64.urlsafe_b64encode(json.dumps({'sub': username, 'iat': int(time.time()),
|
||||
'v': version, 'csrf': secrets.token_urlsafe(24)}, separators=(',', ':')).encode()).decode().rstrip('=')
|
||||
signature = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
return f'{payload}.{signature}'
|
||||
|
||||
|
||||
def _session_data(token: str | None, settings: Settings) -> dict | None:
|
||||
if not token or len(token) > 2048 or '.' not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit('.', 1)
|
||||
expected = hmac.new(settings.session_secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature.encode(), expected.encode()):
|
||||
return None
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode((payload + '=' * (-len(payload) % 4)).encode()))
|
||||
now = int(time.time())
|
||||
if (not isinstance(data, dict) or not isinstance(data.get('sub'), str)
|
||||
or type(data.get('iat')) is not int or not now - SESSION_SECONDS <= data['iat'] <= now + 60
|
||||
or type(data.get('v')) is not int or not isinstance(data.get('csrf'), str)):
|
||||
return None
|
||||
return data
|
||||
except (ValueError, TypeError, UnicodeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_session(token: str | None, settings: Settings) -> str | None:
|
||||
if not token or "." not in token:
|
||||
return None
|
||||
payload, signature = token.rsplit(".", 1)
|
||||
expected = hmac.new(settings.session_secret.encode("utf-8"), payload.encode("ascii"), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(signature, expected):
|
||||
data = _session_data(token, settings)
|
||||
return data['sub'] if data else None
|
||||
|
||||
|
||||
def session_csrf(request: Request, settings: Settings) -> str:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
return data['csrf'] if data else ''
|
||||
|
||||
|
||||
async def check_csrf(request: Request, settings: Settings = Depends(get_settings)):
|
||||
if request.method != 'POST':
|
||||
return
|
||||
origin = request.headers.get('origin')
|
||||
if origin:
|
||||
try:
|
||||
parsed = urlsplit(origin)
|
||||
except ValueError:
|
||||
raise HTTPException(403, 'Недопустимый источник запроса')
|
||||
if parsed.scheme not in {'http', 'https'} or parsed.netloc.casefold() != request.url.netloc.casefold():
|
||||
raise HTTPException(403, 'Запрос должен исходить со страницы этой системы')
|
||||
# JSON with a custom header cannot be submitted by a cross-site HTML form; CORS stays disabled.
|
||||
if request.headers.get('X-Council-Edit') == '1' and request.headers.get('content-type', '').startswith('application/json'):
|
||||
return
|
||||
expected = (request.cookies.get(LOGIN_CSRF_COOKIE, '') if request.url.path == '/admin/login'
|
||||
else session_csrf(request, settings))
|
||||
if request.url.path == '/admin/logout' and not expected:
|
||||
return
|
||||
supplied = request.headers.get('X-CSRF-Token', '')
|
||||
if not supplied:
|
||||
form = await request.form()
|
||||
supplied = form.get('csrf_token', '')
|
||||
if not expected or not isinstance(supplied, str) or not hmac.compare_digest(supplied.encode(), expected.encode()):
|
||||
raise HTTPException(403, 'Обновите страницу и повторите действие')
|
||||
|
||||
|
||||
def optional_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser | None:
|
||||
data = _session_data(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not data:
|
||||
return None
|
||||
query = select(SystemUser).where(SystemUser.username == data['sub'].casefold())
|
||||
if lock:
|
||||
query = query.with_for_update().execution_options(populate_existing=True)
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
|
||||
except Exception:
|
||||
user = db.scalar(query)
|
||||
if user is None and data['sub'].casefold() == settings.admin_username.strip().casefold():
|
||||
user = ensure_bootstrap_admin(db, settings)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
raise HTTPException(503, 'Не удалось проверить учётную запись. Попробуйте ещё раз')
|
||||
if user is None or not user.is_active or user.session_version != data['v']:
|
||||
return None
|
||||
return data.get("sub")
|
||||
request.state.user = user
|
||||
request.state.csrf_token = data['csrf']
|
||||
if user.must_change_password and not allow_password_change:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(403, 'Сначала смените временный пароль в личном кабинете')
|
||||
raise HTTPException(303, headers={'Location': '/admin/account'})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings) -> str:
|
||||
username = read_session(request.cookies.get(SESSION_COOKIE), settings)
|
||||
if not username:
|
||||
raise HTTPException(status_code=status.HTTP_303_SEE_OTHER, headers={"Location": "/admin/login"})
|
||||
return username
|
||||
def require_user(request: Request, settings: Settings, db: Session, *, allow_password_change=False, lock=False) -> SystemUser:
|
||||
user = optional_user(request, settings, db, allow_password_change=allow_password_change, lock=lock)
|
||||
if user is None:
|
||||
if request.url.path.startswith('/api/') or (request.method == 'POST' and request.url.path == '/admin/council/publications'):
|
||||
raise HTTPException(401, 'Для этого действия войдите в систему')
|
||||
raise HTTPException(303, headers={'Location': '/admin/login'})
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(request: Request, settings: Settings, db: Session) -> SystemUser:
|
||||
user = require_user(request, settings, db)
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(403, 'Это действие доступно только администратору')
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: Session, username: str, password: str, settings: Settings) -> SystemUser | None:
|
||||
ensure_bootstrap_admin(db, settings)
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == username.strip().casefold()).with_for_update())
|
||||
valid = verify_password(password, user.password_hash if user else _DUMMY_HASH)
|
||||
if user is None:
|
||||
return None
|
||||
now = utcnow()
|
||||
locked_until = user.locked_until
|
||||
if locked_until and locked_until.tzinfo is None:
|
||||
locked_until = locked_until.replace(tzinfo=timezone.utc)
|
||||
if not user.is_active or (locked_until and locked_until > now):
|
||||
return None
|
||||
if not valid:
|
||||
if locked_until:
|
||||
|
|
||||
user.locked_until = None
|
||||
user.failed_login_attempts = 0
|
||||
user.failed_login_attempts += 1
|
||||
if user.failed_login_attempts >= 5:
|
||||
user.locked_until = now + timedelta(minutes=15)
|
||||
db.commit()
|
||||
return None
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
if user.first_login_at is None:
|
||||
user.first_login_at = now
|
||||
user.must_change_password = True
|
||||
user.last_login_at = now
|
||||
return user
|
||||
@@ -946,3 +946,23 @@
|
||||
.council__tooltip[hidden] { display: none; }
|
||||
.council__load-status--done { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 600px) { .council__warning-button { min-width: 44px; min-height: 44px; } }
|
||||
|
||||
.users__table-wrap { overflow-x: auto; margin-block: 20px; }
|
||||
.users__table-wrap .table { min-width: 720px; }
|
||||
.users__table-wrap .table__cell { vertical-align: top; overflow-wrap: anywhere; max-width: 420px; }
|
||||
.users__controls { display: grid; gap: 8px; min-width: 160px; }
|
||||
.users__controls label { display: grid; gap: 4px; }
|
||||
.users__create { margin-block: 24px; }
|
||||
.users__create summary { cursor: pointer; padding-block: 10px; font-weight: 600; }
|
||||
.users__create-form, .users__password-form { max-width: 520px; }
|
||||
.users__hint { color: #4c5b64; font-size: 14px; line-height: 1.5; }
|
||||
.users__error { color: #991b1b; line-height: 1.5; }
|
||||
.users__temporary { padding: 16px; background: #e7f0eb; border: 1px solid #8fa8a5; border-radius: 8px; }
|
||||
.users__temporary .form__input { max-width: 240px; font-family: monospace; font-size: 20px; }
|
||||
.users__account-meta { display: flex; gap: 20px; flex-wrap: wrap; }
|
||||
.users__account-meta dt { color: #4c5b64; font-size: 14px; }
|
||||
.users__account-meta dd { margin: 4px 0 0; }
|
||||
.users__change { margin-top: 6px; }
|
||||
@media (max-width: 600px) { .users .form__input { font-size: 16px; } }
|
||||
|
||||
.users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; }
|
||||
@@ -0,0 +1,15 @@
|
||||
(() => {
|
||||
const required = document.querySelector('[data-required-password]');
|
||||
if (required) {
|
||||
const form = document.querySelector('[data-password-form]');
|
||||
required.querySelector('[data-password-dialog-body]').append(form);
|
||||
required.addEventListener('cancel', event => event.preventDefault());
|
||||
required.showModal();
|
||||
(form.querySelector('[aria-invalid="true"]') || form.querySelector('input[type="password"]')).focus();
|
||||
}
|
||||
document.querySelectorAll('[data-password-reset]').forEach(form => {
|
||||
form.addEventListener('submit', event => {
|
||||
if (!window.confirm('Сбросить пароль? Текущие сессии пользователя завершатся. При следующем входе потребуется сменить временный пароль.')) event.preventDefault();
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Личный кабинет · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Личный кабинет</h2>
|
||||
<p>{{ account_user.full_name or account_user.username }} · {{ 'Администратор' if account_user.role == 'admin' else 'Пользователь' }}</p>
|
||||
<dl class="users__account-meta"><div><dt>Первый вход</dt><dd>{{ account_user.first_login_at|admin_datetime }}</dd></div><div><dt>Последний вход</dt><dd>{{ account_user.last_login_at|admin_datetime }}</dd></div><div><dt>Пароль изменён</dt><dd>{{ account_user.password_changed_at|admin_datetime }}</dd></div></dl>
|
||||
{% if password_changed %}<p role="status">Пароль изменён. Другие сессии завершены.</p>{% endif %}
|
||||
</section>
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Сменить пароль</h2>
|
||||
<p class="users__hint">Смена пароля или выход из системы завершает все ваши сессии.</p>
|
||||
<form class="form users__password-form" method="post" action="/admin/account/password" data-password-form>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{% if error %}<p class="users__error" id="password-error" role="alert">{{ error }}</p>{% endif %}
|
||||
<label class="form__label">Текущий пароль<input class="form__input" name="current_password" type="password" required maxlength="1024" autocomplete="current-password"{% if error_field == 'current_password' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<label class="form__label">Новый пароль<input class="form__input" name="new_password" type="password" required minlength="15" maxlength="128" autocomplete="new-password" aria-describedby="password-hint{% if error_field == 'new_password' %} password-error{% endif %}"{% if error_field == 'new_password' %} aria-invalid="true"{% endif %}></label>
|
||||
<label class="form__label">Повторите новый пароль<input class="form__input" name="confirmation" type="password" required minlength="15" maxlength="128" autocomplete="new-password"{% if error_field == 'confirmation' %} aria-invalid="true" aria-describedby="password-error"{% endif %}></label>
|
||||
<p class="users__hint" id="password-hint">От 15 до 128 символов. Можно использовать фразу. Новый пароль должен отличаться от текущего.</p>
|
||||
<button class="button" type="submit">Сохранить новый пароль</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">Последние действия</h2>{% include 'user_activity.html' %}</section>
|
||||
{% if account_user.must_change_password %}
|
||||
<dialog class="columns-modal" data-required-password aria-labelledby="required-password-title" aria-describedby="required-password-note">
|
||||
<div class="columns-modal__panel">
|
||||
<h2 class="columns-modal__title" id="required-password-title">Смените временный пароль</h2>
|
||||
<p id="required-password-note">Прежде чем пользоваться учётной записью, задайте свой пароль. Закрыть это окно без смены пароля нельзя.</p>
|
||||
<div data-password-dialog-body></div>
|
||||
<form method="post" action="/admin/logout"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--ghost" type="submit">Выйти из системы</button></form>
|
||||
</div>
|
||||
</dialog>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
@@ -11,16 +11,19 @@
|
||||
<header class="admin__header">
|
||||
<h1 class="admin__brand"><a class="admin__brand-link" href="{{ '/admin/council' if public_page else '/admin' }}">MIEM Employees</a></h1>
|
||||
<nav class="admin__nav">
|
||||
{% if public_page %}
|
||||
{% if public_page and not current_user %}
|
||||
<a class="admin__link" href="/admin/council" aria-current="page">Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в админку</a>
|
||||
<a class="admin__link" href="/admin/login">Вход в систему</a>
|
||||
{% else %}
|
||||
<a class="admin__link" href="/admin"{% if request.url.path == "/admin" %} aria-current="page"{% endif %}>Обзор</a>
|
||||
<a class="admin__link" href="/admin/directory"{% if request.url.path == "/admin/directory" %} aria-current="page"{% endif %}>Сотрудники</a>
|
||||
<a class="admin__link" href="/admin/council"{% if request.url.path == "/admin/council" %} aria-current="page"{% endif %}>Диссертационный совет</a>
|
||||
<a class="admin__link" href="/admin/runs"{% if request.url.path.startswith("/admin/runs") %} aria-current="page"{% endif %}>Запуски</a>
|
||||
{% if current_user and current_user.role == 'admin' %}<a class="admin__link" href="/admin/users"{% if request.url.path == '/admin/users' %} aria-current="page"{% endif %}>Пользователи</a>{% endif %}
|
||||
<a class="admin__link" href="/admin/account"{% if request.url.path == '/admin/account' %} aria-current="page"{% endif %}>Личный кабинет</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button class="button button--ghost" type="submit">Выйти</button>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--ghost" type="submit" title="Завершить все сессии учётной записи">Выйти</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
@@ -38,8 +38,8 @@
|
||||
</form>
|
||||
<datalist id="council-journals">{% for name in journal_names|default([]) %}<option value="{{ name }}">{% endfor %}</datalist>
|
||||
{% else %}
|
||||
<p>Для редактирования статей и квартилей войдите в админку.</p>
|
||||
<a class="button" href="/admin/login">Войти в админку</a>
|
||||
<p>Для редактирования статей и квартилей войдите в систему.</p>
|
||||
<a class="button" href="/admin/login">Войти в систему</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="council__message" role="alert"><h3>Не удалось загрузить публикации</h3><p>Попробуйте ещё раз позже.</p><a class="admin__link" href="{{ request.url.path }}?{{ request.url.query }}">Повторить загрузку</a></div>
|
||||
{% else %}
|
||||
<p class="council__hint" role="status">Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}</p>
|
||||
{% if not can_edit %}<p class="council__hint">Просмотр доступен без входа. Для сохранения изменений <a class="admin__link" href="/admin/login">войдите в админку</a>.</p>{% endif %}
|
||||
{% if not can_edit %}<p class="council__hint">Просмотр доступен без входа. Для сохранения изменений <a class="admin__link" href="/admin/login">войдите в систему</a>.</p>{% endif %}
|
||||
<ol class="council__articles council__articles--editor" start="{{ offset + 1 }}">
|
||||
{% for article in articles %}
|
||||
<li class="council__article" tabindex="0" data-council-article aria-label="{{ article.title }}. Нажмите, чтобы показать редактирование">
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||
<div class="progress-panel__actions">
|
||||
<form method="post" action="/admin/crawl-now">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/crawl-now">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button" type="submit">Запустить парсинг</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/dismissed/refresh">
|
||||
</form>{% endif %}
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/dismissed/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--secondary" type="submit">Проверить уволенных</button>
|
||||
</form>
|
||||
</form>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% set run = counts.current_running_run or latest_run %}
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
<span class="badge {% if employee_view.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee_view.status_display }}</span>
|
||||
</div>
|
||||
<div class="employee-card__actions">
|
||||
<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/employees/{{ employee.id }}/refresh">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button class="button button--compact" type="submit">Обновить данные</button>
|
||||
</form>
|
||||
</form>{% endif %}
|
||||
<a class="admin__link" href="{{ employee_view.canonical_url }}">{{ employee_view.canonical_url }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
<body class="admin">
|
||||
<main class="admin__main">
|
||||
<section class="panel">
|
||||
<h1 class="panel__title">Вход в админку</h1>
|
||||
<h1 class="panel__title">Вход в систему</h1>
|
||||
{% if error %}<p>{{ error }}</p>{% endif %}
|
||||
<form class="form" method="post" action="/admin/login">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" autocomplete="current-password"></label>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин <input class="form__input" name="username" required maxlength="64" autocomplete="username"></label>
|
||||
<label class="form__label">Пароль <input class="form__input" name="password" type="password" required maxlength="1024" autocomplete="current-password"></label>
|
||||
<button class="button" type="submit">Войти</button>
|
||||
</form>
|
||||
<p>Если забыли пароль, обратитесь к администратору системы.</p>
|
||||
</section>
|
||||
</main>
|
||||
<footer class="admin__footer">
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<section class="panel">
|
||||
<div class="progress-panel__header">
|
||||
<h2 class="panel__title">Запуски парсинга</h2>
|
||||
<form method="post" action="/admin/runs"><button class="button" type="submit">Запустить парсинг</button></form>
|
||||
{% if current_user and current_user.role == 'admin' %}<form method="post" action="/admin/runs">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button" type="submit">Запустить парсинг</button></form>{% endif %}
|
||||
</div>
|
||||
{% set run = runs[0] if runs else none %}
|
||||
{% if run %}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{% macro change_value(field, value) %}
|
||||
{% if field == 'specialty_codes' %}{% if value is none or not value.manual %}по справочникам{% else %}{{ value.codes|join(', ') or 'связь исключена' }}{% endif %}
|
||||
{% elif field == 'role' %}{{ {'admin': 'Администратор', 'user': 'Пользователь'}.get(value, 'не задано') }}
|
||||
{% elif field == 'is_active' %}{{ 'включён' if value else 'отключён' }}
|
||||
{% else %}{{ value if value else 'не указано' }}{% endif %}
|
||||
{% endmacro %}
|
||||
{% set action_labels = {'login': 'Вход в систему', 'card_edit': 'Редактирование статьи', 'password_change': 'Смена пароля', 'password_reset': 'Сброс пароля администратором', 'user_create': 'Создание пользователя', 'user_update': 'Изменение роли или доступа'} %}
|
||||
{% set field_labels = {'title': 'Название статьи', 'journal': 'Журнал', 'quartile': 'Квартиль', 'specialty_codes': 'Специальности журнала', 'role': 'Роль', 'is_active': 'Доступ'} %}
|
||||
<div class="users__table-wrap">
|
||||
<table class="table users__history-table">
|
||||
<thead><tr><th class="table__head" scope="col">Когда</th><th class="table__head" scope="col">Кто</th><th class="table__head" scope="col">Действие</th><th class="table__head" scope="col">Что изменено</th></tr></thead>
|
||||
<tbody>{% for action in actions %}<tr class="table__row">
|
||||
<td class="table__cell">{{ action.created_at|admin_datetime }}</td>
|
||||
<td class="table__cell">{{ user_names.get(action.user_id, '—') }}</td>
|
||||
<td class="table__cell">{{ action_labels.get(action.action, action.action) }}{% if action.target_user_id %}<br>{{ user_names.get(action.target_user_id, '') }}{% endif %}</td>
|
||||
<td class="table__cell">{% if action.employee_name %}<a class="admin__link" href="/admin/council?q={{ action.employee_name|urlencode }}">{{ action.employee_name }}</a><br>{{ action.article_title }}{% if action.publication_year %} · {{ action.publication_year }}{% endif %}{% endif %}
|
||||
{% for field, change in action.changes.items() %}<div class="users__change"><strong>{{ field_labels.get(field, field) }}:</strong> {{ change_value(field, change.before) }} → {{ change_value(field, change.after) }}</div>{% endfor %}
|
||||
{% if not action.changes and not action.employee_name %}—{% endif %}
|
||||
</td>
|
||||
</tr>{% else %}<tr><td class="table__cell" colspan="4">Действий пока нет. Здесь появятся входы, смена пароля и редактирование карточек.</td></tr>{% endfor %}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% if history_total > history_limit %}<nav class="directory__pagination" aria-label="История действий">
|
||||
{% if history_offset > 0 %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset - history_limit }}">Назад</a>{% endif %}
|
||||
<span>{{ history_offset // history_limit + 1 }} / {{ (history_total + history_limit - 1) // history_limit }}</span>
|
||||
{% if history_offset + history_limit < history_total %}<a class="admin__link" href="?{% if selected_user|default(none) %}user_id={{ selected_user.id }}&{% endif %}offset={{ history_offset + history_limit }}">Далее</a>{% endif %}
|
||||
</nav>{% endif %}
|
||||
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Пользователи · MIEM Employees{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel users">
|
||||
<h2 class="panel__title">Управление пользователями</h2>
|
||||
<p>Пользователь может просматривать данные и редактировать карточки. Запуск парсинга и управление пользователями доступны администратору.</p>
|
||||
{% if error %}<p class="users__error" role="alert">{{ error }}</p>{% endif %}
|
||||
{% if temporary %}<div class="users__temporary" role="status">
|
||||
<p>Временный пароль для <strong>{{ temporary.username }}</strong>. Показывается только сейчас — передайте его пользователю. При входе потребуется смена пароля.</p>
|
||||
<label class="form__label">Временный пароль<input class="form__input" type="text" value="{{ temporary.password }}" readonly autocomplete="off" data-temporary-password></label>
|
||||
</div>{% endif %}
|
||||
<details class="users__create" open>
|
||||
<summary>Создать пользователя</summary>
|
||||
<form class="form users__create-form" method="post" action="/admin/users">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label class="form__label">Логин<input class="form__input" name="username" required minlength="3" maxlength="64" pattern="[a-zA-Z0-9][a-zA-Z0-9_.-]{2,63}" autocomplete="off"></label>
|
||||
<label class="form__label">Имя<input class="form__input" name="full_name" maxlength="255" autocomplete="off"></label>
|
||||
<label class="form__label">Роль<select class="form__input" name="role"><option value="user">Пользователь</option><option value="admin">Администратор</option></select></label>
|
||||
<p class="users__hint">Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Временный пароль из 8 символов создаётся автоматически.</p>
|
||||
<button class="button" type="submit">Создать пользователя</button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="users__table-wrap"><table class="table">
|
||||
<thead><tr>{% for title in ['Пользователь', 'Первый вход', 'Последний вход', 'Пароль изменён', 'Временный пароль выдан', 'Роль и доступ', 'Действия'] %}<th class="table__head" scope="col">{{ title }}</th>{% endfor %}</tr></thead>
|
||||
<tbody>{% for user in users %}<tr class="table__row">
|
||||
<td class="table__cell"><a class="admin__link" href="?user_id={{ user.id }}">{{ user.username }}</a>{% if user.full_name %}<br>{{ user.full_name }}{% endif %}{% if user.must_change_password %}<br>Требуется смена пароля{% endif %}{% if user.locked_until %}<br>Ограничение входа до {{ user.locked_until|admin_datetime }}{% endif %}</td>
|
||||
<td class="table__cell">{{ user.first_login_at|admin_datetime if user.first_login_at else 'Ещё не входил' }}</td><td class="table__cell">{{ user.last_login_at|admin_datetime if user.last_login_at else 'Ещё не входил' }}</td>
|
||||
<td class="table__cell">{{ user.password_changed_at|admin_datetime }}</td><td class="table__cell">{{ user.password_reset_at|admin_datetime }}</td>
|
||||
<td class="table__cell">{% if user.id == current_user.id %}Администратор · ваша учётная запись{% else %}
|
||||
<form class="users__controls" method="post" action="/admin/users/{{ user.id }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Роль {{ user.username }}<select class="form__input" name="role"><option value="user"{% if user.role == 'user' %} selected{% endif %}>Пользователь</option><option value="admin"{% if user.role == 'admin' %} selected{% endif %}>Администратор</option></select></label>
|
||||
<label>Доступ {{ user.username }}<select class="form__input" name="is_active"><option value="true"{% if user.is_active %} selected{% endif %}>Включён</option><option value="false"{% if not user.is_active %} selected{% endif %}>Отключён</option></select></label>
|
||||
<button class="button button--secondary" type="submit">Сохранить доступ</button>
|
||||
</form>{% endif %}
|
||||
</td>
|
||||
<td class="table__cell">{% if user.id != current_user.id %}<form method="post" action="/admin/users/{{ user.id }}/reset-password" data-password-reset>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="button button--secondary" type="submit">Сбросить пароль {{ user.username }}</button>
|
||||
</form>{% else %}<a class="admin__link" href="/admin/account">Сменить свой пароль</a>{% endif %}</td>
|
||||
</tr>{% endfor %}</tbody>
|
||||
</table></div>
|
||||
</section>
|
||||
<section class="panel users"><h2 class="panel__title">{% if selected_user %}Действия {{ selected_user.username }}{% else %}Последние действия пользователей{% endif %}</h2>
|
||||
{% if selected_user %}<a class="admin__link" href="/admin/users">Показать всех пользователей</a>{% endif %}
|
||||
{% include 'user_activity.html' %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
{% block scripts %}<script src="/static/users.js?v={{ frontend_version }}" defer></script>{% endblock %}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import re
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import desc, func, or_, select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.admin import _render
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.models import SystemUser, UserAction, utcnow
|
||||
from app.security import (SESSION_COOKIE, SESSION_SECONDS, check_csrf, hash_password,
|
||||
require_admin, require_user, sign_session, temporary_password, verify_password)
|
||||
|
||||
router = APIRouter(prefix='/admin', dependencies=[Depends(check_csrf)])
|
||||
|
||||
|
||||
def _history(db, user_id=None, offset=0):
|
||||
query = select(UserAction)
|
||||
if user_id is not None:
|
||||
query = query.where(or_(UserAction.user_id == user_id, UserAction.target_user_id == user_id))
|
||||
total = db.scalar(select(func.count()).select_from(query.subquery()))
|
||||
offset = min(max(0, offset), ((total - 1) // 30) * 30) if total else 0
|
||||
items = db.scalars(query.order_by(desc(UserAction.created_at), desc(UserAction.id)).offset(offset).limit(30)).all()
|
||||
return {'actions': items, 'history_total': total, 'history_offset': offset, 'history_limit': 30}
|
||||
|
||||
|
||||
def _users_page(request, db, *, user_id=None, offset=0, error=None, temporary=None, status=200):
|
||||
users = db.scalars(select(SystemUser).order_by(SystemUser.username)).all()
|
||||
selected = next((u for u in users if u.id == user_id), None)
|
||||
if user_id is not None and selected is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
response = _render(request, 'users.html', {'users': users, 'selected_user': selected,
|
||||
'user_names': {u.id: u.username for u in users}, 'error': error, 'temporary': temporary,
|
||||
**_history(db, user_id, offset)}, status_code=status)
|
||||
response.headers['Cache-Control'] = 'no-store'
|
||||
return response
|
||||
|
||||
|
||||
def _lock_admin(request, db, settings):
|
||||
# Serialize role/availability changes so concurrent admins cannot disable each other.
|
||||
db.scalars(select(SystemUser).order_by(SystemUser.id).with_for_update()
|
||||
.execution_options(populate_existing=True)).all()
|
||||
return require_admin(request, settings, db)
|
||||
|
||||
|
||||
@router.get('/users')
|
||||
def users(request: Request, user_id: int | None = None, offset: int = 0,
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
require_admin(request, settings, db)
|
||||
return _users_page(request, db, user_id=user_id, offset=offset)
|
||||
|
||||
|
||||
@router.post('/users')
|
||||
def create_user(request: Request, username: str = Form(..., max_length=64), full_name: str = Form('', max_length=255),
|
||||
role: str = Form('user'), db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
username = username.strip().casefold()
|
||||
if not re.fullmatch(r'[a-z0-9][a-z0-9_.-]{2,63}', username) or role not in {'admin', 'user'}:
|
||||
return _users_page(request, db, error='Логин: 3–64 латинских буквы, цифры, точки, дефисы или подчёркивания. Выберите одну из двух ролей', status=422)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
password = temporary_password()
|
||||
user = SystemUser(username=username, full_name=full_name.strip(), role=role,
|
||||
password_hash=hash_password(password), password_reset_at=utcnow())
|
||||
db.add(user)
|
||||
db.flush()
|
||||
db.add(UserAction(user_id=actor.id, action='user_create', target_user_id=user.id,
|
||||
changes={'role': {'before': None, 'after': role}}))
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Этот логин уже занят. Выберите другой', status=409)
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось создать пользователя. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}/reset-password')
|
||||
def reset_password(user_id: int, request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Свой пароль измените в личном кабинете')
|
||||
password = temporary_password()
|
||||
user.password_hash = hash_password(password)
|
||||
user.must_change_password = True
|
||||
user.password_reset_at = utcnow()
|
||||
user.session_version += 1
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=actor.id, action='password_reset', target_user_id=user.id))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось сбросить пароль. Попробуйте ещё раз', status=503)
|
||||
return _users_page(request, db, temporary={'username': user.username, 'password': password})
|
||||
|
||||
|
||||
@router.post('/users/{user_id}')
|
||||
def update_user(user_id: int, request: Request, role: str = Form(...), is_active: str = Form(...),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
actor = require_admin(request, settings, db)
|
||||
if role not in {'admin', 'user'} or is_active not in {'true', 'false'}:
|
||||
raise HTTPException(422, 'Выберите роль и состояние доступа')
|
||||
try:
|
||||
actor = _lock_admin(request, db, settings)
|
||||
user = db.get(SystemUser, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(404, 'Пользователь не найден')
|
||||
if user.id == actor.id:
|
||||
raise HTTPException(422, 'Собственную роль и доступ изменить нельзя')
|
||||
changes = {}
|
||||
for field, value in (('role', role), ('is_active', is_active == 'true')):
|
||||
old = getattr(user, field)
|
||||
if old != value:
|
||||
changes[field] = {'before': old, 'after': value}
|
||||
setattr(user, field, value)
|
||||
if changes:
|
||||
user.session_version += 1
|
||||
db.add(UserAction(user_id=actor.id, action='user_update', target_user_id=user.id, changes=changes))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _users_page(request, db, error='Не удалось изменить доступ. Попробуйте ещё раз', status=503)
|
||||
return RedirectResponse('/admin/users', status_code=303)
|
||||
|
||||
|
||||
@router.get('/account')
|
||||
def account(request: Request, offset: int = 0, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True)
|
||||
return _account_page(request, db, user, offset=offset)
|
||||
|
||||
|
||||
def _account_page(request, db, user, *, offset=0, error=None, error_field=None, status=200):
|
||||
history = _history(db, user.id, offset)
|
||||
ids = {a.user_id for a in history['actions']} | {a.target_user_id for a in history['actions'] if a.target_user_id is not None}
|
||||
names = dict(db.execute(select(SystemUser.id, SystemUser.username).where(SystemUser.id.in_(ids))).all()) if ids else {}
|
||||
return _render(request, 'account.html', {'account_user': user, 'user_names': names, 'error': error,
|
||||
'error_field': error_field, 'password_changed': request.query_params.get('password_changed') == '1', **history}, status_code=status)
|
||||
|
||||
|
||||
@router.post('/account/password')
|
||||
def change_password(request: Request, current_password: str = Form(..., max_length=1024),
|
||||
new_password: str = Form(..., max_length=128), confirmation: str = Form(..., max_length=128),
|
||||
db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||
user = require_user(request, settings, db, allow_password_change=True, lock=True)
|
||||
if not verify_password(current_password, user.password_hash):
|
||||
return _account_page(request, db, user, error='Текущий пароль неверен. Введите пароль, с которым вошли', error_field='current_password', status=422)
|
||||
if len(new_password) < 15 or new_password == current_password:
|
||||
return _account_page(request, db, user, error='Выберите новый пароль от 15 до 128 символов, отличающийся от текущего. Можно использовать фразу', error_field='new_password', status=422)
|
||||
if new_password != confirmation:
|
||||
return _account_page(request, db, user, error='Новые пароли не совпадают. Повторите ввод', error_field='confirmation', status=422)
|
||||
try:
|
||||
user.password_hash = hash_password(new_password)
|
||||
user.must_change_password = False
|
||||
user.password_changed_at = utcnow()
|
||||
user.session_version += 1
|
||||
user.failed_login_attempts = 0
|
||||
user.locked_until = None
|
||||
db.add(UserAction(user_id=user.id, action='password_change'))
|
||||
db.commit()
|
||||
except SQLAlchemyError:
|
||||
db.rollback()
|
||||
return _account_page(request, db, user, error='Не удалось сохранить пароль. Попробуйте ещё раз', status=503)
|
||||
response = RedirectResponse('/admin/account?password_changed=1', status_code=303)
|
||||
response.set_cookie(SESSION_COOKIE, sign_session(user.username, settings, user.session_version),
|
||||
httponly=True, samesite='lax', max_age=SESSION_SECONDS, secure=request.url.scheme == 'https' or request.headers.get('x-forwarded-proto') == 'https')
|
||||
return response
|
||||
+3
-3
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "1.1.1"
|
||||
FRONTEND_VERSION = "1.1.1"
|
||||
BACKEND_VERSION = "1.1.1"
|
||||
APP_VERSION = "1.2.1"
|
||||
FRONTEND_VERSION = "1.2.1"
|
||||
BACKEND_VERSION = "1.2.1"
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE IF NOT EXISTS system_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
full_name VARCHAR(255) NOT NULL DEFAULT '',
|
||||
role VARCHAR(16) NOT NULL DEFAULT 'user' CONSTRAINT ck_system_users_role CHECK (role IN ('admin', 'user')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
must_change_password BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
session_version INTEGER NOT NULL DEFAULT 0,
|
||||
first_login_at TIMESTAMPTZ,
|
||||
last_login_at TIMESTAMPTZ,
|
||||
password_changed_at TIMESTAMPTZ,
|
||||
password_reset_at TIMESTAMPTZ,
|
||||
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_actions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES system_users(id),
|
||||
action VARCHAR(32) NOT NULL,
|
||||
target_user_id INTEGER REFERENCES system_users(id),
|
||||
employee_id INTEGER,
|
||||
employee_name TEXT,
|
||||
publication_year INTEGER,
|
||||
publication_key VARCHAR(64),
|
||||
article_title TEXT,
|
||||
changes JSONB NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_user_created ON user_actions(user_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS ix_user_actions_target_created ON user_actions(target_user_id, created_at);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "1.1.1"
|
||||
version = "1.2.1"
|
||||
description = "MIEM employees parser, admin API, and web admin"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -93,7 +93,7 @@ def main():
|
||||
expect(page.locator("#council-date")).to_have_count(0)
|
||||
assert "Монографии" not in page.content()
|
||||
page.get_by_role("button", name="Применить", exact=True).click()
|
||||
expect(page.get_by_role("link", name="Вход в админку")).to_be_visible()
|
||||
expect(page.get_by_role("link", name="Вход в систему")).to_be_visible()
|
||||
page.get_by_role("link", name="Далее").click()
|
||||
expect(page.locator(".council__list-heading [role=status]")).to_have_text("Показаны 26–28 из 28")
|
||||
page.get_by_role("link", name="Назад").click()
|
||||
|
||||
@@ -73,7 +73,7 @@ def main():
|
||||
expect(pencil).to_be_visible()
|
||||
pencil.click()
|
||||
expect(page.get_by_role('dialog')).to_be_visible()
|
||||
expect(page.get_by_role('link', name='Войти в админку', exact=True)).to_be_visible()
|
||||
expect(page.get_by_role('link', name='Войти в систему', exact=True)).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(pencil).to_be_focused()
|
||||
client.cookies.set(SESSION_COOKIE, sign_session('admin', settings))
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Offline users/roles/password/audit browser scenario; synthetic SQLite data only."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uvicorn
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from playwright.sync_api import expect, sync_playwright
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import Employee, EmployeePublication, utcnow
|
||||
from app.security import ensure_bootstrap_admin
|
||||
from app.services import council
|
||||
|
||||
|
||||
def main():
|
||||
engine = create_engine('sqlite://', connect_args={'check_same_thread': False}, poolclass=StaticPool)
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine)
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='Browser initial admin', session_secret='browser-users-secret')
|
||||
with factory() as db:
|
||||
admin = ensure_bootstrap_admin(db, settings)
|
||||
admin.first_login_at = utcnow()
|
||||
employee = Employee(profile_key='browser-users', full_name='Сотрудник для проверки', canonical_url='https://www.hse.ru/staff/test',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db.add(employee)
|
||||
db.flush()
|
||||
db.add(EmployeePublication(employee_id=employee.id, title='Статья для проверки', year=2024, publication_type='ARTICLE',
|
||||
source_hash='browser-users', raw_data={'journal': 'Журнал для проверки'}))
|
||||
db.commit()
|
||||
def database():
|
||||
with factory() as db:
|
||||
yield db
|
||||
original = council._articles_by_author
|
||||
council._articles_by_author = lambda: {}
|
||||
app.dependency_overrides[get_db] = database
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
output = Path('.impeccable/review/users')
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
listener = socket.socket()
|
||||
listener.bind(('127.0.0.1', 0))
|
||||
server = uvicorn.Server(uvicorn.Config(app, lifespan='off', access_log=False, log_level='critical'))
|
||||
thread = threading.Thread(target=server.run, kwargs={'sockets': [listener]}, daemon=True)
|
||||
thread.start()
|
||||
for _ in range(1000):
|
||||
if server.started:
|
||||
break
|
||||
time.sleep(.01)
|
||||
assert server.started
|
||||
base_url = f'http://127.0.0.1:{listener.getsockname()[1]}'
|
||||
try:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(executable_path=os.getenv('BROWSER_EXECUTABLE'))
|
||||
context = browser.new_context(viewport={'width':1440,'height':1000})
|
||||
page = context.new_page()
|
||||
errors = []
|
||||
page.on('pageerror', lambda error: errors.append(str(error)))
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_be_visible()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
creation = page.locator('form[action="/admin/users"]')
|
||||
creation.get_by_label('Логин', exact=True).fill('reader')
|
||||
creation.get_by_label('Имя', exact=True).fill('Тестовый читатель')
|
||||
creation.get_by_role('button', name='Создать пользователя', exact=True).click()
|
||||
temporary = page.locator('[data-temporary-password]')
|
||||
expect(temporary).to_be_visible()
|
||||
password = temporary.input_value()
|
||||
page.screenshot(path=str(output/'users-desktop.png'), mask=[temporary], full_page=True)
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'users-mobile.png'), mask=[temporary], full_page=True)
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('reader')
|
||||
page.get_by_label('Пароль', exact=True).fill(password)
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
modal = page.get_by_role('dialog', name='Смените временный пароль')
|
||||
expect(modal).to_be_visible()
|
||||
page.keyboard.press('Escape')
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.get_by_label('Текущий пароль', exact=True)).to_be_focused()
|
||||
assert modal.evaluate('node => node.scrollWidth <= node.clientWidth')
|
||||
page.screenshot(path=str(output/'required-mobile.png'))
|
||||
page.set_viewport_size({'width':1440,'height':1000})
|
||||
page.screenshot(path=str(output/'required-desktop.png'))
|
||||
modal.get_by_label('Текущий пароль', exact=True).fill(password)
|
||||
modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123')
|
||||
modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click()
|
||||
expect(page.get_by_role('dialog')).to_have_count(0)
|
||||
expect(page.get_by_text('Пароль изменён. Другие сессии завершены.', exact=True)).to_be_visible()
|
||||
expect(page.get_by_role('link', name='Пользователи', exact=True)).to_have_count(0)
|
||||
page.get_by_role('link', name='Обзор', exact=True).click()
|
||||
expect(page.get_by_role('button', name='Запустить парсинг', exact=True)).to_have_count(0)
|
||||
page.get_by_role('link', name='Диссертационный совет', exact=True).click()
|
||||
page.locator('.council__candidate-summary').click()
|
||||
article = page.locator('[data-council-article]')
|
||||
article.focus()
|
||||
page.keyboard.press('Enter')
|
||||
page.keyboard.press('Enter')
|
||||
editor = page.get_by_role('dialog', name='Редактирование статьи')
|
||||
editor.get_by_label('Название статьи', exact=True).fill('Статья изменена читателем')
|
||||
editor.get_by_role('button', name='Сохранить', exact=True).click()
|
||||
expect(article.locator('strong')).to_have_text('Статья изменена читателем')
|
||||
page.get_by_role('link', name='Личный кабинет', exact=True).click()
|
||||
expect(page.get_by_text('Редактирование статьи', exact=True)).to_be_visible()
|
||||
expect(page.get_by_text('Статья изменена читателем', exact=False)).to_be_visible()
|
||||
page.screenshot(path=str(output/'account-desktop.png'), full_page=True)
|
||||
page.set_viewport_size({'width':320,'height':844})
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= innerWidth')
|
||||
page.screenshot(path=str(output/'account-mobile.png'), full_page=True)
|
||||
page.get_by_role('button', name='Выйти', exact=True).click()
|
||||
page.goto(base_url + '/admin/login')
|
||||
page.get_by_label('Логин', exact=True).fill('admin')
|
||||
page.get_by_label('Пароль', exact=True).fill('Browser initial admin')
|
||||
page.get_by_role('button', name='Войти', exact=True).click()
|
||||
page.get_by_role('link', name='Пользователи', exact=True).click()
|
||||
page.once('dialog', lambda dialog: dialog.accept())
|
||||
page.get_by_role('button', name='Сбросить пароль reader', exact=True).click()
|
||||
expect(page.locator('[data-temporary-password]')).to_be_visible()
|
||||
page.locator('[data-temporary-password]').evaluate("input => input.value = '[скрыто]'")
|
||||
expect(page.locator('.table__cell').filter(has_text='Требуется смена пароля')).to_be_visible()
|
||||
assert not errors, errors
|
||||
browser.close()
|
||||
finally:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
listener.close()
|
||||
council._articles_by_author = original
|
||||
app.dependency_overrides.clear()
|
||||
engine.dispose()
|
||||
print(f'Users browser scenario passed; screenshots: {output}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
+4
-3
@@ -11,14 +11,14 @@ from app.config import Settings, get_settings
|
||||
from app.db import Base, get_db
|
||||
from app.main import app
|
||||
from app.models import CrawlRun, CrawlRunEmployeeChange, Employee
|
||||
from app.security import SESSION_COOKIE, sign_session
|
||||
from app.security import SESSION_COOKIE, sign_session, _session_data
|
||||
|
||||
|
||||
def test_health_returns_versions():
|
||||
response = TestClient(app).get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "1.1.1"
|
||||
assert response.json()["backend_version"] == "1.2.1"
|
||||
|
||||
|
||||
def test_api_employees_and_stats_require_admin_session():
|
||||
@@ -127,7 +127,8 @@ def test_admin_refresh_employee_route_updates_only_requested_employee(monkeypatc
|
||||
client = TestClient(app)
|
||||
client.cookies.set(SESSION_COOKIE, sign_session("admin", settings))
|
||||
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", follow_redirects=False)
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)["csrf"]
|
||||
response = client.post(f"/admin/employees/{employee_id}/refresh", headers={"X-CSRF-Token": csrf}, follow_redirects=False)
|
||||
|
||||
assert response.status_code == 303
|
||||
assert response.headers["location"] == f"/admin/employees/{employee_id}?refresh_status=success"
|
||||
|
||||
@@ -223,8 +223,8 @@ def test_public_page_data_minimization_and_safe_links(db_session, export):
|
||||
assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text
|
||||
assert "javascript:" not in response.text and "private@example.org" not in response.text
|
||||
assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text
|
||||
assert "Backend 1.1.1 · Frontend 1.1.1" in response.text
|
||||
assert "Вход в админку" in response.text
|
||||
assert "Backend 1.2.1 · Frontend 1.2.1" in response.text
|
||||
assert "Вход в систему" in response.text
|
||||
assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403)
|
||||
assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text
|
||||
finally:
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import re
|
||||
import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from app.db import Base
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.config import Settings, get_settings
|
||||
from app.db import get_db
|
||||
from app.main import app
|
||||
from app.models import SystemUser, UserAction, Employee, EmployeePublication, CouncilCorrections, utcnow
|
||||
from app.security import (SESSION_COOKIE, _session_data, ensure_bootstrap_admin, hash_password,
|
||||
sign_session, temporary_password, verify_password)
|
||||
from app.services import council
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def accounts(db_session, monkeypatch):
|
||||
test_engine = None
|
||||
native_url = os.getenv('USERS_TEST_DATABASE_URL')
|
||||
if native_url:
|
||||
assert make_url(native_url).database == 'miem_users_test', 'Use a disposable miem_users_test database only'
|
||||
test_engine = create_engine(native_url)
|
||||
Base.metadata.create_all(test_engine)
|
||||
db_session = sessionmaker(bind=test_engine)()
|
||||
settings = Settings(_env_file=None, admin_username='admin', admin_password='initial-admin-test', session_secret='users-test-secret')
|
||||
admin = ensure_bootstrap_admin(db_session, settings)
|
||||
user = SystemUser(username='reader', full_name='Тестовый пользователь', role='user',
|
||||
password_hash=hash_password('Initial user password'), must_change_password=False,
|
||||
first_login_at=utcnow())
|
||||
db_session.add(user)
|
||||
employee = Employee(profile_key='users:employee', canonical_url='https://www.hse.ru/staff/test', full_name='Тестовый сотрудник',
|
||||
current_data={'sections': [{'title': 'Учёные степени', 'items': ['Доктор технических наук']}]})
|
||||
db_session.add(employee)
|
||||
db_session.flush()
|
||||
db_session.add(EmployeePublication(employee_id=employee.id, title='Исходная статья', year=2024,
|
||||
publication_type='ARTICLE', source_hash='users-publication', raw_data={'journal': 'Журнал'}))
|
||||
db_session.commit()
|
||||
monkeypatch.setattr(council, '_articles_by_author', lambda: {})
|
||||
app.dependency_overrides[get_db] = lambda: db_session
|
||||
app.dependency_overrides[get_settings] = lambda: settings
|
||||
client = TestClient(app)
|
||||
try:
|
||||
yield client, db_session, settings, admin, user, employee
|
||||
finally:
|
||||
client.close()
|
||||
app.dependency_overrides.clear()
|
||||
if test_engine is not None:
|
||||
db_session.close()
|
||||
Base.metadata.drop_all(test_engine)
|
||||
test_engine.dispose()
|
||||
|
||||
|
||||
def act_as(client, user, settings):
|
||||
client.cookies.set(SESSION_COOKIE, sign_session(user.username, settings, user.session_version))
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)['csrf']
|
||||
return {'X-CSRF-Token': csrf}
|
||||
|
||||
|
||||
def login(client, username, password):
|
||||
page = client.get('/admin/login')
|
||||
csrf = BeautifulSoup(page.text, 'html.parser').select_one('[name=csrf_token]')['value']
|
||||
return client.post('/admin/login', data={'username': username, 'password': password, 'csrf_token': csrf}, follow_redirects=False)
|
||||
|
||||
|
||||
def test_password_hash_and_eight_character_generator():
|
||||
for _ in range(100):
|
||||
password = temporary_password()
|
||||
assert len(password) == 8
|
||||
assert all(re.search(pattern, password) for pattern in (r'[A-Z]', r'[a-z]', r'[0-9]', r'[!@#$%+_\-]'))
|
||||
encoded = hash_password('Секретная фраза с пробелами')
|
||||
assert verify_password('Секретная фраза с пробелами', encoded)
|
||||
assert not verify_password('Другой пароль', encoded)
|
||||
assert not verify_password('anything', 'broken hash')
|
||||
assert hash_password('same password') != hash_password('same password')
|
||||
|
||||
|
||||
def test_create_user_first_login_forces_password_change(accounts):
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
response = client.post('/admin/users', data={'username': 'new.reader', 'full_name': 'Новый пользователь', 'role': 'user'}, headers=headers)
|
||||
assert response.status_code == 200 and response.headers['cache-control'] == 'no-store'
|
||||
password = BeautifulSoup(response.text, 'html.parser').select_one('[data-temporary-password]')['value']
|
||||
user = db.scalar(select(SystemUser).where(SystemUser.username == 'new.reader'))
|
||||
assert verify_password(password, user.password_hash) and user.must_change_password
|
||||
assert password not in str(db.scalars(select(UserAction.changes)).all())
|
||||
client.cookies.clear()
|
||||
signed_in = login(client, user.username, password)
|
||||
assert signed_in.status_code == 303 and signed_in.headers['location'] == '/admin/account'
|
||||
db.refresh(user)
|
||||
assert user.first_login_at and user.last_login_at and user.password_changed_at is None
|
||||
first_login = user.first_login_at
|
||||
assert 'data-required-password' in client.get('/admin/account').text
|
||||
assert client.get('/admin/directory', follow_redirects=False).headers['location'] == '/admin/account'
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 403
|
||||
old_token = client.cookies.get(SESSION_COOKIE)
|
||||
csrf = _session_data(old_token, settings)['csrf']
|
||||
changed = client.post('/admin/account/password', data={'current_password': password,
|
||||
'new_password': 'A new personal passphrase', 'confirmation': 'A new personal passphrase'}, headers={'X-CSRF-Token': csrf}, follow_redirects=False)
|
||||
assert changed.status_code == 303
|
||||
db.refresh(user)
|
||||
assert not user.must_change_password and user.password_changed_at and user.session_version == 1
|
||||
assert user.first_login_at == first_login
|
||||
assert 'data-required-password' not in client.get('/admin/account').text
|
||||
assert client.get('/admin/directory').status_code == 200
|
||||
assert client.get('/admin/users').status_code == 403
|
||||
client.cookies.clear()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize('path', ['/admin/crawl-now', '/admin/runs', '/admin/dismissed/refresh', '/admin/employees/1/refresh', '/api/crawl-runs'])
|
||||
def test_regular_user_cannot_start_scraping(accounts, path):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
response = client.post(path, headers=act_as(client, user, settings), follow_redirects=False)
|
||||
assert response.status_code == 403
|
||||
assert 'Запустить парсинг' not in client.get('/admin').text
|
||||
assert 'Обновить данные' not in client.get('/admin/employees/1').text
|
||||
assert client.get('/api/stats').status_code == 200
|
||||
|
||||
|
||||
def test_regular_user_edit_audit_and_personal_history_isolation(accounts):
|
||||
client, db, settings, admin, user, employee = accounts
|
||||
act_as(client, user, settings)
|
||||
candidate = council.council_candidates(db)['candidates'][0]
|
||||
article = candidate['articles'][0]
|
||||
data = {'employee_id': employee.id, 'edit_key': article['edit_key'], 'revision': 0,
|
||||
'title': 'Изменённая статья', 'journal': 'Новый журнал', 'quartile': 'Q2', 'specialty_codes': ['1.2.2']}
|
||||
response = client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'})
|
||||
assert response.status_code == 200
|
||||
action = db.scalar(select(UserAction).where(UserAction.action == 'card_edit'))
|
||||
assert action.user_id == user.id and action.employee_id == employee.id and action.publication_year == 2024
|
||||
assert action.changes['title'] == {'before': 'Исходная статья', 'after': 'Изменённая статья'}
|
||||
assert action.changes['quartile'] == {'before': '', 'after': 'Q2'}
|
||||
assert action.changes['specialty_codes']['after']['codes'] == ['1.2.2']
|
||||
db.add(UserAction(user_id=admin.id, action='card_edit', article_title='Чужое действие', changes={}))
|
||||
db.commit()
|
||||
profile = client.get(f'/admin/account?user_id={admin.id}')
|
||||
assert 'Изменённая статья' in profile.text and 'Исходная статья' in profile.text
|
||||
assert 'Чужое действие' not in profile.text
|
||||
assert client.get(f'/admin/users?user_id={admin.id}').status_code == 403
|
||||
assert client.post('/admin/council/publications', json=data, headers={'X-Council-Edit': '1'}).status_code == 409
|
||||
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == [action]
|
||||
|
||||
|
||||
def test_admin_reset_invalidates_sessions_and_preserves_password_change_date(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
user.password_changed_at = utcnow()
|
||||
db.commit()
|
||||
previous_changed = user.password_changed_at
|
||||
old_token = sign_session(user.username, settings, user.session_version)
|
||||
response = client.post(f'/admin/users/{user.id}/reset-password', headers=act_as(client, admin, settings))
|
||||
assert response.status_code == 200
|
||||
password = BeautifulSoup(response.text, 'html.parser').select_one('[data-temporary-password]')['value']
|
||||
db.refresh(user)
|
||||
assert user.must_change_password and user.password_reset_at and verify_password(password, user.password_hash)
|
||||
assert user.password_changed_at == previous_changed
|
||||
assert password not in client.get('/admin/users').text
|
||||
client.cookies.clear()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
assert login(client, user.username, password).headers['location'] == '/admin/account'
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'password_reset')).target_user_id == user.id
|
||||
|
||||
|
||||
def test_role_changes_disable_and_protection_against_locking_out_self(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
old_token = sign_session(user.username, settings, user.session_version)
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post(f'/admin/users/{admin.id}', data={'role': 'user', 'is_active': 'false'}, headers=headers).status_code == 422
|
||||
assert client.post(f'/admin/users/{admin.id}/reset-password', headers=headers).status_code == 422
|
||||
assert client.post(f'/admin/users/{user.id}', data={'role': 'admin', 'is_active': 'true'}, headers=headers, follow_redirects=False).status_code == 303
|
||||
db.refresh(user)
|
||||
assert user.role == 'admin'
|
||||
client.cookies.clear()
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/admin/users', follow_redirects=False).status_code == 303
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post(f'/admin/users/{user.id}', data={'role': 'user', 'is_active': 'false'}, headers=headers, follow_redirects=False).status_code == 303
|
||||
db.refresh(user)
|
||||
client.cookies.clear()
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
assert not user.is_active
|
||||
|
||||
|
||||
def test_csrf_duplicate_user_validation_and_login_throttling(accounts):
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post('/admin/users', data={'username': 'unprotected'}).status_code == 403
|
||||
assert client.post('/admin/account/password', data={'current_password': 'anything'}).status_code == 403
|
||||
assert client.post('/admin/login', data={'username': user.username, 'password': 'anything'}).status_code == 403
|
||||
for username, role, code in [('reader', 'user', 409), ('bad login', 'user', 422), ('newuser', 'superuser', 422)]:
|
||||
assert client.post('/admin/users', data={'username': username, 'role': role}, headers=headers).status_code == code
|
||||
client.cookies.clear()
|
||||
for _ in range(5):
|
||||
assert login(client, user.username, 'wrong password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.locked_until and user.failed_login_attempts == 5
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
user.locked_until = utcnow() - timedelta(minutes=1)
|
||||
db.commit()
|
||||
for attempt in range(1, 6):
|
||||
assert login(client, user.username, 'wrong password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == attempt
|
||||
assert bool(user.locked_until) == (attempt == 5)
|
||||
assert login(client, user.username, 'Initial user password').status_code == 401
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == 5
|
||||
user.locked_until = utcnow() - timedelta(minutes=1)
|
||||
db.commit()
|
||||
assert login(client, user.username, 'Initial user password').status_code == 303
|
||||
db.refresh(user)
|
||||
assert user.failed_login_attempts == 0 and user.locked_until is None
|
||||
|
||||
|
||||
def test_bootstrap_admin_first_login_and_changed_password_no_env_fallback(accounts):
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
assert login(client, admin.username, 'initial-admin-test').headers['location'] == '/admin/account'
|
||||
csrf = _session_data(client.cookies.get(SESSION_COOKIE), settings)['csrf']
|
||||
assert client.post('/admin/account/password', data={'current_password': 'initial-admin-test',
|
||||
'new_password': 'New administrator passphrase', 'confirmation': 'New administrator passphrase'}, headers={'X-CSRF-Token': csrf}, follow_redirects=False).status_code == 303
|
||||
client.cookies.clear()
|
||||
assert login(client, admin.username, 'initial-admin-test').status_code == 401
|
||||
assert login(client, admin.username, 'New administrator passphrase').status_code == 303
|
||||
|
||||
|
||||
def test_cross_origin_and_session_expiry_fail_closed(accounts, monkeypatch):
|
||||
import time
|
||||
from app import security
|
||||
client, db, settings, admin, user, _ = accounts
|
||||
headers = act_as(client, admin, settings)
|
||||
assert client.post('/admin/users', data={'username': 'attacker'}, headers={**headers, 'Origin': 'https://evil.example'}).status_code == 403
|
||||
assert client.post('/admin/users', data={'username': 'attacker'}, headers={**headers, 'Origin': 'http://['}).status_code == 403
|
||||
assert db.scalar(select(SystemUser).where(SystemUser.username == 'attacker')) is None
|
||||
token = sign_session(user.username, settings, user.session_version)
|
||||
now = int(time.time())
|
||||
monkeypatch.setattr(security.time, 'time', lambda: now + security.SESSION_SECONDS + 1)
|
||||
assert security.read_session(token, settings) is None
|
||||
|
||||
|
||||
def test_password_errors_and_noop_card_edit_are_not_audited(accounts):
|
||||
client, db, settings, _, user, employee = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
before = user.password_hash
|
||||
for current, new, confirmation in [('wrong', 'New valid personal password', 'New valid personal password'),
|
||||
('Initial user password', 'short', 'short'),
|
||||
('Initial user password', 'New valid personal password', 'Different valid password')]:
|
||||
response = client.post('/admin/account/password', headers=headers,
|
||||
data={'current_password': current, 'new_password': new, 'confirmation': confirmation})
|
||||
assert response.status_code == 422 and user.password_hash == before
|
||||
article = council.council_candidates(db)['candidates'][0]['articles'][0]
|
||||
response = client.post('/admin/council/publications', headers={'X-Council-Edit': '1'}, json={
|
||||
'employee_id': employee.id, 'edit_key': article['edit_key'], 'revision': 0, 'title': article['title'],
|
||||
'journal': article['edit_journal'], 'quartile': article['edit_quartile'], 'specialty_codes': None})
|
||||
assert response.status_code == 200
|
||||
assert db.scalars(select(UserAction).where(UserAction.user_id == user.id)).all() == []
|
||||
|
||||
|
||||
def test_logout_revokes_replayed_cookie(accounts):
|
||||
client, db, settings, _, user, _ = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
old_token = client.cookies.get(SESSION_COOKIE)
|
||||
assert client.post('/admin/logout', headers=headers, follow_redirects=False).status_code == 303
|
||||
client.cookies.set(SESSION_COOKIE, old_token)
|
||||
assert client.get('/api/employees', follow_redirects=False).status_code == 401
|
||||
|
||||
|
||||
def test_password_change_and_audit_rollback_atomically(accounts, monkeypatch):
|
||||
from sqlalchemy.exc import OperationalError
|
||||
client, db, settings, _, user, _ = accounts
|
||||
headers = act_as(client, user, settings)
|
||||
before = user.password_hash
|
||||
def unavailable():
|
||||
raise OperationalError('UPDATE', {}, Exception('private database details'))
|
||||
monkeypatch.setattr(db, 'commit', unavailable)
|
||||
response = client.post('/admin/account/password', data={'current_password': 'Initial user password',
|
||||
'new_password': 'A different personal passphrase', 'confirmation': 'A different personal passphrase'}, headers=headers)
|
||||
assert response.status_code == 503 and 'private database details' not in response.text
|
||||
db.refresh(user)
|
||||
assert user.password_hash == before and user.session_version == 0
|
||||
assert db.scalar(select(UserAction).where(UserAction.action == 'password_change')) is None
|
||||
|
||||
|
||||
def test_postgres_concurrent_admin_demotions_keep_an_admin(accounts):
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
client, db, settings, admin, _, _ = accounts
|
||||
if db.get_bind().dialect.name != 'postgresql':
|
||||
pytest.skip('Requires the disposable PostgreSQL test database')
|
||||
second = SystemUser(username='admin-two', role='admin', password_hash=hash_password('Second administrator passphrase'),
|
||||
must_change_password=False, first_login_at=utcnow())
|
||||
db.add(second)
|
||||
db.commit()
|
||||
factory = sessionmaker(bind=db.get_bind())
|
||||
def sessions():
|
||||
with factory() as session:
|
||||
yield session
|
||||
app.dependency_overrides[get_db] = sessions
|
||||
clients = [TestClient(app), TestClient(app)]
|
||||
actors = [admin, second]
|
||||
targets = [second.id, admin.id]
|
||||
def demote(index):
|
||||
headers = act_as(clients[index], actors[index], settings)
|
||||
response = clients[index].post(f'/admin/users/{targets[index]}',
|
||||
data={'role': 'user', 'is_active': 'true'}, headers=headers, follow_redirects=False)
|
||||
return response.status_code, response.headers.get('location')
|
||||
try:
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
responses = list(executor.map(demote, [0, 1]))
|
||||
assert sum(location == '/admin/users' for status, location in responses) == 1
|
||||
assert all(status in {303, 403} for status, location in responses)
|
||||
remaining = db.scalars(select(SystemUser).where(SystemUser.role == 'admin', SystemUser.is_active.is_(True))).all()
|
||||
assert len(remaining) == 1
|
||||
finally:
|
||||
for other in clients:
|
||||
other.close()
|
||||
Reference in new issue
Block a user
[P2] Очищайте истёкшую блокировку один раз, иначе новые ошибки входа перестают накапливаться. locked_until остаётся заполненным после окончания 15 минут. При каждом неверном пароле эта ветка снова присваивает failed_login_attempts=0 и затем увеличивает его только до 1. Воспроизведение: 5 неверных входов → блокировка; продвинуть время на 16 минут; ещё 6 неверных входов → на каждом failed_login_attempts=1, locked_until всё ещё прежний, повторной блокировки нет. Таким образом, после первой паузы можно продолжать подбор без новой блокировки до успешного входа или административного сброса. При окончании периода нужно однократно очистить user.locked_until и сбросить предыдущую серию, затем накапливать новую серию ошибок под текущей блокировкой строки. Добавьте тест: после истечения блокировки очередные пять неверных паролей создают новую блокировку, а правильный пароль в течение неё также отклоняется. Текущий тест сразу выполняет успешный вход после истечения и не выявляет дефект.