diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 4cda449..5ba168d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index a91ee13..06af733 100644 --- a/README.md +++ b/README.md @@ -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: ..." ``` Алгоритм обновления: diff --git a/app/admin.py b/app/admin.py index 64b8398..87ff552 100644 --- a/app/admin.py +++ b/app/admin.py @@ -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): diff --git a/app/api.py b/app/api.py index e5c920c..6bde8b0 100644 --- a/app/api.py +++ b/app/api.py @@ -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) diff --git a/app/db.py b/app/db.py index 4b99494..8805b23 100644 --- a/app/db.py +++ b/app/db.py @@ -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: diff --git a/app/main.py b/app/main.py index 046891c..09a839d 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models.py b/app/models.py index 572f9f1..a3a21f3 100644 --- a/app/models.py +++ b/app/models.py @@ -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) diff --git a/app/security.py b/app/security.py index 8426ef4..48d8d35 100644 --- a/app/security.py +++ b/app/security.py @@ -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 diff --git a/app/static/admin.css b/app/static/admin.css index b88a673..f3b2738 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -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; } diff --git a/app/static/users.js b/app/static/users.js new file mode 100644 index 0000000..342abe3 --- /dev/null +++ b/app/static/users.js @@ -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(); + }); + }); +})(); diff --git a/app/templates/account.html b/app/templates/account.html new file mode 100644 index 0000000..ab91967 --- /dev/null +++ b/app/templates/account.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Личный кабинет · MIEM Employees{% endblock %} +{% block content %} +
+

Личный кабинет

+

{{ account_user.full_name or account_user.username }} · {{ 'Администратор' if account_user.role == 'admin' else 'Пользователь' }}

+
Первый вход
{{ account_user.first_login_at|admin_datetime }}
Последний вход
{{ account_user.last_login_at|admin_datetime }}
Пароль изменён
{{ account_user.password_changed_at|admin_datetime }}
+ {% if password_changed %}

Пароль изменён. Другие сессии завершены.

{% endif %} +
+
+

Сменить пароль

+

Смена пароля или выход из системы завершает все ваши сессии.

+
+ + {% if error %}{% endif %} + + + +

От 15 до 128 символов. Можно использовать фразу. Новый пароль должен отличаться от текущего.

+ +
+
+

Последние действия

{% include 'user_activity.html' %}
+{% if account_user.must_change_password %} + +
+

Смените временный пароль

+

Прежде чем пользоваться учётной записью, задайте свой пароль. Закрыть это окно без смены пароля нельзя.

+
+
+
+
+{% endif %} +{% endblock %} +{% block scripts %}{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index d60a08f..360b73d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -11,16 +11,19 @@

MIEM Employees

diff --git a/app/templates/council_edit_dialog.html b/app/templates/council_edit_dialog.html index 9a69513..482b2e3 100644 --- a/app/templates/council_edit_dialog.html +++ b/app/templates/council_edit_dialog.html @@ -38,8 +38,8 @@ {% for name in journal_names|default([]) %} {% else %} -

Для редактирования статей и квартилей войдите в админку.

- Войти в админку +

Для редактирования статей и квартилей войдите в систему.

+ Войти в систему {% endif %} diff --git a/app/templates/council_publications.html b/app/templates/council_publications.html index 45cae59..fec208b 100644 --- a/app/templates/council_publications.html +++ b/app/templates/council_publications.html @@ -18,7 +18,7 @@ {% else %}

Найдено статей: {{ matched }}{% if matched %} · Показаны {{ offset + 1 }}–{{ [offset + limit, matched]|min }}{% endif %}

- {% if not can_edit %}

Просмотр доступен без входа. Для сохранения изменений войдите в админку.

{% endif %} + {% if not can_edit %}

Просмотр доступен без входа. Для сохранения изменений войдите в систему.

{% endif %}
    {% for article in articles %}
  1. diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index ea19408..a493fa2 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -30,12 +30,14 @@

    Прогресс парсинга

    -
    + {% if current_user and current_user.role == 'admin' %} + -
    -
    +
    {% endif %} + {% if current_user and current_user.role == 'admin' %}
    + -
    + {% endif %}
    {% set run = counts.current_running_run or latest_run %} diff --git a/app/templates/employee_detail.html b/app/templates/employee_detail.html index 591f29c..0004878 100644 --- a/app/templates/employee_detail.html +++ b/app/templates/employee_detail.html @@ -8,9 +8,10 @@ {{ employee_view.status_display }}
    -
    + {% if current_user and current_user.role == 'admin' %} + -
    + {% endif %} {{ employee_view.canonical_url }}
    diff --git a/app/templates/login.html b/app/templates/login.html index 1623a3f..9f86000 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -9,13 +9,15 @@
    -

    Вход в админку

    +

    Вход в систему

    {% if error %}

    {{ error }}

    {% endif %}
    - - + + +
    +

    Если забыли пароль, обратитесь к администратору системы.