mirror of
https://github.com/TronoSfera/Law.git
synced 2026-05-18 10:03:45 +03:00
add cert 2.5
This commit is contained in:
parent
cf7656399b
commit
a06f553406
44 changed files with 2470 additions and 80 deletions
11
Makefile
11
Makefile
|
|
@ -8,7 +8,10 @@
|
|||
|
||||
DOMAIN ?= ruakb.ru
|
||||
WWW_DOMAIN ?= www.ruakb.ru
|
||||
SECOND_DOMAIN ?= ruakb.online
|
||||
SECOND_WWW_DOMAIN ?= www.ruakb.online
|
||||
LETSENCRYPT_EMAIL ?= admin@ruakb.ru
|
||||
CERTBOT_DOMAINS = -d "$(DOMAIN)" -d "$(WWW_DOMAIN)" $(if $(strip $(SECOND_DOMAIN)),-d "$(SECOND_DOMAIN)") $(if $(strip $(SECOND_WWW_DOMAIN)),-d "$(SECOND_WWW_DOMAIN)")
|
||||
|
||||
LOCAL_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.local.yml
|
||||
PROD_COMPOSE = docker compose -f docker-compose.yml -f docker-compose.prod.nginx.yml
|
||||
|
|
@ -29,6 +32,12 @@ help:
|
|||
@echo " prod-migrate - Apply migrations (prod)"
|
||||
@echo " prod-cert-init - Initial Let's Encrypt issue (nginx only 80 during bootstrap)"
|
||||
@echo " prod-cert-renew - Renew existing certificates"
|
||||
@echo ""
|
||||
@echo "Domains:"
|
||||
@echo " DOMAIN=$(DOMAIN)"
|
||||
@echo " WWW_DOMAIN=$(WWW_DOMAIN)"
|
||||
@echo " SECOND_DOMAIN=$(SECOND_DOMAIN)"
|
||||
@echo " SECOND_WWW_DOMAIN=$(SECOND_WWW_DOMAIN)"
|
||||
|
||||
local-up:
|
||||
$(LOCAL_COMPOSE) up -d --build
|
||||
|
|
@ -78,7 +87,7 @@ prod-migrate: check-prod-files
|
|||
# 3) Restart stack in regular prod mode (80/443).
|
||||
prod-cert-init: check-cert-files
|
||||
$(CERT_COMPOSE) up -d --build db redis minio backend chat-service worker beat frontend edge
|
||||
$(CERT_COMPOSE) run --rm certbot certonly --webroot -w /var/www/certbot --email "$(LETSENCRYPT_EMAIL)" --agree-tos --no-eff-email -d "$(DOMAIN)" -d "$(WWW_DOMAIN)"
|
||||
$(CERT_COMPOSE) run --rm certbot certonly --webroot -w /var/www/certbot --email "$(LETSENCRYPT_EMAIL)" --agree-tos --no-eff-email --non-interactive --expand $(CERTBOT_DOMAINS)
|
||||
$(PROD_COMPOSE) up -d --build edge
|
||||
$(PROD_COMPOSE) exec -T backend alembic upgrade head
|
||||
|
||||
|
|
|
|||
84
README.md
84
README.md
|
|
@ -11,13 +11,16 @@ Admin UI: http://localhost:8081/admin
|
|||
API (backend): http://localhost:8002
|
||||
Swagger: http://localhost:8002/docs
|
||||
Chat service health (via nginx): http://localhost:8081/chat-health
|
||||
Email service health (via nginx): http://localhost:8081/email-health
|
||||
|
||||
## Production (ruakb.ru, 80/443, TLS via Nginx + Certbot)
|
||||
## Production (ruakb.ru + ruakb.online, 80/443, TLS via Nginx + Certbot)
|
||||
Production stack uses dedicated edge nginx (`docker-compose.prod.nginx.yml`).
|
||||
|
||||
Prerequisites:
|
||||
- DNS `A` record: `ruakb.ru -> 45.150.36.116`
|
||||
- Optional DNS `A` record: `www.ruakb.ru -> 45.150.36.116`
|
||||
- DNS `A` record: `ruakb.online -> 45.150.36.116`
|
||||
- Optional DNS `A` record: `www.ruakb.online -> 45.150.36.116`
|
||||
- Open server ports: `80/tcp`, `443/tcp`
|
||||
- DB credentials in `.env` must be consistent:
|
||||
- `DATABASE_URL=postgresql+psycopg://postgres:<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):
|
||||
|
|
|
|||
44
alembic/versions/0028_auth_mode_email_support.py
Normal file
44
alembic/versions/0028_auth_mode_email_support.py
Normal 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")
|
||||
34
alembic/versions/0029_admin_totp_fields.py
Normal file
34
alembic/versions/0029_admin_totp_fields.py
Normal 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")
|
||||
43
alembic/versions/0030_attachment_scan_status.py
Normal file
43
alembic/versions/0030_attachment_scan_status.py
Normal 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")
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -13,8 +13,9 @@ from app.db.session import get_db
|
|||
from app.models.otp_session import OtpSession
|
||||
from app.models.request import Request as RequestModel
|
||||
from app.schemas.public import OtpSend, OtpVerify
|
||||
from app.services.email_service import EmailDeliveryError, send_otp_email_message
|
||||
from app.services.rate_limit import get_rate_limiter
|
||||
from app.services.sms_service import SmsDeliveryError, send_otp_message
|
||||
from app.services.sms_service import SmsDeliveryError, send_otp_message, sms_provider_health
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -23,6 +24,14 @@ OTP_MAX_ATTEMPTS = 5
|
|||
OTP_CREATE_PURPOSE = "CREATE_REQUEST"
|
||||
OTP_VIEW_PURPOSE = "VIEW_REQUEST"
|
||||
ALLOWED_PURPOSES = {OTP_CREATE_PURPOSE, OTP_VIEW_PURPOSE}
|
||||
CHANNEL_SMS = "SMS"
|
||||
CHANNEL_EMAIL = "EMAIL"
|
||||
SUPPORTED_CHANNELS = {CHANNEL_SMS, CHANNEL_EMAIL}
|
||||
AUTH_MODE_SMS = "sms"
|
||||
AUTH_MODE_EMAIL = "email"
|
||||
AUTH_MODE_SMS_OR_EMAIL = "sms_or_email"
|
||||
AUTH_MODE_TOTP = "totp"
|
||||
SUPPORTED_AUTH_MODES = {AUTH_MODE_SMS, AUTH_MODE_EMAIL, AUTH_MODE_SMS_OR_EMAIL, AUTH_MODE_TOTP}
|
||||
|
||||
|
||||
def _now_utc() -> datetime:
|
||||
|
|
@ -50,10 +59,68 @@ def _normalize_phone(raw: str | None) -> str:
|
|||
return "".join(digits).strip()
|
||||
|
||||
|
||||
def _normalize_email(raw: str | None) -> str:
|
||||
return str(raw or "").strip().lower()
|
||||
|
||||
|
||||
def _normalize_track(raw: str | None) -> str:
|
||||
return str(raw or "").strip().upper()
|
||||
|
||||
|
||||
def _normalize_channel(raw: str | None) -> str:
|
||||
value = str(raw or "").strip().upper()
|
||||
if value in {"SMS", "PHONE"}:
|
||||
return CHANNEL_SMS
|
||||
if value in {"EMAIL", "MAIL"}:
|
||||
return CHANNEL_EMAIL
|
||||
return ""
|
||||
|
||||
|
||||
def _auth_mode() -> str:
|
||||
mode = str(getattr(settings, "PUBLIC_AUTH_MODE", AUTH_MODE_SMS) or "").strip().lower()
|
||||
if mode not in SUPPORTED_AUTH_MODES:
|
||||
return AUTH_MODE_SMS
|
||||
return mode
|
||||
|
||||
|
||||
def _resolve_channel(requested_channel: str | None, *, client_phone: str, client_email: str) -> str:
|
||||
explicit = _normalize_channel(requested_channel)
|
||||
mode = _auth_mode()
|
||||
if mode == AUTH_MODE_TOTP:
|
||||
raise HTTPException(status_code=501, detail="Режим TOTP еще не реализован")
|
||||
if mode == AUTH_MODE_SMS:
|
||||
if explicit and explicit != CHANNEL_SMS:
|
||||
raise HTTPException(status_code=400, detail="Разрешен только SMS-канал")
|
||||
return CHANNEL_SMS
|
||||
if mode == AUTH_MODE_EMAIL:
|
||||
if explicit and explicit != CHANNEL_EMAIL:
|
||||
raise HTTPException(status_code=400, detail="Разрешен только Email-канал")
|
||||
return CHANNEL_EMAIL
|
||||
# sms_or_email
|
||||
if explicit:
|
||||
return explicit
|
||||
if client_email and not client_phone:
|
||||
return CHANNEL_EMAIL
|
||||
return CHANNEL_SMS
|
||||
|
||||
|
||||
def _email_fallback_allowed(email: str | None) -> bool:
|
||||
return bool(str(email or "").strip()) and bool(getattr(settings, "OTP_EMAIL_FALLBACK_ENABLED", True))
|
||||
|
||||
|
||||
def _sms_balance_low() -> bool:
|
||||
health = sms_provider_health()
|
||||
if str(health.get("mode") or "").lower() != "real":
|
||||
return False
|
||||
amount = health.get("balance_amount")
|
||||
try:
|
||||
balance = float(amount)
|
||||
except Exception:
|
||||
return False
|
||||
threshold = float(getattr(settings, "OTP_SMS_MIN_BALANCE", 20.0))
|
||||
return balance < threshold
|
||||
|
||||
|
||||
def _generate_code() -> str:
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
|
@ -75,7 +142,15 @@ def _hash_key_part(value: str | None) -> str:
|
|||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:20]
|
||||
|
||||
|
||||
def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str | None, track_number: str | None) -> None:
|
||||
def _rate_limit_or_429(
|
||||
action: str,
|
||||
*,
|
||||
purpose: str,
|
||||
client_ip: str,
|
||||
phone: str | None,
|
||||
email: str | None,
|
||||
track_number: str | None,
|
||||
) -> None:
|
||||
limiter = get_rate_limiter()
|
||||
window = int(max(settings.OTP_RATE_LIMIT_WINDOW_SECONDS, 1))
|
||||
limit = int(max(settings.OTP_SEND_RATE_LIMIT if action == "send" else settings.OTP_VERIFY_RATE_LIMIT, 1))
|
||||
|
|
@ -85,6 +160,8 @@ def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str
|
|||
]
|
||||
if phone:
|
||||
keys.append(f"otp:{action}:phone:{_hash_key_part(phone)}:purpose:{purpose_norm}")
|
||||
if email:
|
||||
keys.append(f"otp:{action}:email:{_hash_key_part(email)}:purpose:{purpose_norm}")
|
||||
if track_number:
|
||||
keys.append(f"otp:{action}:track:{_hash_key_part(track_number)}:purpose:{purpose_norm}")
|
||||
|
||||
|
|
@ -97,9 +174,9 @@ def _rate_limit_or_429(action: str, *, purpose: str, client_ip: str, phone: str
|
|||
)
|
||||
|
||||
|
||||
def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> None:
|
||||
def _set_public_cookie(response: Response, *, subject: str, purpose: str, auth_channel: str) -> None:
|
||||
token = create_jwt(
|
||||
{"sub": subject, "purpose": purpose},
|
||||
{"sub": subject, "purpose": purpose, "auth_channel": auth_channel},
|
||||
settings.PUBLIC_JWT_SECRET,
|
||||
timedelta(days=settings.PUBLIC_JWT_TTL_DAYS),
|
||||
)
|
||||
|
|
@ -113,6 +190,25 @@ def _set_public_cookie(response: Response, *, subject: str, purpose: str) -> Non
|
|||
)
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
def get_auth_config():
|
||||
mode = _auth_mode()
|
||||
available_channels: list[str] = [CHANNEL_SMS]
|
||||
if mode == AUTH_MODE_EMAIL:
|
||||
available_channels = [CHANNEL_EMAIL]
|
||||
elif mode == AUTH_MODE_SMS_OR_EMAIL:
|
||||
available_channels = [CHANNEL_SMS, CHANNEL_EMAIL]
|
||||
elif mode == AUTH_MODE_TOTP:
|
||||
available_channels = []
|
||||
return {
|
||||
"public_auth_mode": mode,
|
||||
"available_channels": available_channels,
|
||||
"totp_implemented": False,
|
||||
"email_provider": str(getattr(settings, "EMAIL_PROVIDER", "dummy") or "dummy").strip().lower(),
|
||||
"sms_provider": str(getattr(settings, "SMS_PROVIDER", "dummy") or "dummy").strip().lower(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
||||
purpose = _normalize_purpose(payload.purpose)
|
||||
|
|
@ -120,56 +216,111 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
|||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||
|
||||
track_number: str | None = None
|
||||
phone = ""
|
||||
phone = _normalize_phone(payload.client_phone)
|
||||
email = _normalize_email(payload.client_email)
|
||||
channel = _resolve_channel(payload.channel, client_phone=phone, client_email=email)
|
||||
|
||||
if purpose == OTP_CREATE_PURPOSE:
|
||||
phone = _normalize_phone(payload.client_phone)
|
||||
if not phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||
if channel == CHANNEL_SMS and not phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для SMS OTP')
|
||||
if channel == CHANNEL_EMAIL and not email:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_email" обязательно для Email OTP')
|
||||
else:
|
||||
track_number = _normalize_track(payload.track_number)
|
||||
phone = _normalize_phone(payload.client_phone)
|
||||
if track_number:
|
||||
request_row = db.query(RequestModel).filter(RequestModel.track_number == track_number).first()
|
||||
if request_row is None:
|
||||
raise HTTPException(status_code=404, detail="Заявка не найдена")
|
||||
phone = _normalize_phone(request_row.client_phone)
|
||||
elif phone:
|
||||
email = _normalize_email(request_row.client_email)
|
||||
elif channel == CHANNEL_SMS and phone:
|
||||
has_requests = db.query(RequestModel.id).filter(RequestModel.client_phone == phone).first()
|
||||
if has_requests is None:
|
||||
raise HTTPException(status_code=404, detail="Заявки по номеру телефона не найдены")
|
||||
elif channel == CHANNEL_EMAIL and email:
|
||||
has_requests = (
|
||||
db.query(RequestModel.id)
|
||||
.filter(RequestModel.client_email.isnot(None), RequestModel.client_email == email)
|
||||
.first()
|
||||
)
|
||||
if has_requests is None:
|
||||
raise HTTPException(status_code=404, detail="Заявки по email не найдены")
|
||||
else:
|
||||
if channel == CHANNEL_EMAIL:
|
||||
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_email"')
|
||||
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
|
||||
if not phone:
|
||||
if channel == CHANNEL_SMS and not phone:
|
||||
raise HTTPException(status_code=400, detail="У заявки отсутствует номер телефона")
|
||||
if channel == CHANNEL_EMAIL and not email:
|
||||
raise HTTPException(status_code=400, detail="У заявки отсутствует email")
|
||||
|
||||
_rate_limit_or_429(
|
||||
"send",
|
||||
purpose=purpose,
|
||||
client_ip=_client_ip(request),
|
||||
phone=phone or None,
|
||||
email=email or None,
|
||||
track_number=track_number,
|
||||
)
|
||||
|
||||
code = _generate_code()
|
||||
try:
|
||||
sms_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||||
except SmsDeliveryError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP: {exc}") from exc
|
||||
effective_channel = channel
|
||||
fallback_reason: str | None = None
|
||||
if channel == CHANNEL_SMS and _email_fallback_allowed(email):
|
||||
try:
|
||||
if _sms_balance_low():
|
||||
effective_channel = CHANNEL_EMAIL
|
||||
fallback_reason = "low_sms_balance"
|
||||
except Exception:
|
||||
effective_channel = channel
|
||||
|
||||
if effective_channel == CHANNEL_EMAIL:
|
||||
try:
|
||||
delivery_response = send_otp_email_message(
|
||||
email=email,
|
||||
code=code,
|
||||
purpose=purpose,
|
||||
track_number=track_number,
|
||||
)
|
||||
except EmailDeliveryError as exc:
|
||||
raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP по email: {exc}") from exc
|
||||
else:
|
||||
try:
|
||||
delivery_response = send_otp_message(phone=phone, code=code, purpose=purpose, track_number=track_number)
|
||||
except SmsDeliveryError as exc:
|
||||
if _email_fallback_allowed(email):
|
||||
try:
|
||||
delivery_response = send_otp_email_message(
|
||||
email=email,
|
||||
code=code,
|
||||
purpose=purpose,
|
||||
track_number=track_number,
|
||||
)
|
||||
effective_channel = CHANNEL_EMAIL
|
||||
fallback_reason = "sms_send_failed"
|
||||
except EmailDeliveryError:
|
||||
pass
|
||||
if effective_channel == CHANNEL_SMS:
|
||||
raise HTTPException(status_code=502, detail=f"Не удалось отправить OTP: {exc}") from exc
|
||||
|
||||
now = _now_utc()
|
||||
expires_at = now + timedelta(minutes=OTP_TTL_MINUTES)
|
||||
|
||||
existing_query = db.query(OtpSession).filter(
|
||||
OtpSession.purpose == purpose,
|
||||
OtpSession.phone == phone,
|
||||
OtpSession.track_number == track_number,
|
||||
)
|
||||
existing_query = db.query(OtpSession).filter(OtpSession.purpose == purpose, OtpSession.channel == effective_channel)
|
||||
if track_number:
|
||||
existing_query = existing_query.filter(OtpSession.track_number == track_number)
|
||||
if effective_channel == CHANNEL_EMAIL:
|
||||
existing_query = existing_query.filter(OtpSession.email == email)
|
||||
else:
|
||||
existing_query = existing_query.filter(OtpSession.phone == phone)
|
||||
existing_query.delete(synchronize_session=False)
|
||||
|
||||
row = OtpSession(
|
||||
purpose=purpose,
|
||||
channel=effective_channel,
|
||||
track_number=track_number,
|
||||
phone=phone,
|
||||
phone=phone if effective_channel == CHANNEL_SMS else "",
|
||||
email=email if effective_channel == CHANNEL_EMAIL else None,
|
||||
code_hash=hash_password(code),
|
||||
attempts=0,
|
||||
expires_at=expires_at,
|
||||
|
|
@ -182,9 +333,12 @@ def send_otp(payload: OtpSend, request: Request, db: Session = Depends(get_db)):
|
|||
return {
|
||||
"status": "sent",
|
||||
"purpose": purpose,
|
||||
"channel": effective_channel,
|
||||
"track_number": track_number,
|
||||
"ttl_seconds": OTP_TTL_MINUTES * 60,
|
||||
"sms_response": sms_response,
|
||||
"delivery_response": delivery_response,
|
||||
"sms_response": delivery_response if effective_channel == CHANNEL_SMS else None,
|
||||
"fallback_reason": fallback_reason,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -195,29 +349,39 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
|
|||
raise HTTPException(status_code=400, detail="Некорректная цель OTP")
|
||||
|
||||
track_number: str | None = None
|
||||
phone: str | None = None
|
||||
phone: str = _normalize_phone(payload.client_phone)
|
||||
email: str = _normalize_email(payload.client_email)
|
||||
channel = _resolve_channel(payload.channel, client_phone=phone, client_email=email)
|
||||
|
||||
if purpose == OTP_CREATE_PURPOSE:
|
||||
phone = _normalize_phone(payload.client_phone)
|
||||
if not phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для CREATE_REQUEST')
|
||||
if channel == CHANNEL_SMS and not phone:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_phone" обязательно для SMS OTP')
|
||||
if channel == CHANNEL_EMAIL and not email:
|
||||
raise HTTPException(status_code=400, detail='Поле "client_email" обязательно для Email OTP')
|
||||
else:
|
||||
track_number = _normalize_track(payload.track_number)
|
||||
phone = _normalize_phone(payload.client_phone)
|
||||
if not track_number and not phone:
|
||||
raise HTTPException(status_code=400, detail='Для VIEW_REQUEST укажите "track_number" или "client_phone"')
|
||||
if not track_number and not phone and not email:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail='Для VIEW_REQUEST укажите "track_number" или "client_phone/client_email"',
|
||||
)
|
||||
|
||||
_rate_limit_or_429(
|
||||
"verify",
|
||||
purpose=purpose,
|
||||
client_ip=_client_ip(request),
|
||||
phone=phone,
|
||||
phone=phone or None,
|
||||
email=email or None,
|
||||
track_number=track_number,
|
||||
)
|
||||
|
||||
query = db.query(OtpSession).filter(OtpSession.purpose == purpose)
|
||||
query = db.query(OtpSession).filter(OtpSession.purpose == purpose, OtpSession.channel == channel)
|
||||
if track_number is not None and track_number != "":
|
||||
query = query.filter(OtpSession.track_number == track_number)
|
||||
if phone is not None and phone != "":
|
||||
if channel == CHANNEL_EMAIL:
|
||||
if email:
|
||||
query = query.filter(OtpSession.email == email)
|
||||
elif phone:
|
||||
query = query.filter(OtpSession.phone == phone)
|
||||
|
||||
row = query.order_by(OtpSession.created_at.desc()).first()
|
||||
|
|
@ -241,19 +405,21 @@ def verify_otp(payload: OtpVerify, request: Request, response: Response, db: Ses
|
|||
raise HTTPException(status_code=400, detail="Неверный OTP-код")
|
||||
|
||||
if purpose == OTP_CREATE_PURPOSE:
|
||||
subject = str(row.phone or "")
|
||||
subject = str((row.email or "").strip() if channel == CHANNEL_EMAIL else (row.phone or ""))
|
||||
else:
|
||||
if phone:
|
||||
subject = str(row.phone or "")
|
||||
subject = str((row.phone or "").strip())
|
||||
elif email:
|
||||
subject = str((row.email or "").strip())
|
||||
elif track_number:
|
||||
subject = str(row.track_number or "")
|
||||
else:
|
||||
subject = str(row.phone or row.track_number or "")
|
||||
subject = str((row.phone or row.email or row.track_number or "")).strip()
|
||||
if not subject:
|
||||
raise HTTPException(status_code=400, detail="Некорректная OTP-сессия")
|
||||
|
||||
_set_public_cookie(response, subject=subject, purpose=purpose)
|
||||
_set_public_cookie(response, subject=subject, purpose=purpose, auth_channel=channel)
|
||||
|
||||
db.delete(row)
|
||||
db.commit()
|
||||
return {"status": "verified", "purpose": purpose}
|
||||
return {"status": "verified", "purpose": purpose, "channel": channel}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
34
app/email_main.py
Normal 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}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
307
app/services/attachment_scan.py
Normal file
307
app/services/attachment_scan.py
Normal 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))
|
||||
247
app/services/email_service.py
Normal file
247
app/services/email_service.py
Normal 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}"],
|
||||
}
|
||||
|
|
@ -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"}:
|
||||
|
|
|
|||
149
app/services/totp_service.py
Normal file
149
app/services/totp_service.py
Normal 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()
|
||||
|
|
@ -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}>
|
||||
Обновить
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
```
|
||||
|
||||
|
|
|
|||
74
context/16_security_pdn_hardening_plan.md
Normal file
74
context/16_security_pdn_hardening_plan.md
Normal 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.
|
||||
|
|
@ -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/ {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
154
tests/test_attachment_scan.py
Normal file
154
tests/test_attachment_scan.py
Normal 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")
|
||||
62
tests/test_email_service.py
Normal file
62
tests/test_email_service.py
Normal 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")
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
Loading…
Reference in a new issue