diff --git a/docs/customization/encrypted_model_fields.rst b/docs/customization/encrypted_model_fields.rst index cae7263bd..f6763f5d8 100644 --- a/docs/customization/encrypted_model_fields.rst +++ b/docs/customization/encrypted_model_fields.rst @@ -1,107 +1,230 @@ Encrypted Model Fields ====================== -If your ``custom_code`` or reusable app contains a Model field storing user-specific -sensitive data, then you may want to encrypt that data. +If your ``custom_code`` or reusable app needs to store sensitive data, +TOM Toolkit provides a way to encrypt that data in the database. -Examples of user-specific sensitive -data include a password or API key for an external service that your TOM uses. -For example, TOMToolkit Facility modules can use the mechanism described here to store, -encrypted, user-specific credentials in a user profile model. Examples include the -`tom_eso `__ and the -`tom_swift `__ facility modules. +Examples of this type of sensitive data include passwords or API keys +for external services that your TOM stores on the user's behalf. TOM +Toolkit's Facility modules, use the mechanism described +here to store user-specific external-service credentials in a user +profile model. Examples live in +`tom_demoapp `__ , +`tom_eso `__ and +`tom_swift `__. -As we explain below, TOMToolkit provides a *mix-in* class, a *property descriptor*, and -utility functions to help encrypt user-specific sensitive data and access it when it's needed. +Getting Started +--------------- -.. note:: For sensitive data that is used by the TOM itself and is not user-specific, - we suggest that this data be stored outside the TOM and accessed through - environment variables. +Here is a brief overview of the steps you'll need to create, display, and update and encrypted field. +Each step is covered in its own section below. -Creating and accessing an encrypted Model field ------------------------------------------------ +1. Add an ``EncryptedModelField`` to your model. +2. List the field in your ``UpdateView``'s ``fields``. +3. Pass the plaintext (from the model) to your profile-card template and + include the ``revealable_password_input.html`` partial. -Creating an encrypted Model field -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -If your Model has a field that should be encrypted, follow these steps: +Adding an encrypted field to a model +++++++++++++++++++++++++++++++++++++ -1. Import the mix-in class and property descriptor in your ``models.py``: +Declare an :class:`~tom_common.encryption.EncryptedModelField` alongside +your other model fields: .. code-block:: python + :caption: models.py - from tom_common.models import EncryptableModelMixin, EncryptedProperty + from django.db import models + from tom_common.encryption import EncryptedModelField -2. Make your Model subclass a subclass of ``EcryptableModelMixin``. For example: + + class MyAppProfile(models.Model): + # you probably have a user OneToOneField here as well + api_key = EncryptedModelField(null=True, blank=True) + +The underlying database column is a ``BinaryField`` (it holds +the encrypted ciphertext as bytes). Django sees ``api_key`` as a normal +named field. ``ModelForm``, the Django admin, DRF ``ModelSerializer``, etc +all introspect it appropriately. + +Reading and writing the value in code ++++++++++++++++++++++++++++++++++++++ + +Access the field like any other ``models.Field`` subclass: .. code-block:: python - class MyAppModel(EncryptableModelMixin, models.Model): - ... + # assignment + profile.api_key = 'something-secret' + profile.save() + + # retrieval; later, possibly in a different process / request: + plaintext_value = profile.api_key # 'something-secret' + + +The field handles encryption on save and decryption on load. +Assigning ``None`` or the empty string ``''`` clears the stored value +(column stored as ``NULL``). Reading an unset value yields ``None``. +For implementation details, see ``tom_common/encryption.py``. + +Displaying the current value to your TOM users (read-only) +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +To display an encrypted field value with a user-driven "reveal +control" (i.e. the "click the eye icon to see the value" pattern), +use ``tom_common``'s ``revealable_password_input.html`` partial template. -This gives your model access to a set of methods that will manage the encryption and -decryption of your data into and out of the ``BinaryField`` that stores the encrypted data. +The partial needs the **plaintext** as its ``value`` argument, and +plaintext is available via direct attribute access on the model +instance — e.g. ``profile.api_key``. Django introspection paths +(``model_to_dict``, ``ModelSerializer``, ``dumpdata``, admin display) +all go through ``EncryptedModelField.value_from_object``, which +returns the ``REDACTED`` placeholder by design. -3. Add the ``BinaryField`` that will store the encrypted data and the property descriptor -through which the ``BinaryField`` will be accessed. +In practice, when a profile card uses an inclusion tag (or a view's +``get_context_data``) to provide fields to a template, the encrypted +field has to be excluded from any auto-iteration over the model and +passed in explicitly: .. code-block:: python + :caption: [function returning context to template] - _ciphertext_api_key = BinaryField(null=True, blank=True) # encrypted data field (private) - api_key = EncryptedProperty('_ciphertext_api_key') # descriptor that provides access (public) + ... + # exclude the encrypted field from the auto-iteration: model_to_dict + # would only return the REDACTED placeholder for it + excluded_fields = ['user', 'id', 'api_key'] + profile_data = model_to_dict(profile, exclude=excluded_fields) -By convention name of the ``BinaryField`` field should begin with and underscore -(``_ciphertext_api_key`` in our example) because is it private to the Model class. + context = { + 'profile_data': profile_data, # dictionary without the excluded_fields + 'api_key': profile.api_key, # direct attribute access -> plaintext + } + return context + +Then in the template, render the encrypted field through the partial: + +.. code-block:: html+django + :caption: my_template.html + + {% if api_key %} + {% include 'tom_common/partials/revealable_password_input.html' with value=api_key %} + {% else %} + (not set) + {% endif %} + +The partial renders a masked input of fixed length; the real value is +only injected into the DOM when the user clicks the reveal icon. + +A worked example of this pattern lives in +`tom_demoapp `__'s +``demo_extras.py`` and ``profile_demo.html``. + +Editing the value in an UpdateView +++++++++++++++++++++++++++++++++++ + +Include the ``EncryptedModelField`` in the ``fields`` list of your +``ProfileUpdateView`` (or any other ``ModelForm``-based view) and the +form renders the field automatically as a composite control with a masked +password input joined to a "Clear" checkbox in a single input-group. + +.. code-block:: python -Accessing encrypted data -~~~~~~~~~~~~~~~~~~~~~~~~ -The following example shows how to get and set an encrypted field using the utility -methods provided in ``tom_common.session_utils.py``: + class MyProfileUpdateView(UpdateView): + model = MyAppProfile + fields = ['api_key', 'display_name'] + +How the control behaves on submit: + +- A typed value replaces the stored value. +- An empty input leaves the stored value unchanged. This protects + the secret when a user edits an unrelated field on the same form + and saves without retyping. +- Checking **Clear** with an empty input clears the stored value + (column becomes ``NULL``). +- If a typed value and the **Clear** checkbox are submitted together, + the typed value wins and the checkbox is ignored — the more + conservative choice, since "don't lose what the user just typed" is + safer than the alternative. + +The stored value is never revealed by this +toggle (or by anything else in the form), because the stored value +is never rendered into the form's HTML to begin with. + +To bridge the resulting information gap (the form input is masked +whether or not a value is stored), the input's placeholder is +state-aware: it reads ``(A stored value is hidden) — type to replace`` +when a value is stored, and ``(not set) — type to add`` when nothing +is stored. A hover tooltip on the control reinforces the same idea. +The actual stored value is not communicated through either channel; +to view it, follow the read-only display pattern above. + +How encryption works +-------------------- + +Encrypted fields are protected by a single Fernet cipher derived from +``settings.SECRET_KEY``. For TOM administrator concerns (rotating +``SECRET_KEY`` without losing data, etc.) see +:doc:`/deployment/encryption`. + +.. note:: + + Encryption protects data from passive database exposure. It does NOT + protect against a server administrator with access to the + ``settings.SECRET_KEY``. If you need user-level isolation from + administrators, the toolkit's current scheme is not sufficient. + +What you cannot do with an EncryptedModelField +---------------------------------------------- + +**Filter on the value.** Fernet is non-deterministic — every +``encrypt()`` produces a different ciphertext for the same plaintext, +so database-level equality lookups can never match. +:meth:`EncryptedModelField.get_lookup` raises ``FieldError`` rather +than silently returning empty querysets: .. code-block:: python - from tom_common.session_utils import get_encrypted_field, set_encrypted_field - from tom_app_example.models import MyAppModel - - profile: MyAppModel = user.myappmodel # Model instance containing an encrypted field - - # getter example - decrypted_api_key: str = get_encrypted_field(user, profile, 'api_key') - - # setter example - new_api_key: str = 'something_secret' - set_encrypted_field(user, profile, 'api_key', new_api_key) - -Note here that the User instance (``user``) is used to access the ``EncryptableModelMixin`` -subclass and its encrypted data. The ``user`` property of the Model subclass containing the -encrypted field (``MyAppModel`` in our example) is provided by the ``EncryptableModelMixin``. -As such, the model *should not define a* ``user`` *property of its own*. - -Some Explanations ------------------ - -EncryptableModelMixin (`source `__) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The User's data is encrypted using (among other things) their password (i.e the -password they use to login to your TOM). When the User changes their password, -their encrypted data re-encrypted accordingly. The ``EncryptableModelMixin`` adds -method for this to your otherwise normal Django model. - -EncryptedProperty (`source `__) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -A *property descriptor* implements the Python descriptor protocol (``__get__``, -``__set__``, etc). The ``EncryptedProperty`` property descriptor handles the details -of decrypting the encrypted ``BinaryField`` on its way out of the database and -encrypting it on the way in. It is invoked when the property is accessed -(e.g. ``model_instance.api_key``). - -Session Utils (`example `__) -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The ``get_encrypted_field`` and ``set_encrypted_field`` functions implement -boilerplate code for creating and destroying the cipher used to encrypt and -decrypt the ``BinaryField``. *These methods must always be used to access any -encrypted field*. - - -The rest of the details are in the source code. If reading source code isn't your thing, -please do feel free to get in touch and we'll be happy to answer any questions you may have. + MyAppProfile.objects.filter(api_key='foo') # raises FieldError + +If you need equality search on the encrypted column, store a +companion HMAC-derived hash column alongside it and query the hash. + +**Round-trip via dumpdata / loaddata.** Serialization paths emit a +placeholder (``EncryptedModelField.REDACTED``, currently +``'******** (encrypted, not shown)'``) rather than the plaintext. +This keeps secrets out of fixture files, DRF API responses, and +admin history by default. As a consequence, attempting to load a +``dumpdata`` fixture back fails loudly when +:meth:`~EncryptedModelField.to_python` encounters the placeholder. +To migrate encrypted data between environments, copy the database +row directly (the ciphertext survives) or write a one-off +decrypt-and-re-encrypt script. + +Direct attribute access (``getattr(instance, field_name)``) bypasses +the redaction and remains the only path to the plaintext — code that +legitimately needs the secret reads the attribute directly. + +What happens on decryption failure +---------------------------------- + +If a stored ciphertext cannot be decrypted under any active key +(``settings.SECRET_KEY`` plus any ``settings.SECRET_KEY_FALLBACKS``), +:meth:`EncryptedModelField.from_db_value` raises +``cryptography.fernet.InvalidToken`` at row-load time. The most +common cause is a key removed from the rotation set before its data +was re-encrypted under a new primary. See +:doc:`/deployment/encryption` for the rotation procedure and the +``rotate_encryption_key`` management command. + +API reference +------------- + +:class:`~tom_common.encryption.EncryptedModelField` (`source `__) + A ``models.BinaryField`` subclass that transparently encrypts on + save and decrypts on load. See the class docstring in + ``tom_common/encryption.py`` for the full method-level contract. + +:class:`~tom_common.encryption.EncryptedFormField` (`source `__) + The form-side companion. Handles the masked-input UX and the + blank-submission-preserves-existing behavior. ``ModelForm`` picks + it up automatically via :meth:`EncryptedModelField.formfield`. diff --git a/docs/deployment/deployment_tips.rst b/docs/deployment/deployment_tips.rst index 380e589da..7660d7c77 100644 --- a/docs/deployment/deployment_tips.rst +++ b/docs/deployment/deployment_tips.rst @@ -1,8 +1,10 @@ General Deployment Tips ----------------------- -When it comes to deploying your tom for general use, there are a few -things you might want to consider. +Your TOM is a Django project. First and foremost, you should be following +`Django's deployment checklist `__. +Subsequently, when it comes to deploying your TOM for general use, there are a +few things you might want to consider. Choosing a database ~~~~~~~~~~~~~~~~~~~ @@ -71,4 +73,4 @@ If you provide the path to a file that does not exist, TOM Toolkit will still serve the default ``robots.txt`` file and log a warning message to that effect. Additional background on the ``robots.txt`` file can be found -`here `_. \ No newline at end of file +`here `_. diff --git a/docs/deployment/encryption.rst b/docs/deployment/encryption.rst new file mode 100644 index 000000000..eaea41e5d --- /dev/null +++ b/docs/deployment/encryption.rst @@ -0,0 +1,145 @@ +Encryption and the SECRET_KEY +===================================== + +This section is for TOM administrators and describes the relationship +between the ``settings.SECRET_KEY`` and the way TOMToolkit encrypts +sensitive user data. If you are a TOM developer looking for how to +create an encrypted database field, your documentation is here: +:doc:`/customization/encrypted_model_fields` + +------ + +TOM Toolkit encrypts sensitive user data (API keys, observatory +credentials, anything declared with :class:`EncryptedProperty`) using a +single Fernet cipher derived from Django's ``settings.SECRET_KEY``. +That means when an encrypted field is written to or read from the database, +a cipher is created. The cipher can encrypt unencrypted plaintext (in) and decrypt +encrypted ciphertext (out). Cipher creation requires an encryption key. +TOMToolkit creates an encryption key that is based upon, but not identical +to, Django's ``settings.SECRET_KEY``. That's how the ``SECRET_KEY`` is related +to TOMToolkit's encryption. + +Treat ``SECRET_KEY`` like an encryption key +------------------------------------------- + +If you lose ``SECRET_KEY`` (and any active +``SECRET_KEY_FALLBACKS`` entries), every encrypted field becomes +unrecoverable. Keep ``SECRET_KEY`` secret, never commit it, and back +it up through whatever channel your other production secrets use. + +The standard Django guidance applies — see the +`Django deployment checklist +`_ +and the +`SECRET_KEY documentation +`_. + +.. warning:: + + Rotating ``SECRET_KEY`` without first running the rotation procedure + below will leave every previously-encrypted field unreadable. Follow + the procedure exactly — don't just edit ``SECRET_KEY`` in your env. + + +Graceful ``SECRET_KEY`` rotation +-------------------------------- + +Because TOMToolkit's encryption scheme depends on the value of ``settings.SECRET_KEY``, +if you need to change your ``SECRET_KEY``, we must decrypt the encrypted data +with a cipher derived from the old ``SECRET_KEY`` and re-encrypt it with a cipher derived +from the new ``SECRET_KEY``. The following procedure explains the process in full. + +We use Django's built-in +`SECRET_KEY_FALLBACKS `_ +mechanism to rotate keys without an outage and without data loss. The +encryption module's ``decrypt()`` tries the primary derived key first +and then a derived key for each ``SECRET_KEY_FALLBACKS`` entry; the +``encrypt()`` path always uses the primary. So once a new +``SECRET_KEY`` is in place with the old key in fallbacks, **reads of +existing encrypted data continue to work**, and **new writes use the new +key**. + +Scaffolded TOMs already include ``SECRET_KEY_FALLBACKS = []`` in their +``settings.py`` (added by ``tom_setup``); if your TOM predates that +template change, just add the line yourself. + +The end-to-end procedure: + +1. **Stage the old key as a fallback and install a new + ``SECRET_KEY``**. In ``settings.py``, move the existing + ``SECRET_KEY`` value into ``SECRET_KEY_FALLBACKS`` and set + ``SECRET_KEY`` to the new value:: + + SECRET_KEY = '' + SECRET_KEY_FALLBACKS = [''] + + (Or via env vars, whatever pattern your deployment uses.) + +.. warning:: + Remember to keep secret keys secret! The actual values should never be committed to github, or saved anywhere publicly accessible. + +2. **Restart the server.** All existing encrypted data is still + readable (via the fallback). All new writes — including any + re-encryption — use the new primary key. Django's HMAC signing + machinery also honours the fallback, so existing signed cookies + and password-reset tokens stay valid. + +3. **Re-encrypt existing data forward** so it no longer depends on the + fallback:: + + python manage.py rotate_encryption_key + + The command walks every :class:`EncryptedProperty` field across + ``INSTALLED_APPS``, decrypts each value (transparently using either + the primary or a fallback), and re-encrypts under the primary. After + this, no value in the database requires the fallback to decrypt. + +4. **Remove the fallback** from ``settings.py``:: + + SECRET_KEY = '' + SECRET_KEY_FALLBACKS = [] + + Restart. You're now fully on the new key. + +If anything goes wrong between steps 1 and 3, simply leave the fallback +in place — the system stays functional indefinitely with both keys +active. ``rotate_encryption_key`` is idempotent: running it again is +always safe. + +If the command reports per-row failures, those rows were encrypted under +a key that is no longer in either ``SECRET_KEY`` or ``SECRET_KEY_FALLBACKS`` +(i.e., that key has been forgotten). Add it back if you can; otherwise +the data on those rows is lost. + +What if ``SECRET_KEY`` is lost? +------------------------------- + +If you lose ``SECRET_KEY`` and have no backup: + +- Every :class:`EncryptedProperty` value (saved API keys, observatory + credentials) becomes unrecoverable. The ciphertext is still in the + database; the key needed to decrypt it is gone. +- All Django signing-dependent states also break: outstanding password-reset + tokens, signed URLs, persistent session cookies. Users will + need to log in again and possibly re-enter any saved secrets. + +Treat ``SECRET_KEY`` backup with the same seriousness as your database +backup. + +Key Derivation Function Implementation Details +---------------------------------------------- +The derivation uses `HKDF `_ +(RFC 5869) with a domain-separator label, so the encryption key is +cryptographically independent of the way Django +uses ``SECRET_KEY`` for HMAC signing. See +:mod:`tom_common.encryption` for the implementation. + + +See also +-------- + +- :doc:`/customization/encrypted_model_fields` — how plugin authors + declare and use encrypted fields. +- `Django deployment checklist + `_ + — the broader hardening you should do before going live. diff --git a/docs/deployment/index.rst b/docs/deployment/index.rst index 8b7e6f5fb..425be69bb 100644 --- a/docs/deployment/index.rst +++ b/docs/deployment/index.rst @@ -8,6 +8,7 @@ Deploying your TOM Online deployment_tips deployment_heroku amazons3 + encryption Once you’ve got a TOM up and running on your machine, you’ll probably want to deploy it somewhere so it is permanently accessible by you and your colleagues. @@ -16,4 +17,6 @@ accessible by you and your colleagues. :doc:`Deploy to Heroku ` - Heroku is a PaaS that allows you to publicly deploy your web applications without the need for managing the infrastructure yourself. -:doc:`Using Amazon S3 to Store Data for a TOM ` - Enable storing data on the cloud storage service Amazon S3 instead of your local disk. \ No newline at end of file +:doc:`Using Amazon S3 to Store Data for a TOM ` - Enable storing data on the cloud storage service Amazon S3 instead of your local disk. + +:doc:`Encryption and the SECRET_KEY ` - How Django's ``settings.SECRET_KEY`` relates to TOM Toolkit encryption. diff --git a/tom_base/settings.py b/tom_base/settings.py index f6f14f001..f3adbf5f4 100644 --- a/tom_base/settings.py +++ b/tom_base/settings.py @@ -1,13 +1,17 @@ -""" -Django settings for tom_base project. +"""Django settings for the tom_base repository itself. + +THIS IS NOT YOUR TOM's `settings.py`. -Generated by 'django-admin startproject' using Django 2.0.6. +This file is used when running commands directly from the tom_base repo — +for example, ``python manage.py test`` within the tom_base repo. It is NOT +the settings file that individual TOM projects use. -For more information on this file, see -https://docs.djangoproject.com/en/2.0/topics/settings/ +Each TOM project gets its own standalone ``settings.py``, generated by +``tom_setup`` from the template ``tom_setup/templates/tom_setup/settings.tmpl``. +That project-level settings file is what a TOM runs in production. -For the full list of settings and their values, see -https://docs.djangoproject.com/en/2.0/ref/settings/ +This file exists only so that the tom_base repo has a working +Django configuration for development, testing, and CI. """ import logging.config import os @@ -25,6 +29,10 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.getenv('SECRET_KEY', 'testkey') +# Old SECRET_KEY values to keep accepting during a graceful rotation. See +# docs/deployment/encryption.rst. +SECRET_KEY_FALLBACKS = [] + # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True diff --git a/tom_common/apps.py b/tom_common/apps.py index 8eb4ddd90..3af115428 100644 --- a/tom_common/apps.py +++ b/tom_common/apps.py @@ -7,7 +7,9 @@ class TomCommonConfig(AppConfig): name = 'tom_common' def ready(self): - # Import signals for automatically saving profiles when updating User objects + # Import signals so their @receiver decorators are executed, which + # registers the signal handlers. Without this import, signal handlers + # in signals.py would never fire. # https://docs.djangoproject.com/en/5.1/topics/signals/#connecting-receiver-functions import tom_common.signals # noqa diff --git a/tom_common/encryption.py b/tom_common/encryption.py new file mode 100644 index 000000000..1dd226736 --- /dev/null +++ b/tom_common/encryption.py @@ -0,0 +1,568 @@ +"""Encrypted-at-rest data: low-level helpers and Django integration classes. + +A single Fernet cipher is derived from ``settings.SECRET_KEY`` via HKDF +(see Glossary below for acryonym definitions) with a domain-separator +label. See the TOMToolkit Deployment documentation for the ``SECRET_KEY`` +rotation procedure. + +Decryption transparently honours ``settings.SECRET_KEY_FALLBACKS`` (the +same Django pattern used by ``signing`` for graceful HMAC-key rotation): +the primary key is tried first, then each fallback in turn. Encryption +always uses the primary key. + +Public surface for application code: + +- :func:`encrypt` / :func:`decrypt` — low-level helpers. +- :class:`EncryptedModelField` — ``models.BinaryField`` subclass that + transparently encrypts strings on save and decrypts on load. The + preferred way to add an encrypted field to a model. +- :class:`EncryptedFormField` — form-side companion to + :class:`EncryptedModelField`. Handles the masked-input UX and the + blank-submission-preserves-existing-value behavior. + +Glossary: + Fernet — symmetric authenticated encryption recipe from the + ``cryptography`` library: AES-128-CBC for confidentiality plus + HMAC-SHA256 for integrity, with a versioned, URL-safe token format. + HMAC — Hash-based Message Authentication Code (RFC 2104); a keyed + construction used here both inside Fernet (for integrity) and inside + HKDF (as its core primitive). + HKDF (RFC 5869) — HMAC-based Key Derivation Function; turns a + high-entropy secret into one or more independent cryptographic keys. + Used here to derive the Fernet key from ``SECRET_KEY`` so the same + secret can also feed Django's signing without key reuse across + purposes. + Domain separator — the ``info`` label passed to HKDF + (``_HKDF_INFO``). Different labels produce independent keys from the + same input secret, which is what isolates this module's key from any + other HKDF use of ``SECRET_KEY``. + SECRET_KEY / SECRET_KEY_FALLBACKS — Django settings holding the + current signing/encryption secret and an ordered list of retired + secrets still accepted for verification/decryption during rotation. +""" +from __future__ import annotations + +from base64 import urlsafe_b64encode +from typing import Any, Iterable + +from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +from django import forms +from django.conf import settings +from django.core.exceptions import FieldError, ValidationError +from django.db import models +from django.utils.text import capfirst + + +# Domain separator for the HKDF derivation. Bump the ``v1`` suffix only on +# an intentional derivation change; existing encrypted data will not +# decrypt under a new label. +_HKDF_INFO = b'tom-toolkit-encryption-v1' + + +def _derive_fernet_key(secret: str) -> bytes: + """Derive a Fernet key (URL-safe base64 of 32 bytes) from ``secret``. + + HKDF (RFC 5869) with SHA-256, no salt, and a fixed domain-separator + label keeps this derivation cryptographically independent from any + other use of the same ``secret`` (notably Django's HMAC signing of + cookies and tokens, which also reads ``SECRET_KEY``). + """ + # HKDF operates on bytes, not str, so encode the secret as its + # "input keying material" (IKM, in RFC 5869 terminology). + input_keying_material_bytes = secret.encode() + + # configure the key derivation function + fernet_raw_key_byte_length = 32 # length required by Fernet cipher generator + hkdf_key_derivation = HKDF( + algorithm=hashes.SHA256(), + length=fernet_raw_key_byte_length, + salt=None, # SECRET_KEY is already high entropy + info=_HKDF_INFO, + ) + + # apply the key derivation function to produce the raw key. + derived_raw_key_bytes = hkdf_key_derivation.derive(input_keying_material_bytes) + + # encode the raw bytes + fernet_formatted_key_bytes = urlsafe_b64encode(derived_raw_key_bytes) + return fernet_formatted_key_bytes + + +def _get_cipher() -> Fernet: + """Build the Fernet cipher from the current ``settings.SECRET_KEY``. + + Computed fresh each call rather than cached at import time so that + Django's ``override_settings`` works in tests without cache-busting. + HKDF is cheap; Fernet construction dominates only marginally. + """ + cipher = Fernet(_derive_fernet_key(settings.SECRET_KEY)) + return cipher + + +def _iter_ciphers() -> Iterable[Fernet]: + """Yield the primary cipher, then one per ``SECRET_KEY_FALLBACKS`` entry.""" + yield _get_cipher() + for fallback in getattr(settings, 'SECRET_KEY_FALLBACKS', []): + yield Fernet(_derive_fernet_key(fallback)) + + +def encrypt(plaintext: str) -> bytes: + """Encrypt a string under the primary cipher. + + Returns the ciphertext as bytes suitable for storing in a Django + ``BinaryField``. The primary cipher is derived from the current + ``settings.SECRET_KEY``; fallback keys are never used for encryption. + """ + cipher = _get_cipher() + ciphertext = cipher.encrypt(plaintext.encode()) + return ciphertext + + +def decrypt(ciphertext: bytes | memoryview) -> str: + """Decrypt ciphertext, trying the primary cipher then each fallback. + + Raises ``cryptography.fernet.InvalidToken`` if no key in + ``SECRET_KEY`` ∪ ``SECRET_KEY_FALLBACKS`` can decrypt the blob — + that typically means the data was encrypted under a key that has + since been removed from the rotation set. + """ + # Django's BinaryField returns different Python types depending on the + # database backend: SQLite returns ``bytes`` directly, while psycopg + # (PostgreSQL) returns a ``memoryview``. Fernet's ``decrypt`` accepts + # only ``bytes``/``str``, so normalise here. + if isinstance(ciphertext, memoryview): + ciphertext = ciphertext.tobytes() + + # decrypt (the loop and exception handling is for the FALLBACK mechanism) + last_err: Exception | None = None + for cipher in _iter_ciphers(): + try: + plaintext = cipher.decrypt(ciphertext).decode() # decryption happens here + return plaintext + except InvalidToken as e: + last_err = e + raise last_err if last_err is not None else InvalidToken() + + +# These flags are private to this module and implement the +# wierd (but standard) logic surrounding editting encrypted values. +# They are object() instances and not magic strings so there +# is no confusion (i.e. that the string is real data). + +# This flag is produced by EncryptedFormField.clean() +# when a blank value is submitted. The flag is detected by +# EncryptedModelField.pre_save, which interprets it as "leave the +# existing ciphertext alone" (rather than overwriting with blank). +_KEEP_EXISTING_VALUE = object() + +# This flag is produced by ClearableEncryptedInput when the +# user checks the "Clear stored value" box and submits an empty input. +# EncryptedFormField.clean translates this into None, which routes +# through get_prep_value as NULL storage. +_CLEAR_EXISTING_VALUE = object() + + +class ClearableEncryptedInput(forms.PasswordInput): + """Password input rendered alongside a "Clear stored value" checkbox. + + Mirrors Django's :class:`ClearableFileInput` pattern. The form's + POST data contains two keys: the password value and the checkbox + state. :meth:`value_from_datadict` composes them into a single + submitted value: + + - typed password, checkbox state ignored → the typed value + (an explicit new value wins over a contradictory clear request) + - empty password, checkbox checked → the :data:`_CLEAR_EXISTING_VALUE` flag + - empty password, checkbox unchecked → empty string + (:class:`EncryptedFormField` translates this into + :data:`_KEEP_EXISTING_VALUE`) + """ + + template_name = 'tom_common/partials/clearable_encrypted_input.html' + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Extends :meth:`forms.PasswordInput.__init__` to add a clear button for the hidden secret. + + The placeholder text is chosen at render time in :meth:`get_context` + because it depends on whether a value is currently stored. + (The widget only knows that when ``get_context`` is called). + """ + # don't render the value + kwargs.setdefault('render_value', False) + + default_attrs = { + # form-control opts the input into Bootstrap's input-group + # styling so the field and Clear checkbox + # render as one visually-attached control. + 'class': 'form-control', + # Suppress browser autocomplete by setting the field type to text. + 'type': 'text', + } + user_attrs = kwargs.get('attrs') or {} + kwargs['attrs'] = {**default_attrs, **user_attrs} + super().__init__(*args, **kwargs) + + def clear_checkbox_name(self, name: str) -> str: + """New helper (not a super-class override). + + Naming convention mirrors Django's + :meth:`ClearableFileInput.clear_checkbox_name` — the companion + checkbox key in POST data is the field name suffixed with + ``-clear``. + """ + return f'{name}-clear' + + def clear_checkbox_id(self, name: str) -> str: + """New helper (not a super-class override). + + Naming convention mirrors Django's + :meth:`ClearableFileInput.clear_checkbox_id` — the companion + checkbox HTML ``id`` derives from the field name. + """ + return f'id_{name}-clear' + + def get_context(self, name: str, value: Any, attrs: Any) -> dict: + """Extends :meth:`forms.Widget.get_context` to + 1. inject the checkbox name and id into the widget template context, + and 2. set a value-aware placeholder on the input. + + The placeholder reflects whether the bound model instance has a + stored value. The rendered form communicates the field's + state without ever putting the stored value into the HTML: + + - truthy ``value`` (stored plaintext from + ``EncryptedModelField.from_db_value``) → "A stored value is hidden) — + type to replace" + - ``None`` / empty / flag-on-re-render → "(not set) — + type to add" + + A developer-supplied ``placeholder`` in ``attrs`` wins over the + default. + """ + # get any context from the parent Widget + context = super().get_context(name, value, attrs) + + # set a placeholder according to whether a value is stored + if 'placeholder' not in context['widget']['attrs']: + if value: + # A stored value is hidden + context['widget']['attrs']['placeholder'] = ( + '(A stored value is hidden) — type to replace' + ) + else: + # there is no stored value at the moment + context['widget']['attrs']['placeholder'] = ( + '(not set) — type to add' + ) + + # inject the checkbox that allows the user to clear the value (if any) + context['widget']['checkbox_name'] = self.clear_checkbox_name(name) + context['widget']['checkbox_id'] = self.clear_checkbox_id(name) + return context + + def value_from_datadict(self, data: Any, files: Any, name: str) -> Any: + """Overrides :meth:`forms.Widget.value_from_datadict` to compose + the password input and the companion clear-checkbox into a single + submitted value. See the class docstring for the precedence rules. + """ + typed_value = super().value_from_datadict(data, files, name) + if typed_value: + # Explicit new value wins over a contradictory clear request. + return typed_value + clear_checked = forms.CheckboxInput().value_from_datadict( + data, files, self.clear_checkbox_name(name) + ) + if clear_checked: + return _CLEAR_EXISTING_VALUE + return typed_value + + +class EncryptedFormField(forms.CharField): + """Form-side companion to :class:`EncryptedModelField`. + + NOTE: This class does not perform encryption itself. The actual + encryption happens in :meth:`EncryptedModelField.get_prep_value` + at model-save time. + + Default widget is a masked password input that does NOT render the + existing value (``render_value=False``). An admin opening a change + form for an instance that already has a secret therefore sees an + empty masked input rather than the real value displayed as dots. + + Blank-submission behavior + ------------------------- + A blank submission is treated as "leave the existing secret + unchanged," not "set the secret to empty." The motivating scenario: + with the default masked widget, the input renders empty whether or + not a secret is stored; a user editing an unrelated field on the + same ModelForm would otherwise silently wipe the stored secret on + Save. + + The mechanism uses a module-private flag (``_KEEP_EXISTING_VALUE``). + On a blank submission, :meth:`clean` returns the flag; + ``ModelForm.save()`` writes the flag onto the instance + attribute via ``construct_instance``; + :meth:`EncryptedModelField.pre_save` detects it, performs a one-row + SELECT to retrieve the existing plaintext (decrypted by + :meth:`from_db_value` in the normal way), restores the in-memory + attribute, and returns the plaintext for re-encryption under the + current primary cipher. + + The trade-off: users cannot clear a secret via a blank form + submission. Clearing requires an explicit code path, + e.g. ``instance.api_key = None; instance.save()``. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Extends :meth:`forms.CharField.__init__` with our default widget + and ``required=False``; everything else is forwarded. + """ + # The composite widget renders the masked input plus a + # "Clear stored value" checkbox so the user has a way to set + # the stored value to None through the form. + kwargs.setdefault('widget', ClearableEncryptedInput()) + # Default to not required: the field's purpose is in-place rotation + # of an existing secret, and required=True interacts badly with the + # blank-as-no-change behavior. + kwargs.setdefault('required', False) + super().__init__(*args, **kwargs) + + def clean(self, value: Any) -> Any: + """Overrides :meth:`forms.CharField.clean` to translate the two + widget-produced flags into the values ``EncryptedModelField`` + understands. + + - ``_CLEAR_EXISTING_VALUE`` (clear checkbox checked) → ``None``, which + ``EncryptedModelField.get_prep_value`` stores as ``NULL``. + - empty input (None or '') → ``_KEEP_EXISTING_VALUE``, which + ``EncryptedModelField.pre_save`` resolves to the existing + stored value (preserves it). + - anything else → forwarded to ``CharField.clean`` for normal + validation. + """ + if value is _CLEAR_EXISTING_VALUE: + return None + if value in (None, ''): + return _KEEP_EXISTING_VALUE + return super().clean(value) + + def has_changed(self, initial: Any, data: Any) -> bool: + """Overrides :meth:`forms.Field.has_changed` to keep + ``ModelForm.changed_data`` consistent with our :meth:`clean` + semantics: + + - clear request → counts as a change. + - blank-as-no-change → not a change. + - real value → defer to ``CharField.has_changed``. + """ + if data is _CLEAR_EXISTING_VALUE: + return True + if not data: + return False + return super().has_changed(initial, data) + + +class EncryptedModelField(models.BinaryField): + """A string field whose value is encrypted with the project Fernet cipher. + + Stores the ciphertext in a ``BinaryField`` column. The Python + interface is ``str``: assigning ``instance.api_key = '...'`` encrypts + on save; reading ``instance.api_key`` after a load returns the + decrypted plaintext. + + Example:: + + class MyProfile(models.Model): + user = models.OneToOneField(...) + api_key = EncryptedModelField(null=True, blank=True) + + Form integration + ---------------- + :meth:`formfield` returns an :class:`EncryptedFormField` by default + — see that class for the masked-input UX and the + blank-submission-preserves-existing-value behavior. + + Lookups + ------- + Filtering on encrypted columns is not supported. + :meth:`get_lookup` raises ``FieldError`` rather than silently + returning empty querysets. + + Decryption errors + ----------------- + If a stored ciphertext cannot be decrypted under any active key + (``settings.SECRET_KEY`` ∪ ``settings.SECRET_KEY_FALLBACKS``), + :meth:`from_db_value` raises ``cryptography.fernet.InvalidToken`` + at row-load time. The error surfaces when the queryset iterator + reaches the bad row. + """ + description = 'Encrypted text' + + REDACTED: str = '******** (encrypted, not shown)' + + def __init__(self, *args: Any, **kwargs: Any) -> None: + # BinaryField defaults editable=False because why would raw + # binary data be in a form? However, we want the field to be editable + # by default. Required for ``modelform_factory`` to create + # the ModelForm correctly. + kwargs.setdefault('editable', True) # edittable BinaryField for reasons above + super().__init__(*args, **kwargs) + + def from_db_value(self, value: bytes | memoryview | None, + expression: Any, connection: Any,) -> str | None: + """Decrypt on load. Normalises psycopg's ``memoryview`` to ``bytes``. + """ + if value is None: + return None + + # sqlite3 and psycopg store BinaryFields differently: + if isinstance(value, memoryview): + # this is a psycopg memoryview and must be converted to bytes + value = value.tobytes() + + plaintext: str = decrypt(value) # an sqlite BinaryField value is already bytes + return plaintext + + def to_python(self, value: Any) -> Any: + """Coerce to the canonical Python type (``str`` or ``None``). + + Invoked by ``Field.clean()`` during model-level ``full_clean`` + (which ``ModelForm._post_clean`` runs after ``construct_instance``) + and by fixture deserialization. Form-field cleaning does NOT + flow through this method; :class:`EncryptedFormField` has its + own ``clean()``. + + The ``_KEEP_EXISTING_VALUE`` flag must pass through untouched — + otherwise ``Field.clean`` would coerce it to ``str(flag)`` + via the fall-through below, and that string would replace the + flag on the instance before :meth:`pre_save` ever ran, + breaking blank-submission preservation. + """ + if value is None: + return None + if value is _KEEP_EXISTING_VALUE: + return value + if isinstance(value, str): + # we're expecting an actual value, not the **** placeholder text + if value == self.REDACTED: + # oh no! we somehow got the **** placeholder text! + # so fail loudly rather than encrypting the placeholder as + # the new value. + raise ValidationError( + f'Refusing to deserialize the redaction placeholder ' + f'{self.REDACTED!r}. EncryptedModelField does not ' + f'support dumpdata/loaddata round-trips: serialization ' + f'paths emit only the placeholder, not the secret.' + ) + return value + + # this is more psycopg vs sqlite logic + if isinstance(value, (bytes, memoryview)): + raw = value if isinstance(value, bytes) else value.tobytes() + return decrypt(raw) + + return str(value) + + def get_prep_value(self, value: Any) -> bytes | None: + """Prepare a Python value for database storage — i.e. encrypt it. + + Q: Why the wierd method name? + A: ``get_prep_value`` is Django's standard Field override hook + (the name is fixed by the framework) for converting a Python + attribute value into the form the database expects. For an + EncryptedModelField, that conversion is encryption. + + Treats ``None`` and ``''`` identically as "no value" (stored as + ``NULL``), so a caller writing ``instance.api_key = ''`` instead + of ``= None`` doesn't end up with an encrypted empty string. + + Defensive guard: the ``_KEEP_EXISTING_VALUE`` flag must never reach + ``encrypt()`` — that would persist the object's string repr. + Under normal flow, :meth:`pre_save` resolves the flag before + this method runs; this guard backstops any path that bypasses + ``pre_save``. + """ + if value is None or value == '' or value is _KEEP_EXISTING_VALUE: + return None + return encrypt(str(value)) + + def pre_save(self, model_instance: models.Model, add: bool) -> Any: + """Resolve the blank-preservation flag from EncryptedFormField. + + When a ModelForm submits blank for an EncryptedFormField, the + flag ``_KEEP_EXISTING_VALUE`` flows through ``cleaned_data`` and + is set on the instance attribute by ``construct_instance``. We + intercept it here, fetch the existing plaintext (one-row SELECT, + decrypted by :meth:`from_db_value` along the way), restore the + in-memory attribute, and return the plaintext for the normal + encryption pipeline. Net effect: the secret is preserved + (re-encrypted under the current primary cipher, which is fine + — the plaintext is unchanged). + """ + value = getattr(model_instance, self.attname) + if value is _KEEP_EXISTING_VALUE: + if add: + # New instance — there is no existing value to preserve. + value = None + else: + value = ( + type(model_instance) + ._default_manager + .filter(pk=model_instance.pk) + .values_list(self.attname, flat=True) + .first() + ) + # Sync the in-memory state so post-save reads return the + # plaintext, not the flag. + setattr(model_instance, self.attname, value) + return value + + def formfield(self, **kwargs: Any) -> forms.Field: + """Return an :class:`EncryptedFormField` for ``ModelForm`` integration. + + Overrides ``BinaryField.formfield``, which returns ``None`` + because (normally) raw binary data is not editable in a regular form. + """ + defaults: dict[str, Any] = { + 'form_class': EncryptedFormField, + 'required': not self.blank, + 'label': capfirst(self.verbose_name), + 'help_text': self.help_text, + } + defaults.update(kwargs) + form_class = defaults.pop('form_class') + return form_class(**defaults) + + def get_lookup(self, lookup_name: str) -> Any: + """Refuse all lookups — Fernet ciphertext cannot match a plaintext query. + + Raised on any ``.filter()``, ``.exclude()``, ``.get()``, etc. + that targets this column. + """ + raise FieldError( + f'{type(self).__name__} does not support the {lookup_name!r} ' + f'lookup. Fernet encryption is non-deterministic, so the same ' + f'plaintext encrypts to different ciphertext each time and ' + f'database-level equality cannot match. If you need equality ' + f'search on this value, store a companion HMAC-derived hash ' + f'column alongside.' + ) + + def value_from_object(self, obj: models.Model) -> str | None: + """Return the :attr:`REDACTED` placeholder, never the plaintext. + + Called by DRF's default ``ModelSerializer`` field-value + discovery and by admin display introspection. Direct attribute + access (``getattr(instance, field_name)``) bypasses this method + and returns plaintext — that is the intended escape hatch. + """ + if obj.__dict__.get(self.attname): + return self.REDACTED + return None + + def value_to_string(self, obj: models.Model) -> str: + """Return the :attr:`REDACTED` placeholder for ``dumpdata`` output.""" + return self.REDACTED if obj.__dict__.get(self.attname) else '' diff --git a/tom_common/management/__init__.py b/tom_common/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_common/management/commands/__init__.py b/tom_common/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_common/management/commands/rotate_encryption_key.py b/tom_common/management/commands/rotate_encryption_key.py new file mode 100644 index 000000000..6152cf29b --- /dev/null +++ b/tom_common/management/commands/rotate_encryption_key.py @@ -0,0 +1,104 @@ +"""Re-encrypt every ``EncryptedModelField`` value under the current ``SECRET_KEY``. + +Use this after a graceful ``SECRET_KEY`` rotation +(``SECRET_KEY_FALLBACKS``-based): once the new key is the primary and +the old key is in the fallbacks list, run this command and Django will +walk every ``EncryptedModelField`` across ``INSTALLED_APPS``, decrypt +each value through :func:`tom_common.encryption.decrypt` (which +transparently tries the primary then each fallback), and re-encrypt +through :func:`tom_common.encryption.encrypt` (which always uses the +primary). After it finishes, the data no longer depends on any fallback +key and the admin can safely remove the fallback from settings. + +No arguments — the cipher rotation is fully expressed by the current +``SECRET_KEY`` and ``SECRET_KEY_FALLBACKS`` settings. +""" +from __future__ import annotations + +from typing import Iterator + +from cryptography.fernet import InvalidToken + +from django.apps import apps +from django.core.management.base import BaseCommand +from django.db.models import Model + +from tom_common.encryption import EncryptedModelField + + +class Command(BaseCommand): + help = ( + 'Re-encrypt every EncryptedModelField value under the primary cipher ' + '(settings.SECRET_KEY). After this completes successfully, the ' + 'entries in SECRET_KEY_FALLBACKS are no longer needed and may be ' + 'removed from settings.' + ) + + def handle(self, *args, **options) -> None: + success_count = 0 + failures: list[tuple[str, int, str, str]] = [] + + for model, field in _iter_encrypted_fields(): + label = f'{model._meta.label}.{field.name}' + # Fetch PKs first, then load each row individually. This keeps a + # single bad row's InvalidToken (raised from from_db_value during + # the row load) from halting iteration over the rest of the + # table — we can record the failure and move on. + pks = list(model._default_manager.values_list('pk', flat=True)) + for pk in pks: + try: + instance = model._default_manager.get(pk=pk) + except InvalidToken: + failures.append(( + model._meta.label, + pk, + field.name, + 'not decryptable with current SECRET_KEY or any ' + 'SECRET_KEY_FALLBACKS entry', + )) + continue + if getattr(instance, field.attname) is None: + continue + # The in-memory attribute holds plaintext (decrypted by + # from_db_value on load). save() routes it through + # get_prep_value, which encrypts under the current primary + # cipher. The resulting ciphertext is fresh — Fernet is + # non-deterministic — but the plaintext is unchanged. + instance.save(update_fields=[field.name]) + success_count += 1 + self.stdout.write(f' scanned {label}') + + self.stdout.write('') + self.stdout.write(self.style.SUCCESS( + f'Re-encrypted {success_count} value(s) under the primary cipher.' + )) + + if failures: + self.stdout.write('') + self.stdout.write(self.style.ERROR( + f'{len(failures)} value(s) could NOT be re-encrypted ' + '(decryption failed under every active key):' + )) + for failed_label, failed_pk, failed_field, reason in failures: + self.stderr.write(f' {failed_label}(pk={failed_pk}).{failed_field}: {reason}') + + self.stdout.write('') + self.stdout.write( + 'All encrypted data is now under the primary SECRET_KEY. You may ' + 'safely remove any/all entries from SECRET_KEY_FALLBACKS in your ' + 'settings.py and restart.' + ) + + +def _iter_encrypted_fields() -> Iterator[tuple[type[Model], EncryptedModelField]]: + """Yield ``(model, field)`` for every :class:`EncryptedModelField` + declared on any concrete model across ``INSTALLED_APPS``. + + Uses ``_meta.fields`` so we get only forward concrete fields (not + reverse relations) and so each field is yielded exactly once per + declaring model — no inheritance double-counting. + """ + for model in apps.get_models(): + for field in model._meta.fields: + if isinstance(field, EncryptedModelField): + yield model, field diff --git a/tom_common/migrations/0004_delete_usersession.py b/tom_common/migrations/0004_delete_usersession.py new file mode 100644 index 000000000..f1de5a905 --- /dev/null +++ b/tom_common/migrations/0004_delete_usersession.py @@ -0,0 +1,16 @@ +# Generated by Django 5.2.12 on 2026-03-27 22:29 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_common', '0002_usersession'), + ] + + operations = [ + migrations.DeleteModel( + name='UserSession', + ), + ] diff --git a/tom_common/models.py b/tom_common/models.py index 8091ed2f4..55f4c79b5 100644 --- a/tom_common/models.py +++ b/tom_common/models.py @@ -1,188 +1,13 @@ -import logging -from django.conf import settings -from django.db import models -from django.contrib.auth.models import User -from django.contrib.sessions.models import Session -from cryptography.fernet import Fernet - +from __future__ import annotations -logger = logging.getLogger(__name__) +from django.contrib.auth.models import User +from django.db import models class Profile(models.Model): - """Profile model for a TOMToolkit User""" + """Profile model for a TOMToolkit User.""" user = models.OneToOneField(User, on_delete=models.CASCADE) affiliation = models.CharField(max_length=100, null=True, blank=True) - def __str__(self): + def __str__(self) -> str: return f'{self.user.username} Profile' - - -class UserSession(models.Model): - """Mapping model to associate the User and their Sessions - - An instance of this model is created whenever we receive the user_logged_in - signal (see signals.py). Upon receiving user_logged_out, we delete all instances - of UserSession for the specific User logging out. - - This allows us to manage the User's encrypted data in their app profiles, - should they change their password (see signals.py). - """ - # if either of the referenced objects are deleted, delete this object (CASCADE). - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - session = models.ForeignKey(Session, on_delete=models.CASCADE) - - def __str__(self): - return f'UserSession for {self.user.username} with Session key {self.session.session_key}' - - -class EncryptedProperty: - """ - A Python descriptor that provides transparent encryption and decryption for a - model field. - - This descriptor is used in conjunction with the EncryptableModelMixin. It - requires a cipher to be temporarily attached to the model instance as `_cipher` - before accessing the property. - - Usage: - class MyModel(EncryptableModelMixin, models.Model): - _my_secret_encrypted = models.BinaryField(null=True) - my_secret = EncryptedProperty('_my_secret_encrypted') - """ - def __init__(self, db_field_name: str): - self.db_field_name = db_field_name - self.property_name = None # Set by __set_name__ - - def __set_name__(self, owner, name): - self.property_name = name - - def __get__(self, instance, owner): - if instance is None: - return self - - cipher = getattr(instance, '_cipher', None) - if not isinstance(cipher, Fernet): - raise AttributeError( - f"A Fernet cipher must be set on the '{owner.__name__}' instance " - f"as '_cipher' to access property '{self.property_name}'. " - f"Please use session_utils.get_encrypted_field() instead of direct access." - ) - - encrypted_value = getattr(instance, self.db_field_name) - if not encrypted_value: - return '' - - # Handle bytes (sqlite3) vs memoryview (postgresql) - if isinstance(encrypted_value, memoryview): - # postgresql/psycopg uses a memoryview object for BinaryFields. - # Sqlite3 uses bytes. When needed, convert to the encrypted_value - # to bytes before we decrypt and decode it. - encrypted_value = encrypted_value.tobytes() - - return cipher.decrypt(encrypted_value).decode() - - def __set__(self, instance, value: str): - cipher = getattr(instance, '_cipher', None) - if not isinstance(cipher, Fernet): - raise AttributeError( - f"A Fernet cipher must be set on the '{instance.__class__.__name__}' instance " - f"as '_cipher' to set property '{self.property_name}'." - ) - - if not value: - encrypted_value = None - else: - encrypted_value = cipher.encrypt(str(value).encode()) - - setattr(instance, self.db_field_name, encrypted_value) - - -class EncryptableModelMixin(models.Model): - """ - A mixin for models that use EncryptedProperty to handle sensitive data. - - Provides a generic re-encryption mechanism for all encrypted properties - in the model. - """ - # By defining the user relationship here, we ensure that any model using this - # mixin has a standardized way to associate with a user. This removes - # ambiguity and the need for assumptions in utility functions that need to - # find the user associated with an encryptable model instance. - # Subclasses should not redefine this field. - user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - - def reencrypt_model_fields(self, decoding_cipher: Fernet, encoding_cipher: Fernet) -> None: - """Re-encrypts all fields managed by an EncryptedProperty descriptor. - - Re-encryption means decypting to plaintext with the old cipher based on the old - password and re-encrypting the plaintext with the new cipher based on the new - password. - - The `EncryptableModelMixin` and the `EncyptedProperty` descriptor work together - to access the `Model`'s encytped `BinaryField`s (for setting, getting, and - re-encrypting, which involves both). - - The `EncryptedProperty` descriptor uses the `_cipher` attribute on the encyrpted - `BinaryField`-containing `Model` and this method sets and resets `_cipher` in the - process of re-encrypting: First, `Model._cipher` is the `decoding_cipher` to get the - plaintext value from the encrypted `BinaryField`. Second, `Model._cipher` is reset - to the `encoding_cipher` to encrypt the plaintext value and save it in the - `BinaryField`. Third, the `_cipher` attribute is removed from the `Model` until - the next time it's needed, when it's attached again. - - So, to re-encrpyt, for each of the Model's encrypted `BinaryField`s, we need to: - 1. Use the `decoding_cipher` to get the `plaintext` of the value stored in the - BinaryField. `self._cipher` is set to the `decoding_cipher` for this purpose - and the `EncyptedProperty` descriptor handles the getting. - 2. Reset `self._cipher` to be the `encoding_cipher` and have the `EncyptedProperty` - descriptor handle the encryption and setting. - 3. Remove the `_cipher` attribute from the Model. - """ - model_save_needed = False - for attr_name in dir(self.__class__): - attr = getattr(self.__class__, attr_name) - if isinstance(attr, EncryptedProperty): - try: - # Set decoding cipher and get plaintext - self._cipher = decoding_cipher - plaintext = getattr(self, attr_name) - - if plaintext: - # Set encoding cipher and set new value - self._cipher = encoding_cipher - setattr(self, attr_name, plaintext) - model_save_needed = True - except Exception as e: - logger.error(f"Error re-encrypting property {attr_name} for {self.__class__.__name__}" - f" instance {getattr(self, 'pk', 'UnknownPK')}: {e}") - finally: - # Clean up the temporary cipher - if hasattr(self, '_cipher'): - del self._cipher - if model_save_needed: - self.save() - - def clear_encrypted_fields(self) -> None: - """ - Clears all fields managed by an EncryptedProperty descriptor. - - This is a destructive operation used when re-encryption is not possible, - e.g., when a user's password is reset by an admin and the old - decryption key is unavailable. It sets the value of each encrypted - field to None. - """ - model_save_needed = False - for attr_name in dir(self.__class__): - attr = getattr(self.__class__, attr_name) - if isinstance(attr, EncryptedProperty): - # Directly set the underlying db field to None - setattr(self, attr.db_field_name, None) - model_save_needed = True - logger.info(f"Cleared encrypted property '{attr_name}' for {self.__class__.__name__} " - f"instance {getattr(self, 'pk', 'UnknownPK')}.") - if model_save_needed: - self.save() - - class Meta: - abstract = True diff --git a/tom_common/session_utils.py b/tom_common/session_utils.py deleted file mode 100644 index 54d81148b..000000000 --- a/tom_common/session_utils.py +++ /dev/null @@ -1,354 +0,0 @@ -import base64 -import logging -from typing import Optional, TypeVar - -from cryptography.fernet import Fernet -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.backends import default_backend - -from django.apps import AppConfig, apps -from django.db import models -from django.contrib.auth.models import User -from django.contrib.sessions.models import Session -from django.contrib.sessions.backends.db import SessionStore - -from tom_common.models import EncryptableModelMixin, UserSession - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) - -# Constant for storing the cipher encryption key in the session -SESSION_KEY_FOR_CIPHER_ENCRYPTION_KEY = 'key' - -# A generic TypeVar for a Django models.Model subclass instance. -# The `bound=models.Model` constraint ensures that any -# type used for ModelType must be a subclass of `models.Model`. -ModelType = TypeVar('ModelType', bound=models.Model) - - -def create_cipher_encryption_key(user: User, password: str) -> bytes: - """Creates a Fernet cipher encryption key derived from the user's password. - - This key is intended to be stored (e.g., in the session) and used to - instantiate Fernet ciphers for encrypting and decrypting sensitive data - associated with the user, such as API keys or external service credentials. - - The key derivation process uses PBKDF2HMAC with a salt generated from - the user's username, making the key unique per user and password. - - Args: - user: The Django User object. - password: The user's plaintext password. - - Returns: - A URL-safe base64-encoded Fernet encryption key as bytes. - - See Also: - https://cryptography.io/en/latest/fernet/#using-passwords-with-fernet - """ - - # Generate a salt from hash and username - salt = hashes.Hash(hashes.SHA256(), backend=default_backend()) - salt.update(user.username.encode()) - - # Derive encryption_key using PBKDF2-HMAC and the newly generated salt - kdf = PBKDF2HMAC( # key derivation function - algorithm=hashes.SHA256(), - length=32, - salt=salt.finalize()[:16], # only finalize once; returns bytes; use 16 bytes - iterations=1_000_000, # Django recommendation of jan-2025 - backend=default_backend(), - ) - encryption_key: bytes = base64.urlsafe_b64encode(kdf.derive(password.encode())) - return encryption_key - - -def save_key_to_session_store(key: bytes, session_store: SessionStore) -> None: - """Saves the provided encryption key to the given Django session store. - - The key is first base64 encoded and converted to a string before being - stored in the session under a predefined session key. - - Args: - key: The encryption key (bytes) to be saved. - session_store: The Django SessionStore instance where the key will be saved. - """ - try: - assert isinstance(session_store, SessionStore), \ - f"session_store is not a SessionStore; it's a {type(session_store)}" - except AssertionError as e: - logger.error(str(e)) - - # The key is bytes, but session values must be JSON-serializable. - # A Fernet key is already base64-encoded, so we just decode it to a string for storage. - session_store[SESSION_KEY_FOR_CIPHER_ENCRYPTION_KEY] = key.decode('utf-8') - session_store.save() # we might be accessing the session before it's saved (in the middleware?) - - -def get_key_from_session_model(session: Session) -> bytes: - """Extracts and decodes the encryption key from a Django Session object. - - Retrieves the base64 encoded key string from the session, decodes it - from base64, and returns the raw bytes of the encryption key. - - Args: - session: The Django Session object from which to extract the key. - - Returns: - The encryption key as bytes. - """ - - logger.debug(f"Extracting key from Session model: {type(session)} = {session} - {session.get_decoded()}") - key_as_str: str = session.get_decoded()[SESSION_KEY_FOR_CIPHER_ENCRYPTION_KEY] # type: ignore - # The key was stored as a string, so we encode it back to bytes. - return key_as_str.encode('utf-8') - - -def get_key_from_session_store(session_store: SessionStore) -> bytes: - """Extracts the encryption key from a Django SessionStore instance. - - Use the dictionary-like API that the SessionStore provides to retreive - the encryption key. - - Args: - session_store: The Django SessionStore instance. - - Returns: - The encryption key as bytes. - """ - if not isinstance(session_store, SessionStore): - # manual type checking - raise TypeError(f"Expected a SessionStore object, but got {type(session_store)}") - - key_as_str: str = session_store[SESSION_KEY_FOR_CIPHER_ENCRYPTION_KEY] - return key_as_str.encode('utf-8') - - -def get_encrypted_field(user: User, - model_instance: ModelType, # type: ignore - field_name: str) -> Optional[str]: - """ - Helper function to safely get the decrypted value of an EncryptedProperty. - - This function encapsulates the logic of fetching the user's session key, - creating a cipher, attaching it to the model instance, reading the - decrypted value, and cleaning up. - - Args: - user: The User object associated with the encrypted data. - model_instance: The model instance containing the EncryptedProperty. - field_name: The string name of the EncryptedProperty to access. - - Returns: - The decrypted string value, or None if decryption fails for any reason - (e.g., no active session, key not found). - """ - try: - # Get the current Session from the UserSession - # A user can be logged in from multiple browsers, resulting in multiple - # UserSession objects. Since the encryption key is derived from the - # password and is the same for all sessions, we can safely take the first one. - user_session = UserSession.objects.filter(user=user).first() - if not user_session: - raise UserSession.DoesNotExist(f"No active session found for user {user.username}") - - session: Session = user_session.session - cipher_key: bytes = get_key_from_session_model(session) - cipher: Fernet = Fernet(cipher_key) - - # Attach the cipher, get the value, and then clean up - model_instance._cipher = cipher # type: ignore - decrypted_value = getattr(model_instance, field_name) - return decrypted_value - except (UserSession.DoesNotExist, KeyError) as e: - logger.warning(f"Could not get encryption key for user {user.username} to access " - f"'{field_name}': {e}") - return None - except Exception as e: - logger.error(f"An unexpected error occurred while decrypting field '{field_name}' " - f"for user {user.username}: {e}") - return None - finally: - # Ensure the temporary cipher is always removed from the instance - if hasattr(model_instance, '_cipher'): - del model_instance._cipher # type: ignore - - -def set_encrypted_field(user: User, - model_instance: ModelType, # type: ignore - field_name: str, - value: str) -> bool: - """ - Helper function to safely set the value of an EncryptedProperty. - - This function encapsulates the logic of fetching the user's session key, - creating a cipher, attaching it to the model instance, setting the new - encrypted value, and cleaning up. - - Note: This function does NOT save the instance. The caller is responsible - for calling `instance.save()` after the field has been set. - - Args: - user: The User object associated with the encrypted data. - model_instance: The model instance containing the EncryptedProperty. - field_name: The string name of the EncryptedProperty to set. - value: The plaintext string value to encrypt and set. - - Returns: - True if the field was set successfully, False otherwise. - """ - try: - # Get the current Session from the UserSession - user_session = UserSession.objects.filter(user=user).first() # see comment above - if not user_session: - raise UserSession.DoesNotExist(f"No active session found for user {user.username}") - - session: Session = user_session.session - cipher_key: bytes = get_key_from_session_model(session) - cipher = Fernet(cipher_key) - - # Attach the cipher, set the value, and then clean up - model_instance._cipher = cipher # type: ignore - setattr(model_instance, field_name, value) - return True - except (UserSession.DoesNotExist, KeyError) as e: - logger.error(f"Could not get encryption key for user {user.username} to set " - f"'{field_name}': {e}") - return False - except Exception as e: - logger.error(f"An unexpected error occurred while encrypting field '{field_name}' " - f"for user {user.username}: {e}") - return False - finally: - # Ensure the temporary cipher is always removed from the instance - if hasattr(model_instance, '_cipher'): - del model_instance._cipher # type: ignore - - -def reencrypt_data(user) -> None: - """Re-encrypts sensitive data for a user after a password change. - - If an Administrator is changing another user's password, and - the `user: User` is not logged-in, then they have no SessionStore, - and, thus, no encryption key is available. In that case, the User's - encrypted fields are cleared out because they are stale, having - been ecrypted with an encryption key derived from a password that - is no longer in use. - - Args: - user: The Django User object whose password has changed. - """ - logger.debug("Re-encrypting sensitive data...") - - # Get the current Session from the UserSession - user_session = UserSession.objects.filter(user=user.id).first() # see comment above - - if not user_session: - logger.warning(f"User {user.username} is not logged in. Cannot re-encrypt sensitive data. " - f"Clearing all encrypted fields instead.") - # Loop through all the installed apps and ask them to clear their encrypted profile fields - for app_config in apps.get_app_configs(): - clear_encrypted_fields_for_user(app_config, user) # type: ignore - return - - session: Session = user_session.session - # Get the current encryption_key from the Session - current_encryption_key: bytes = get_key_from_session_model(session) - # Generate a decoding Fernet cipher with the current encryption key - decoding_cipher = Fernet(current_encryption_key) - - # Get the new raw password from the User instance - new_raw_password = user._password # CAUTION: this is implemenation dependent (using _) - if not new_raw_password: - logger.error(f"User {user.username} does not have a raw password available. Cannot re-encrypt sensitive data.") - return - # Generate a new encryption_key with the new raw password - new_encryption_key: bytes = create_cipher_encryption_key(user, new_raw_password) - # Generate a new encoding Fernet cipher with the new encryption key - encoding_cipher = Fernet(new_encryption_key) - - # Save the new encryption key in the User's Session - session_store: SessionStore = SessionStore(session_key=session.session_key) - save_key_to_session_store(new_encryption_key, session_store) - # also, attach the new encryption key to the User instance so it can be inserted - # into the Session before we call update_session_auth_hash in - # tom_common.views.UserUpdateView.form_valid() - user._temp_new_fernet_key = new_encryption_key - - # Loop through all the installed apps and ask them to reencrypt their encrypted profile fields - for app_config in apps.get_app_configs(): - try: - reencrypt_encypted_fields_for_user(app_config, user, decoding_cipher, encoding_cipher) # type: ignore - except AttributeError: - logger.debug(f'App: {app_config.name} does not have a reencrypt_app_fields method.') - continue - - -def reencrypt_encypted_fields_for_user(app_config: AppConfig, user: 'User', - decoding_cipher: Fernet, encoding_cipher: Fernet): - """ - Automatically finds models in the app_config that inherit from EncryptableModelMixin - and attempts to re-encrypt their fields for the given user. - - :param app_config: The AppConfig instance of the plugin app. - :param user: The User whose data needs re-encryption. - :param decoding_cipher: Fernet cipher to decrypt existing data. - :param encoding_cipher: Fernet cipher to encrypt new data. - """ - for model_class in app_config.get_models(): - if issubclass(model_class, EncryptableModelMixin): - logger.debug(f"Found EncryptableModelMixin subclass: {model_class.__name__} in app {app_config.name}") - # The EncryptableModelMixin guarantees a 'user' field, which is a OneToOneField. - try: - encryptable_model_instance = model_class.objects.get(user=user) - # instance of the Model which is a subclass of EncryptableModelMixin - encryptable_model_instance.reencrypt_model_fields(decoding_cipher, encoding_cipher) # re-entrpt here - except model_class.DoesNotExist: - logger.info(f"No {model_class.__name__} instance found for user {user.username}.") - except model_class.MultipleObjectsReturned: - # This should not be reached if the mixin correctly enforces a OneToOneField. - # It's kept here as a safeguard against unexpected configurations. - logger.error(f"Multiple {model_class.__name__} instances found for user {user.username}. " - f"This is unexpected for an EncryptableModelMixin. Re-encrypting all found.") - instances = model_class.objects.filter(user=user) - for encryptable_model_instance in instances: - encryptable_model_instance.reencrypt_model_fields(decoding_cipher, encoding_cipher) - except Exception as e: - logger.error(f"Error processing model {model_class.__name__} for re-encryption for " - f"user {user.username}: {e}") - - -def clear_encrypted_fields_for_user(app_config: AppConfig, user: 'User',) -> None: - """ - Finds models in an app that are Encryptable and clears their encrypted fields for the given user. - - This is a destructive operation used when a user's password is reset without - them being logged in, making the old decryption key unavailable. This happens, - for example, when an adminitrator resets their password. - - :param app_config: The AppConfig instance of the plugin app. - :param user: The User whose data needs to be cleared. - """ - for model_class in app_config.get_models(): - if issubclass(model_class, EncryptableModelMixin): - logger.debug(f"Found EncryptableModelMixin subclass: {model_class.__name__} in " - f"app {app_config.name} for clearing.") - # The EncryptableModelMixin now guarantees a 'user' field, which is a OneToOneField. - try: - encryptable_model_instance = model_class.objects.get(user=user) - # instance of the Model which is a subclass of EncryptableModelMixin - encryptable_model_instance.clear_encrypted_fields() # do the clearing of the fields here - except model_class.DoesNotExist: - logger.info(f"No {model_class.__name__} instance found for user {user.username} to clear.") - except model_class.MultipleObjectsReturned: - # This should not be reached if the mixin correctly enforces a OneToOneField. - # It's kept here as a safeguard against unexpected configurations. - logger.error(f"Multiple {model_class.__name__} instances found for user {user.username}. " - f"This is unexpected for an EncryptableModelMixin. Clearing all found.") - instances = model_class.objects.filter(user=user) - for encryptable_model_instance in instances: - encryptable_model_instance.clear_encrypted_fields() - except Exception as e: - logger.error(f"Error clearing encrypted fields for model {model_class.__name__} for " - f"user {user.username}: {e}") diff --git a/tom_common/signals.py b/tom_common/signals.py index 108720412..abfd679fe 100644 --- a/tom_common/signals.py +++ b/tom_common/signals.py @@ -1,17 +1,13 @@ import logging from django.conf import settings -from django.contrib.auth import get_user_model from django.contrib.auth.models import User -from django.contrib.auth.signals import user_logged_in, user_logged_out -from django.contrib.sessions.models import Session -from django.db.models.signals import post_save, pre_save +from django.db.models.signals import post_save from django.dispatch import receiver from rest_framework.authtoken.models import Token -from tom_common.models import Profile, UserSession -from tom_common import session_utils +from tom_common.models import Profile logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) @@ -23,15 +19,33 @@ # while get_user_model() is valid after INSTALLED_APPS are loaded. -# Signal: Create a Profile for the User when the User instance is created +# Signal: Ensure every User has a Profile. @receiver(post_save, sender=User) -def save_profile_on_user_post_save(sender, instance, **kwargs): - """When a user is saved, save their profile.""" - # Take advantage of the fact that logging in updates a user's last_login field - # to create a profile for users that don't have one. - try: - instance.profile.save() - except User.profile.RelatedObjectDoesNotExist: # type: ignore +def save_profile_on_user_post_save(sender, instance, created, **kwargs) -> None: + """Guarantee the saved User has an associated :class:`Profile`. + + On creation, ``CustomUserCreationForm``'s inline Profile formset may + have pre-attached a pk=None Profile to ``instance.profile`` carrying + form-supplied data like ``affiliation``; we honour that by saving it + via the descriptor. Otherwise we create a bare Profile from scratch. + + On subsequent saves we do not write back ``instance.profile`` — the + cached value can be stale (e.g. ``update_last_login`` fires this + signal on every login). Concurrent out-of-band Profile updates would + be clobbered by an unconditional save. So on non-creation saves we + only ensure a Profile *exists* (legacy users from before the Profile + model existed); we never write to one that's already there. + """ + if created: + try: + instance.profile.save() + except User.profile.RelatedObjectDoesNotExist: # type: ignore[attr-defined] + logger.info(f'No Profile found for {instance}. Creating Profile.') + Profile.objects.create(user=instance) + return + + # Non-creation save: just backfill a Profile if (somehow) missing. + if not Profile.objects.filter(user=instance).exists(): logger.info(f'No Profile found for {instance}. Creating Profile.') Profile.objects.create(user=instance) @@ -49,120 +63,3 @@ def create_auth_token_on_user_post_save(sender, instance=None, created=False, ** """ if created: Token.objects.create(user=instance) - - -# Signal: Create UserSession on login -@receiver(user_logged_in) -def create_user_session_on_user_logged_in(sender, request, user, **kwargs) -> None: - """Whenever a user logs in, create a UserSession instance to associate - the User with the new Session. - """ - logger.debug(f"User {user.username} has logged in. request: {request}") - logger.debug(f"Request session: {type(request.session)} = {request.session}") - - # the request.session is a SessionStore object, we need the Session - # and we can get it using the session_key - try: - session: Session = Session.objects.get(pk=request.session.session_key) - except Session.DoesNotExist: - # this request should have a sesssion: SessionStore object, and if it - # doesn't, it could be because the user was logged in as part of a test, but - # TODO: sort out whether the test code should be updated or ??? - logger.error(f"Session {request.session.session_key} does not exist.") - return - - logger.debug(f"Session: {type(session)} = {session}") - - user_session, created = UserSession.objects.get_or_create(user=user, session=session) - if created: - logger.debug(f"UserSession created: {user_session}") - else: - logger.debug(f"UserSession already exists: {user_session}") - - -# Signal: Delete UserSession on logout -@receiver(user_logged_out) -def delete_user_session_on_user_logged_out(sender, request, user, **kwargs) -> None: - """Whenever a user logs out, delete all their UserSession instances. - """ - user_sessions = UserSession.objects.filter(user=user) - for user_session in user_sessions: - user_session.session.delete() - # TODO: consider if the User has logged in from multiple browsers/devices - # (i.e. we want to delete all their sessions or just the one they logged out from) - # this could probably be done by filtering on the session_key of the request in - # addition to the user above. - - -# Signal: Set cipher on login -@receiver(user_logged_in) -def set_cipher_on_user_logged_in(sender, request, user, **kwargs) -> None: - """When the user logs in, capture their password and use it to - generate a cipher encryption key and save it in the User's Session. - """ - logger.debug(f"User {user.username} has logged in. request: {request}") - - password = request.POST.get("password") # Capture password from login - if password: - encryption_key: bytes = session_utils.create_cipher_encryption_key(user, password) - session_utils.save_key_to_session_store(encryption_key, request.session) - else: - logger.error(f'User {user.username} logged in without a password. Cannot create encryption key.') - - -# Signal: Clear cipher encryption key on logout -@receiver(user_logged_out) -def clear_encryption_key_on_user_logged_out(sender, request, user, **kwargs) -> None: - """Clear the cipher encryption key when a user logs out. - """ - if user: - logger.debug(f'User {user.username} has logged out. Deleting key from Session.' - f'sender: {sender}; request: {request}') - request.session.pop(session_utils.SESSION_KEY_FOR_CIPHER_ENCRYPTION_KEY, None) - - -# Signal: Update the User's sensitive data when the password changes -@receiver(pre_save, sender=get_user_model()) -def user_updated_on_user_pre_save(sender, **kwargs): - """When the User model is saved, detect if the password has changed. - - kwargs: - * signal: - * instance: - * raw: Boolean - * using: str - * update_fields: frozenset | NoneType - - If the User's password has changed, take the following actions: - - Current list of actions to be taken upon User password change: - * re-encrypt the user's sensitive data (see session_utils.reencrypt_data() function) - * - """ - logger.debug(f"kwargs: {kwargs}") - user = kwargs.get("instance", None) - - if user and not user.username == 'AnonymousUser' and not user.is_anonymous: - # user.password vs. user._password: - # the user.password field is used for authentication (via comparison to the (hashed) password - # being tested for validity). The _password field is the raw password and is what we need to - # create a new cipher for the User's sensitive data. - - # This Signal is called for ANY change to the User model, not just password changes. - # So, determine if the password has changed by comparing new and old (hashed) passwords. - # NOTE: the update_fields kwarg is a frozenset of changed updated fields, but it does not contain - # 'password' when the User is changing their password. So, compare new and old: - - new_hashed_password = user.password # from the not-yet-saved User instance - try: - old_hashed_password = User.objects.get(id=user.id).password # from the previously-saved User instance - except User.DoesNotExist: - old_hashed_password = None - - if new_hashed_password != old_hashed_password: - # New password detected - logger.debug(f'User {user.username} is changing their password.') - session_utils.reencrypt_data(user) # need new RAW password to re-create cipher and re-encrypt - else: - # No new password detected - logger.debug(f'User {user.username} is updating their profile without a password change.') diff --git a/tom_common/templates/tom_common/create_user.html b/tom_common/templates/tom_common/create_user.html index 5ceffb565..f1a019db1 100644 --- a/tom_common/templates/tom_common/create_user.html +++ b/tom_common/templates/tom_common/create_user.html @@ -10,10 +10,6 @@ {% csrf_token %} {% bootstrap_form form %} {% bootstrap_formset form.user_profile_formset %} - {% if object.pk != current_user.pk %} -

WARNING: Changing the password for user {{ object.username }} will clear out all of - their saved external service API keys and passwords (if any).

- {% endif %} {% buttons %}