authenticated_actions.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. from __future__ import annotations
  2. import json
  3. from hashlib import sha256
  4. from django.db import models
  5. from django.utils import timezone
  6. from .domains import Domain
  7. class AuthenticatedAction(models.Model):
  8. """
  9. Represents a procedure call on a defined set of arguments.
  10. Subclasses can define additional arguments by adding Django model fields and must define the action to be taken by
  11. implementing the `_act` method.
  12. AuthenticatedAction provides the `state` property which by default is a hash of the action type (defined by the
  13. action's class path). Other information such as user state can be included in the state hash by (carefully)
  14. overriding the `_state_fields` property. Instantiation of the model, if given a `state` kwarg, will raise an error
  15. if the given state argument does not match the state computed from `_state_fields` at the moment of instantiation.
  16. The same applies to the `act` method: If called on an object that was instantiated without a `state` kwargs, an
  17. error will be raised.
  18. This effectively allows hash-authenticated procedure calls by third parties as long as the server-side state is
  19. unaltered, according to the following protocol:
  20. (1) Instantiate the AuthenticatedAction subclass representing the action to be taken (no `state` kwarg here),
  21. (2) provide information on how to instantiate the instance, and the state hash, to a third party,
  22. (3) when provided with data that allows instantiation and a valid state hash, take the defined action, possibly with
  23. additional parameters chosen by the third party that do not belong to the verified state.
  24. """
  25. _validated = False
  26. class Meta:
  27. managed = False
  28. def __init__(self, *args, **kwargs):
  29. state = kwargs.pop('state', None)
  30. super().__init__(*args, **kwargs)
  31. if state is not None:
  32. self._validated = self.validate_state(state)
  33. if not self._validated:
  34. raise ValueError
  35. @property
  36. def _state_fields(self) -> list:
  37. """
  38. Returns a list that defines the state of this action (used for authentication of this action).
  39. Return value must be JSON-serializable.
  40. Values not included in the return value will not be used for authentication, i.e. those values can be varied
  41. freely and function as unauthenticated action input parameters.
  42. Use caution when overriding this method. You will usually want to append a value to the list returned by the
  43. parent. Overriding the behavior altogether could result in reducing the state to fewer variables, resulting
  44. in valid signatures when they were intended to be invalid. The suggested method for overriding is
  45. @property
  46. def _state_fields:
  47. return super()._state_fields + [self.important_value, self.another_added_value]
  48. :return: List of values to be signed.
  49. """
  50. name = '.'.join([self.__module__, self.__class__.__qualname__])
  51. return [name]
  52. @staticmethod
  53. def state_of(fields: list):
  54. state = json.dumps(fields).encode()
  55. h = sha256()
  56. h.update(state)
  57. return h.hexdigest()
  58. @property
  59. def state(self):
  60. return self.state_of(self._state_fields)
  61. def validate_state(self, value):
  62. return value == self.state
  63. def _act(self):
  64. """
  65. Conduct the action represented by this class.
  66. :return: None
  67. """
  68. raise NotImplementedError
  69. def act(self):
  70. if not self._validated:
  71. raise RuntimeError('Action state could not be verified.')
  72. return self._act()
  73. class AuthenticatedBasicUserAction(AuthenticatedAction):
  74. """
  75. Abstract AuthenticatedAction involving a user instance.
  76. """
  77. user = models.ForeignKey('User', on_delete=models.DO_NOTHING)
  78. class Meta:
  79. managed = False
  80. @property
  81. def _state_fields(self):
  82. return super()._state_fields + [str(self.user.id)]
  83. class AuthenticatedEmailUserAction(AuthenticatedBasicUserAction):
  84. """
  85. Abstract AuthenticatedAction involving a user instance with unmodified email address.
  86. Only child class is now AuthenticatedChangeOutreachPreferenceUserAction. Conceptually, we could
  87. flatten the Authenticated*Action class hierarchy, but that would break migration 0024 that depends
  88. on it (see https://docs.djangoproject.com/en/4.1/topics/migrations/#historical-models).
  89. """
  90. class Meta:
  91. managed = False
  92. @property
  93. def _state_fields(self):
  94. return super()._state_fields + [self.user.email]
  95. class AuthenticatedChangeOutreachPreferenceUserAction(AuthenticatedEmailUserAction):
  96. outreach_preference = models.BooleanField(default=False)
  97. class Meta:
  98. managed = False
  99. def _act(self):
  100. self.user.outreach_preference = self.outreach_preference
  101. self.user.save()
  102. class AuthenticatedUserAction(AuthenticatedBasicUserAction):
  103. """
  104. Abstract AuthenticatedBasicUserAction, incorporating the user's id, email, password, and is_active flag into the
  105. Message Authentication Code state.
  106. """
  107. class Meta:
  108. managed = False
  109. def validate_legacy_state(self, value):
  110. # NEW: (1) classname, (2) user.id, (3) user.credentials_changed, (4) user.is_active, (7 ...) [subclasses]
  111. # OLD: (1) classname, (2) user.id, (3) user.email, (4) user.password, (5) user.is_active, (6 ...) [subclasses]
  112. legacy_state_fields = self._state_fields[:2] + [self.user.email, self.user.password] + self._state_fields[3:]
  113. return value == self.state_of(legacy_state_fields)
  114. def validate_state(self, value):
  115. # Retry with structure before migration 0027 for transition period
  116. # TODO Remove once old links expired (>= 2022-08-29 && DESECSTACK_API_AUTHACTION_VALIDITY hours after deploying)
  117. return super().validate_state(value) or self.validate_legacy_state(value)
  118. @property
  119. def _state_fields(self):
  120. return super()._state_fields + [self.user.credentials_changed.isoformat(), self.user.is_active]
  121. class AuthenticatedActivateUserAction(AuthenticatedUserAction):
  122. domain = models.CharField(max_length=191)
  123. class Meta:
  124. managed = False
  125. @property
  126. def _state_fields(self):
  127. return super()._state_fields + [self.domain]
  128. def _act(self):
  129. self.user.activate()
  130. class AuthenticatedChangeEmailUserAction(AuthenticatedUserAction):
  131. new_email = models.EmailField()
  132. class Meta:
  133. managed = False
  134. @property
  135. def _state_fields(self):
  136. return super()._state_fields + [self.new_email]
  137. def _act(self):
  138. self.user.change_email(self.new_email)
  139. class AuthenticatedNoopUserAction(AuthenticatedUserAction):
  140. class Meta:
  141. managed = False
  142. def _act(self):
  143. pass
  144. class AuthenticatedResetPasswordUserAction(AuthenticatedUserAction):
  145. new_password = models.CharField(max_length=128)
  146. class Meta:
  147. managed = False
  148. def _act(self):
  149. self.user.change_password(self.new_password)
  150. class AuthenticatedDeleteUserAction(AuthenticatedUserAction):
  151. class Meta:
  152. managed = False
  153. def _act(self):
  154. self.user.delete()
  155. class AuthenticatedDomainBasicUserAction(AuthenticatedBasicUserAction):
  156. """
  157. Abstract AuthenticatedBasicUserAction involving an domain instance, incorporating the domain's id, name as well as
  158. the owner ID into the Message Authentication Code state.
  159. """
  160. domain = models.ForeignKey('Domain', on_delete=models.DO_NOTHING)
  161. class Meta:
  162. managed = False
  163. @property
  164. def _state_fields(self):
  165. return super()._state_fields + [
  166. str(self.domain.id), # ensures the domain object is identical
  167. self.domain.name, # exclude renamed domains
  168. str(self.domain.owner.id), # exclude transferred domains
  169. ]
  170. class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction):
  171. class Meta:
  172. managed = False
  173. @property
  174. def _state_fields(self):
  175. return super()._state_fields + [str(self.domain.renewal_changed)]
  176. def _act(self):
  177. self.domain.renewal_state = Domain.RenewalState.FRESH
  178. self.domain.renewal_changed = timezone.now()
  179. self.domain.save(update_fields=['renewal_state', 'renewal_changed'])