authenticated_actions.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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. self.request.user.refresh_from_db() # subsequent action link generation needs current state
  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 AuthenticatedCreateTOTPFactorUserActionView(AuthenticatedActionView):
  179. html_url = "/confirm/create-totp/{code}/"
  180. serializer_class = serializers.AuthenticatedCreateTOTPFactorUserActionSerializer
  181. def post(self, request, *args, **kwargs):
  182. factor = self.authenticated_action.act()
  183. serializer = serializers.TOTPFactorSerializer(factor, include_secret=True)
  184. return Response(serializer.data)
  185. class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
  186. html_url = "/confirm/reset-password/{code}/"
  187. serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
  188. def post(self, request, *args, **kwargs):
  189. super().post(request, *args, **kwargs)
  190. return Response({"detail": "Success! Your password has been changed."})
  191. class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
  192. html_url = "/confirm/delete-account/{code}/"
  193. serializer_class = serializers.AuthenticatedDeleteUserActionSerializer
  194. def post(self, request, *args, **kwargs):
  195. if self.request.user.domains.exists():
  196. return AccountDeleteView.response_still_has_domains
  197. super().post(request, *args, **kwargs)
  198. return Response(
  199. {"detail": "All your data has been deleted. Bye bye, see you soon! <3"}
  200. )
  201. class AuthenticatedRenewDomainBasicUserActionView(AuthenticatedActionView):
  202. html_url = "/confirm/renew-domain/{code}/"
  203. serializer_class = serializers.AuthenticatedRenewDomainBasicUserActionSerializer
  204. def post(self, request, *args, **kwargs):
  205. super().post(request, *args, **kwargs)
  206. return Response(
  207. {
  208. "detail": f"We recorded that your domain {self.authenticated_action.domain} is still in use."
  209. }
  210. )