diff --git a/acore/settings/base.py b/acore/settings/base.py index f746a29..357ce11 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -10,6 +10,12 @@ environ.Env.read_env(BASE_DIR / '.env') SECRET_KEY = env('SECRET_KEY') ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) +# Ключ для шифрования секретных полей в БД (EncryptedCharField: пароли/токены +# коннекторов, access-токены Mattermost). Задавайте ОТДЕЛЬНО от SECRET_KEY — +# тогда утечка SECRET_KEY не раскрывает секреты at-rest. При отсутствии +# core.utils.crypto делает fallback на SECRET_KEY (см. предупреждение ниже). +FIELD_ENCRYPTION_KEY = env('FIELD_ENCRYPTION_KEY', default=env('SECRET_KEY')) + # Базовый URL портала — используется для абсолютных ссылок в уведомлениях # (например, Mattermost-бот шлёт ссылку на задачу) PORTAL_BASE_URL = env('PORTAL_BASE_URL', default='http://localhost:8000') diff --git a/core/migrations/0004_encrypt_connector_secrets.py b/core/migrations/0004_encrypt_connector_secrets.py new file mode 100644 index 0000000..8817b46 --- /dev/null +++ b/core/migrations/0004_encrypt_connector_secrets.py @@ -0,0 +1,89 @@ +from django.db import migrations, models + +import core.models.fields +from core.utils.crypto import encrypt_secret, decrypt_secret + + +def _is_encrypted(value: str) -> bool: + """True, если значение уже зашифровано (успешно расшифровывается).""" + if not value: + return False + try: + decrypt_secret(value) + return True + except Exception: + return False + + +def encrypt_existing(apps, schema_editor): + """Зашифровать открытые password/token в существующих записях. + + Выполняется, пока поля ещё объявлены как обычные CharField (шире), + поэтому save() пишет шифртекст в БД как есть, без двойного шифрования. + """ + ConnectorConfig = apps.get_model('core', 'ConnectorConfig') + for cfg in ConnectorConfig.objects.all(): + changed = False + for field in ('password', 'token'): + value = getattr(cfg, field) + if value and not _is_encrypted(value): + setattr(cfg, field, encrypt_secret(value)) + changed = True + if changed: + cfg.save(update_fields=['password', 'token']) + + +def decrypt_existing(apps, schema_editor): + """Обратная операция: вернуть открытый текст.""" + ConnectorConfig = apps.get_model('core', 'ConnectorConfig') + for cfg in ConnectorConfig.objects.all(): + changed = False + for field in ('password', 'token'): + value = getattr(cfg, field) + if value and _is_encrypted(value): + setattr(cfg, field, decrypt_secret(value)) + changed = True + if changed: + cfg.save(update_fields=['password', 'token']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0003_directory_ad_ou_dn'), + ] + + operations = [ + # 1. Расширить колонки под шифртекст (пока обычные CharField). + migrations.AlterField( + model_name='connectorconfig', + name='password', + field=models.CharField(blank=True, max_length=512, verbose_name='Пароль'), + ), + migrations.AlterField( + model_name='connectorconfig', + name='token', + field=models.CharField( + blank=True, max_length=1024, + help_text='Bearer-токен или API-ключ, если используется вместо логина/пароля', + verbose_name='Токен / API Key', + ), + ), + # 2. Зашифровать уже существующие значения. + migrations.RunPython(encrypt_existing, decrypt_existing), + # 3. Переключить поля на прозрачное шифрование. + migrations.AlterField( + model_name='connectorconfig', + name='password', + field=core.models.fields.EncryptedCharField(blank=True, max_length=512, verbose_name='Пароль'), + ), + migrations.AlterField( + model_name='connectorconfig', + name='token', + field=core.models.fields.EncryptedCharField( + blank=True, max_length=1024, + help_text='Bearer-токен или API-ключ, если используется вместо логина/пароля', + verbose_name='Токен / API Key', + ), + ), + ] diff --git a/core/models/connector_config.py b/core/models/connector_config.py index 0314da1..932bb16 100644 --- a/core/models/connector_config.py +++ b/core/models/connector_config.py @@ -2,6 +2,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from django.core.exceptions import ValidationError from .base import BaseModel +from .fields import EncryptedCharField class ConnectorConfig(BaseModel): @@ -25,9 +26,11 @@ class ConnectorConfig(BaseModel): # Аутентификация username = models.CharField(_('Логин'), max_length=255, blank=True) - password = models.CharField(_('Пароль'), max_length=255, blank=True) - token = models.CharField( - _('Токен / API Key'), max_length=500, blank=True, + # password/token хранятся в БД зашифрованными (Fernet). max_length задан с + # запасом под шифртекст, который длиннее открытого текста. + password = EncryptedCharField(_('Пароль'), max_length=512, blank=True) + token = EncryptedCharField( + _('Токен / API Key'), max_length=1024, blank=True, help_text=_('Bearer-токен или API-ключ, если используется вместо логина/пароля') ) diff --git a/core/models/fields.py b/core/models/fields.py new file mode 100644 index 0000000..8b6dc53 --- /dev/null +++ b/core/models/fields.py @@ -0,0 +1,30 @@ +from django.db import models + +from core.utils.crypto import encrypt_secret, decrypt_secret + + +class EncryptedCharField(models.CharField): + """CharField, который хранит значение в БД в зашифрованном виде (Fernet). + + Для приложения поле ведёт себя как обычная строка: чтение отдаёт открытый + текст, запись сохраняет шифртекст. Шифрование выполняется на границе с БД, + поэтому читатели (`cfg.password`, `cfg.token`) и админка изменений не требуют. + + Шифртекст длиннее исходного текста (~1.4×) плюс постоянный оверхед Fernet, + поэтому колонку нужно объявлять с запасом по `max_length`. + """ + + def get_db_prep_save(self, value, connection): + value = super().get_db_prep_save(value, connection) + if value in (None, ''): + return value + return encrypt_secret(value) + + def from_db_value(self, value, expression, connection): + if value in (None, ''): + return value + try: + return decrypt_secret(value) + except Exception: + # Значение записано до включения шифрования — вернуть как есть. + return value