authenticated_actions.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. from django.contrib.auth.hashers import is_password_usable
  2. from django.shortcuts import redirect
  3. from rest_framework import generics, status
  4. from rest_framework.exceptions import NotAcceptable, ValidationError
  5. from rest_framework.permissions import SAFE_METHODS
  6. from rest_framework.renderers import JSONRenderer, StaticHTMLRenderer
  7. from rest_framework.response import Response
  8. import desecapi.authentication as auth
  9. from desecapi import permissions, serializers
  10. from desecapi.models import Token
  11. from desecapi.pdns_change_tracker import PDNSChangeTracker
  12. from .domains import DomainViewSet
  13. from .users import AccountDeleteView
  14. class AuthenticatedActionView(generics.GenericAPIView):
  15. """
  16. Abstract class. Deserializes the given payload according the serializers specified by the view extending
  17. this class. If the `serializer.is_valid`, `act` is called on the action object.
  18. Summary of the behavior depending on HTTP method and Accept: header:
  19. GET POST other method
  20. Accept: text/html forward to `self.html_url` if any perform action 405 Method Not Allowed
  21. else HTTP 406 Not Acceptable perform action 405 Method Not Allowed
  22. """
  23. authenticated_action = None
  24. html_url = None # Redirect GET requests to this webapp GUI URL
  25. http_method_names = ['get', 'post'] # GET is for redirect only
  26. renderer_classes = [JSONRenderer, StaticHTMLRenderer]
  27. _authenticated_action = None
  28. @property
  29. def authenticated_action(self):
  30. if self._authenticated_action is None:
  31. serializer = self.get_serializer(data=self.request.data)
  32. serializer.is_valid(raise_exception=True)
  33. try:
  34. self._authenticated_action = serializer.Meta.model(**serializer.validated_data)
  35. except ValueError: # this happens when state cannot be verified
  36. ex = ValidationError('This action cannot be carried out because another operation has been performed, '
  37. 'invalidating this one. (Are you trying to perform this action twice?)')
  38. ex.status_code = status.HTTP_409_CONFLICT
  39. raise ex
  40. return self._authenticated_action
  41. @property
  42. def authentication_classes(self):
  43. # This prevents both auth action code evaluation and user-specific throttling when we only want a redirect
  44. return () if self.request.method in SAFE_METHODS else (auth.AuthenticatedBasicUserActionAuthentication,)
  45. @property
  46. def permission_classes(self):
  47. return () if self.request.method in SAFE_METHODS else (permissions.IsActiveUser,)
  48. @property
  49. def throttle_scope(self):
  50. return 'account_management_passive' if self.request.method in SAFE_METHODS else 'account_management_active'
  51. def get_serializer_context(self):
  52. return {
  53. **super().get_serializer_context(),
  54. 'code': self.kwargs['code'],
  55. 'validity_period': self.get_serializer_class().validity_period,
  56. }
  57. def get(self, request, *args, **kwargs):
  58. # Redirect browsers to frontend if available
  59. is_redirect = (request.accepted_renderer.format == 'html') and self.html_url is not None
  60. if is_redirect:
  61. # Careful: This can generally lead to an open redirect if values contain slashes!
  62. # However, it cannot happen for Django view kwargs.
  63. return redirect(self.html_url.format(**kwargs))
  64. else:
  65. raise NotAcceptable
  66. def post(self, request, *args, **kwargs):
  67. self.authenticated_action.act()
  68. return Response(status=status.HTTP_202_ACCEPTED)
  69. class AuthenticatedChangeOutreachPreferenceUserActionView(AuthenticatedActionView):
  70. html_url = '/confirm/change-outreach-preference/{code}/'
  71. serializer_class = serializers.AuthenticatedChangeOutreachPreferenceUserActionSerializer
  72. def post(self, request, *args, **kwargs):
  73. super().post(request, *args, **kwargs)
  74. return Response({'detail': 'Thank you! We have recorded that you would not like to receive outreach messages.'})
  75. class AuthenticatedActivateUserActionView(AuthenticatedActionView):
  76. html_url = '/confirm/activate-account/{code}/'
  77. permission_classes = () # don't require that user is activated already
  78. serializer_class = serializers.AuthenticatedActivateUserActionSerializer
  79. def post(self, request, *args, **kwargs):
  80. super().post(request, *args, **kwargs)
  81. if not self.authenticated_action.domain:
  82. return self._finalize_without_domain()
  83. else:
  84. domain = self._create_domain()
  85. return self._finalize_with_domain(domain)
  86. def _create_domain(self):
  87. serializer = serializers.DomainSerializer(
  88. data={'name': self.authenticated_action.domain},
  89. context=self.get_serializer_context()
  90. )
  91. try:
  92. serializer.is_valid(raise_exception=True)
  93. except ValidationError as e: # e.g. domain name unavailable
  94. self.request.user.delete()
  95. reasons = ', '.join([detail.code for detail in e.detail.get('name', [])])
  96. raise ValidationError(
  97. f'The requested domain {self.authenticated_action.domain} could not be registered (reason: {reasons}). '
  98. f'Please start over and sign up again.'
  99. )
  100. # TODO the following line is subject to race condition and can fail, as for the domain name, we have that
  101. # time-of-check != time-of-action
  102. return PDNSChangeTracker.track(lambda: serializer.save(owner=self.request.user))
  103. def _finalize_without_domain(self):
  104. if not is_password_usable(self.request.user.password):
  105. serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(user=self.request.user)
  106. return Response({'detail': 'Success! We sent you instructions on how to set your password.'})
  107. return Response({'detail': 'Success! Your account has been activated, and you can now log in.'})
  108. def _finalize_with_domain(self, domain):
  109. if domain.is_locally_registrable:
  110. # TODO the following line raises Domain.DoesNotExist under unknown conditions
  111. PDNSChangeTracker.track(lambda: DomainViewSet.auto_delegate(domain))
  112. token = Token.objects.create(user=domain.owner, name='dyndns')
  113. return Response({
  114. 'detail': 'Success! Here is the password ("token") to configure your router (or any other dynDNS '
  115. 'client). This password is different from your account password for security reasons.',
  116. 'domain': serializers.DomainSerializer(domain).data,
  117. **serializers.TokenSerializer(token, include_plain=True).data,
  118. })
  119. else:
  120. return Response({
  121. 'detail': 'Success! Please check the docs for the next steps, https://desec.readthedocs.io/.',
  122. 'domain': serializers.DomainSerializer(domain, include_keys=True).data,
  123. })
  124. class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
  125. html_url = '/confirm/change-email/{code}/'
  126. serializer_class = serializers.AuthenticatedChangeEmailUserActionSerializer
  127. def post(self, request, *args, **kwargs):
  128. super().post(request, *args, **kwargs)
  129. return Response({
  130. 'detail': f'Success! Your email address has been changed to {self.authenticated_action.user.email}.'
  131. })
  132. class AuthenticatedConfirmAccountUserActionView(AuthenticatedActionView):
  133. html_url = '/confirm/confirm-account/{code}'
  134. serializer_class = serializers.AuthenticatedConfirmAccountUserActionSerializer
  135. def post(self, request, *args, **kwargs):
  136. super().post(request, *args, **kwargs)
  137. return Response({'detail': 'Success! Your account status has been confirmed.'})
  138. class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
  139. html_url = '/confirm/reset-password/{code}/'
  140. serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
  141. def post(self, request, *args, **kwargs):
  142. super().post(request, *args, **kwargs)
  143. return Response({'detail': 'Success! Your password has been changed.'})
  144. class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
  145. html_url = '/confirm/delete-account/{code}/'
  146. serializer_class = serializers.AuthenticatedDeleteUserActionSerializer
  147. def post(self, request, *args, **kwargs):
  148. if self.request.user.domains.exists():
  149. return AccountDeleteView.response_still_has_domains
  150. super().post(request, *args, **kwargs)
  151. return Response({'detail': 'All your data has been deleted. Bye bye, see you soon! <3'})
  152. class AuthenticatedRenewDomainBasicUserActionView(AuthenticatedActionView):
  153. html_url = '/confirm/renew-domain/{code}/'
  154. serializer_class = serializers.AuthenticatedRenewDomainBasicUserActionSerializer
  155. def post(self, request, *args, **kwargs):
  156. super().post(request, *args, **kwargs)
  157. return Response({'detail': f'We recorded that your domain {self.authenticated_action.domain} is still in use.'})