authenticated_actions.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  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. from desecapi import permissions, serializers
  9. from desecapi.authentication import AuthenticatedBasicUserActionAuthentication
  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. html_url = None # Redirect GET requests to this webapp GUI URL
  24. http_method_names = ["get", "post"] # GET is for redirect only
  25. renderer_classes = [JSONRenderer, StaticHTMLRenderer]
  26. _authenticated_action = None
  27. @property
  28. def authenticated_action(self):
  29. if self._authenticated_action is None:
  30. serializer = self.get_serializer(data=self.request.data)
  31. serializer.is_valid(raise_exception=True)
  32. try:
  33. self._authenticated_action = serializer.Meta.model(
  34. **serializer.validated_data
  35. )
  36. except ValueError: # this happens when state cannot be verified
  37. ex = ValidationError(
  38. "This action cannot be carried out because another operation has been performed, "
  39. "invalidating this one. (Are you trying to perform this action twice?)"
  40. )
  41. ex.status_code = status.HTTP_409_CONFLICT
  42. raise ex
  43. return self._authenticated_action
  44. @property
  45. def authentication_classes(self):
  46. # This prevents both auth action code evaluation and user-specific throttling when we only want a redirect
  47. return (
  48. ()
  49. if self.request.method in SAFE_METHODS
  50. else (AuthenticatedBasicUserActionAuthentication,)
  51. )
  52. @property
  53. def permission_classes(self):
  54. return (
  55. () if self.request.method in SAFE_METHODS else (permissions.IsActiveUser,)
  56. )
  57. @property
  58. def throttle_scope(self):
  59. return (
  60. "account_management_passive"
  61. if self.request.method in SAFE_METHODS
  62. else "account_management_active"
  63. )
  64. def get_serializer_context(self):
  65. return {
  66. **super().get_serializer_context(),
  67. "code": self.kwargs["code"],
  68. "validity_period": self.get_serializer_class().validity_period,
  69. }
  70. def get(self, request, *args, **kwargs):
  71. # Redirect browsers to frontend if available
  72. is_redirect = (
  73. request.accepted_renderer.format == "html"
  74. ) and self.html_url is not None
  75. if is_redirect:
  76. # Careful: This can generally lead to an open redirect if values contain slashes!
  77. # However, it cannot happen for Django view kwargs.
  78. return redirect(self.html_url.format(**kwargs))
  79. else:
  80. raise NotAcceptable
  81. def post(self, request, *args, **kwargs):
  82. self.authenticated_action.act()
  83. return Response(status=status.HTTP_202_ACCEPTED)
  84. class AuthenticatedChangeOutreachPreferenceUserActionView(AuthenticatedActionView):
  85. html_url = "/confirm/change-outreach-preference/{code}/"
  86. serializer_class = (
  87. serializers.AuthenticatedChangeOutreachPreferenceUserActionSerializer
  88. )
  89. def post(self, request, *args, **kwargs):
  90. super().post(request, *args, **kwargs)
  91. return Response(
  92. {
  93. "detail": "Thank you! We have recorded that you would not like to receive outreach messages."
  94. }
  95. )
  96. class AuthenticatedActivateUserActionView(AuthenticatedActionView):
  97. html_url = "/confirm/activate-account/{code}/"
  98. permission_classes = () # don't require that user is activated already
  99. serializer_class = serializers.AuthenticatedActivateUserActionSerializer
  100. def post(self, request, *args, **kwargs):
  101. super().post(request, *args, **kwargs)
  102. if not self.authenticated_action.domain:
  103. return self._finalize_without_domain()
  104. else:
  105. domain = self._create_domain()
  106. return self._finalize_with_domain(domain)
  107. def _create_domain(self):
  108. serializer = serializers.DomainSerializer(
  109. data={"name": self.authenticated_action.domain},
  110. context=self.get_serializer_context(),
  111. )
  112. try:
  113. serializer.is_valid(raise_exception=True)
  114. except ValidationError as e: # e.g. domain name unavailable
  115. self.request.user.delete()
  116. reasons = ", ".join([detail.code for detail in e.detail.get("name", [])])
  117. raise ValidationError(
  118. f"The requested domain {self.authenticated_action.domain} could not be registered (reason: {reasons}). "
  119. f"Please start over and sign up again."
  120. )
  121. # TODO the following line is subject to race condition and can fail, as for the domain name, we have that
  122. # time-of-check != time-of-action
  123. return PDNSChangeTracker.track(lambda: serializer.save(owner=self.request.user))
  124. def _finalize_without_domain(self):
  125. if not is_password_usable(self.request.user.password):
  126. serializers.AuthenticatedResetPasswordUserActionSerializer.build_and_save(
  127. user=self.request.user
  128. )
  129. return Response(
  130. {
  131. "detail": "Success! We sent you instructions on how to set your password."
  132. }
  133. )
  134. return Response(
  135. {
  136. "detail": "Success! Your account has been activated, and you can now log in."
  137. }
  138. )
  139. def _finalize_with_domain(self, domain):
  140. if domain.is_locally_registrable:
  141. # TODO the following line raises Domain.DoesNotExist under unknown conditions
  142. PDNSChangeTracker.track(lambda: DomainViewSet.auto_delegate(domain))
  143. token = Token.objects.create(user=domain.owner, name="dyndns")
  144. return Response(
  145. {
  146. "detail": 'Success! Here is the password ("token") to configure your router (or any other dynDNS '
  147. "client). This password is different from your account password for security reasons.",
  148. "domain": serializers.DomainSerializer(domain).data,
  149. **serializers.TokenSerializer(token, include_plain=True).data,
  150. }
  151. )
  152. else:
  153. return Response(
  154. {
  155. "detail": "Success! Please check the docs for the next steps, https://desec.readthedocs.io/.",
  156. "domain": serializers.DomainSerializer(
  157. domain, include_keys=True
  158. ).data,
  159. }
  160. )
  161. class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
  162. html_url = "/confirm/change-email/{code}/"
  163. serializer_class = serializers.AuthenticatedChangeEmailUserActionSerializer
  164. def post(self, request, *args, **kwargs):
  165. super().post(request, *args, **kwargs)
  166. return Response(
  167. {
  168. "detail": f"Success! Your email address has been changed to {self.authenticated_action.user.email}."
  169. }
  170. )
  171. class AuthenticatedConfirmAccountUserActionView(AuthenticatedActionView):
  172. html_url = "/confirm/confirm-account/{code}"
  173. serializer_class = serializers.AuthenticatedConfirmAccountUserActionSerializer
  174. def post(self, request, *args, **kwargs):
  175. super().post(request, *args, **kwargs)
  176. return Response({"detail": "Success! Your account status has been confirmed."})
  177. class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
  178. html_url = "/confirm/reset-password/{code}/"
  179. serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
  180. def post(self, request, *args, **kwargs):
  181. super().post(request, *args, **kwargs)
  182. return Response({"detail": "Success! Your password has been changed."})
  183. class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
  184. html_url = "/confirm/delete-account/{code}/"
  185. serializer_class = serializers.AuthenticatedDeleteUserActionSerializer
  186. def post(self, request, *args, **kwargs):
  187. if self.request.user.domains.exists():
  188. return AccountDeleteView.response_still_has_domains
  189. super().post(request, *args, **kwargs)
  190. return Response(
  191. {"detail": "All your data has been deleted. Bye bye, see you soon! <3"}
  192. )
  193. class AuthenticatedRenewDomainBasicUserActionView(AuthenticatedActionView):
  194. html_url = "/confirm/renew-domain/{code}/"
  195. serializer_class = serializers.AuthenticatedRenewDomainBasicUserActionSerializer
  196. def post(self, request, *args, **kwargs):
  197. super().post(request, *args, **kwargs)
  198. return Response(
  199. {
  200. "detail": f"We recorded that your domain {self.authenticated_action.domain} is still in use."
  201. }
  202. )