authenticated_actions.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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 = {'%s__%s' % (self.lookup_field or field_name, self.lookup): value}
  26. return qs_filter(queryset, **filter_kwargs)
  27. class AuthenticatedActionSerializer(serializers.ModelSerializer):
  28. state = serializers.CharField() # serializer read-write, but model read-only field
  29. validity_period = settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE
  30. _crypto_context = 'desecapi.serializers.AuthenticatedActionSerializer'
  31. timestamp = None # is set to the code's timestamp during validation
  32. class Meta:
  33. model = models.AuthenticatedAction
  34. fields = ('state',)
  35. @classmethod
  36. def _pack_code(cls, data):
  37. payload = json.dumps(data).encode()
  38. code = crypto.encrypt(payload, context=cls._crypto_context).decode()
  39. return code.rstrip('=')
  40. @classmethod
  41. def _unpack_code(cls, code, *, ttl):
  42. code += -len(code) % 4 * '='
  43. try:
  44. timestamp, payload = crypto.decrypt(code.encode(), context=cls._crypto_context, ttl=ttl)
  45. return timestamp, json.loads(payload.decode())
  46. except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError, binascii.Error):
  47. raise ValueError
  48. def to_representation(self, instance: models.AuthenticatedAction):
  49. # do the regular business
  50. data = super().to_representation(instance)
  51. # encode into single string
  52. return {'code': self._pack_code(data)}
  53. def to_internal_value(self, data):
  54. # Allow injecting validity period from context. This is used, for example, for authentication, where the code's
  55. # integrity and timestamp is checked by AuthenticatedBasicUserActionSerializer with validity injected as needed.
  56. validity_period = self.context.get('validity_period', self.validity_period)
  57. # calculate code TTL
  58. try:
  59. ttl = validity_period.total_seconds()
  60. except AttributeError:
  61. ttl = None # infinite
  62. # decode from single string
  63. try:
  64. self.timestamp, unpacked_data = self._unpack_code(self.context['code'], ttl=ttl)
  65. except KeyError:
  66. raise serializers.ValidationError({'code': ['This field is required.']})
  67. except ValueError:
  68. if ttl is None:
  69. msg = 'This code is invalid.'
  70. else:
  71. msg = f'This code is invalid, possibly because it expired (validity: {validity_period}).'
  72. raise serializers.ValidationError({api_settings.NON_FIELD_ERRORS_KEY: msg})
  73. # add extra fields added by the user, but give precedence to fields unpacked from the code
  74. data = {**data, **unpacked_data}
  75. # do the regular business
  76. return super().to_internal_value(data)
  77. def act(self):
  78. self.instance.act()
  79. return self.instance
  80. def save(self, **kwargs):
  81. raise ValueError
  82. class AuthenticatedBasicUserActionMixin():
  83. def save(self, **kwargs):
  84. context = {**self.context, 'action_serializer': self}
  85. return self.action_user.send_email(self.reason, context=context, **kwargs)
  86. class AuthenticatedBasicUserActionSerializer(AuthenticatedBasicUserActionMixin, AuthenticatedActionSerializer):
  87. user = serializers.PrimaryKeyRelatedField(
  88. queryset=models.User.objects.all(),
  89. error_messages={'does_not_exist': 'This user does not exist.'},
  90. pk_field=serializers.UUIDField()
  91. )
  92. reason = None
  93. class Meta:
  94. model = models.AuthenticatedBasicUserAction
  95. fields = AuthenticatedActionSerializer.Meta.fields + ('user',)
  96. @property
  97. def action_user(self):
  98. return self.instance.user
  99. @classmethod
  100. def build_and_save(cls, **kwargs):
  101. action = cls.Meta.model(**kwargs)
  102. return cls(action).save()
  103. class AuthenticatedBasicUserActionListSerializer(AuthenticatedBasicUserActionMixin, serializers.ListSerializer):
  104. @property
  105. def reason(self):
  106. return self.child.reason
  107. @property
  108. def action_user(self):
  109. user = self.instance[0].user
  110. if any(instance.user != user for instance in self.instance):
  111. raise ValueError('Actions must belong to the same user.')
  112. return user
  113. class AuthenticatedChangeOutreachPreferenceUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  114. reason = 'change-outreach-preference'
  115. validity_period = None
  116. class Meta:
  117. model = models.AuthenticatedChangeOutreachPreferenceUserAction
  118. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('outreach_preference',)
  119. class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  120. captcha = CaptchaSolutionSerializer(required=False)
  121. reason = 'activate-account'
  122. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  123. model = models.AuthenticatedActivateUserAction
  124. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('captcha', 'domain',)
  125. extra_kwargs = {
  126. 'domain': {'default': None, 'allow_null': True}
  127. }
  128. def validate(self, attrs):
  129. try:
  130. attrs.pop('captcha') # remove captcha from internal value to avoid passing to Meta.model(**kwargs)
  131. except KeyError:
  132. if attrs['user'].needs_captcha:
  133. raise serializers.ValidationError({'captcha': fields.Field.default_error_messages['required']})
  134. return attrs
  135. class AuthenticatedChangeEmailUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  136. new_email = serializers.EmailField(
  137. validators=[
  138. CustomFieldNameUniqueValidator(
  139. queryset=models.User.objects.all(),
  140. lookup_field='email',
  141. message='You already have another account with this email address.',
  142. )
  143. ],
  144. required=True,
  145. )
  146. reason = 'change-email'
  147. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  148. model = models.AuthenticatedChangeEmailUserAction
  149. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_email',)
  150. def save(self):
  151. return super().save(recipient=self.instance.new_email)
  152. class AuthenticatedConfirmAccountUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  153. reason = 'confirm-account'
  154. validity_period = timedelta(days=14)
  155. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  156. model = models.AuthenticatedNoopUserAction # confirmation happens during authentication, so nothing left to do
  157. class AuthenticatedResetPasswordUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  158. new_password = serializers.CharField(write_only=True)
  159. reason = 'reset-password'
  160. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  161. model = models.AuthenticatedResetPasswordUserAction
  162. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('new_password',)
  163. class AuthenticatedDeleteUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  164. reason = 'delete-account'
  165. class Meta(AuthenticatedBasicUserActionSerializer.Meta):
  166. model = models.AuthenticatedDeleteUserAction
  167. class AuthenticatedDomainBasicUserActionSerializer(AuthenticatedBasicUserActionSerializer):
  168. domain = serializers.PrimaryKeyRelatedField(
  169. queryset=models.Domain.objects.all(),
  170. error_messages={'does_not_exist': 'This domain does not exist.'},
  171. )
  172. class Meta:
  173. model = models.AuthenticatedDomainBasicUserAction
  174. fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('domain',)
  175. class AuthenticatedRenewDomainBasicUserActionSerializer(AuthenticatedDomainBasicUserActionSerializer):
  176. reason = 'renew-domain'
  177. validity_period = None
  178. class Meta(AuthenticatedDomainBasicUserActionSerializer.Meta):
  179. model = models.AuthenticatedRenewDomainBasicUserAction
  180. list_serializer_class = AuthenticatedBasicUserActionListSerializer