authenticated_actions.py 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. import binascii
  2. import json
  3. from datetime import timedelta
  4. from rest_framework import fields, serializers
  5. from rest_framework.settings import api_settings
  6. from rest_framework.validators import UniqueValidator, qs_filter
  7. from api import settings
  8. from desecapi import crypto, models
  9. from .captcha import CaptchaSolutionSerializer
  10. class CustomFieldNameUniqueValidator(UniqueValidator):
  11. """
  12. Does exactly what rest_framework's UniqueValidator does, however allows to further customize the
  13. query that is used to determine the uniqueness.
  14. More specifically, we allow that the field name the value is queried against is passed when initializing
  15. this validator. (At the time of writing, UniqueValidator insists that the field's name is used for the
  16. database query field; only how the lookup must match is allowed to be changed.)
  17. """
  18. def __init__(self, queryset, message=None, lookup="exact", lookup_field=None):
  19. self.lookup_field = lookup_field
  20. super().__init__(queryset, message, lookup)
  21. def filter_queryset(self, value, queryset, field_name):
  22. """
  23. Filter the queryset to all instances matching the given value on the specified lookup field.
  24. """
  25. filter_kwargs = {
  26. "%s__%s" % (self.lookup_field or field_name, self.lookup): value
  27. }
  28. return qs_filter(queryset, **filter_kwargs)
  29. class AuthenticatedActionSerializer(serializers.ModelSerializer):
  30. state = serializers.CharField() # serializer read-write, but model read-only field
  31. validity_period = settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
  32. _crypto_context = "desecapi.serializers.AuthenticatedActionSerializer"
  33. timestamp = None # is set to the code's timestamp during validation
  34. class Meta:
  35. model = models.AuthenticatedAction
  36. fields = ("state",)
  37. @classmethod
  38. def _pack_code(cls, data):
  39. payload = json.dumps(data).encode()
  40. code = crypto.encrypt(payload, context=cls._crypto_context).decode()
  41. return code.rstrip("=")
  42. @classmethod
  43. def _unpack_code(cls, code, *, ttl):
  44. code += -len(code) % 4 * "="
  45. try:
  46. timestamp, payload = crypto.decrypt(
  47. code.encode(), context=cls._crypto_context, ttl=ttl
  48. )
  49. return timestamp, json.loads(payload.decode())
  50. except (
  51. TypeError,
  52. UnicodeDecodeError,
  53. UnicodeEncodeError,
  54. json.JSONDecodeError,
  55. binascii.Error,
  56. ):
  57. raise ValueError
  58. def to_representation(self, instance: models.AuthenticatedAction):
  59. # do the regular business
  60. data = super().to_representation(instance)
  61. # encode into single string
  62. return {"code": self._pack_code(data)}
  63. def to_internal_value(self, data):
  64. # Allow injecting validity period from context. This is used, for example, for authentication, where the code's
  65. # integrity and timestamp is checked by AuthenticatedBasicUserActionSerializer with validity injected as needed.
  66. validity_period = self.context.get("validity_period", self.validity_period)
  67. # calculate code TTL
  68. try:
  69. ttl = validity_period.total_seconds()
  70. except AttributeError:
  71. ttl = None # infinite
  72. # decode from single string
  73. try:
  74. self.timestamp, unpacked_data = self._unpack_code(
  75. self.context["code"], ttl=ttl
  76. )
  77. except KeyError:
  78. raise serializers.ValidationError({"code": ["This field is required."]})
  79. except ValueError:
  80. if ttl is None:
  81. msg = "This code is invalid."
  82. else:
  83. msg = f"This code is invalid, possibly because it expired (validity: {validity_period})."
  84. raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: msg})
  85. # add extra fields added by the user, but give precedence to fields unpacked from the code
  86. data = {**data, **unpacked_data}
  87. # do the regular business
  88. return super().to_internal_value(data)
  89. def act(self):
  90. self.instance.act()
  91. return self.instance
  92. def save(self, **kwargs):
  93. raise ValueError
  94. class AuthenticatedBasicUserActionMixin:
  95. def save(self, **kwargs):
  96. context = {**self.context, "action_serializer": self}
  97. return self.action_user.send_email(self.reason, context=context, **kwargs)
  98. class AuthenticatedBasicUserActionSerializer(
  99. AuthenticatedBasicUserActionMixin, AuthenticatedActionSerializer
  100. ):
  101. user = serializers.PrimaryKeyRelatedField(
  102. queryset=models.User.objects.all(),
  103. error_messages={"does_not_exist": "This user does not exist."},
  104. pk_field=serializers.UUIDField(),
  105. )
  106. reason = None
  107. class Meta:
  108. model = models.AuthenticatedBasicUserAction
  109. fields = AuthenticatedActionSerializer.Meta.fields + ("user",)
  110. @property
  111. def action_user(self):
  112. return self.instance.user
  113. @classmethod
  114. def build_and_save(cls, **kwargs):
  115. action = cls.Meta.model(**kwargs)
  116. return cls(action).save()
  117. class AuthenticatedBasicUserActionListSerializer(
  118. AuthenticatedBasicUserActionMixin, serializers.ListSerializer
  119. ):
  120. @property
  121. def reason(self):
  122. return self.child.reason
  123. @property
  124. def action_user(self):
  125. user = self.instance[0].user
  126. if any(instance.user != user for instance in self.instance):
  127. raise ValueError("Actions must belong to the same user.")
  128. return user
  129. class AuthenticatedChangeOutreachPreferenceUserActionSerializer(
  130. AuthenticatedBasicUserActionSerializer
  131. ):
  132. reason = "change-outreach-preference"
  133. validity_period = None
  134. class Meta:
  135. model = models.AuthenticatedChangeOutreachPreferenceUserAction
  136. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + (
  137. "outreach_preference",
  138. )
  139. class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  140. captcha = CaptchaSolutionSerializer(required=False)
  141. reason = "activate-account"
  142. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  143. model = models.AuthenticatedActivateUserAction
  144. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + (
  145. "captcha",
  146. "domain",
  147. )
  148. extra_kwargs = {"domain": {"default": None, "allow_null": True}}
  149. def validate(self, attrs):
  150. try:
  151. attrs.pop(
  152. "captcha"
  153. ) # remove captcha from internal value to avoid passing to Meta.model(**kwargs)
  154. except KeyError:
  155. if attrs["user"].needs_captcha:
  156. raise serializers.ValidationError(
  157. {"captcha": fields.Field.default_error_messages["required"]}
  158. )
  159. return attrs
  160. class AuthenticatedChangeEmailUserActionSerializer(
  161. AuthenticatedBasicUserActionSerializer
  162. ):
  163. new_email = serializers.EmailField(
  164. validators=[
  165. CustomFieldNameUniqueValidator(
  166. queryset=models.User.objects.all(),
  167. lookup_field="email",
  168. message="You already have another account with this email address.",
  169. )
  170. ],
  171. required=True,
  172. )
  173. reason = "change-email"
  174. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  175. model = models.AuthenticatedChangeEmailUserAction
  176. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("new_email",)
  177. def save(self):
  178. return super().save(recipient=self.instance.new_email)
  179. class AuthenticatedConfirmAccountUserActionSerializer(
  180. AuthenticatedBasicUserActionSerializer
  181. ):
  182. reason = "confirm-account"
  183. validity_period = timedelta(days=14)
  184. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  185. model = (
  186. models.AuthenticatedNoopUserAction
  187. ) # confirmation happens during authentication, so nothing left to do
  188. class AuthenticatedResetPasswordUserActionSerializer(
  189. AuthenticatedBasicUserActionSerializer
  190. ):
  191. new_password = serializers.CharField(write_only=True)
  192. reason = "reset-password"
  193. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  194. model = models.AuthenticatedResetPasswordUserAction
  195. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("new_password",)
  196. class AuthenticatedDeleteUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  197. reason = "delete-account"
  198. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  199. model = models.AuthenticatedDeleteUserAction
  200. class AuthenticatedDomainBasicUserActionSerializer(
  201. AuthenticatedBasicUserActionSerializer
  202. ):
  203. domain = serializers.PrimaryKeyRelatedField(
  204. queryset=models.Domain.objects.all(),
  205. error_messages={"does_not_exist": "This domain does not exist."},
  206. )
  207. class Meta:
  208. model = models.AuthenticatedDomainBasicUserAction
  209. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ("domain",)
  210. class AuthenticatedRenewDomainBasicUserActionSerializer(
  211. AuthenticatedDomainBasicUserActionSerializer
  212. ):
  213. reason = "renew-domain"
  214. validity_period = None
  215. class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):
  216. model = models.AuthenticatedRenewDomainBasicUserAction
  217. list_serializer_class = AuthenticatedBasicUserActionListSerializer