add cert 2.5

This commit is contained in:
TronoSfera 2026-03-01 17:31:09 +03:00
parent cf7656399b
commit a06f553406
44 changed files with 2470 additions and 80 deletions

View file

@ -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

View file

@ -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:<password>@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=<strong-random-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):

View file

@ -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")

View file

@ -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")

View file

@ -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")

View file

@ -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"}

View file

@ -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()

View file

@ -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)

View file

@ -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 = ""
if purpose == OTP_CREATE_PURPOSE:
phone = _normalize_phone(payload.client_phone)
if not phone:
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
email = _normalize_email(payload.client_email)
channel = _resolve_channel(payload.channel, client_phone=phone, client_email=email)
if purpose == OTP_CREATE_PURPOSE:
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()
effective_channel = channel
fallback_reason: str | None = None
if channel == CHANNEL_SMS and _email_fallback_allowed(email):
try:
sms_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number)
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}

View file

@ -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)

View file

@ -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:

View file

@ -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

34
app/email_main.py Normal file
View file

@ -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}

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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))

View file

@ -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}"],
}

View file

@ -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"}:

View file

@ -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()

View file

@ -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)}
/>
</div>
<div className="field">
<label htmlFor="login-totp">TOTP / резервный код</label>
<input
id="login-totp"
type="text"
placeholder="123456 или backup-code"
value={totpCode}
onChange={(event) => setTotpCode(event.target.value)}
/>
</div>
<button className="btn" type="submit">
Войти
</button>
@ -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__";
Пользователь: <b>{email}</b>
<br />
Роль: <b>{roleLabel(role)}</b>
<br />
2FA: <b>{totpStatus.enabled ? "Включена" : "Выключена"}</b>
</>
) : (
"Не авторизован"
)}
</div>
{token && role ? (
<div style={{ marginTop: "0.5rem", display: "flex", gap: "0.4rem", flexWrap: "wrap" }}>
<button className="btn secondary" type="button" onClick={setupTotp}>
Настроить 2FA
</button>
{totpStatus.enabled ? (
<>
<button className="btn secondary" type="button" onClick={regenerateTotpBackupCodes}>
Backup-коды
</button>
<button className="btn danger" type="button" onClick={disableTotp}>
Отключить 2FA
</button>
</>
) : null}
</div>
) : null}
<div style={{ marginTop: "0.75rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
<button className="btn secondary" type="button" onClick={refreshAll}>
Обновить

View file

@ -191,6 +191,10 @@
<label for="phone">Телефон</label>
<input id="phone" name="phone" type="tel" required placeholder="+7 (900) 000-00-00">
</div>
<div class="field">
<label for="email">Email (для входа без SMS)</label>
<input id="email" name="email" type="email" placeholder="name@example.com">
</div>
<div class="field full">
<label for="topic">Тема обращения</label>
<select id="topic" name="topic" required>
@ -214,15 +218,19 @@
<div class="modal-head">
<div>
<h3 id="access-title">Вход в страницу заявок</h3>
<p>Введите номер телефона, получите OTP и перейдите к своим заявкам.</p>
<p>Введите телефон или email (в зависимости от режима), получите OTP и перейдите к своим заявкам.</p>
</div>
<button class="close" type="button" data-close-access aria-label="Закрыть">×</button>
</div>
<form id="access-form" class="form">
<div class="field full">
<div class="field">
<label for="access-phone">Телефон</label>
<input id="access-phone" name="access-phone" type="tel" required placeholder="+7 (900) 000-00-00">
</div>
<div class="field">
<label for="access-email">Email</label>
<input id="access-email" name="access-email" type="email" placeholder="name@example.com">
</div>
<div class="field full">
<label for="access-code">Одноразовый пароль (OTP)</label>
<input id="access-code" name="access-code" type="text" inputmode="numeric" pattern="[0-9]{4,8}" placeholder="Введите код из SMS">

View file

@ -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();

View file

@ -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},

View file

@ -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` |

View file

@ -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'
```

View file

@ -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.

View file

@ -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/ {

View file

@ -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;

View file

@ -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:

View file

@ -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;
}
}

View file

@ -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"

View file

@ -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"

View file

@ -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")))

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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(

View file

@ -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")

View file

@ -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())