123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- from __future__ import annotations
- import json
- from hashlib import sha256
- from django.db import models
- from django.utils import timezone
- from .domains import Domain
- class AuthenticatedAction(models.Model):
- """
- Represents a procedure call on a defined set of arguments.
- Subclasses can define additional arguments by adding Django model fields and must define the action to be taken by
- implementing the `_act` method.
- AuthenticatedAction provides the `state` property which by default is a hash of the action type (defined by the
- action's class path). Other information such as user state can be included in the state hash by (carefully)
- overriding the `_state_fields` property. Instantiation of the model, if given a `state` kwarg, will raise an error
- if the given state argument does not match the state computed from `_state_fields` at the moment of instantiation.
- The same applies to the `act` method: If called on an object that was instantiated without a `state` kwargs, an
- error will be raised.
- This effectively allows hash-authenticated procedure calls by third parties as long as the server-side state is
- unaltered, according to the following protocol:
- (1) Instantiate the AuthenticatedAction subclass representing the action to be taken (no `state` kwarg here),
- (2) provide information on how to instantiate the instance, and the state hash, to a third party,
- (3) when provided with data that allows instantiation and a valid state hash, take the defined action, possibly with
- additional parameters chosen by the third party that do not belong to the verified state.
- """
- _validated = False
- class Meta:
- managed = False
- def __init__(self, *args, **kwargs):
- state = kwargs.pop("state", None)
- super().__init__(*args, **kwargs)
- if state is not None:
- self._validated = self.validate_state(state)
- if not self._validated:
- raise ValueError
- @property
- def _state_fields(self) -> list:
- """
- Returns a list that defines the state of this action (used for authentication of this action).
- Return value must be JSON-serializable.
- Values not included in the return value will not be used for authentication, i.e. those values can be varied
- freely and function as unauthenticated action input parameters.
- Use caution when overriding this method. You will usually want to append a value to the list returned by the
- parent. Overriding the behavior altogether could result in reducing the state to fewer variables, resulting
- in valid signatures when they were intended to be invalid. The suggested method for overriding is
- @property
- def _state_fields:
- return super()._state_fields + [self.important_value, self.another_added_value]
- :return: List of values to be signed.
- """
- name = ".".join([self.__module__, self.__class__.__qualname__])
- return [name]
- @staticmethod
- def state_of(fields: list):
- state = json.dumps(fields).encode()
- h = sha256()
- h.update(state)
- return h.hexdigest()
- @property
- def state(self):
- return self.state_of(self._state_fields)
- def validate_state(self, value):
- return value == self.state
- def _act(self):
- """
- Conduct the action represented by this class.
- :return: None
- """
- raise NotImplementedError
- def act(self):
- if not self._validated:
- raise RuntimeError("Action state could not be verified.")
- return self._act()
- class AuthenticatedBasicUserAction(AuthenticatedAction):
- """
- Abstract AuthenticatedAction involving a user instance.
- """
- user = models.ForeignKey("User", on_delete=models.DO_NOTHING)
- class Meta:
- managed = False
- @property
- def _state_fields(self):
- return super()._state_fields + [str(self.user.id)]
- class AuthenticatedEmailUserAction(AuthenticatedBasicUserAction):
- """
- Abstract AuthenticatedAction involving a user instance with unmodified email address.
- Only child class is now AuthenticatedChangeOutreachPreferenceUserAction. Conceptually, we could
- flatten the Authenticated*Action class hierarchy, but that would break migration 0024 that depends
- on it (see https://docs.djangoproject.com/en/4.1/topics/migrations/#historical-models).
- """
- class Meta:
- managed = False
- @property
- def _state_fields(self):
- return super()._state_fields + [self.user.email]
- class AuthenticatedChangeOutreachPreferenceUserAction(AuthenticatedEmailUserAction):
- outreach_preference = models.BooleanField(default=False)
- class Meta:
- managed = False
- def _act(self):
- self.user.outreach_preference = self.outreach_preference
- self.user.save()
- class AuthenticatedUserAction(AuthenticatedBasicUserAction):
- """
- Abstract AuthenticatedBasicUserAction, incorporating the user's id, email, password, and is_active flag into the
- Message Authentication Code state.
- """
- class Meta:
- managed = False
- def validate_legacy_state(self, value):
- # NEW: (1) classname, (2) user.id, (3) user.credentials_changed, (4) user.is_active, (7 ...) [subclasses]
- # OLD: (1) classname, (2) user.id, (3) user.email, (4) user.password, (5) user.is_active, (6 ...) [subclasses]
- legacy_state_fields = (
- self._state_fields[:2]
- + [self.user.email, self.user.password]
- + self._state_fields[3:]
- )
- return value == self.state_of(legacy_state_fields)
- def validate_state(self, value):
- # Retry with structure before migration 0027 for transition period
- # TODO Remove once old links expired (>= 2022-08-29 && DESECSTACK_API_AUTHACTION_VALIDITY hours after deploying)
- return super().validate_state(value) or self.validate_legacy_state(value)
- @property
- def _state_fields(self):
- return super()._state_fields + [
- self.user.credentials_changed.isoformat(),
- self.user.is_active,
- ]
- class AuthenticatedActivateUserAction(AuthenticatedUserAction):
- domain = models.CharField(max_length=191)
- class Meta:
- managed = False
- @property
- def _state_fields(self):
- return super()._state_fields + [self.domain]
- def _act(self):
- self.user.activate()
- class AuthenticatedChangeEmailUserAction(AuthenticatedUserAction):
- new_email = models.EmailField()
- class Meta:
- managed = False
- @property
- def _state_fields(self):
- return super()._state_fields + [self.new_email]
- def _act(self):
- self.user.change_email(self.new_email)
- class AuthenticatedNoopUserAction(AuthenticatedUserAction):
- class Meta:
- managed = False
- def _act(self):
- pass
- class AuthenticatedResetPasswordUserAction(AuthenticatedUserAction):
- new_password = models.CharField(max_length=128)
- class Meta:
- managed = False
- def _act(self):
- self.user.change_password(self.new_password)
- class AuthenticatedDeleteUserAction(AuthenticatedUserAction):
- class Meta:
- managed = False
- def _act(self):
- self.user.delete()
- class AuthenticatedDomainBasicUserAction(AuthenticatedBasicUserAction):
- """
- Abstract AuthenticatedBasicUserAction involving an domain instance, incorporating the domain's id, name as well as
- the owner ID into the Message Authentication Code state.
- """
- domain = models.ForeignKey("Domain", on_delete=models.DO_NOTHING)
- class Meta:
- managed = False
- @property
- def _state_fields(self):
- return super()._state_fields + [
- str(self.domain.id), # ensures the domain object is identical
- self.domain.name, # exclude renamed domains
- str(self.domain.owner.id), # exclude transferred domains
- ]
- class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction):
- class Meta:
- managed = False
- @property
- def _state_fields(self):
- return super()._state_fields + [str(self.domain.renewal_changed)]
- def _act(self):
- self.domain.renewal_state = Domain.RenewalState.FRESH
- self.domain.renewal_changed = timezone.now()
- self.domain.save(update_fields=["renewal_state", "renewal_changed"])
|