From a06f553406863e46bc9eb5f4e949c90d3e14e0cf Mon Sep 17 00:00:00 2001 From: TronoSfera <119615520+TronoSfera@users.noreply.github.com> Date: Sun, 1 Mar 2026 17:31:09 +0300 Subject: [PATCH] add cert 2.5 --- Makefile | 11 +- README.md | 84 ++++- .../versions/0028_auth_mode_email_support.py | 44 +++ alembic/versions/0029_admin_totp_fields.py | 34 ++ .../versions/0030_attachment_scan_status.py | 43 +++ app/api/admin/auth.py | 170 +++++++++- app/api/admin/system.py | 7 + app/api/admin/uploads.py | 23 ++ app/api/public/otp.py | 240 +++++++++++--- app/api/public/requests.py | 41 ++- app/api/public/uploads.py | 15 + app/core/config.py | 27 ++ app/email_main.py | 34 ++ app/models/admin_user.py | 8 +- app/models/attachment.py | 11 +- app/models/client.py | 1 + app/models/otp_session.py | 2 + app/models/request.py | 1 + app/schemas/admin.py | 35 ++ app/schemas/public.py | 5 + app/services/attachment_scan.py | 307 ++++++++++++++++++ app/services/email_service.py | 247 ++++++++++++++ app/services/sms_service.py | 32 +- app/services/totp_service.py | 149 +++++++++ app/web/admin.jsx | 136 +++++++- app/web/landing.html | 12 +- app/web/landing.js | 110 ++++++- app/workers/celery_app.py | 7 + context/11_test_runbook.md | 2 + context/13_production_deploy_ruakb.md | 10 +- context/16_security_pdn_hardening_plan.md | 74 +++++ deploy/nginx/edge-http-only.conf | 2 +- deploy/nginx/edge-https.conf | 4 +- docker-compose.yml | 30 +- frontend/nginx.conf | 6 + scripts/ops/check_chat_health.sh | 8 +- scripts/ops/deploy_prod.sh | 1 + tests/test_admin_auth.py | 122 +++++++ tests/test_attachment_scan.py | 154 +++++++++ tests/test_email_service.py | 62 ++++ tests/test_migrations.py | 21 +- tests/test_public_requests.py | 111 +++++++ tests/test_sms_service.py | 18 + tests/test_uploads_s3.py | 89 +++++ 44 files changed, 2470 insertions(+), 80 deletions(-) create mode 100644 alembic/versions/0028_auth_mode_email_support.py create mode 100644 alembic/versions/0029_admin_totp_fields.py create mode 100644 alembic/versions/0030_attachment_scan_status.py create mode 100644 app/email_main.py create mode 100644 app/services/attachment_scan.py create mode 100644 app/services/email_service.py create mode 100644 app/services/totp_service.py create mode 100644 context/16_security_pdn_hardening_plan.md create mode 100644 tests/test_attachment_scan.py create mode 100644 tests/test_email_service.py diff --git a/Makefile b/Makefile index 2ce0027..abd894e 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,10 @@ DOMAIN ?= ruakb.ru WWW_DOMAIN ?= www.ruakb.ru +SECOND_DOMAIN ?= ruakb.online +SECOND_WWW_DOMAIN ?= www.ruakb.online LETSENCRYPT_EMAIL ?= admin@ruakb.ru +CERTBOT_DOMAINS = -d "$(DOMAIN)" -d "$(WWW_DOMAIN)" $(if $(strip $(SECOND_DOMAIN)),-d "$(SECOND_DOMAIN)") $(if $(strip $(SECOND_WWW_DOMAIN)),-d "$(SECOND_WWW_DOMAIN)") LOCAL_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.local.yml PROD_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml @@ -29,6 +32,12 @@ help: @echo " prod-migrate - Apply migrations (prod)" @echo " prod-cert-init - Initial Let's Encrypt issue (nginx only 80 during bootstrap)" @echo " prod-cert-renew - Renew existing certificates" + @echo "" + @echo "Domains:" + @echo " DOMAIN=$(DOMAIN)" + @echo " WWW_DOMAIN=$(WWW_DOMAIN)" + @echo " SECOND_DOMAIN=$(SECOND_DOMAIN)" + @echo " SECOND_WWW_DOMAIN=$(SECOND_WWW_DOMAIN)" local-up: $(LOCAL_COMPOSE) up -d --build @@ -78,7 +87,7 @@ prod-migrate: check-prod-files # 3) Restart stack in regular prod mode (80/443). prod-cert-init: check-cert-files $(CERT_COMPOSE) up -d --build db redis minio backend chat-service worker beat frontend edge - $(CERT_COMPOSE) run --rm certbot certonly --webroot -w /var/www/certbot --email "$(LETSENCRYPT_EMAIL)" --agree-tos --no-eff-email -d "$(DOMAIN)" -d "$(WWW_DOMAIN)" + $(CERT_COMPOSE) run --rm certbot certonly --webroot -w /var/www/certbot --email "$(LETSENCRYPT_EMAIL)" --agree-tos --no-eff-email --non-interactive --expand $(CERTBOT_DOMAINS) $(PROD_COMPOSE) up -d --build edge $(PROD_COMPOSE) exec -T backend alembic upgrade head diff --git a/README.md b/README.md index 0b887c3..af07b99 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,16 @@ Admin UI: http://localhost:8081/admin API (backend): http://localhost:8002 Swagger: http://localhost:8002/docs Chat service health (via nginx): http://localhost:8081/chat-health +Email service health (via nginx): http://localhost:8081/email-health -## Production (ruakb.ru, 80/443, TLS via Nginx + Certbot) +## Production (ruakb.ru + ruakb.online, 80/443, TLS via Nginx + Certbot) Production stack uses dedicated edge nginx (`docker-compose.prod.nginx.yml`). Prerequisites: - DNS `A` record: `ruakb.ru -> 45.150.36.116` - Optional DNS `A` record: `www.ruakb.ru -> 45.150.36.116` +- DNS `A` record: `ruakb.online -> 45.150.36.116` +- Optional DNS `A` record: `www.ruakb.online -> 45.150.36.116` - Open server ports: `80/tcp`, `443/tcp` - DB credentials in `.env` must be consistent: - `DATABASE_URL=postgresql+psycopg://postgres:@db:5432/legal` @@ -27,6 +30,14 @@ Initial certificate issue (bootstrap with nginx on port 80 only): ```bash make prod-cert-init LETSENCRYPT_EMAIL=you@example.com DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru ``` +By default `prod-cert-init` also includes `ruakb.online` and `www.ruakb.online`. +If needed, override: +```bash +make prod-cert-init \ + LETSENCRYPT_EMAIL=you@example.com \ + DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru \ + SECOND_DOMAIN=ruakb.online SECOND_WWW_DOMAIN=www.ruakb.online +``` Regular production start/update: ```bash @@ -60,6 +71,18 @@ Loads 50 justice-themed quotes into `quotes` with idempotent upsert by `(author, ## OTP SMS provider (SMS Aero) OTP sending is implemented through a dedicated SMS service layer (`app/services/sms_service.py`). +Public auth mode can be selected via environment: +```bash +PUBLIC_AUTH_MODE=sms # sms | email | sms_or_email | totp +EMAIL_PROVIDER=dummy # dummy | smtp +EMAIL_SERVICE_URL=http://email-service:8010 +INTERNAL_SERVICE_TOKEN=change_me_internal_service_token +OTP_EMAIL_FALLBACK_ENABLED=true +OTP_SMS_MIN_BALANCE=20 +ADMIN_AUTH_MODE=password_totp_optional # password | password_totp_optional | password_totp_required +TOTP_ISSUER=Правовой Трекер +``` + Configure provider in `.env`: ```bash SMS_PROVIDER=smsaero @@ -67,8 +90,37 @@ SMSAERO_EMAIL=your_email@example.com SMSAERO_API_KEY=your_api_key OTP_SMS_TEMPLATE=Your verification code: {code} OTP_DEV_MODE=false +OTP_AUTOTEST_FORCE_MOCK_SMS=true ``` +For SMTP email OTP: +```bash +EMAIL_PROVIDER=smtp +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=mailer@example.com +SMTP_PASSWORD=your_password +SMTP_FROM=mailer@example.com +SMTP_USE_TLS=true +SMTP_USE_SSL=false +OTP_EMAIL_SUBJECT_TEMPLATE=Код подтверждения: {code} +OTP_EMAIL_TEMPLATE=Ваш код подтверждения: {code} +``` + +For dedicated email microservice (recommended in production): +```bash +EMAIL_PROVIDER=service +EMAIL_SERVICE_URL=http://email-service:8010 +INTERNAL_SERVICE_TOKEN= +``` + +Admin/Lawyer TOTP endpoints: +- `GET /api/admin/auth/totp/status` +- `POST /api/admin/auth/totp/setup` +- `POST /api/admin/auth/totp/enable` +- `POST /api/admin/auth/totp/backup/regenerate` +- `POST /api/admin/auth/totp/disable` + For local/dev mock mode: ```bash SMS_PROVIDER=dummy @@ -81,6 +133,13 @@ OTP_DEV_MODE=true ``` When enabled, real SMS sending is disabled and OTP code is printed to backend logs. +Additionally, to protect SMS budget during automated tests: +```bash +OTP_AUTOTEST_FORCE_MOCK_SMS=true +``` +When this flag is enabled and runtime is detected as autotest (`pytest/unittest/APP_ENV=test|ci`), +`SMS_PROVIDER=smsaero` is automatically forced to mock mode for OTP sending. + Admin health-check endpoint (no SMS send): `GET /api/admin/system/sms-provider-health` @@ -106,6 +165,28 @@ Nginx routes only chat API prefixes to the chat container: - `/api/public/chat/*` - `/api/admin/chat/*` +## Attachment antivirus and content checks +Attachment scanning is asynchronous (Celery queue `uploads`) and supports ClamAV + content policy checks. + +Environment flags: +```bash +ATTACHMENT_SCAN_ENABLED=true +ATTACHMENT_SCAN_ENFORCE=true +ATTACHMENT_ALLOWED_MIME_TYPES=application/pdf,image/jpeg,image/png,video/mp4,text/plain +CLAMAV_ENABLED=true +CLAMAV_HOST=clamav +CLAMAV_PORT=3310 +CLAMAV_TIMEOUT_SECONDS=20 +``` + +Scan statuses on `attachments`: +- `PENDING` (file uploaded, scan in progress) +- `CLEAN` (safe to download) +- `INFECTED` (blocked) +- `ERROR` (scan failed, blocked when enforcement is on) + +When `ATTACHMENT_SCAN_ENFORCE=true`, public/admin download endpoints block non-clean files. + ## Container health and alerting Docker Compose is configured with: - `restart: unless-stopped` for core services @@ -118,6 +199,7 @@ docker compose up -d docker compose ps curl -fsS http://localhost:8081/health curl -fsS http://localhost:8081/chat-health +curl -fsS http://localhost:8081/email-health ``` Alert-ready smoke script (for cron/CI): diff --git a/alembic/versions/0028_auth_mode_email_support.py b/alembic/versions/0028_auth_mode_email_support.py new file mode 100644 index 0000000..9c0ce2a --- /dev/null +++ b/alembic/versions/0028_auth_mode_email_support.py @@ -0,0 +1,44 @@ +"""auth mode email support + +Revision ID: 0028_auth_mode_email +Revises: 0027_encrypt_chat_messages +Create Date: 2026-03-01 13:15:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "0028_auth_mode_email" +down_revision = "0027_encrypt_chat_messages" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("clients", sa.Column("email", sa.String(length=255), nullable=True)) + op.create_index("ix_clients_email", "clients", ["email"], unique=False) + + op.add_column("requests", sa.Column("client_email", sa.String(length=255), nullable=True)) + op.create_index("ix_requests_client_email", "requests", ["client_email"], unique=False) + + op.add_column("otp_sessions", sa.Column("channel", sa.String(length=16), nullable=True, server_default="SMS")) + op.add_column("otp_sessions", sa.Column("email", sa.String(length=255), nullable=True)) + op.create_index("ix_otp_sessions_email", "otp_sessions", ["email"], unique=False) + op.execute("UPDATE otp_sessions SET channel = 'SMS' WHERE channel IS NULL") + op.alter_column("otp_sessions", "channel", nullable=False, server_default=None) + + +def downgrade() -> None: + op.drop_index("ix_otp_sessions_email", table_name="otp_sessions") + op.drop_column("otp_sessions", "email") + op.drop_column("otp_sessions", "channel") + + op.drop_index("ix_requests_client_email", table_name="requests") + op.drop_column("requests", "client_email") + + op.drop_index("ix_clients_email", table_name="clients") + op.drop_column("clients", "email") diff --git a/alembic/versions/0029_admin_totp_fields.py b/alembic/versions/0029_admin_totp_fields.py new file mode 100644 index 0000000..0461f58 --- /dev/null +++ b/alembic/versions/0029_admin_totp_fields.py @@ -0,0 +1,34 @@ +"""admin totp fields + +Revision ID: 0029_admin_totp +Revises: 0028_auth_mode_email +Create Date: 2026-03-01 13:55:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "0029_admin_totp" +down_revision = "0028_auth_mode_email" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("admin_users", sa.Column("totp_enabled", sa.Boolean(), nullable=True, server_default=sa.false())) + op.add_column("admin_users", sa.Column("totp_secret_encrypted", sa.String(length=2000), nullable=True)) + op.add_column("admin_users", sa.Column("totp_backup_codes_hashes", sa.JSON(), nullable=True)) + op.add_column("admin_users", sa.Column("totp_last_used_at", sa.DateTime(timezone=True), nullable=True)) + op.execute("UPDATE admin_users SET totp_enabled = FALSE WHERE totp_enabled IS NULL") + op.alter_column("admin_users", "totp_enabled", nullable=False, server_default=None) + + +def downgrade() -> None: + op.drop_column("admin_users", "totp_last_used_at") + op.drop_column("admin_users", "totp_backup_codes_hashes") + op.drop_column("admin_users", "totp_secret_encrypted") + op.drop_column("admin_users", "totp_enabled") diff --git a/alembic/versions/0030_attachment_scan_status.py b/alembic/versions/0030_attachment_scan_status.py new file mode 100644 index 0000000..aeee624 --- /dev/null +++ b/alembic/versions/0030_attachment_scan_status.py @@ -0,0 +1,43 @@ +"""attachment antivirus scan status + +Revision ID: 0030_attachment_scan +Revises: 0029_admin_totp +Create Date: 2026-03-01 18:10:00.000000 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + + +# revision identifiers, used by Alembic. +revision = "0030_attachment_scan" +down_revision = "0029_admin_totp" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "attachments", + sa.Column("scan_status", sa.String(length=20), nullable=True, server_default="CLEAN"), + ) + op.add_column("attachments", sa.Column("scan_signature", sa.String(length=255), nullable=True)) + op.add_column("attachments", sa.Column("scan_error", sa.String(length=500), nullable=True)) + op.add_column("attachments", sa.Column("scanned_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("attachments", sa.Column("content_sha256", sa.String(length=64), nullable=True)) + op.add_column("attachments", sa.Column("detected_mime", sa.String(length=150), nullable=True)) + op.execute("UPDATE attachments SET scan_status = 'CLEAN' WHERE scan_status IS NULL") + op.alter_column("attachments", "scan_status", nullable=False, server_default=None) + op.create_index("ix_attachments_scan_status", "attachments", ["scan_status"]) + + +def downgrade() -> None: + op.drop_index("ix_attachments_scan_status", table_name="attachments") + op.drop_column("attachments", "detected_mime") + op.drop_column("attachments", "content_sha256") + op.drop_column("attachments", "scanned_at") + op.drop_column("attachments", "scan_error") + op.drop_column("attachments", "scan_signature") + op.drop_column("attachments", "scan_status") diff --git a/app/api/admin/auth.py b/app/api/admin/auth.py index db6cbe2..6852a69 100644 --- a/app/api/admin/auth.py +++ b/app/api/admin/auth.py @@ -1,14 +1,76 @@ -from fastapi import APIRouter, HTTPException, Depends from datetime import timedelta +from uuid import UUID + +from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -from app.schemas.admin import AdminLogin, AdminToken -from app.core.security import create_jwt, verify_password + from app.core.config import settings +from app.core.deps import get_current_admin +from app.core.security import create_jwt, verify_password from app.db.session import get_db -from app.services.admin_bootstrap import ensure_bootstrap_admin_for_login, get_active_admin_by_email, normalize_admin_email +from app.models.admin_user import AdminUser +from app.schemas.admin import ( + AdminLogin, + AdminToken, + AdminTotpEnableIn, + AdminTotpEnableOut, + AdminTotpSetupIn, + AdminTotpSetupOut, + AdminTotpStatusOut, + AdminTotpVerifyIn, +) +from app.services.admin_bootstrap import ( + ensure_bootstrap_admin_for_login, + get_active_admin_by_email, + normalize_admin_email, +) +from app.services.totp_service import ( + admin_auth_mode, + admin_totp_required, + build_otpauth_uri, + decrypt_totp_secret, + encrypt_totp_secret, + generate_backup_codes, + generate_totp_secret, + mark_totp_used_timestamp, + totp_issuer, + verify_and_consume_backup_code, + verify_totp_code, +) router = APIRouter() + +def _require_user_or_404(db: Session, user_id: str) -> AdminUser: + uid_text = str(user_id or "").strip() + if not uid_text: + raise HTTPException(status_code=401, detail="Некорректный токен") + try: + uid_value = UUID(uid_text) + except Exception as exc: + raise HTTPException(status_code=401, detail="Некорректный токен") from exc + row = db.query(AdminUser).filter(AdminUser.id == uid_value).first() + if row is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + return row + + +def _verify_totp_or_401(*, user: AdminUser, totp_code: str | None, backup_code: str | None) -> list[str]: + if not bool(user.totp_enabled): + raise HTTPException(status_code=403, detail="Для учетной записи не настроен TOTP") + + secret = decrypt_totp_secret(user.totp_secret_encrypted) + if totp_code and verify_totp_code(secret, totp_code, window=1): + return list(user.totp_backup_codes_hashes or []) + + if backup_code: + ok, remaining = verify_and_consume_backup_code(backup_code, user.totp_backup_codes_hashes) + if ok: + return remaining + + raise HTTPException(status_code=401, detail="Неверный TOTP-код или резервный код") + + @router.post("/login", response_model=AdminToken) def login(payload: AdminLogin, db: Session = Depends(get_db)): email = normalize_admin_email(payload.email) @@ -17,6 +79,102 @@ def login(payload: AdminLogin, db: Session = Depends(get_db)): user = get_active_admin_by_email(db, email) if not user or not verify_password(payload.password, user.password_hash): raise HTTPException(status_code=401, detail="Неверный логин или пароль") - token = create_jwt({"sub": str(user.id), "email": user.email, "role": user.role}, - settings.ADMIN_JWT_SECRET, timedelta(minutes=settings.ADMIN_JWT_TTL_MINUTES)) + + if admin_totp_required(user_totp_enabled=bool(user.totp_enabled)): + if not payload.totp_code and not payload.backup_code: + raise HTTPException(status_code=401, detail="Требуется TOTP-код или резервный код") + remaining = _verify_totp_or_401(user=user, totp_code=payload.totp_code, backup_code=payload.backup_code) + user.totp_backup_codes_hashes = remaining + user.totp_last_used_at = mark_totp_used_timestamp() + user.responsible = user.email + db.add(user) + db.commit() + + token = create_jwt( + {"sub": str(user.id), "email": user.email, "role": user.role}, + settings.ADMIN_JWT_SECRET, + timedelta(minutes=settings.ADMIN_JWT_TTL_MINUTES), + ) return AdminToken(access_token=token) + + +@router.get("/totp/status", response_model=AdminTotpStatusOut) +def totp_status(admin: dict = Depends(get_current_admin), db: Session = Depends(get_db)): + user = _require_user_or_404(db, str(admin.get("sub") or "")) + return AdminTotpStatusOut( + mode=admin_auth_mode(), + enabled=bool(user.totp_enabled), + required=admin_totp_required(user_totp_enabled=bool(user.totp_enabled)), + has_backup_codes=bool(user.totp_backup_codes_hashes), + ) + + +@router.post("/totp/setup", response_model=AdminTotpSetupOut) +def totp_setup( + payload: AdminTotpSetupIn, + admin: dict = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = _require_user_or_404(db, str(admin.get("sub") or "")) + issuer = str(payload.issuer or "").strip() or totp_issuer("Law Portal") + account_name = str(user.email or "").strip().lower() + secret = generate_totp_secret() + uri = build_otpauth_uri(secret=secret, account_name=account_name, issuer=issuer) + return AdminTotpSetupOut(secret=secret, otpauth_uri=uri, issuer=issuer, account_name=account_name) + + +@router.post("/totp/enable", response_model=AdminTotpEnableOut) +def totp_enable( + payload: AdminTotpEnableIn, + admin: dict = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = _require_user_or_404(db, str(admin.get("sub") or "")) + if not verify_totp_code(payload.secret, payload.code, window=1): + raise HTTPException(status_code=400, detail="Неверный TOTP-код") + backup_plain, backup_hashes = generate_backup_codes() + user.totp_secret_encrypted = encrypt_totp_secret(payload.secret) + user.totp_backup_codes_hashes = backup_hashes + user.totp_enabled = True + user.responsible = user.email + db.add(user) + db.commit() + return AdminTotpEnableOut(enabled=True, backup_codes=backup_plain) + + +@router.post("/totp/backup/regenerate", response_model=AdminTotpEnableOut) +def totp_regenerate_backup_codes( + payload: AdminTotpVerifyIn, + admin: dict = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = _require_user_or_404(db, str(admin.get("sub") or "")) + if not bool(user.totp_enabled): + raise HTTPException(status_code=400, detail="TOTP не настроен") + _ = _verify_totp_or_401(user=user, totp_code=payload.code, backup_code=payload.backup_code) + backup_plain, backup_hashes = generate_backup_codes() + user.totp_backup_codes_hashes = backup_hashes + user.totp_last_used_at = mark_totp_used_timestamp() + user.responsible = user.email + db.add(user) + db.commit() + return AdminTotpEnableOut(enabled=True, backup_codes=backup_plain) + + +@router.post("/totp/disable") +def totp_disable( + payload: AdminTotpVerifyIn, + admin: dict = Depends(get_current_admin), + db: Session = Depends(get_db), +): + user = _require_user_or_404(db, str(admin.get("sub") or "")) + if bool(user.totp_enabled): + _ = _verify_totp_or_401(user=user, totp_code=payload.code, backup_code=payload.backup_code) + user.totp_enabled = False + user.totp_secret_encrypted = None + user.totp_backup_codes_hashes = None + user.totp_last_used_at = None + user.responsible = user.email + db.add(user) + db.commit() + return {"status": "disabled"} diff --git a/app/api/admin/system.py b/app/api/admin/system.py index 367e06b..79ae03d 100644 --- a/app/api/admin/system.py +++ b/app/api/admin/system.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends from app.core.deps import require_role +from app.services.email_service import email_provider_health from app.services.sms_service import sms_provider_health router = APIRouter() @@ -12,3 +13,9 @@ router = APIRouter() def get_sms_provider_health(admin: dict = Depends(require_role("ADMIN"))): _ = admin return sms_provider_health() + + +@router.get("/email-provider-health") +def get_email_provider_health(admin: dict = Depends(require_role("ADMIN"))): + _ = admin + return email_provider_health() diff --git a/app/api/admin/uploads.py b/app/api/admin/uploads.py index 1bd5139..e9f185d 100644 --- a/app/api/admin/uploads.py +++ b/app/api/admin/uploads.py @@ -20,6 +20,12 @@ from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, U from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_client from app.services.security_audit import record_file_security_event +from app.services.attachment_scan import ( + SCAN_STATUS_ERROR, + enqueue_attachment_scan, + ensure_attachment_download_allowed_or_4xx, + initial_scan_status_for_new_attachment, +) from app.services.s3_storage import build_object_key, get_s3_storage router = APIRouter() @@ -227,6 +233,7 @@ def upload_complete( mime_type=payload.mime_type, size_bytes=actual_size, s3_key=payload.key, + scan_status=initial_scan_status_for_new_attachment(), responsible=responsible, ) mark_unread_for_client(request, EVENT_ATTACHMENT) @@ -258,6 +265,13 @@ def upload_complete( ) db.commit() db.refresh(row) + try: + enqueue_attachment_scan(str(row.id)) + except Exception as exc: + row.scan_status = SCAN_STATUS_ERROR + row.scan_error = str(exc)[:500] + db.add(row) + db.commit() return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) if payload.scope == UploadScope.USER_AVATAR: @@ -355,8 +369,17 @@ def get_object_proxy( assigned = str(request.assigned_lawyer_id or "").strip() if assigned and assigned != str(actor_id): raise HTTPException(status_code=403, detail="Недостаточно прав") + attachment = db.query(Attachment).filter(Attachment.s3_key == key).order_by(Attachment.created_at.desc()).first() + if attachment is None: + raise HTTPException(status_code=404, detail="Файл не найден") + ensure_attachment_download_allowed_or_4xx(attachment) else: raise HTTPException(status_code=403, detail="Недостаточно прав") + elif scope == "requests": + attachment = db.query(Attachment).filter(Attachment.s3_key == key).order_by(Attachment.created_at.desc()).first() + if attachment is None: + raise HTTPException(status_code=404, detail="Файл не найден") + ensure_attachment_download_allowed_or_4xx(attachment) try: obj = get_s3_storage().get_object(key) diff --git a/app/api/public/otp.py b/app/api/public/otp.py index 8d4b3cb..3588738 100644 --- a/app/api/public/otp.py +++ b/app/api/public/otp.py @@ -13,8 +13,9 @@ from app.db.session import get_db from app.models.otp_session import OtpSession from app.models.request import Request as RequestModel from app.schemas.public import OtpSend, OtpVerify +from app.services.email_service import EmailDeliveryError, send_otp_email_message from app.services.rate_limit import get_rate_limiter -from app.services.sms_service import SmsDeliveryError, send_otp_message +from app.services.sms_service import SmsDeliveryError, send_otp_message, sms_provider_health router = APIRouter() @@ -23,6 +24,14 @@ OTP_MAX_ATTEMPTS = 5 OTP_CREATE_PURPOSE = "CREATE_REQUEST" OTP_VIEW_PURPOSE = "VIEW_REQUEST" ALLOWED_PURPOSES = {OTP_CREATE_PURPOSE, OTP_VIEW_PURPOSE} +CHANNEL_SMS = "SMS" +CHANNEL_EMAIL = "EMAIL" +SUPPORTED_CHANNELS = {CHANNEL_SMS, CHANNEL_EMAIL} +AUTH_MODE_SMS = "sms" +AUTH_MODE_EMAIL = "email" +AUTH_MODE_SMS_OR_EMAIL = "sms_or_email" +AUTH_MODE_TOTP = "totp" +SUPPORTED_AUTH_MODES = {AUTH_MODE_SMS, AUTH_MODE_EMAIL, AUTH_MODE_SMS_OR_EMAIL, AUTH_MODE_TOTP} def _now_utc() -> datetime: @@ -50,10 +59,68 @@ def _normalize_phone(raw: str | None) -> str: return "".join(digits).strip() +def _normalize_email(raw: str | None) -> str: + return str(raw or "").strip().lower() + + def _normalize_track(raw: str | None) -> str: return str(raw or "").strip().upper() +def _normalize_channel(raw: str | None) -> str: + value = str(raw or "").strip().upper() + if value in {"SMS", "PHONE"}: + return CHANNEL_SMS + if value in {"EMAIL", "MAIL"}: + return CHANNEL_EMAIL + return "" + + +def _auth_mode() -> str: + mode = str(getattr(settings, "PUBLIC_AUTH_MODE", AUTH_MODE_SMS) or "").strip().lower() + if mode not in SUPPORTED_AUTH_MODES: + return AUTH_MODE_SMS + return mode + + +def _resolve_channel(requested_channel: str | None, *, client_phone: str, client_email: str) -> str: + explicit = _normalize_channel(requested_channel) + mode = _auth_mode() + if mode == AUTH_MODE_TOTP: + raise HTTPException(status_code=501, detail="Режим TOTP еще не реализован") + if mode == AUTH_MODE_SMS: + if explicit and explicit != CHANNEL_SMS: + raise HTTPException(status_code=400, detail="Разрешен только SMS-канал") + return CHANNEL_SMS + if mode == AUTH_MODE_EMAIL: + if explicit and explicit != CHANNEL_EMAIL: + raise HTTPException(status_code=400, detail="Разрешен только Email-канал") + return CHANNEL_EMAIL + # sms_or_email + if explicit: + return explicit + if client_email and not client_phone: + return CHANNEL_EMAIL + return CHANNEL_SMS + + +def _email_fallback_allowed(email: str | None) -> bool: + return bool(str(email or "").strip()) and bool(getattr(settings, "OTP_EMAIL_FALLBACK_ENABLED", True)) + + +def _sms_balance_low() -> bool: + health = sms_provider_health() + if str(health.get("mode") or "").lower() != "real": + return False + amount = health.get("balance_amount") + try: + balance = float(amount) + except Exception: + return False + threshold = float(getattr(settings, "OTP_SMS_MIN_BALANCE", 20.0)) + return balance < threshold + + def _generate_code() -> str: return f"{secrets.randbelow(1_000_000):06d}" @@ -75,7 +142,15 @@ def _hash_key_part(value: str | None) -> str: return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20] -def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str | None, track_number: str | None) -> None: +def _rate_limit_or_429( + action: str, + *, + purpose: str, + client_ip: str, + phone: str | None, + email: str | None, + track_number: str | None, +) -> None: limiter = get_rate_limiter() window = int(max(settings.OTP_RATE_LIMIT_WINDOW_SECONDS, 1)) limit = int(max(settings.OTP_SEND_RATE_LIMIT if action == "send" else settings.OTP_VERIFY_RATE_LIMIT, 1)) @@ -85,6 +160,8 @@ def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str ] if phone: keys.append(f"otp:{action}:phone:{_hash_key_part(phone)}:purpose:{purpose_norm}") + if email: + keys.append(f"otp:{action}:email:{_hash_key_part(email)}:purpose:{purpose_norm}") if track_number: keys.append(f"otp:{action}:track:{_hash_key_part(track_number)}:purpose:{purpose_norm}") @@ -97,9 +174,9 @@ def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str ) -def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None: +def _set_public_cookie(response: Response, *, subject: str, purpose: str, auth_channel: str) -> None: token = create_jwt( - {"sub": subject, "purpose": purpose}, + {"sub": subject, "purpose": purpose, "auth_channel": auth_channel}, settings.PUBLIC_JWT_SECRET, timedelta(days=settings.PUBLIC_JWT_TTL_DAYS), ) @@ -113,6 +190,25 @@ def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> Non ) +@router.get("/config") +def get_auth_config(): + mode = _auth_mode() + available_channels: list[str] = [CHANNEL_SMS] + if mode == AUTH_MODE_EMAIL: + available_channels = [CHANNEL_EMAIL] + elif mode == AUTH_MODE_SMS_OR_EMAIL: + available_channels = [CHANNEL_SMS, CHANNEL_EMAIL] + elif mode == AUTH_MODE_TOTP: + available_channels = [] + return { + "public_auth_mode": mode, + "available_channels": available_channels, + "totp_implemented": False, + "email_provider": str(getattr(settings, "EMAIL_PROVIDER", "dummy") or "dummy").strip().lower(), + "sms_provider": str(getattr(settings, "SMS_PROVIDER", "dummy") or "dummy").strip().lower(), + } + + @router.post("/send") def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): purpose = _normalize_purpose(payload.purpose) @@ -120,56 +216,111 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): raise HTTPException(status_code=400, detail="Некорректная цель OTP") track_number: str | None = None - phone = "" + phone = _normalize_phone(payload.client_phone) + email = _normalize_email(payload.client_email) + channel = _resolve_channel(payload.channel, client_phone=phone, client_email=email) + if purpose == OTP_CREATE_PURPOSE: - phone = _normalize_phone(payload.client_phone) - if not phone: - raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST') + if channel == CHANNEL_SMS and not phone: + raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для SMS OTP') + if channel == CHANNEL_EMAIL and not email: + raise HTTPException(status_code=400, detail='Поле "client_email" обязательно для Email OTP') else: track_number = _normalize_track(payload.track_number) - phone = _normalize_phone(payload.client_phone) if track_number: request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first() if request_row is None: raise HTTPException(status_code=404, detail="Заявка не найдена") phone = _normalize_phone(request_row.client_phone) - elif phone: + email = _normalize_email(request_row.client_email) + elif channel == CHANNEL_SMS and phone: has_requests = db.query(RequestModel.id).filter(RequestModel.client_phone == phone).first() if has_requests is None: raise HTTPException(status_code=404, detail="Заявки по номеру телефона не найдены") + elif channel == CHANNEL_EMAIL and email: + has_requests = ( + db.query(RequestModel.id) + .filter(RequestModel.client_email.isnot(None), RequestModel.client_email == email) + .first() + ) + if has_requests is None: + raise HTTPException(status_code=404, detail="Заявки по email не найдены") else: + if channel == CHANNEL_EMAIL: + raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_email"') raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"') - if not phone: + if channel == CHANNEL_SMS and not phone: raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона") + if channel == CHANNEL_EMAIL and not email: + raise HTTPException(status_code=400, detail="У заявки отсутствует email") _rate_limit_or_429( "send", purpose=purpose, client_ip=_client_ip(request), phone=phone or None, + email=email or None, track_number=track_number, ) code = _generate_code() - try: - sms_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number) - except SmsDeliveryError as exc: - raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP: {exc}") from exc + effective_channel = channel + fallback_reason: str | None = None + if channel == CHANNEL_SMS and _email_fallback_allowed(email): + try: + if _sms_balance_low(): + effective_channel = CHANNEL_EMAIL + fallback_reason = "low_sms_balance" + except Exception: + effective_channel = channel + + if effective_channel == CHANNEL_EMAIL: + try: + delivery_response = send_otp_email_message( + email=email, + code=code, + purpose=purpose, + track_number=track_number, + ) + except EmailDeliveryError as exc: + raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP по email: {exc}") from exc + else: + try: + delivery_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number) + except SmsDeliveryError as exc: + if _email_fallback_allowed(email): + try: + delivery_response = send_otp_email_message( + email=email, + code=code, + purpose=purpose, + track_number=track_number, + ) + effective_channel = CHANNEL_EMAIL + fallback_reason = "sms_send_failed" + except EmailDeliveryError: + pass + if effective_channel == CHANNEL_SMS: + raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP: {exc}") from exc now = _now_utc() expires_at = now + timedelta(minutes=OTP_TTL_MINUTES) - existing_query = db.query(OtpSession).filter( - OtpSession.purpose == purpose, - OtpSession.phone == phone, - OtpSession.track_number == track_number, - ) + existing_query = db.query(OtpSession).filter(OtpSession.purpose == purpose, OtpSession.channel == effective_channel) + if track_number: + existing_query = existing_query.filter(OtpSession.track_number == track_number) + if effective_channel == CHANNEL_EMAIL: + existing_query = existing_query.filter(OtpSession.email == email) + else: + existing_query = existing_query.filter(OtpSession.phone == phone) existing_query.delete(synchronize_session=False) row = OtpSession( purpose=purpose, + channel=effective_channel, track_number=track_number, - phone=phone, + phone=phone if effective_channel == CHANNEL_SMS else "", + email=email if effective_channel == CHANNEL_EMAIL else None, code_hash=hash_password(code), attempts=0, expires_at=expires_at, @@ -182,9 +333,12 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)): return { "status": "sent", "purpose": purpose, + "channel": effective_channel, "track_number": track_number, "ttl_seconds": OTP_TTL_MINUTES * 60, - "sms_response": sms_response, + "delivery_response": delivery_response, + "sms_response": delivery_response if effective_channel == CHANNEL_SMS else None, + "fallback_reason": fallback_reason, } @@ -195,29 +349,39 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses raise HTTPException(status_code=400, detail="Некорректная цель OTP") track_number: str | None = None - phone: str | None = None + phone: str = _normalize_phone(payload.client_phone) + email: str = _normalize_email(payload.client_email) + channel = _resolve_channel(payload.channel, client_phone=phone, client_email=email) + if purpose == OTP_CREATE_PURPOSE: - phone = _normalize_phone(payload.client_phone) - if not phone: - raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST') + if channel == CHANNEL_SMS and not phone: + raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для SMS OTP') + if channel == CHANNEL_EMAIL and not email: + raise HTTPException(status_code=400, detail='Поле "client_email" обязательно для Email OTP') else: track_number = _normalize_track(payload.track_number) - phone = _normalize_phone(payload.client_phone) - if not track_number and not phone: - raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"') + if not track_number and not phone and not email: + raise HTTPException( + status_code=400, + detail='Для VIEW_REQUEST укажите "track_number" или "client_phone/client_email"', + ) _rate_limit_or_429( "verify", purpose=purpose, client_ip=_client_ip(request), - phone=phone, + phone=phone or None, + email=email or None, track_number=track_number, ) - query = db.query(OtpSession).filter(OtpSession.purpose == purpose) + query = db.query(OtpSession).filter(OtpSession.purpose == purpose, OtpSession.channel == channel) if track_number is not None and track_number != "": query = query.filter(OtpSession.track_number == track_number) - if phone is not None and phone != "": + if channel == CHANNEL_EMAIL: + if email: + query = query.filter(OtpSession.email == email) + elif phone: query = query.filter(OtpSession.phone == phone) row = query.order_by(OtpSession.created_at.desc()).first() @@ -241,19 +405,21 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses raise HTTPException(status_code=400, detail="Неверный OTP-код") if purpose == OTP_CREATE_PURPOSE: - subject = str(row.phone or "") + subject = str((row.email or "").strip() if channel == CHANNEL_EMAIL else (row.phone or "")) else: if phone: - subject = str(row.phone or "") + subject = str((row.phone or "").strip()) + elif email: + subject = str((row.email or "").strip()) elif track_number: subject = str(row.track_number or "") else: - subject = str(row.phone or row.track_number or "") + subject = str((row.phone or row.email or row.track_number or "")).strip() if not subject: raise HTTPException(status_code=400, detail="Некорректная OTP-сессия") - _set_public_cookie(response, subject=subject, purpose=purpose) + _set_public_cookie(response, subject=subject, purpose=purpose, auth_channel=channel) db.delete(row) db.commit() - return {"status": "verified", "purpose": purpose} + return {"status": "verified", "purpose": purpose, "channel": channel} diff --git a/app/api/public/requests.py b/app/api/public/requests.py index 70163b8..40518fc 100644 --- a/app/api/public/requests.py +++ b/app/api/public/requests.py @@ -70,6 +70,10 @@ def _normalize_phone(raw: str | None) -> str: return "".join(ch for ch in value if ch.isdigit() or ch in allowed).strip() +def _normalize_email(raw: str | None) -> str: + return str(raw or "").strip().lower() + + def _normalize_track(raw: str | None) -> str: return str(raw or "").strip().upper() @@ -90,10 +94,17 @@ def _set_view_cookie(response: Response, subject: str) -> None: ) -def _require_create_session_or_403(session: dict, client_phone: str) -> None: +def _require_create_session_or_403(session: dict, client_phone: str, client_email: str | None = None) -> None: purpose = str(session.get("purpose") or "").strip().upper() - sub = _normalize_phone(session.get("sub")) - if purpose != OTP_CREATE_PURPOSE or not sub or sub != _normalize_phone(client_phone): + subject = str(session.get("sub") or "").strip() + auth_channel = str(session.get("auth_channel") or "SMS").strip().upper() + if purpose != OTP_CREATE_PURPOSE or not subject: + raise HTTPException(status_code=403, detail="Требуется подтверждение контакта через OTP") + if auth_channel == "EMAIL": + if _normalize_email(subject) != _normalize_email(client_email): + raise HTTPException(status_code=403, detail="Требуется подтверждение email через OTP") + return + if _normalize_phone(subject) != _normalize_phone(client_phone): raise HTTPException(status_code=403, detail="Требуется подтверждение телефона через OTP") @@ -111,6 +122,8 @@ def _ensure_view_access_or_403(session: dict, req: Request) -> None: return if _normalize_phone(subject) and _normalize_phone(subject) == _normalize_phone(req.client_phone): return + if _normalize_email(subject) and _normalize_email(subject) == _normalize_email(req.client_email): + return raise HTTPException(status_code=403, detail="Нет доступа к заявке") @@ -127,8 +140,9 @@ def _request_for_track_or_404(db: Session, session: dict, track_number: str) -> return req -def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str) -> Client: +def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str, email: str | None = None) -> Client: normalized_phone = _normalize_phone(phone) + normalized_email = _normalize_email(email) if not normalized_phone: raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно') normalized_name = str(full_name or "").strip() or "Клиент" @@ -138,6 +152,7 @@ def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str) -> Clien client = Client( full_name=normalized_name, phone=normalized_phone, + email=normalized_email or None, responsible="Клиент", ) db.add(client) @@ -148,6 +163,11 @@ def _upsert_client_by_phone(db: Session, *, full_name: str, phone: str) -> Clien client.responsible = "Клиент" db.add(client) db.flush() + if normalized_email and client.email != normalized_email: + client.email = normalized_email + client.responsible = "Клиент" + db.add(client) + db.flush() return client @@ -194,9 +214,14 @@ def create_request( db: Session = Depends(get_db), session: dict = Depends(get_public_session), ): - _require_create_session_or_403(session, payload.client_phone) + _require_create_session_or_403(session, payload.client_phone, payload.client_email) validate_required_topic_fields_or_400(db, payload.topic_code, payload.extra_fields) - client = _upsert_client_by_phone(db, full_name=payload.client_name, phone=payload.client_phone) + client = _upsert_client_by_phone( + db, + full_name=payload.client_name, + phone=payload.client_phone, + email=payload.client_email, + ) track = f"TRK-{uuid4().hex[:10].upper()}" row = Request( @@ -204,6 +229,7 @@ def create_request( client_id=client.id, client_name=client.full_name, client_phone=client.phone, + client_email=client.email, topic_code=payload.topic_code, description=payload.description, extra_fields=payload.extra_fields, @@ -236,10 +262,13 @@ def list_my_requests( subject = _require_view_session_or_403(session) normalized_track = _normalize_track(subject) normalized_phone = _normalize_phone(subject) + normalized_email = _normalize_email(subject) query = db.query(Request) if normalized_track.startswith("TRK-"): query = query.filter(Request.track_number == normalized_track) + elif normalized_email and "@" in normalized_email: + query = query.filter(Request.client_email == normalized_email) else: query = query.filter(Request.client_phone == normalized_phone) diff --git a/app/api/public/uploads.py b/app/api/public/uploads.py index d85ea95..7dbe485 100644 --- a/app/api/public/uploads.py +++ b/app/api/public/uploads.py @@ -18,6 +18,12 @@ from app.schemas.uploads import UploadCompletePayload, UploadCompleteResponse, U from app.services.notifications import EVENT_ATTACHMENT as NOTIFICATION_EVENT_ATTACHMENT, notify_request_event from app.services.request_read_markers import EVENT_ATTACHMENT, mark_unread_for_lawyer from app.services.security_audit import record_file_security_event +from app.services.attachment_scan import ( + SCAN_STATUS_ERROR, + enqueue_attachment_scan, + ensure_attachment_download_allowed_or_4xx, + initial_scan_status_for_new_attachment, +) from app.services.s3_storage import build_object_key, get_s3_storage router = APIRouter() @@ -206,6 +212,7 @@ def upload_complete( mime_type=payload.mime_type, size_bytes=actual_size, s3_key=payload.key, + scan_status=initial_scan_status_for_new_attachment(), responsible="Клиент", ) mark_unread_for_lawyer(request, EVENT_ATTACHMENT) @@ -236,6 +243,13 @@ def upload_complete( ) db.commit() db.refresh(row) + try: + enqueue_attachment_scan(str(row.id)) + except Exception as exc: + row.scan_status = SCAN_STATUS_ERROR + row.scan_error = str(exc)[:500] + db.add(row) + db.commit() return UploadCompleteResponse(status="ok", attachment_id=str(row.id)) except HTTPException as exc: record_file_security_event( @@ -270,6 +284,7 @@ def get_public_attachment_object( key = None try: attachment = _load_attachment_with_access_or_4xx(attachment_id, db, session) + ensure_attachment_download_allowed_or_4xx(attachment) key = attachment.s3_key request_id = attachment.request_id try: diff --git a/app/core/config.py b/app/core/config.py index 0394d69..45e8977 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -14,6 +14,8 @@ class Settings(BaseSettings): PUBLIC_JWT_TTL_DAYS: int = 7 ADMIN_JWT_TTL_MINUTES: int = 240 ADMIN_JWT_SECRET: str = "change_me_admin" + ADMIN_AUTH_MODE: str = "password_totp_optional" # password | password_totp_optional | password_totp_required + TOTP_ISSUER: str = "Правовой Трекер" PUBLIC_JWT_SECRET: str = "change_me_public" PUBLIC_COOKIE_NAME: str = "public_jwt" @@ -30,6 +32,15 @@ class Settings(BaseSettings): S3_USE_SSL: bool = False MAX_FILE_MB: int = 25 MAX_CASE_MB: int = 250 + ATTACHMENT_SCAN_ENABLED: bool = False + ATTACHMENT_SCAN_ENFORCE: bool = False + ATTACHMENT_ALLOWED_MIME_TYPES: str = ( + "application/pdf,image/jpeg,image/png,video/mp4,text/plain" + ) + CLAMAV_ENABLED: bool = False + CLAMAV_HOST: str = "clamav" + CLAMAV_PORT: int = 3310 + CLAMAV_TIMEOUT_SECONDS: int = 20 TELEGRAM_BOT_TOKEN: str = "change_me" TELEGRAM_CHAT_ID: str = "0" @@ -37,6 +48,22 @@ class Settings(BaseSettings): SMSAERO_EMAIL: str = "" SMSAERO_API_KEY: str = "" OTP_SMS_TEMPLATE: str = "Your verification code: {code}" + OTP_AUTOTEST_FORCE_MOCK_SMS: bool = True + PUBLIC_AUTH_MODE: str = "sms" # sms | email | sms_or_email | totp + EMAIL_PROVIDER: str = "dummy" # dummy | smtp + EMAIL_SERVICE_URL: str = "http://email-service:8010" + INTERNAL_SERVICE_TOKEN: str = "change_me_internal_service_token" + SMTP_HOST: str = "" + SMTP_PORT: int = 587 + SMTP_USER: str = "" + SMTP_PASSWORD: str = "" + SMTP_FROM: str = "" + SMTP_USE_TLS: bool = True + SMTP_USE_SSL: bool = False + OTP_EMAIL_SUBJECT_TEMPLATE: str = "Код подтверждения: {code}" + OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}" + OTP_EMAIL_FALLBACK_ENABLED: bool = True + OTP_SMS_MIN_BALANCE: float = 20.0 DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption" CHAT_ENCRYPTION_SECRET: str = "" OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300 diff --git a/app/email_main.py b/app/email_main.py new file mode 100644 index 0000000..ebd79be --- /dev/null +++ b/app/email_main.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from fastapi import FastAPI, Header, HTTPException +from pydantic import BaseModel + +from app.core.config import settings +from app.services.email_service import EmailDeliveryError, send_email_via_smtp + +app = FastAPI(title="law-email-service") + + +class InternalEmailSend(BaseModel): + email: str + subject: str + body: str + + +@app.get("/health") +def health(): + return {"status": "ok", "service": "email-service"} + + +@app.post("/internal/send-otp") +def internal_send_otp(payload: InternalEmailSend, x_internal_token: str | None = Header(default=None)): + expected = str(settings.INTERNAL_SERVICE_TOKEN or "").strip() + if not expected: + raise HTTPException(status_code=500, detail="INTERNAL_SERVICE_TOKEN не настроен") + if str(x_internal_token or "").strip() != expected: + raise HTTPException(status_code=401, detail="Недействительный internal token") + try: + result = send_email_via_smtp(email=payload.email, subject=payload.subject, body=payload.body) + except EmailDeliveryError as exc: + raise HTTPException(status_code=502, detail=str(exc)) from exc + return {"status": "sent", "result": result} diff --git a/app/models/admin_user.py b/app/models/admin_user.py index b4e8a2d..3493902 100644 --- a/app/models/admin_user.py +++ b/app/models/admin_user.py @@ -1,4 +1,6 @@ -from sqlalchemy import Boolean, Numeric, String +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, JSON, Numeric, String from sqlalchemy.orm import Mapped, mapped_column from app.db.session import Base from app.models.common import UUIDMixin, TimestampMixin @@ -14,4 +16,8 @@ class AdminUser(Base, UUIDMixin, TimestampMixin): primary_topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) default_rate: Mapped[float | None] = mapped_column(Numeric(12, 2), nullable=True) salary_percent: Mapped[float | None] = mapped_column(Numeric(5, 2), nullable=True) + totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + totp_secret_encrypted: Mapped[str | None] = mapped_column(String(2000), nullable=True) + totp_backup_codes_hashes: Mapped[list[str] | None] = mapped_column(JSON, nullable=True) + totp_last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) diff --git a/app/models/attachment.py b/app/models/attachment.py index b62a7af..88c5643 100644 --- a/app/models/attachment.py +++ b/app/models/attachment.py @@ -1,10 +1,13 @@ import uuid -from sqlalchemy import String, Integer, Boolean +from datetime import datetime + +from sqlalchemy import String, Integer, Boolean, DateTime from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.dialects.postgresql import UUID from app.db.session import Base from app.models.common import UUIDMixin, TimestampMixin + class Attachment(Base, UUIDMixin, TimestampMixin): __tablename__ = "attachments" request_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), index=True, nullable=False) @@ -14,3 +17,9 @@ class Attachment(Base, UUIDMixin, TimestampMixin): size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) s3_key: Mapped[str] = mapped_column(String(500), nullable=False) immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + scan_status: Mapped[str] = mapped_column(String(20), nullable=False, default="CLEAN", index=True) + scan_signature: Mapped[str | None] = mapped_column(String(255), nullable=True) + scan_error: Mapped[str | None] = mapped_column(String(500), nullable=True) + scanned_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + detected_mime: Mapped[str | None] = mapped_column(String(150), nullable=True) diff --git a/app/models/client.py b/app/models/client.py index b1700bf..a9de6bc 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -12,3 +12,4 @@ class Client(Base, UUIDMixin, TimestampMixin): full_name: Mapped[str] = mapped_column(String(200), nullable=False) phone: Mapped[str] = mapped_column(String(30), nullable=False, unique=True, index=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) diff --git a/app/models/otp_session.py b/app/models/otp_session.py index 7b48290..0bf5327 100644 --- a/app/models/otp_session.py +++ b/app/models/otp_session.py @@ -10,7 +10,9 @@ def utcnow(): class OtpSession(Base, UUIDMixin, TimestampMixin): __tablename__ = "otp_sessions" purpose: Mapped[str] = mapped_column(String(30), nullable=False) + channel: Mapped[str] = mapped_column(String(16), nullable=False, default="SMS") track_number: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) + email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) code_hash: Mapped[str] = mapped_column(String(255), nullable=False) attempts: Mapped[int] = mapped_column(Integer, default=0, nullable=False) diff --git a/app/models/request.py b/app/models/request.py index 7f433e1..8d6cdac 100644 --- a/app/models/request.py +++ b/app/models/request.py @@ -13,6 +13,7 @@ class Request(Base, UUIDMixin, TimestampMixin): client_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True, index=True) client_name: Mapped[str] = mapped_column(String(200), nullable=False) client_phone: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + client_email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) topic_code: Mapped[str | None] = mapped_column(String(50), nullable=True, index=True) status_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True, default="NEW") important_date_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) diff --git a/app/schemas/admin.py b/app/schemas/admin.py index 367f121..fa49a23 100644 --- a/app/schemas/admin.py +++ b/app/schemas/admin.py @@ -6,11 +6,46 @@ from typing import Optional class AdminLogin(BaseModel): email: str password: str + totp_code: Optional[str] = None + backup_code: Optional[str] = None class AdminToken(BaseModel): access_token: str token_type: str = "Bearer" + +class AdminTotpSetupIn(BaseModel): + issuer: Optional[str] = None + + +class AdminTotpSetupOut(BaseModel): + secret: str + otpauth_uri: str + issuer: str + account_name: str + + +class AdminTotpEnableIn(BaseModel): + secret: str + code: str + + +class AdminTotpVerifyIn(BaseModel): + code: Optional[str] = None + backup_code: Optional[str] = None + + +class AdminTotpEnableOut(BaseModel): + enabled: bool + backup_codes: list[str] + + +class AdminTotpStatusOut(BaseModel): + mode: str + enabled: bool + required: bool + has_backup_codes: bool + class QuoteUpsert(BaseModel): text: str author: str diff --git a/app/schemas/public.py b/app/schemas/public.py index 3d06892..a42608d 100644 --- a/app/schemas/public.py +++ b/app/schemas/public.py @@ -5,6 +5,7 @@ from uuid import UUID class PublicRequestCreate(BaseModel): client_name: str client_phone: str + client_email: Optional[str] = None topic_code: Optional[str] = None description: Optional[str] = None extra_fields: Dict[str, Any] = Field(default_factory=dict) @@ -19,11 +20,15 @@ class OtpSend(BaseModel): purpose: str track_number: Optional[str] = None client_phone: Optional[str] = None + client_email: Optional[str] = None + channel: Optional[str] = None class OtpVerify(BaseModel): purpose: str track_number: Optional[str] = None client_phone: Optional[str] = None + client_email: Optional[str] = None + channel: Optional[str] = None code: str diff --git a/app/services/attachment_scan.py b/app/services/attachment_scan.py new file mode 100644 index 0000000..91a36c4 --- /dev/null +++ b/app/services/attachment_scan.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import hashlib +import os +import socket +import struct +from datetime import datetime, timezone +from typing import Iterable +from uuid import UUID + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from app.core.config import settings +from app.db.session import SessionLocal +from app.models.attachment import Attachment +from app.services.s3_storage import get_s3_storage +from app.services.security_audit import record_file_security_event +from app.workers.celery_app import celery_app + +SCAN_STATUS_PENDING = "PENDING" +SCAN_STATUS_CLEAN = "CLEAN" +SCAN_STATUS_INFECTED = "INFECTED" +SCAN_STATUS_ERROR = "ERROR" +SCAN_STATUS_VALUES = {SCAN_STATUS_PENDING, SCAN_STATUS_CLEAN, SCAN_STATUS_INFECTED, SCAN_STATUS_ERROR} + +_MIME_BY_EXT = { + ".pdf": {"application/pdf"}, + ".jpg": {"image/jpeg"}, + ".jpeg": {"image/jpeg"}, + ".png": {"image/png"}, + ".mp4": {"video/mp4"}, + ".txt": {"text/plain"}, +} + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _scan_enabled() -> bool: + return bool(getattr(settings, "ATTACHMENT_SCAN_ENABLED", False)) + + +def _scan_enforced() -> bool: + return bool(getattr(settings, "ATTACHMENT_SCAN_ENFORCE", False)) + + +def _clamav_enabled() -> bool: + return bool(getattr(settings, "CLAMAV_ENABLED", False)) + + +def _allowed_mimes() -> set[str]: + raw = str(getattr(settings, "ATTACHMENT_ALLOWED_MIME_TYPES", "") or "").strip() + values = {value.strip().lower() for value in raw.split(",") if value.strip()} + return values or {"application/pdf", "image/jpeg", "image/png", "video/mp4", "text/plain"} + + +def initial_scan_status_for_new_attachment() -> str: + return SCAN_STATUS_PENDING if _scan_enabled() else SCAN_STATUS_CLEAN + + +def enqueue_attachment_scan(attachment_id: str) -> bool: + if not _scan_enabled(): + return False + celery_app.send_task("app.workers.tasks.attachment_scan.scan_attachment_file", args=[str(attachment_id)], queue="uploads") + return True + + +def _iter_bytes_from_s3(key: str) -> Iterable[bytes]: + obj = get_s3_storage().get_object(key) + body = obj.get("Body") + if body is None: + return [] + return body.iter_chunks(chunk_size=64 * 1024) + + +def _download_object_bytes(key: str, max_bytes: int) -> bytes: + chunks = [] + total = 0 + for chunk in _iter_bytes_from_s3(key): + if not chunk: + continue + total += len(chunk) + if total > max_bytes: + raise ValueError("Файл превышает допустимый размер для антивирусной проверки") + chunks.append(chunk) + return b"".join(chunks) + + +def _detect_mime(data: bytes) -> str: + if data.startswith(b"%PDF-"): + return "application/pdf" + if data.startswith(b"\xFF\xD8\xFF"): + return "image/jpeg" + if data.startswith(b"\x89PNG\r\n\x1a\n"): + return "image/png" + if len(data) > 12 and data[4:8] == b"ftyp": + return "video/mp4" + try: + text = data.decode("utf-8") + if text: + printable = sum(1 for ch in text[:2048] if ch.isprintable() or ch in "\r\n\t") + ratio = printable / max(1, min(len(text), 2048)) + if ratio > 0.95: + return "text/plain" + except Exception: + pass + return "application/octet-stream" + + +def _file_ext(file_name: str) -> str: + _, ext = os.path.splitext(str(file_name or "").strip().lower()) + return ext + + +def _content_policy_check(*, file_name: str, declared_mime: str, detected_mime: str) -> tuple[bool, str | None]: + normalized_detected = str(detected_mime or "").strip().lower() + normalized_declared = str(declared_mime or "").strip().lower() + if normalized_detected not in _allowed_mimes(): + return False, f"Неподдерживаемый MIME: {normalized_detected or '-'}" + + ext = _file_ext(file_name) + allowed_by_ext = _MIME_BY_EXT.get(ext) + if allowed_by_ext and normalized_detected not in allowed_by_ext: + return False, f"Несоответствие контента расширению ({ext})" + + if normalized_declared and normalized_declared != normalized_detected: + # Fail closed on MIME spoofing. + return False, "Декларируемый MIME не совпадает с фактическим" + return True, None + + +def _clamav_scan_bytes(data: bytes) -> tuple[bool, str | None]: + host = str(getattr(settings, "CLAMAV_HOST", "clamav") or "clamav").strip() + port = int(getattr(settings, "CLAMAV_PORT", 3310) or 3310) + timeout = int(getattr(settings, "CLAMAV_TIMEOUT_SECONDS", 20) or 20) + with socket.create_connection((host, port), timeout=timeout) as sock: + sock.settimeout(timeout) + sock.sendall(b"zINSTREAM\0") + offset = 0 + chunk_size = 64 * 1024 + while offset < len(data): + chunk = data[offset : offset + chunk_size] + offset += len(chunk) + sock.sendall(struct.pack(">I", len(chunk))) + sock.sendall(chunk) + sock.sendall(struct.pack(">I", 0)) + response = b"" + while True: + part = sock.recv(4096) + if not part: + break + response += part + text = response.decode("utf-8", errors="replace").strip().strip("\x00") + if " FOUND" in text: + signature = text.split(":", 1)[-1].replace("FOUND", "").strip() or "MALWARE_FOUND" + return False, signature + if text.endswith("OK") or " OK" in text: + return True, None + raise ValueError(f"Некорректный ответ ClamAV: {text or '-'}") + + +def ensure_attachment_download_allowed_or_4xx(attachment: Attachment) -> None: + if not _scan_enforced(): + return + status = str(getattr(attachment, "scan_status", "") or "").strip().upper() or SCAN_STATUS_CLEAN + if status == SCAN_STATUS_CLEAN: + return + if status == SCAN_STATUS_PENDING: + raise HTTPException(status_code=423, detail="Файл проверяется антивирусом") + if status == SCAN_STATUS_INFECTED: + raise HTTPException(status_code=403, detail="Файл заблокирован антивирусной проверкой") + raise HTTPException(status_code=403, detail="Файл временно недоступен из-за ошибки проверки") + + +def scan_attachment_file_impl(attachment_id: str) -> dict: + db: Session = SessionLocal() + try: + row = db.get(Attachment, UUID(str(attachment_id))) + if row is None: + return {"status": "missing"} + + row.scan_status = SCAN_STATUS_PENDING + row.scan_error = None + row.scan_signature = None + row.scanned_at = None + db.add(row) + db.flush() + + data = _download_object_bytes(row.s3_key, max_bytes=max(1, int(settings.MAX_FILE_MB)) * 1024 * 1024) + digest = hashlib.sha256(data).hexdigest() + detected_mime = _detect_mime(data) + row.content_sha256 = digest + row.detected_mime = detected_mime + + allowed, reason = _content_policy_check( + file_name=row.file_name, + declared_mime=row.mime_type, + detected_mime=detected_mime, + ) + if not allowed: + row.scan_status = SCAN_STATUS_INFECTED + row.scan_signature = "CONTENT_POLICY" + row.scan_error = reason + row.scanned_at = _now_utc() + db.add(row) + record_file_security_event( + db, + actor_role="SYSTEM", + actor_subject="attachment-scanner", + actor_ip=None, + action="ANTIVIRUS_SCAN_INFECTED", + scope="REQUEST_ATTACHMENT", + allowed=False, + reason=reason, + object_key=row.s3_key, + request_id=row.request_id, + attachment_id=row.id, + details={"scan_status": row.scan_status, "signature": row.scan_signature, "detected_mime": detected_mime}, + responsible="Система AV", + ) + db.commit() + return {"status": row.scan_status, "signature": row.scan_signature, "reason": reason} + + if _clamav_enabled(): + clean, signature = _clamav_scan_bytes(data) + if not clean: + row.scan_status = SCAN_STATUS_INFECTED + row.scan_signature = signature or "MALWARE_FOUND" + row.scan_error = None + row.scanned_at = _now_utc() + db.add(row) + record_file_security_event( + db, + actor_role="SYSTEM", + actor_subject="attachment-scanner", + actor_ip=None, + action="ANTIVIRUS_SCAN_INFECTED", + scope="REQUEST_ATTACHMENT", + allowed=False, + reason=row.scan_signature, + object_key=row.s3_key, + request_id=row.request_id, + attachment_id=row.id, + details={"scan_status": row.scan_status, "signature": row.scan_signature, "detected_mime": detected_mime}, + responsible="Система AV", + ) + db.commit() + return {"status": row.scan_status, "signature": row.scan_signature} + + row.scan_status = SCAN_STATUS_CLEAN + row.scan_error = None + row.scan_signature = None + row.scanned_at = _now_utc() + db.add(row) + record_file_security_event( + db, + actor_role="SYSTEM", + actor_subject="attachment-scanner", + actor_ip=None, + action="ANTIVIRUS_SCAN_CLEAN", + scope="REQUEST_ATTACHMENT", + allowed=True, + object_key=row.s3_key, + request_id=row.request_id, + attachment_id=row.id, + details={"scan_status": row.scan_status, "detected_mime": detected_mime}, + responsible="Система AV", + ) + db.commit() + return {"status": row.scan_status} + except Exception as exc: + db.rollback() + try: + row = db.get(Attachment, UUID(str(attachment_id))) + except Exception: + row = None + if row is not None: + row.scan_status = SCAN_STATUS_ERROR + row.scan_error = str(exc)[:500] + row.scanned_at = _now_utc() + db.add(row) + record_file_security_event( + db, + actor_role="SYSTEM", + actor_subject="attachment-scanner", + actor_ip=None, + action="ANTIVIRUS_SCAN_ERROR", + scope="REQUEST_ATTACHMENT", + allowed=False, + reason=row.scan_error, + object_key=row.s3_key, + request_id=row.request_id, + attachment_id=row.id, + details={"scan_status": row.scan_status}, + responsible="Система AV", + ) + db.commit() + raise + finally: + db.close() + + +@celery_app.task(name="app.workers.tasks.attachment_scan.scan_attachment_file", queue="uploads") +def scan_attachment_file(attachment_id: str) -> dict: + return scan_attachment_file_impl(str(attachment_id)) diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 0000000..f055853 --- /dev/null +++ b/app/services/email_service.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import logging +import smtplib +from email.message import EmailMessage +from typing import Any +import httpx + +from app.core.config import settings + + +class EmailDeliveryError(Exception): + pass + + +logger = logging.getLogger("uvicorn.error") + + +def _otp_dev_mode_enabled() -> bool: + return bool(getattr(settings, "OTP_DEV_MODE", False)) + + +def _normalize_email(value: str | None) -> str: + return str(value or "").strip().lower() + + +def _build_subject(*, code: str, purpose: str, track_number: str | None) -> str: + template = str(settings.OTP_EMAIL_SUBJECT_TEMPLATE or "").strip() or "Код подтверждения: {code}" + try: + return template.format(code=code, purpose=purpose, track_number=track_number or "") + except Exception: + return f"Код подтверждения: {code}" + + +def _build_body(*, code: str, purpose: str, track_number: str | None) -> str: + template = str(settings.OTP_EMAIL_TEMPLATE or "").strip() or "Ваш код подтверждения: {code}" + try: + return template.format(code=code, purpose=purpose, track_number=track_number or "") + except Exception: + return f"Ваш код подтверждения: {code}" + + +def _mock_send(*, email: str, code: str, purpose: str, track_number: str | None) -> dict[str, Any]: + line = f"[OTP EMAIL MOCK] purpose={purpose} email={email} track={track_number or '-'} code={code}" + logger.warning(line) + return { + "provider": "mock_email", + "status": "accepted", + "message": "Email provider response mocked", + "sent": False, + "mocked": True, + "dev_mode": bool(_otp_dev_mode_enabled()), + "debug_code": str(code), + } + + +def _send_smtp(*, email: str, subject: str, body: str) -> dict[str, Any]: + host = str(settings.SMTP_HOST or "").strip() + port = int(settings.SMTP_PORT or 0) + username = str(settings.SMTP_USER or "").strip() + password = str(settings.SMTP_PASSWORD or "").strip() + sender = str(settings.SMTP_FROM or "").strip() + use_tls = bool(getattr(settings, "SMTP_USE_TLS", True)) + use_ssl = bool(getattr(settings, "SMTP_USE_SSL", False)) + + if not host or not port or not sender: + raise EmailDeliveryError("Не заданы SMTP_HOST/SMTP_PORT/SMTP_FROM") + if use_tls and use_ssl: + raise EmailDeliveryError("Нельзя включать одновременно SMTP_USE_TLS и SMTP_USE_SSL") + + msg = EmailMessage() + msg["From"] = sender + msg["To"] = email + msg["Subject"] = subject + msg.set_content(body) + + try: + if use_ssl: + smtp = smtplib.SMTP_SSL(host=host, port=port, timeout=15) + else: + smtp = smtplib.SMTP(host=host, port=port, timeout=15) + with smtp as client: + client.ehlo() + if use_tls: + client.starttls() + client.ehlo() + if username: + client.login(username, password) + client.send_message(msg) + except Exception as exc: + raise EmailDeliveryError(f"Ошибка отправки Email OTP: {exc}") from exc + + return { + "provider": "smtp", + "status": "accepted", + "message": "Email отправлен", + "sent": True, + } + + +def send_email_via_smtp(*, email: str, subject: str, body: str) -> dict[str, Any]: + normalized_email = _normalize_email(email) + if not normalized_email: + raise EmailDeliveryError("Некорректный email") + return _send_smtp(email=normalized_email, subject=subject, body=body) + + +def _send_via_email_service(*, email: str, subject: str, body: str) -> dict[str, Any]: + base_url = str(settings.EMAIL_SERVICE_URL or "").strip().rstrip("/") + token = str(settings.INTERNAL_SERVICE_TOKEN or "").strip() + if not base_url: + raise EmailDeliveryError("Не задан EMAIL_SERVICE_URL") + if not token: + raise EmailDeliveryError("Не задан INTERNAL_SERVICE_TOKEN") + try: + with httpx.Client(timeout=15.0) as client: + response = client.post( + f"{base_url}/internal/send-otp", + headers={"X-Internal-Token": token, "Content-Type": "application/json"}, + json={"email": email, "subject": subject, "body": body}, + ) + except Exception as exc: + raise EmailDeliveryError(f"Ошибка обращения к email-service: {exc}") from exc + payload: dict[str, Any] = {} + try: + payload = response.json() if response.content else {} + except Exception: + payload = {} + if response.status_code >= 400: + detail = str(payload.get("detail") or payload.get("error") or response.text or response.status_code) + raise EmailDeliveryError(f"email-service ошибка: {detail}") + return { + "provider": "email-service", + "status": "accepted", + "message": "Email отправлен через отдельный сервис", + "sent": True, + "response": payload, + } + + +def send_otp_email_message(*, email: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]: + normalized_email = _normalize_email(email) + if not normalized_email: + raise EmailDeliveryError("Некорректный email") + + if _otp_dev_mode_enabled(): + return _mock_send(email=normalized_email, code=code, purpose=purpose, track_number=track_number) + + provider = str(settings.EMAIL_PROVIDER or "dummy").strip().lower() + if provider in {"", "dummy", "mock", "console"}: + return _mock_send(email=normalized_email, code=code, purpose=purpose, track_number=track_number) + + subject = _build_subject(code=code, purpose=purpose, track_number=track_number) + body = _build_body(code=code, purpose=purpose, track_number=track_number) + + if provider in {"service", "email_service"}: + return _send_via_email_service(email=normalized_email, subject=subject, body=body) + + if provider == "smtp": + return _send_smtp(email=normalized_email, subject=subject, body=body) + + raise EmailDeliveryError(f"Неизвестный EMAIL_PROVIDER: {provider}") + + +def email_provider_health() -> dict[str, Any]: + provider = str(settings.EMAIL_PROVIDER or "dummy").strip().lower() + if _otp_dev_mode_enabled(): + return { + "provider": provider or "dummy", + "effective_provider": "mock_email", + "status": "ok", + "mode": "mock", + "dev_mode": True, + "can_send": True, + "checks": {"otp_dev_mode": True}, + "issues": ["OTP_DEV_MODE включен: реальная Email-рассылка отключена"], + } + + if provider in {"", "dummy", "mock", "console"}: + return { + "provider": "dummy", + "status": "ok", + "mode": "mock", + "dev_mode": False, + "can_send": True, + "checks": {"mock_mode": True}, + "issues": [], + } + + if provider in {"service", "email_service"}: + base_url = str(settings.EMAIL_SERVICE_URL or "").strip().rstrip("/") + token = str(settings.INTERNAL_SERVICE_TOKEN or "").strip() + checks = {"email_service_url_configured": bool(base_url), "internal_service_token_configured": bool(token)} + issues: list[str] = [] + if not checks["email_service_url_configured"]: + issues.append("Не задан EMAIL_SERVICE_URL") + if not checks["internal_service_token_configured"]: + issues.append("Не задан INTERNAL_SERVICE_TOKEN") + can_send = all(checks.values()) + if can_send: + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(f"{base_url}/health") + if response.status_code >= 400: + can_send = False + issues.append(f"email-service недоступен: HTTP {response.status_code}") + except Exception as exc: + can_send = False + issues.append(f"email-service недоступен: {exc}") + return { + "provider": "email-service", + "status": "ok" if can_send else "degraded", + "mode": "service", + "dev_mode": False, + "can_send": can_send, + "checks": checks, + "issues": issues, + } + + if provider == "smtp": + host = str(settings.SMTP_HOST or "").strip() + sender = str(settings.SMTP_FROM or "").strip() + checks = {"smtp_host_configured": bool(host), "smtp_from_configured": bool(sender)} + issues = [] + if not checks["smtp_host_configured"]: + issues.append("Не задан SMTP_HOST") + if not checks["smtp_from_configured"]: + issues.append("Не задан SMTP_FROM") + return { + "provider": "smtp", + "status": "ok" if all(checks.values()) else "degraded", + "mode": "real", + "dev_mode": False, + "can_send": all(checks.values()), + "checks": checks, + "issues": issues, + } + + return { + "provider": provider, + "status": "error", + "mode": "unknown", + "dev_mode": False, + "can_send": False, + "checks": {"provider_supported": False}, + "issues": [f"Неизвестный EMAIL_PROVIDER: {provider}"], + } diff --git a/app/services/sms_service.py b/app/services/sms_service.py index 69b8929..8b7de6a 100644 --- a/app/services/sms_service.py +++ b/app/services/sms_service.py @@ -3,6 +3,8 @@ from __future__ import annotations import asyncio import importlib.util import logging +import os +import sys from typing import Any from app.core.config import settings @@ -19,6 +21,26 @@ def _otp_dev_mode_enabled() -> bool: return bool(getattr(settings, "OTP_DEV_MODE", False)) +def _is_automated_test_context() -> bool: + if str(os.getenv("PYTEST_CURRENT_TEST", "")).strip(): + return True + if str(os.getenv("UNITTEST_CURRENT_TEST", "")).strip(): + return True + if str(os.getenv("AUTOTEST_MODE", "")).strip().lower() in {"1", "true", "yes", "on"}: + return True + app_env = str(getattr(settings, "APP_ENV", "") or "").strip().lower() + if app_env in {"test", "ci"}: + return True + argv = " ".join(str(part) for part in sys.argv).lower() + if "pytest" in argv or "unittest" in argv: + return True + return False + + +def _autotest_force_mock_enabled() -> bool: + return bool(getattr(settings, "OTP_AUTOTEST_FORCE_MOCK_SMS", True)) and _is_automated_test_context() + + def _module_available(module_name: str) -> bool: return importlib.util.find_spec(module_name) is not None @@ -205,13 +227,21 @@ def sms_provider_health() -> dict[str, Any]: def send_otp_message(*, phone: str, code: str, purpose: str, track_number: str | None = None) -> dict[str, Any]: + provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() + if _otp_dev_mode_enabled(): payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) payload["dev_mode"] = True payload["debug_code"] = str(code) return payload - provider = str(settings.SMS_PROVIDER or "dummy").strip().lower() + # Safety rail: automated tests must never call paid SMS providers. + if _autotest_force_mock_enabled() and provider in {"smsaero", "sms_aero"}: + payload = _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) + payload["autotest_forced_mock"] = True + payload["debug_code"] = str(code) + return payload + if provider in {"", "dummy", "mock", "console"}: return _mock_sms_send(phone=phone, code=code, purpose=purpose, track_number=track_number) if provider in {"smsaero", "sms_aero"}: diff --git a/app/services/totp_service.py b/app/services/totp_service.py new file mode 100644 index 0000000..583a0e9 --- /dev/null +++ b/app/services/totp_service.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from datetime import datetime, timezone +from typing import Iterable +from urllib.parse import quote + +from app.core.config import settings +from app.services.invoice_crypto import decrypt_requisites, encrypt_requisites + + +_TOTP_DIGITS = 6 +_TOTP_PERIOD_SECONDS = 30 +_BACKUP_CODES_COUNT = 10 +_BACKUP_CODE_BYTES = 5 + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _normalize_base32_secret(secret: str) -> str: + value = "".join(ch for ch in str(secret or "").strip().upper() if ch.isalnum()) + if not value: + raise ValueError("Пустой TOTP secret") + return value + + +def generate_totp_secret() -> str: + raw = secrets.token_bytes(20) + return base64.b32encode(raw).decode("ascii").rstrip("=") + + +def build_otpauth_uri(*, secret: str, account_name: str, issuer: str) -> str: + clean_secret = _normalize_base32_secret(secret) + label = quote(f"{issuer}:{account_name}") + issuer_q = quote(issuer) + return ( + f"otpauth://totp/{label}?secret={clean_secret}" + f"&issuer={issuer_q}&algorithm=SHA1&digits={_TOTP_DIGITS}&period={_TOTP_PERIOD_SECONDS}" + ) + + +def _counter(for_time: float | None = None) -> int: + ts = float(for_time if for_time is not None else time.time()) + return int(ts // _TOTP_PERIOD_SECONDS) + + +def _totp_at(secret: str, counter_value: int) -> str: + clean_secret = _normalize_base32_secret(secret) + padded = clean_secret + "=" * (-len(clean_secret) % 8) + key = base64.b32decode(padded, casefold=True) + msg = struct.pack(">Q", int(counter_value)) + digest = hmac.new(key, msg, hashlib.sha1).digest() + offset = digest[-1] & 0x0F + code_int = struct.unpack(">I", digest[offset : offset + 4])[0] & 0x7FFFFFFF + return str(code_int % (10**_TOTP_DIGITS)).zfill(_TOTP_DIGITS) + + +def verify_totp_code(secret: str, code: str, *, window: int = 1, for_time: float | None = None) -> bool: + raw_code = "".join(ch for ch in str(code or "").strip() if ch.isdigit()) + if len(raw_code) != _TOTP_DIGITS: + return False + current = _counter(for_time) + for delta in range(-abs(int(window)), abs(int(window)) + 1): + if _totp_at(secret, current + delta) == raw_code: + return True + return False + + +def current_totp_code(secret: str, *, for_time: float | None = None) -> str: + return _totp_at(secret, _counter(for_time)) + + +def _backup_code_pepper() -> str: + secret = str(settings.DATA_ENCRYPTION_SECRET or "").strip() or str(settings.ADMIN_JWT_SECRET or "").strip() + return secret or "totp-backup-pepper" + + +def _hash_backup_code(code: str) -> str: + normalized = "".join(ch for ch in str(code or "").strip().upper() if ch.isalnum()) + digest = hashlib.sha256(f"{_backup_code_pepper()}:{normalized}".encode("utf-8")).hexdigest() + return digest + + +def generate_backup_codes() -> tuple[list[str], list[str]]: + plain: list[str] = [] + hashes: list[str] = [] + for _ in range(_BACKUP_CODES_COUNT): + code = base64.b32encode(secrets.token_bytes(_BACKUP_CODE_BYTES)).decode("ascii").rstrip("=") + normalized = code[:4] + "-" + code[4:8] + plain.append(normalized) + hashes.append(_hash_backup_code(normalized)) + return plain, hashes + + +def verify_and_consume_backup_code(code: str, hashes: Iterable[str] | None) -> tuple[bool, list[str]]: + existing = [str(item) for item in (hashes or []) if str(item or "").strip()] + if not existing: + return False, existing + target = _hash_backup_code(code) + if target not in existing: + return False, existing + remaining = [item for item in existing if item != target] + return True, remaining + + +def encrypt_totp_secret(secret: str) -> str: + clean_secret = _normalize_base32_secret(secret) + return encrypt_requisites({"secret": clean_secret}) + + +def decrypt_totp_secret(token: str | None) -> str: + payload = decrypt_requisites(token) + secret = _normalize_base32_secret(payload.get("secret")) + return secret + + +def admin_auth_mode() -> str: + raw = str(getattr(settings, "ADMIN_AUTH_MODE", "password") or "").strip().lower() + if raw in {"password", "password_totp_optional", "password_totp_required"}: + return raw + return "password" + + +def admin_totp_required(*, user_totp_enabled: bool) -> bool: + mode = admin_auth_mode() + if mode == "password": + return False + if mode == "password_totp_required": + return True + return bool(user_totp_enabled) + + +def totp_issuer(default: str = "Law Portal") -> str: + preferred = str(getattr(settings, "TOTP_ISSUER", "") or "").strip() + if preferred: + return preferred + app_name = str(getattr(settings, "APP_NAME", "") or "").strip() + return app_name or default + + +def mark_totp_used_timestamp() -> datetime: + return _now_utc() diff --git a/app/web/admin.jsx b/app/web/admin.jsx index 47cfb46..0f6580d 100644 --- a/app/web/admin.jsx +++ b/app/web/admin.jsx @@ -257,10 +257,11 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; function LoginScreen({ onSubmit, status }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const [totpCode, setTotpCode] = useState(""); const submit = (event) => { event.preventDefault(); - onSubmit(email, password); + onSubmit(email, password, totpCode); }; return ( @@ -291,6 +292,16 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; onChange={(event) => setPassword(event.target.value)} /> +
+ + setTotpCode(event.target.value)} + /> +
@@ -926,6 +937,12 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; const [statusMap, setStatusMap] = useState({}); const [smsProviderHealth, setSmsProviderHealth] = useState(null); + const [totpStatus, setTotpStatus] = useState({ + mode: "password_totp_optional", + enabled: false, + required: false, + has_backup_codes: false, + }); const [recordModal, setRecordModal] = useState({ open: false, @@ -2614,6 +2631,77 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; [api, loadDashboard, loadTable, setStatus] ); + const loadTotpStatus = useCallback( + async (tokenOverride) => { + const activeToken = tokenOverride !== undefined ? tokenOverride : token; + if (!activeToken) return; + try { + const data = await api("/api/admin/auth/totp/status", { method: "GET" }, activeToken); + if (data && typeof data === "object") { + setTotpStatus({ + mode: String(data.mode || "password_totp_optional"), + enabled: Boolean(data.enabled), + required: Boolean(data.required), + has_backup_codes: Boolean(data.has_backup_codes), + }); + } + } catch (_) {} + }, + [api, token] + ); + + const setupTotp = useCallback(async () => { + try { + const setup = await api("/api/admin/auth/totp/setup", { method: "POST", body: {} }); + const secret = String(setup?.secret || "").trim(); + const uri = String(setup?.otpauth_uri || "").trim(); + if (!secret || !uri) throw new Error("Не удалось получить секрет TOTP"); + window.alert( + "Сканируйте QR/URI в Google Authenticator:\n\n" + + uri + + "\n\nИли введите ключ вручную:\n" + + secret + ); + const code = String(window.prompt("Введите текущий 6-значный код из Authenticator", "") || "").trim(); + if (!code) return; + const enabled = await api("/api/admin/auth/totp/enable", { method: "POST", body: { secret, code } }); + const backupCodes = Array.isArray(enabled?.backup_codes) ? enabled.backup_codes : []; + window.alert( + "2FA включена.\nСохраните резервные коды (однократно):\n\n" + (backupCodes.length ? backupCodes.join("\n") : "-") + ); + await loadTotpStatus(); + } catch (error) { + setStatus("login", "Ошибка настройки 2FA: " + error.message, "error"); + } + }, [api, loadTotpStatus, setStatus]); + + const regenerateTotpBackupCodes = useCallback(async () => { + try { + const code = String(window.prompt("Введите TOTP код (или резервный код) для регенерации", "") || "").trim(); + if (!code) return; + const payload = /^\d{6}$/.test(code) ? { code } : { backup_code: code }; + const data = await api("/api/admin/auth/totp/backup/regenerate", { method: "POST", body: payload }); + const backupCodes = Array.isArray(data?.backup_codes) ? data.backup_codes : []; + window.alert("Новые резервные коды:\n\n" + (backupCodes.length ? backupCodes.join("\n") : "-")); + await loadTotpStatus(); + } catch (error) { + setStatus("login", "Ошибка регенерации backup-кодов: " + error.message, "error"); + } + }, [api, loadTotpStatus, setStatus]); + + const disableTotp = useCallback(async () => { + try { + const code = String(window.prompt("Введите TOTP код (или резервный код) для отключения 2FA", "") || "").trim(); + if (!code) return; + const payload = /^\d{6}$/.test(code) ? { code } : { backup_code: code }; + await api("/api/admin/auth/totp/disable", { method: "POST", body: payload }); + setStatus("login", "2FA отключена", "ok"); + await loadTotpStatus(); + } catch (error) { + setStatus("login", "Ошибка отключения 2FA: " + error.message, "error"); + } + }, [api, loadTotpStatus, setStatus]); + const logout = useCallback(() => { localStorage.removeItem(LS_TOKEN); setToken(""); @@ -2653,19 +2741,36 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; }); setStatusMap({}); setSmsProviderHealth(null); + setTotpStatus({ + mode: "password_totp_optional", + enabled: false, + required: false, + has_backup_codes: false, + }); setActiveSection("dashboard"); }, [resetKanbanState, resetRequestWorkspaceState, resetTablesState]); const login = useCallback( - async (emailInput, passwordInput) => { + async (emailInput, passwordInput, totpCodeInput) => { try { setStatus("login", "Выполняем вход...", ""); + const rawTotp = String(totpCodeInput || "").trim(); + const digitsOnly = rawTotp.replace(/\D+/g, ""); + const loginBody = { + email: String(emailInput || "").trim(), + password: passwordInput || "", + ...(rawTotp + ? digitsOnly.length === 6 + ? { totp_code: digitsOnly } + : { backup_code: rawTotp } + : {}), + }; const data = await api( "/api/admin/auth/login", { method: "POST", auth: false, - body: { email: String(emailInput || "").trim(), password: passwordInput || "" }, + body: loginBody, }, "" ); @@ -2683,13 +2788,14 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; await bootstrapReferenceData(nextToken, payload.role); setActiveSection("dashboard"); await loadDashboard(nextToken); + await loadTotpStatus(nextToken); setStatus("login", "Успешный вход", "ok"); } catch (error) { setStatus("login", "Ошибка входа: " + error.message, "error"); } }, - [api, bootstrapReferenceData, loadDashboard, setStatus] + [api, bootstrapReferenceData, loadDashboard, loadTotpStatus, setStatus] ); useEffect(() => { @@ -2712,11 +2818,12 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; (async () => { await bootstrapReferenceData(token, role); if (!cancelled) await loadDashboard(token); + if (!cancelled) await loadTotpStatus(token); })(); return () => { cancelled = true; }; - }, [bootstrapReferenceData, loadDashboard, role, token]); + }, [bootstrapReferenceData, loadDashboard, loadTotpStatus, role, token]); useEffect(() => { if (!token || !role) return; @@ -2901,11 +3008,30 @@ const NEW_REQUEST_CLIENT_OPTION = "__new_client__"; Пользователь: {email}
Роль: {roleLabel(role)} +
+ 2FA: {totpStatus.enabled ? "Включена" : "Выключена"} ) : ( "Не авторизован" )} + {token && role ? ( +
+ + {totpStatus.enabled ? ( + <> + + + + ) : null} +
+ ) : null}
+
+ + +
+
+ + +
diff --git a/app/web/landing.js b/app/web/landing.js index d672c2a..1e41f6d 100644 --- a/app/web/landing.js +++ b/app/web/landing.js @@ -14,6 +14,7 @@ const accessForm = document.getElementById("access-form"); const accessPhoneInput = document.getElementById("access-phone"); + const accessEmailInput = document.getElementById("access-email"); const accessCodeInput = document.getElementById("access-code"); const accessSendOtpButton = document.getElementById("access-send-otp"); const accessStatus = document.getElementById("access-status"); @@ -31,7 +32,11 @@ const featuredTeamDots = document.getElementById("featured-team-dots"); const featuredTeamPrev = document.getElementById("featured-team-prev"); const featuredTeamNext = document.getElementById("featured-team-next"); + const requestEmailInput = document.getElementById("email"); let otpModalResolver = null; + let lastAccessOtpChannel = "SMS"; + let lastCreateOtpChannel = "SMS"; + let authConfig = { public_auth_mode: "sms", available_channels: ["SMS"] }; function setStatus(el, message, kind) { if (!el) return; @@ -54,6 +59,46 @@ return fallbackMessage; } + function normalizeEmail(value) { + return String(value || "").trim().toLowerCase(); + } + + function currentAuthMode() { + return String(authConfig?.public_auth_mode || "sms").trim().toLowerCase(); + } + + function preferredChannel({ phone, email }) { + const mode = currentAuthMode(); + if (mode === "email") return "EMAIL"; + if (mode === "sms_or_email") return email ? "EMAIL" : "SMS"; + if (mode === "totp") return ""; + return "SMS"; + } + + function otpCodeDeliveryLabel(channel) { + return String(channel || "").toUpperCase() === "EMAIL" ? "Email" : "SMS"; + } + + function showAuthHints() { + const mode = currentAuthMode(); + const emailRequired = mode === "email"; + const smsOnly = mode === "sms"; + if (accessPhoneInput) accessPhoneInput.required = smsOnly; + if (accessEmailInput) accessEmailInput.required = emailRequired; + if (requestEmailInput) requestEmailInput.required = emailRequired; + } + + async function loadAuthConfig() { + try { + const response = await fetch("/api/public/otp/config"); + const data = await parseJsonSafe(response); + if (response.ok && data && typeof data === "object") { + authConfig = data; + } + } catch (_) {} + showAuthHints(); + } + function openModal(modal) { if (!modal) return; modal.classList.add("open"); @@ -362,7 +407,17 @@ accessSendOtpButton.addEventListener("click", async () => { const phone = String(accessPhoneInput.value || "").trim(); - if (!phone) { + const email = normalizeEmail(accessEmailInput?.value); + const channel = preferredChannel({ phone, email }); + if (currentAuthMode() === "totp") { + setStatus(accessStatus, "Режим TOTP пока не реализован в публичном кабинете.", "error"); + return; + } + if (channel === "EMAIL" && !email) { + setStatus(accessStatus, "Введите email.", "error"); + return; + } + if (channel === "SMS" && !phone) { setStatus(accessStatus, "Введите номер телефона.", "error"); return; } @@ -375,15 +430,19 @@ body: JSON.stringify({ purpose: "VIEW_REQUEST", client_phone: phone, + client_email: email, + channel, }), }); const data = await parseJsonSafe(response); if (!response.ok) throw new Error(apiErrorDetail(data, "Не удалось отправить OTP")); - const debugCode = String(data?.sms_response?.debug_code || "").trim(); + const effectiveChannel = String(data?.channel || channel || "SMS").toUpperCase(); + lastAccessOtpChannel = effectiveChannel; + const debugCode = String(data?.delivery_response?.debug_code || data?.sms_response?.debug_code || "").trim(); if (debugCode) { - console.info("[OTP DEV] VIEW_REQUEST code:", debugCode); + console.info("[OTP DEV] VIEW_REQUEST code (" + otpCodeDeliveryLabel(effectiveChannel) + "):", debugCode); } - setStatus(accessStatus, "Код отправлен. Проверьте SMS.", "ok"); + setStatus(accessStatus, "Код отправлен. Проверьте " + otpCodeDeliveryLabel(effectiveChannel) + ".", "ok"); } catch (error) { setStatus(accessStatus, error?.message || "Не удалось отправить OTP", "error"); } @@ -392,8 +451,14 @@ accessForm.addEventListener("submit", async (event) => { event.preventDefault(); const phone = String(accessPhoneInput.value || "").trim(); + const email = normalizeEmail(accessEmailInput?.value); const code = String(accessCodeInput.value || "").trim(); - if (!phone || !code) { + const channel = preferredChannel({ phone, email }); + if (channel === "EMAIL" && (!email || !code)) { + setStatus(accessStatus, "Введите email и OTP-код.", "error"); + return; + } + if (channel === "SMS" && (!phone || !code)) { setStatus(accessStatus, "Введите телефон и OTP-код.", "error"); return; } @@ -406,6 +471,8 @@ body: JSON.stringify({ purpose: "VIEW_REQUEST", client_phone: phone, + client_email: email, + channel: lastAccessOtpChannel || channel, code, }), }); @@ -425,13 +492,23 @@ const payload = { client_name: String(document.getElementById("name").value || "").trim(), client_phone: String(document.getElementById("phone").value || "").trim(), + client_email: normalizeEmail(requestEmailInput?.value), topic_code: String(document.getElementById("topic").value || "").trim(), description: String(document.getElementById("description").value || "").trim(), extra_fields: {}, }; - if (!payload.client_name || !payload.client_phone || !payload.topic_code) { - setStatus(requestStatus, "Заполните имя, телефон и тему обращения.", "error"); + const createChannel = preferredChannel({ phone: payload.client_phone, email: payload.client_email }); + if (createChannel === "EMAIL" && !payload.client_email) { + setStatus(requestStatus, "Введите email для получения OTP.", "error"); + return; + } + if (createChannel === "SMS" && !payload.client_phone) { + setStatus(requestStatus, "Введите телефон для получения OTP.", "error"); + return; + } + if (!payload.client_name || !payload.topic_code) { + setStatus(requestStatus, "Заполните имя и тему обращения.", "error"); return; } @@ -443,18 +520,26 @@ body: JSON.stringify({ purpose: "CREATE_REQUEST", client_phone: payload.client_phone, + client_email: payload.client_email, + channel: createChannel, }), }); const otpSendData = await parseJsonSafe(otpSend); if (!otpSend.ok) throw new Error(apiErrorDetail(otpSendData, "Не удалось отправить OTP")); - const debugCode = String(otpSendData?.sms_response?.debug_code || "").trim(); + const effectiveChannel = String(otpSendData?.channel || createChannel || "SMS").toUpperCase(); + lastCreateOtpChannel = effectiveChannel; + const debugCode = String(otpSendData?.delivery_response?.debug_code || otpSendData?.sms_response?.debug_code || "").trim(); if (debugCode) { - console.info("[OTP DEV] CREATE_REQUEST code:", debugCode); + console.info("[OTP DEV] CREATE_REQUEST code (" + otpCodeDeliveryLabel(effectiveChannel) + "):", debugCode); } - const isMocked = Boolean(otpSendData?.sms_response?.mocked) || String(otpSendData?.sms_response?.provider || "") === "mock_sms"; + const deliveryResponse = otpSendData?.delivery_response || otpSendData?.sms_response || {}; + const provider = String(deliveryResponse?.provider || "").toLowerCase(); + const isMocked = Boolean(deliveryResponse?.mocked) || provider === "mock_sms" || provider === "mock_email"; const code = await requestOtpCode( - isMocked ? "Введите OTP-код из SMS (dev-режим: смотрите backend console)." : "Введите OTP-код из SMS." + isMocked + ? "Введите OTP-код (dev-режим: смотрите backend console)." + : "Введите OTP-код из " + otpCodeDeliveryLabel(effectiveChannel) + "." ); if (!code) throw new Error("Код OTP не введен"); @@ -465,6 +550,8 @@ body: JSON.stringify({ purpose: "CREATE_REQUEST", client_phone: payload.client_phone, + client_email: payload.client_email, + channel: lastCreateOtpChannel || createChannel, code: String(code).trim(), }), }); @@ -488,6 +575,7 @@ } }); + loadAuthConfig(); loadTopics(); loadQuotes(); loadFeaturedStaff(); diff --git a/app/workers/celery_app.py b/app/workers/celery_app.py index 7dd71f2..5220959 100644 --- a/app/workers/celery_app.py +++ b/app/workers/celery_app.py @@ -2,6 +2,13 @@ from celery import Celery from app.core.config import settings celery_app = Celery("legal_case_tracker", broker=settings.REDIS_URL, backend=settings.REDIS_URL) +celery_app.conf.imports = ( + "app.workers.tasks.assign", + "app.workers.tasks.sla", + "app.workers.tasks.security", + "app.workers.tasks.uploads", + "app.services.attachment_scan", +) celery_app.conf.beat_schedule = { "sla_check": {"task": "app.workers.tasks.sla.sla_check", "schedule": 300.0}, diff --git a/context/11_test_runbook.md b/context/11_test_runbook.md index 1e3cd2e..d66dcfb 100644 --- a/context/11_test_runbook.md +++ b/context/11_test_runbook.md @@ -110,6 +110,8 @@ echo $? # 0=OK, >0=ALERT | P46 | Финализация декомпозиции | полный backend + frontend + e2e регресс | базовые команды 1-5 | | P47 | Запросы клиента по заявке (модель/миграции) | `tests/test_migrations.py`, `tests/test_public_requests.py`, `tests/admin/test_service_requests.py` | `docker compose exec -T backend alembic upgrade head`; затем `docker compose exec -T backend python -m unittest tests.test_migrations tests.test_public_requests tests.admin.test_service_requests -v` | | P48 | RBAC/видимость запросов + CURATOR extension points | `tests/admin/test_service_requests.py` + регресс `tests/admin/*` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests -v` + `docker compose exec -T backend python -m unittest discover -s tests/admin -p 'test_*.py' -v` | + +| SEC-07 | Антивирус и контент-проверка вложений | `tests/test_attachment_scan.py`, `tests/test_uploads_s3.py`, `tests/test_migrations.py` | `docker compose exec -T backend python -m unittest tests.test_attachment_scan tests.test_uploads_s3 tests.test_migrations -v` | | P49 | Клиентский UI запросов (куратор/смена юриста) | e2e `e2e/tests/service_requests_flow.spec.js`, `e2e/tests/public_client_flow.spec.js` | `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js e2e/tests/public_client_flow.spec.js` | | P50 | Админ UI: вкладка `Запросы` + topbar индикатор | `tests/admin/test_metrics_templates.py`, `tests/admin/test_service_requests.py`, e2e `e2e/tests/admin_role_flow.spec.js`, `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_metrics_templates tests.admin.test_service_requests -v` + Playwright прогон указанных spec | | P51 | Тесты контура запросов | backend: `tests/admin/test_service_requests.py`, `tests/admin/test_metrics_templates.py`, `tests/test_public_requests.py`; e2e: `e2e/tests/service_requests_flow.spec.js` | `docker compose exec -T backend python -m unittest tests.admin.test_service_requests tests.admin.test_metrics_templates tests.test_public_requests -v` + `docker compose run --rm --no-deps -e E2E_BASE_URL=http://frontend e2e playwright test --config=playwright.config.js e2e/tests/service_requests_flow.spec.js` | diff --git a/context/13_production_deploy_ruakb.md b/context/13_production_deploy_ruakb.md index 896744a..a3ef098 100644 --- a/context/13_production_deploy_ruakb.md +++ b/context/13_production_deploy_ruakb.md @@ -1,7 +1,7 @@ -# Production deploy (ruakb.ru) +# Production deploy (ruakb.ru + ruakb.online) ## Цель -Развернуть платформу на сервере `45.150.36.116` c HTTPS на `80/443` для домена `ruakb.ru`. +Развернуть платформу на сервере `45.150.36.116` c HTTPS на `80/443` для доменов `ruakb.ru` и `ruakb.online`. ## Что добавлено - `docker-compose.local.yml` — локальные публикации портов (`8081/8080/8002/5432/6379/9000/9001`) @@ -19,6 +19,8 @@ 1. DNS: - `A ruakb.ru -> 45.150.36.116` - `A www.ruakb.ru -> 45.150.36.116` (опционально) + - `A ruakb.online -> 45.150.36.116` + - `A www.ruakb.online -> 45.150.36.116` (опционально) 2. Открыты порты сервера: - `80/tcp`, `443/tcp` @@ -28,6 +30,8 @@ cd /opt/law make prod-cert-init LETSENCRYPT_EMAIL=you@example.com DOMAIN=ruakb.ru WWW_DOMAIN=www.ruakb.ru ``` +По умолчанию цель также включает `SECOND_DOMAIN=ruakb.online` и `SECOND_WWW_DOMAIN=www.ruakb.online`. + ## Запуск production ```bash cd /opt/law @@ -38,8 +42,10 @@ make prod-up ```bash curl -I http://ruakb.ru curl -I https://ruakb.ru +curl -I https://ruakb.online curl -fsS https://ruakb.ru/health curl -fsS https://ruakb.ru/chat-health +curl -fsS https://ruakb.ru/email-health ss -lntp | egrep ':(80|443|5432|6379|8002|8081|9000|9001)\b' ``` diff --git a/context/16_security_pdn_hardening_plan.md b/context/16_security_pdn_hardening_plan.md new file mode 100644 index 0000000..8416005 --- /dev/null +++ b/context/16_security_pdn_hardening_plan.md @@ -0,0 +1,74 @@ +# План доработки конфигурации безопасности ПДн (РФ) + +Дата: 01.03.2026 +Статус документа: `в работе` +Цель: привести техническую конфигурацию платформы к актуальным базовым требованиям по защите ПДн в РФ, с приоритетом на быстрое снижение юридических и эксплуатационных рисков. + +## Контекст для ИИ-агента +- Система: FastAPI + Postgres + Redis + MinIO + Celery + frontend nginx + edge nginx. +- В проекте уже есть: RBAC, OTP/TOTP, шифрование чата/реквизитов, аудит файловых операций, TLS на edge. +- Критичные текущие риски: + - `secure=False` у public cookie. + - bootstrap-админ включен по умолчанию и дефолтные креды. + - дефолтные root-учетные данные MinIO в compose. + - отсутствует полноформатный operational compliance-контур (retention, инциденты, регламенты, проверка загрузок файлов). + +## Нормативный baseline (для трассировки требований) +- 152-ФЗ (персональные данные): [https://www.consultant.ru/document/cons_doc_LAW_61801/](https://www.consultant.ru/document/cons_doc_LAW_61801/) +- ПП РФ №1119: [https://www.consultant.ru/document/cons_doc_LAW_137356/](https://www.consultant.ru/document/cons_doc_LAW_137356/) +- Приказ ФСТЭК №21: [https://www.consultant.ru/document/cons_doc_LAW_149175/](https://www.consultant.ru/document/cons_doc_LAW_149175/) +- Приказ ФСБ №378: [https://www.consultant.ru/document/cons_doc_LAW_167258/](https://www.consultant.ru/document/cons_doc_LAW_167258/) +- Официальная публикация ФЗ 23-ФЗ от 28.02.2025: [https://publication.pravo.gov.ru/document/0001202502280034](https://publication.pravo.gov.ru/document/0001202502280034) + +## Принцип приоритизации +- `P0` — блокеры прод-безопасности и высокой вероятности санкций/инцидентов. +- `P1` — обязательные усиления, закрывающие значимые пробелы. +- `P2` — организационное и эксплуатационное развитие контура. + +## Backlog задач (для исполнения ИИ-агентом) + +| ID | Приоритет | Статус | Задача | Что сделать | Артефакт / DoD | +|---|---|---|---|---|---| +| SEC-01 | P0 | к разработке | Secure cookie на проде | Вынести флаг `PUBLIC_COOKIE_SECURE` и ставить `secure=True` в prod. Добавить `PUBLIC_COOKIE_SAMESITE` в env. | В `app/api/public/otp.py` и `app/api/public/requests.py` cookie выставляется через конфиг; тесты на cookie flags проходят. | +| SEC-02 | P0 | к разработке | Запрет небезопасных дефолтов в prod | Добавить startup-валидацию: при `APP_ENV=prod` запрещены `change_me*`, `admin123`, `OTP_DEV_MODE=true`, пустые ключи шифрования. | Фейл старта с понятной ошибкой; документировано в README. | +| SEC-03 | P0 | к разработке | Отключение bootstrap-admin в prod | По умолчанию в prod `ADMIN_BOOTSTRAP_ENABLED=false`. Разовый безопасный init admin через скрипт. | Скрипт `scripts/ops/create_admin.py` (или аналог), bootstrap отключен на проде. | +| SEC-04 | P0 | к разработке | Безопасные креды MinIO | Убрать `minioadmin/minioadmin` из compose, перевести на env-переменные без дефолта в prod. | В `docker-compose*.yml` нет хардкод-кредов; добавлена проверка env при старте. | +| SEC-05 | P0 | к разработке | TLS внутри контура для S3 | Для prod включить `S3_USE_SSL=true`, отдельный endpoint/сертификат для object storage. | Загрузка/скачивание работает по TLS; health-check и smoke зафиксированы в runbook. | +| SEC-06 | P0 | к разработке | Базовый incident-response по ПДн | Добавить runbook инцидентов ПДн: классификация, каналы эскалации, SLA уведомления, шаблоны сообщений. | Новый файл в `context/` + `scripts/ops/incident_checklist.sh`. | +| SEC-07 | P1 | сделано | Антивирусная проверка вложений | Добавить сервис сканирования (ClamAV container), статус проверки файла (`pending/clean/infected`), запрет выдачи `infected`. | Реализовано: миграция `0030_attachment_scan`, async scan-task, content-policy check, блокировка выдачи при enforcement, тесты `tests/test_attachment_scan.py` + обновления `tests/test_uploads_s3.py`. | +| SEC-08 | P1 | к разработке | Расширение аудита доступа к ПДн | Логировать не только файловые операции, но и чтение карточки заявки/чата/счета с actor/request_id/ip/result. | Новые события аудита + read-only доступ для ADMIN + тесты deny/allow. | +| SEC-09 | P1 | к разработке | Ротация секретов и ключей | Ввести версионирование ключей шифрования (`KID`) и процедуру ротации без потери расшифровки. | Документ + миграция формата (если нужна) + smoke ротации ключа. | +| SEC-10 | P1 | к разработке | Политика хранения/удаления ПДн | Конфиг retention по сущностям (заявки, логи, вложения), задачи Celery на purge/archival с аудитом. | Конфиг retention + job + отчёт по удалению + тесты. | +| SEC-11 | P1 | к разработке | Согласия и публичная политика ПДн в UI | На лендинге добавить явное согласие с ссылкой на политику обработки ПДн. Логировать факт согласия. | Новый публичный документ политики + поле/аудит согласия при создании заявки. | +| SEC-12 | P1 | к разработке | Ужесточение CORS/CSP для prod | Разделить dev/prod CORS, ограничить `script-src` и убрать внешние источники без необходимости. | Конфиг профилей + тесты/проверка заголовков. | +| SEC-13 | P2 | к разработке | Комплект ИСПДн-документов (техчасть) | Подготовить техблок: модель угроз, матрица контролей, границы ИСПДн, ответственные роли. | Папка `docs/security/` с шаблонами и заполненными draft. | +| SEC-14 | P2 | к разработке | Контроль уязвимостей в CI | Добавить SAST/dep-scan и базовый container scan в pipeline. | CI job + пороги fail + отчёт в артефактах. | +| SEC-15 | P2 | к разработке | Регулярный security smoke | Набор cron-проверок: cookie flags, TLS, headers, доступность audit/scan сервисов. | `scripts/ops/security_smoke.sh` + запись в runbook. | + +## Последовательность внедрения +1. `SEC-01` → `SEC-05` (закрытие P0 в коде/конфиге). +2. `SEC-06` (операционный минимум на инциденты). +3. `SEC-07` → `SEC-12` (P1, прикладное усиление). +4. `SEC-13` → `SEC-15` (P2, зрелость и устойчивость процесса). + +## Технические указания ИИ-агенту +- Любую prod-задачу сопровождать: + - миграцией (если меняется схема), + - unit/integration тестом, + - обновлением `README.md` и `context/11_test_runbook.md`. +- Для security-конфига использовать feature flags/env: + - изменения должны быть обратимо включаемыми. +- В PR/коммите фиксировать: + - риск, который закрыт, + - как проверить вручную, + - как откатить. + +## Минимальный check-list приёмки для каждого SEC-* пункта +- Есть код/конфиг + тесты. +- Нет regression по e2e основных ролей. +- Обновлена документация (`README` + runbook + context). +- Указан rollback шаг. + +## Статус исполнения +- `SEC-01` … `SEC-15`: `к разработке`. +- После выполнения переводить поштучно в `сделано` с датой и ссылкой на commit/PR. diff --git a/deploy/nginx/edge-http-only.conf b/deploy/nginx/edge-http-only.conf index 20c067f..6f17b15 100644 --- a/deploy/nginx/edge-http-only.conf +++ b/deploy/nginx/edge-http-only.conf @@ -1,6 +1,6 @@ server { listen 80; - server_name ruakb.ru www.ruakb.ru; + server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_tokens off; location /.well-known/acme-challenge/ { diff --git a/deploy/nginx/edge-https.conf b/deploy/nginx/edge-https.conf index e0f33d9..d53bf58 100644 --- a/deploy/nginx/edge-https.conf +++ b/deploy/nginx/edge-https.conf @@ -1,6 +1,6 @@ server { listen 80; - server_name ruakb.ru www.ruakb.ru; + server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_tokens off; location /.well-known/acme-challenge/ { @@ -16,7 +16,7 @@ server { server { listen 443 ssl; http2 on; - server_name ruakb.ru www.ruakb.ru; + server_name ruakb.ru www.ruakb.ru ruakb.online www.ruakb.online; server_tokens off; ssl_certificate /etc/letsencrypt/live/ruakb.ru/fullchain.pem; diff --git a/docker-compose.yml b/docker-compose.yml index 2d9e3e5..9d175b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,8 +10,10 @@ services: condition: service_healthy chat-service: condition: service_healthy + email-service: + condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/chat-health >/dev/null 2>&1"] + test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1/health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/chat-health >/dev/null 2>&1 && wget -q -O - http://127.0.0.1/email-health >/dev/null 2>&1"] interval: 20s timeout: 5s retries: 5 @@ -46,6 +48,8 @@ services: condition: service_healthy minio: condition: service_started + email-service: + condition: service_healthy healthcheck: test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=3)\""] interval: 20s @@ -73,6 +77,23 @@ services: start_period: 25s volumes: [".:/app"] + email-service: + build: . + container_name: law-email-service + restart: unless-stopped + env_file: .env + depends_on: + redis: + condition: service_healthy + command: ["uvicorn", "app.email_main:app", "--host", "0.0.0.0", "--port", "8010"] + healthcheck: + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8010/health', timeout=3)\""] + interval: 20s + timeout: 5s + retries: 5 + start_period: 25s + volumes: [".:/app"] + worker: build: . container_name: law-worker @@ -85,6 +106,8 @@ services: condition: service_healthy minio: condition: service_started + clamav: + condition: service_started command: ["celery","-A","app.workers.celery_app:celery_app","worker","-Q","notifications,maintenance,uploads","-l","INFO"] volumes: [".:/app"] @@ -136,6 +159,11 @@ services: MINIO_ROOT_PASSWORD: minioadmin volumes: ["miniodata:/data"] + clamav: + image: clamav/clamav:latest + container_name: law-clamav + restart: unless-stopped + volumes: pgdata: miniodata: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 62f78e2..4c87210 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -107,4 +107,10 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; } + + location /email-health { + proxy_pass http://email-service:8010/health; + proxy_http_version 1.1; + proxy_set_header Host $host; + } } diff --git a/scripts/ops/check_chat_health.sh b/scripts/ops/check_chat_health.sh index 1d3be41..39e7d48 100755 --- a/scripts/ops/check_chat_health.sh +++ b/scripts/ops/check_chat_health.sh @@ -4,6 +4,7 @@ set -eu BASE_URL="${1:-http://localhost:8081}" CHAT_HEALTH_URL="${BASE_URL%/}/chat-health" BACKEND_HEALTH_URL="${BASE_URL%/}/health" +EMAIL_HEALTH_URL="${BASE_URL%/}/email-health" check_http_200() { url="$1" @@ -21,9 +22,14 @@ if ! check_http_200 "$BACKEND_HEALTH_URL"; then exit 3 fi +if ! check_http_200 "$EMAIL_HEALTH_URL"; then + echo "[ALERT] email-service health check failed: $EMAIL_HEALTH_URL" >&2 + exit 5 +fi + if docker compose ps --format json 2>/dev/null | grep -q '"Health":"unhealthy"'; then echo "[ALERT] at least one container has unhealthy state" >&2 exit 4 fi -echo "[OK] chat-service and backend are healthy" +echo "[OK] chat-service, backend and email-service are healthy" diff --git a/scripts/ops/deploy_prod.sh b/scripts/ops/deploy_prod.sh index 25c317c..0d50235 100755 --- a/scripts/ops/deploy_prod.sh +++ b/scripts/ops/deploy_prod.sh @@ -21,5 +21,6 @@ docker compose -f docker-compose.yml -f docker-compose.prod.yml ps echo "[4/4] Smoke checks..." curl -fsS http://localhost/health >/dev/null curl -fsS http://localhost/chat-health >/dev/null +curl -fsS http://localhost/email-health >/dev/null echo "Done. Open https://ruakb.ru" diff --git a/tests/test_admin_auth.py b/tests/test_admin_auth.py index 97bf0ff..512dc77 100644 --- a/tests/test_admin_auth.py +++ b/tests/test_admin_auth.py @@ -18,6 +18,12 @@ from app.core.security import decode_jwt, hash_password from app.db.session import get_db from app.main import app from app.models.admin_user import AdminUser +from app.services.totp_service import ( + current_totp_code, + encrypt_totp_secret, + generate_backup_codes, + generate_totp_secret, +) class AdminAuthTests(unittest.TestCase): @@ -56,11 +62,13 @@ class AdminAuthTests(unittest.TestCase): "ADMIN_BOOTSTRAP_EMAIL": settings.ADMIN_BOOTSTRAP_EMAIL, "ADMIN_BOOTSTRAP_PASSWORD": settings.ADMIN_BOOTSTRAP_PASSWORD, "ADMIN_BOOTSTRAP_NAME": settings.ADMIN_BOOTSTRAP_NAME, + "ADMIN_AUTH_MODE": settings.ADMIN_AUTH_MODE, } settings.ADMIN_BOOTSTRAP_ENABLED = True settings.ADMIN_BOOTSTRAP_EMAIL = "admin@example.com" settings.ADMIN_BOOTSTRAP_PASSWORD = "admin123" settings.ADMIN_BOOTSTRAP_NAME = "Администратор системы" + settings.ADMIN_AUTH_MODE = "password_totp_optional" def tearDown(self): self.client.close() @@ -123,3 +131,117 @@ class AdminAuthTests(unittest.TestCase): json={"email": "admin@example.com", "password": "custom-pass-1"}, ) self.assertEqual(wrong.status_code, 401) + + def test_totp_required_mode_rejects_login_without_totp_code(self): + settings.ADMIN_AUTH_MODE = "password_totp_required" + secret = generate_totp_secret() + with self.SessionLocal() as db: + db.add( + AdminUser( + role="ADMIN", + name="TOTP Admin", + email="totp@example.com", + password_hash=hash_password("pass123"), + is_active=True, + totp_enabled=True, + totp_secret_encrypted=encrypt_totp_secret(secret), + totp_backup_codes_hashes=[], + ) + ) + db.commit() + + response = self.client.post( + "/api/admin/auth/login", + json={"email": "totp@example.com", "password": "pass123"}, + ) + self.assertEqual(response.status_code, 401) + self.assertIn("TOTP", str(response.json().get("detail", ""))) + + def test_totp_required_mode_allows_login_with_valid_totp(self): + settings.ADMIN_AUTH_MODE = "password_totp_required" + secret = generate_totp_secret() + code = current_totp_code(secret) + with self.SessionLocal() as db: + db.add( + AdminUser( + role="ADMIN", + name="TOTP Admin", + email="totp2@example.com", + password_hash=hash_password("pass123"), + is_active=True, + totp_enabled=True, + totp_secret_encrypted=encrypt_totp_secret(secret), + totp_backup_codes_hashes=[], + ) + ) + db.commit() + + response = self.client.post( + "/api/admin/auth/login", + json={"email": "totp2@example.com", "password": "pass123", "totp_code": code}, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(bool(response.json().get("access_token"))) + + def test_totp_backup_code_is_single_use(self): + settings.ADMIN_AUTH_MODE = "password_totp_required" + secret = generate_totp_secret() + backup_plain, backup_hashes = generate_backup_codes() + backup_code = backup_plain[0] + with self.SessionLocal() as db: + db.add( + AdminUser( + role="ADMIN", + name="Backup Admin", + email="totp3@example.com", + password_hash=hash_password("pass123"), + is_active=True, + totp_enabled=True, + totp_secret_encrypted=encrypt_totp_secret(secret), + totp_backup_codes_hashes=backup_hashes, + ) + ) + db.commit() + + first = self.client.post( + "/api/admin/auth/login", + json={"email": "totp3@example.com", "password": "pass123", "backup_code": backup_code}, + ) + self.assertEqual(first.status_code, 200) + + second = self.client.post( + "/api/admin/auth/login", + json={"email": "totp3@example.com", "password": "pass123", "backup_code": backup_code}, + ) + self.assertEqual(second.status_code, 401) + + def test_totp_setup_enable_and_status_flow(self): + login = self.client.post( + "/api/admin/auth/login", + json={"email": "admin@example.com", "password": "admin123"}, + ) + self.assertEqual(login.status_code, 200) + token = login.json().get("access_token") + self.assertTrue(token) + headers = {"Authorization": "Bearer " + token} + + setup = self.client.post("/api/admin/auth/totp/setup", json={}, headers=headers) + self.assertEqual(setup.status_code, 200) + setup_body = setup.json() + secret = str(setup_body.get("secret") or "") + self.assertTrue(secret) + self.assertIn("otpauth://totp/", str(setup_body.get("otpauth_uri") or "")) + + code = current_totp_code(secret) + enable = self.client.post( + "/api/admin/auth/totp/enable", + json={"secret": secret, "code": code}, + headers=headers, + ) + self.assertEqual(enable.status_code, 200) + backup_codes = enable.json().get("backup_codes") or [] + self.assertTrue(isinstance(backup_codes, list) and len(backup_codes) > 0) + + status = self.client.get("/api/admin/auth/totp/status", headers=headers) + self.assertEqual(status.status_code, 200) + self.assertTrue(bool(status.json().get("enabled"))) diff --git a/tests/test_attachment_scan.py b/tests/test_attachment_scan.py new file mode 100644 index 0000000..8a71f8d --- /dev/null +++ b/tests/test_attachment_scan.py @@ -0,0 +1,154 @@ +import os +import unittest +from uuid import UUID +from unittest.mock import patch + +from botocore.exceptions import ClientError +from sqlalchemy import create_engine, delete +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.models.attachment import Attachment +from app.models.request import Request +from app.models.security_audit_log import SecurityAuditLog +from app.services.attachment_scan import SCAN_STATUS_CLEAN, SCAN_STATUS_INFECTED, scan_attachment_file_impl +import app.services.attachment_scan as attachment_scan_module +from app.db import session as db_session + + +class _FakeBody: + def __init__(self, payload: bytes): + self.payload = payload + + def iter_chunks(self, chunk_size=65536): + for i in range(0, len(self.payload), chunk_size): + yield self.payload[i : i + chunk_size] + + +class _FakeS3Storage: + def __init__(self): + self.objects = {} + + def get_object(self, key: str) -> dict: + obj = self.objects.get(key) + if obj is None: + raise ClientError({"Error": {"Code": "404", "Message": "Not Found"}}, "GetObject") + return {"Body": _FakeBody(obj["content"]), "ContentType": obj["mime"], "ContentLength": obj["size"]} + + +class AttachmentScanTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.engine = create_engine( + "sqlite+pysqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + cls.SessionLocal = sessionmaker(bind=cls.engine, autocommit=False, autoflush=False) + Request.__table__.create(bind=cls.engine) + Attachment.__table__.create(bind=cls.engine) + SecurityAuditLog.__table__.create(bind=cls.engine) + cls._orig_session_local = db_session.SessionLocal + cls._orig_scan_session_local = attachment_scan_module.SessionLocal + db_session.SessionLocal = cls.SessionLocal + attachment_scan_module.SessionLocal = cls.SessionLocal + + @classmethod + def tearDownClass(cls): + db_session.SessionLocal = cls._orig_session_local + attachment_scan_module.SessionLocal = cls._orig_scan_session_local + SecurityAuditLog.__table__.drop(bind=cls.engine) + Attachment.__table__.drop(bind=cls.engine) + Request.__table__.drop(bind=cls.engine) + cls.engine.dispose() + + def setUp(self): + with self.SessionLocal() as db: + db.execute(delete(SecurityAuditLog)) + db.execute(delete(Attachment)) + db.execute(delete(Request)) + db.commit() + + def test_scan_marks_clean_for_valid_pdf_when_clamav_disabled(self): + fake_s3 = _FakeS3Storage() + with self.SessionLocal() as db: + req = Request( + track_number="TRK-SCAN-001", + client_name="Клиент", + client_phone="+79990000001", + topic_code="consulting", + status_code="NEW", + extra_fields={}, + ) + db.add(req) + db.flush() + key = f"requests/{req.id}/contract.pdf" + att = Attachment( + request_id=req.id, + file_name="contract.pdf", + mime_type="application/pdf", + size_bytes=64, + s3_key=key, + ) + db.add(att) + db.commit() + attachment_id = str(att.id) + + fake_s3.objects[key] = {"size": 64, "mime": "application/pdf", "content": b"%PDF-1.4\nhello"} + with ( + patch("app.services.attachment_scan.get_s3_storage", return_value=fake_s3), + patch("app.services.attachment_scan.settings.CLAMAV_ENABLED", False), + ): + result = scan_attachment_file_impl(attachment_id) + + self.assertEqual(result.get("status"), SCAN_STATUS_CLEAN) + with self.SessionLocal() as db: + row = db.get(Attachment, UUID(attachment_id)) + self.assertEqual(row.scan_status, SCAN_STATUS_CLEAN) + self.assertTrue(bool(row.content_sha256)) + self.assertEqual(row.detected_mime, "application/pdf") + + def test_scan_marks_infected_for_content_policy_mismatch(self): + fake_s3 = _FakeS3Storage() + with self.SessionLocal() as db: + req = Request( + track_number="TRK-SCAN-002", + client_name="Клиент", + client_phone="+79990000002", + topic_code="consulting", + status_code="NEW", + extra_fields={}, + ) + db.add(req) + db.flush() + key = f"requests/{req.id}/wrong.pdf" + att = Attachment( + request_id=req.id, + file_name="wrong.pdf", + mime_type="application/pdf", + size_bytes=64, + s3_key=key, + ) + db.add(att) + db.commit() + attachment_id = str(att.id) + + fake_s3.objects[key] = {"size": 64, "mime": "application/pdf", "content": b"\x89PNG\r\n\x1a\nbad"} + with ( + patch("app.services.attachment_scan.get_s3_storage", return_value=fake_s3), + patch("app.services.attachment_scan.settings.CLAMAV_ENABLED", False), + ): + result = scan_attachment_file_impl(attachment_id) + + self.assertEqual(result.get("status"), SCAN_STATUS_INFECTED) + with self.SessionLocal() as db: + row = db.get(Attachment, UUID(attachment_id)) + self.assertEqual(row.scan_status, SCAN_STATUS_INFECTED) + self.assertEqual(row.scan_signature, "CONTENT_POLICY") diff --git a/tests/test_email_service.py b/tests/test_email_service.py new file mode 100644 index 0000000..4270c1d --- /dev/null +++ b/tests/test_email_service.py @@ -0,0 +1,62 @@ +import os +import unittest +from unittest.mock import Mock, patch + +os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") +os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") +os.environ.setdefault("S3_ENDPOINT", "http://localhost:9000") +os.environ.setdefault("S3_ACCESS_KEY", "test") +os.environ.setdefault("S3_SECRET_KEY", "test") +os.environ.setdefault("S3_BUCKET", "test") + +from app.core.config import settings +from app.services.email_service import EmailDeliveryError, send_otp_email_message + + +class EmailServiceTests(unittest.TestCase): + def setUp(self): + self._backup = { + "EMAIL_PROVIDER": settings.EMAIL_PROVIDER, + "EMAIL_SERVICE_URL": settings.EMAIL_SERVICE_URL, + "INTERNAL_SERVICE_TOKEN": settings.INTERNAL_SERVICE_TOKEN, + "OTP_DEV_MODE": settings.OTP_DEV_MODE, + } + + def tearDown(self): + for key, value in self._backup.items(): + setattr(settings, key, value) + + def test_dev_mode_forces_mock_send(self): + settings.EMAIL_PROVIDER = "smtp" + settings.OTP_DEV_MODE = True + payload = send_otp_email_message(email="user@example.com", code="123456", purpose="CREATE_REQUEST") + self.assertEqual(payload.get("provider"), "mock_email") + self.assertTrue(bool(payload.get("dev_mode"))) + self.assertEqual(payload.get("debug_code"), "123456") + + def test_service_provider_calls_internal_email_service(self): + settings.OTP_DEV_MODE = False + settings.EMAIL_PROVIDER = "service" + settings.EMAIL_SERVICE_URL = "http://email-service:8010" + settings.INTERNAL_SERVICE_TOKEN = "token" + + mock_response = Mock() + mock_response.status_code = 200 + mock_response.content = b'{"status":"sent"}' + mock_response.json.return_value = {"status": "sent"} + + mock_client = Mock() + mock_client.__enter__ = Mock(return_value=mock_client) + mock_client.__exit__ = Mock(return_value=False) + mock_client.post.return_value = mock_response + + with patch("app.services.email_service.httpx.Client", return_value=mock_client): + payload = send_otp_email_message(email="user@example.com", code="654321", purpose="VIEW_REQUEST") + self.assertEqual(payload.get("provider"), "email-service") + self.assertTrue(bool(payload.get("sent"))) + + def test_unknown_provider_raises(self): + settings.OTP_DEV_MODE = False + settings.EMAIL_PROVIDER = "unknown" + with self.assertRaises(EmailDeliveryError): + send_otp_email_message(email="user@example.com", code="111111", purpose="CREATE_REQUEST") diff --git a/tests/test_migrations.py b/tests/test_migrations.py index 49cfc1b..88c221a 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -113,7 +113,7 @@ class MigrationTests(unittest.TestCase): def test_alembic_version_is_set(self): with self.engine.connect() as conn: version = conn.execute(text("SELECT version_num FROM alembic_version")).scalar_one() - self.assertEqual(version, "0026_srv_req_str_ids") + self.assertEqual(version, "0030_attachment_scan") def test_responsible_column_exists_in_all_domain_tables(self): tables = { @@ -185,10 +185,14 @@ class MigrationTests(unittest.TestCase): self.assertIn("default_rate", columns) self.assertIn("salary_percent", columns) self.assertIn("phone", columns) + self.assertIn("totp_enabled", columns) + self.assertIn("totp_secret_encrypted", columns) + self.assertIn("totp_backup_codes_hashes", columns) def test_requests_contains_financial_columns(self): columns = {column["name"] for column in self.inspector.get_columns("requests")} self.assertIn("client_id", columns) + self.assertIn("client_email", columns) self.assertIn("important_date_at", columns) self.assertIn("effective_rate", columns) self.assertIn("request_cost", columns) @@ -234,9 +238,15 @@ class MigrationTests(unittest.TestCase): self.assertIn("id", columns) self.assertIn("full_name", columns) self.assertIn("phone", columns) + self.assertIn("email", columns) self.assertIn("created_at", columns) self.assertIn("responsible", columns) + def test_otp_sessions_contains_channel_and_email_columns(self): + columns = {column["name"] for column in self.inspector.get_columns("otp_sessions")} + self.assertIn("channel", columns) + self.assertIn("email", columns) + def test_topic_data_templates_contains_request_data_catalog_fields(self): columns = {column["name"] for column in self.inspector.get_columns("topic_data_templates")} self.assertIn("value_type", columns) @@ -278,6 +288,15 @@ class MigrationTests(unittest.TestCase): self.assertIn("admin_read_at", columns) self.assertIn("lawyer_read_at", columns) + def test_attachments_contains_antivirus_scan_columns(self): + columns = {column["name"] for column in self.inspector.get_columns("attachments")} + self.assertIn("scan_status", columns) + self.assertIn("scan_signature", columns) + self.assertIn("scan_error", columns) + self.assertIn("scanned_at", columns) + self.assertIn("content_sha256", columns) + self.assertIn("detected_mime", columns) + def test_landing_featured_staff_contains_core_columns(self): columns = {column["name"] for column in self.inspector.get_columns("landing_featured_staff")} self.assertIn("admin_user_id", columns) diff --git a/tests/test_public_requests.py b/tests/test_public_requests.py index fe764cf..95c2557 100644 --- a/tests/test_public_requests.py +++ b/tests/test_public_requests.py @@ -78,10 +78,19 @@ class PublicRequestCreateTests(unittest.TestCase): app.dependency_overrides[get_db] = override_get_db self.client = TestClient(app) + self._otp_limits_backup = ( + settings.OTP_SEND_RATE_LIMIT, + settings.OTP_VERIFY_RATE_LIMIT, + settings.OTP_RATE_LIMIT_WINDOW_SECONDS, + ) + settings.OTP_SEND_RATE_LIMIT = 10_000 + settings.OTP_VERIFY_RATE_LIMIT = 10_000 + settings.OTP_RATE_LIMIT_WINDOW_SECONDS = 1 def tearDown(self): self.client.close() app.dependency_overrides.clear() + settings.OTP_SEND_RATE_LIMIT, settings.OTP_VERIFY_RATE_LIMIT, settings.OTP_RATE_LIMIT_WINDOW_SECONDS = self._otp_limits_backup @staticmethod def _unique_phone() -> str: @@ -259,6 +268,108 @@ class PublicRequestCreateTests(unittest.TestCase): denied = self.client.get("/api/public/requests/TRK-FOREIGN-1") self.assertEqual(denied.status_code, 403) + def test_email_auth_mode_allows_create_flow_via_email_otp(self): + phone = self._unique_phone() + email = "client.email.mode@example.com" + with ( + patch("app.api.public.otp.settings.PUBLIC_AUTH_MODE", "email"), + patch("app.api.public.otp.settings.EMAIL_PROVIDER", "dummy"), + patch("app.api.public.otp._generate_code", return_value="112233"), + ): + sent = self.client.post( + "/api/public/otp/send", + json={"purpose": "CREATE_REQUEST", "client_email": email}, + ) + self.assertEqual(sent.status_code, 200) + self.assertEqual(sent.json()["channel"], "EMAIL") + + verified = self.client.post( + "/api/public/otp/verify", + json={"purpose": "CREATE_REQUEST", "client_email": email, "code": "112233"}, + ) + self.assertEqual(verified.status_code, 200) + self.assertEqual(verified.json()["channel"], "EMAIL") + + create = self.client.post( + "/api/public/requests", + json={ + "client_name": "Email Client", + "client_phone": phone, + "client_email": email, + "topic_code": "consulting", + "description": "Email auth mode create", + "extra_fields": {}, + }, + ) + self.assertEqual(create.status_code, 201) + body = create.json() + with self.SessionLocal() as db: + req = db.query(Request).filter(Request.track_number == body["track_number"]).first() + self.assertIsNotNone(req) + self.assertEqual(req.client_email, email) + + def test_view_otp_email_channel_by_track(self): + track_number = f"TRK-EMAIL-{uuid4().hex[:8].upper()}" + email = "view.track.email@example.com" + with self.SessionLocal() as db: + row = Request( + track_number=track_number, + client_name="Клиент Email", + client_phone=self._unique_phone(), + client_email=email, + topic_code="consulting", + status_code="NEW", + description="Проверка просмотра по email", + extra_fields={}, + ) + db.add(row) + db.commit() + + with ( + patch("app.api.public.otp.settings.PUBLIC_AUTH_MODE", "sms_or_email"), + patch("app.api.public.otp.settings.EMAIL_PROVIDER", "dummy"), + patch("app.api.public.otp._generate_code", return_value="445566"), + ): + sent = self.client.post( + "/api/public/otp/send", + json={"purpose": "VIEW_REQUEST", "track_number": track_number, "channel": "email"}, + ) + self.assertEqual(sent.status_code, 200) + self.assertEqual(sent.json()["channel"], "EMAIL") + + verified = self.client.post( + "/api/public/otp/verify", + json={"purpose": "VIEW_REQUEST", "track_number": track_number, "channel": "email", "code": "445566"}, + ) + self.assertEqual(verified.status_code, 200) + self.assertEqual(verified.json()["channel"], "EMAIL") + + ok = self.client.get(f"/api/public/requests/{track_number}") + self.assertEqual(ok.status_code, 200) + + def test_send_otp_falls_back_to_email_when_sms_balance_low(self): + with ( + patch("app.api.public.otp.settings.PUBLIC_AUTH_MODE", "sms_or_email"), + patch("app.api.public.otp.settings.OTP_EMAIL_FALLBACK_ENABLED", True), + patch("app.api.public.otp.settings.OTP_SMS_MIN_BALANCE", 100.0), + patch("app.api.public.otp.sms_provider_health", return_value={"mode": "real", "balance_amount": 0.0}), + patch("app.api.public.otp.send_otp_email_message", return_value={"provider": "mock_email", "debug_code": "778899"}), + patch("app.api.public.otp._generate_code", return_value="778899"), + ): + sent = self.client.post( + "/api/public/otp/send", + json={ + "purpose": "CREATE_REQUEST", + "client_phone": "+79991112233", + "client_email": "fallback@example.com", + "channel": "sms", + }, + ) + self.assertEqual(sent.status_code, 200) + body = sent.json() + self.assertEqual(body.get("channel"), "EMAIL") + self.assertEqual(body.get("fallback_reason"), "low_sms_balance") + def test_open_request_marks_client_updates_as_read(self): with self.SessionLocal() as db: row = Request( diff --git a/tests/test_sms_service.py b/tests/test_sms_service.py index 7aa225b..accfb12 100644 --- a/tests/test_sms_service.py +++ b/tests/test_sms_service.py @@ -1,5 +1,6 @@ import os import unittest +from unittest.mock import patch os.environ.setdefault("DATABASE_URL", "sqlite+pysqlite:///:memory:") os.environ.setdefault("REDIS_URL", "redis://localhost:6379/0") @@ -19,6 +20,7 @@ class SmsServiceTests(unittest.TestCase): "SMSAERO_EMAIL": settings.SMSAERO_EMAIL, "SMSAERO_API_KEY": settings.SMSAERO_API_KEY, "OTP_DEV_MODE": settings.OTP_DEV_MODE, + "OTP_AUTOTEST_FORCE_MOCK_SMS": settings.OTP_AUTOTEST_FORCE_MOCK_SMS, } def tearDown(self): @@ -40,3 +42,19 @@ class SmsServiceTests(unittest.TestCase): settings.OTP_DEV_MODE = False with self.assertRaises(SmsDeliveryError): send_otp_message(phone="+79990000000", code="111111", purpose="CREATE_REQUEST") + + def test_autotest_context_forces_mock_for_real_provider(self): + settings.SMS_PROVIDER = "smsaero" + settings.SMSAERO_EMAIL = "prod@example.com" + settings.SMSAERO_API_KEY = "real-key" + settings.OTP_DEV_MODE = False + settings.OTP_AUTOTEST_FORCE_MOCK_SMS = True + with ( + patch("app.services.sms_service._is_automated_test_context", return_value=True), + patch("app.services.sms_service._send_sms_aero") as send_real, + ): + payload = send_otp_message(phone="+79990000000", code="222222", purpose="CREATE_REQUEST") + send_real.assert_not_called() + self.assertEqual(payload.get("provider"), "mock_sms") + self.assertTrue(bool(payload.get("autotest_forced_mock"))) + self.assertEqual(payload.get("debug_code"), "222222") diff --git a/tests/test_uploads_s3.py b/tests/test_uploads_s3.py index dcdd548..134ca4f 100644 --- a/tests/test_uploads_s3.py +++ b/tests/test_uploads_s3.py @@ -270,6 +270,46 @@ class UploadsS3Tests(unittest.TestCase): self.assertIn("application/pdf", response.headers.get("content-type", "")) self.assertIn("inline;", response.headers.get("content-disposition", "")) + def test_public_attachment_object_is_blocked_while_scan_pending(self): + fake_s3 = _FakeS3Storage() + with self.SessionLocal() as db: + req = Request( + track_number="TRK-PUB-SCAN-PENDING", + client_name="Клиент", + client_phone="+79995551122", + topic_code="civil-law", + status_code="IN_PROGRESS", + extra_fields={}, + ) + db.add(req) + db.flush() + key = f"requests/{req.id}/pending.pdf" + attachment = Attachment( + request_id=req.id, + file_name="pending.pdf", + mime_type="application/pdf", + size_bytes=1280, + s3_key=key, + scan_status="PENDING", + ) + db.add(attachment) + db.commit() + attachment_id = str(attachment.id) + track = req.track_number + + fake_s3.objects[key] = {"size": 1280, "mime": "application/pdf", "content": b"pdf-preview"} + public_token = create_jwt({"sub": track, "purpose": "VIEW_REQUEST"}, settings.PUBLIC_JWT_SECRET, timedelta(days=1)) + cookies = {settings.PUBLIC_COOKIE_NAME: public_token} + + with ( + patch("app.api.public.uploads.get_s3_storage", return_value=fake_s3), + patch("app.services.attachment_scan.settings.ATTACHMENT_SCAN_ENFORCE", True), + ): + response = self.client.get(f"/api/public/uploads/object/{attachment_id}", cookies=cookies) + + self.assertEqual(response.status_code, 423) + self.assertIn("проверяется", str(response.json().get("detail", "")).lower()) + def test_admin_request_attachment_upload_sets_client_unread_marker(self): fake_s3 = _FakeS3Storage() with self.SessionLocal() as db: @@ -681,3 +721,52 @@ class UploadsS3Tests(unittest.TestCase): with patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3): response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}") self.assertEqual(response.status_code, 403) + + def test_admin_object_proxy_blocks_infected_attachment_when_scan_enforced(self): + fake_s3 = _FakeS3Storage() + with self.SessionLocal() as db: + admin = AdminUser( + role="ADMIN", + name="Админ AV", + email="admin-av@example.com", + password_hash="hash", + is_active=True, + ) + db.add(admin) + db.flush() + req = Request( + track_number="TRK-ADM-SCAN-INF", + client_name="Клиент", + client_phone="+79990007777", + topic_code="civil-law", + status_code="IN_PROGRESS", + extra_fields={}, + total_attachments_bytes=0, + ) + db.add(req) + db.flush() + key = f"requests/{req.id}/infected.pdf" + att = Attachment( + request_id=req.id, + file_name="infected.pdf", + mime_type="application/pdf", + size_bytes=1024, + s3_key=key, + scan_status="INFECTED", + scan_signature="Eicar-Test-Signature", + ) + db.add(att) + db.commit() + admin_id = str(admin.id) + + token = self._admin_headers(sub=admin_id, role="ADMIN", email="admin-av@example.com")["Authorization"].replace( + "Bearer ", "" + ) + fake_s3.objects[key] = {"size": 1024, "mime": "application/pdf", "content": b"x" * 1024} + with ( + patch("app.api.admin.uploads.get_s3_storage", return_value=fake_s3), + patch("app.services.attachment_scan.settings.ATTACHMENT_SCAN_ENFORCE", True), + ): + response = self.client.get(f"/api/admin/uploads/object/{key}?token={token}") + self.assertEqual(response.status_code, 403) + self.assertIn("антивирус", str(response.json().get("detail", "")).lower())