authenticated_actions.py 7.8 KB

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