authenticated_actions.py 9.7 KB

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