Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96987e90c9 | ||
|
|
a82f63d6cb | ||
|
|
03ac2f5ee8 |
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