views.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550
  1. import base64
  2. import binascii
  3. import ipaddress
  4. import os
  5. import re
  6. from datetime import timedelta
  7. import django.core.exceptions
  8. import djoser.views
  9. import psl_dns
  10. from django.contrib.auth import user_logged_in, user_logged_out
  11. from django.core.mail import EmailMessage
  12. from django.db.models import Q
  13. from django.http import Http404, HttpResponseRedirect
  14. from django.shortcuts import render
  15. from django.template.loader import get_template
  16. from django.utils import timezone
  17. from djoser import views, signals
  18. from djoser.serializers import TokenSerializer as DjoserTokenSerializer
  19. from rest_framework import generics
  20. from rest_framework import mixins
  21. from rest_framework import status
  22. from rest_framework.authentication import get_authorization_header
  23. from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError)
  24. from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView, UpdateAPIView, get_object_or_404
  25. from rest_framework.permissions import IsAuthenticated
  26. from rest_framework.response import Response
  27. from rest_framework.reverse import reverse
  28. from rest_framework.views import APIView
  29. from rest_framework.viewsets import GenericViewSet
  30. import desecapi.authentication as auth
  31. from api import settings
  32. from desecapi.emails import send_account_lock_email, send_token_email
  33. from desecapi.forms import UnlockForm
  34. from desecapi.models import Domain, User, RRset, Token
  35. from desecapi.pdns import PDNSException
  36. from desecapi.pdns_change_tracker import PDNSChangeTracker
  37. from desecapi.permissions import IsOwner, IsUnlocked, IsDomainOwner
  38. from desecapi.renderers import PlainTextRenderer
  39. from desecapi.serializers import DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer
  40. patternDyn = re.compile(r'^[A-Za-z-][A-Za-z0-9_-]*\.dedyn\.io$')
  41. patternNonDyn = re.compile(r'^([A-Za-z0-9-][A-Za-z0-9_-]*\.)*[A-Za-z]+$')
  42. class IdempotentDestroy:
  43. def destroy(self, request, *args, **kwargs):
  44. try:
  45. # noinspection PyUnresolvedReferences
  46. super().destroy(request, *args, **kwargs)
  47. except Http404:
  48. pass
  49. return Response(status=status.HTTP_204_NO_CONTENT)
  50. class DomainView:
  51. def initial(self, request, *args, **kwargs):
  52. # noinspection PyUnresolvedReferences
  53. super().initial(request, *args, **kwargs)
  54. try:
  55. # noinspection PyAttributeOutsideInit, PyUnresolvedReferences
  56. self.domain = self.request.user.domains.get(name=self.kwargs['name'])
  57. except Domain.DoesNotExist:
  58. raise Http404
  59. class TokenCreateView(djoser.views.TokenCreateView):
  60. def _action(self, serializer):
  61. user = serializer.user
  62. token = Token(user=user, name="login")
  63. token.save()
  64. user_logged_in.send(sender=user.__class__, request=self.request, user=user)
  65. token_serializer_class = DjoserTokenSerializer
  66. return Response(
  67. data=token_serializer_class(token).data,
  68. status=status.HTTP_201_CREATED,
  69. )
  70. class TokenDestroyView(djoser.views.TokenDestroyView):
  71. def post(self, request):
  72. _, token = auth.TokenAuthentication().authenticate(request)
  73. token.delete()
  74. user_logged_out.send(
  75. sender=request.user.__class__, request=request, user=request.user
  76. )
  77. return Response(status=status.HTTP_204_NO_CONTENT)
  78. class TokenViewSet(IdempotentDestroy,
  79. mixins.CreateModelMixin,
  80. mixins.DestroyModelMixin,
  81. mixins.ListModelMixin,
  82. GenericViewSet):
  83. serializer_class = TokenSerializer
  84. permission_classes = (IsAuthenticated, )
  85. lookup_field = 'user_specific_id'
  86. def get_queryset(self):
  87. return self.request.user.auth_tokens.all()
  88. def perform_create(self, serializer):
  89. serializer.save(user=self.request.user)
  90. class DomainList(ListCreateAPIView):
  91. serializer_class = DomainSerializer
  92. permission_classes = (IsAuthenticated, IsOwner,)
  93. psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER)
  94. def get_queryset(self):
  95. return Domain.objects.filter(owner=self.request.user.pk)
  96. def perform_create(self, serializer):
  97. domain_name = serializer.validated_data['name']
  98. pattern = patternDyn if self.request.user.dyn else patternNonDyn
  99. if pattern.match(domain_name) is None:
  100. ex = ValidationError(detail={
  101. "detail": "This domain name is not well-formed, by policy.",
  102. "code": "domain-illformed"}
  103. )
  104. ex.status_code = status.HTTP_409_CONFLICT
  105. raise ex
  106. # Check if domain is a public suffix
  107. try:
  108. public_suffix = self.psl.get_public_suffix(domain_name)
  109. is_public_suffix = self.psl.is_public_suffix(domain_name)
  110. except psl_dns.exceptions.UnsupportedRule as e:
  111. # It would probably be fine to just create the domain (with the TLD acting as the
  112. # public suffix and setting both public_suffix and is_public_suffix accordingly).
  113. # However, in order to allow to investigate the situation, it's better not catch
  114. # this exception. Our error handler turns it into a 503 error and makes sure
  115. # admins are notified.
  116. raise e
  117. is_restricted_suffix = is_public_suffix and domain_name not in settings.LOCAL_PUBLIC_SUFFIXES
  118. # Generate a list of all domains connecting this one and its public suffix.
  119. # If another user owns a zone with one of these names, then the requested
  120. # domain is unavailable because it is part of the other user's zone.
  121. private_components = domain_name.rsplit(public_suffix, 1)[0].rstrip('.')
  122. private_components = private_components.split('.') if private_components else []
  123. private_components += [public_suffix]
  124. private_domains = ['.'.join(private_components[i:]) for i in range(0, len(private_components) - 1)]
  125. assert is_public_suffix or domain_name == private_domains[0]
  126. # Deny registration for non-local public suffixes and for domains covered by other users' zones
  127. queryset = Domain.objects.filter(Q(name__in=private_domains) & ~Q(owner=self.request.user))
  128. if is_restricted_suffix or queryset.exists():
  129. ex = ValidationError(detail={"detail": "This domain name is unavailable.", "code": "domain-unavailable"})
  130. ex.status_code = status.HTTP_409_CONFLICT
  131. raise ex
  132. if (self.request.user.limit_domains is not None and
  133. self.request.user.domains.count() >= self.request.user.limit_domains):
  134. ex = ValidationError(detail={
  135. "detail": "You reached the maximum number of domains allowed for your account.",
  136. "code": "domain-limit"
  137. })
  138. ex.status_code = status.HTTP_403_FORBIDDEN
  139. raise ex
  140. parent_domain_name = Domain.partition_name(domain_name)[1]
  141. domain_is_local = parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES
  142. try:
  143. with PDNSChangeTracker():
  144. domain_kwargs = {'owner': self.request.user}
  145. if domain_is_local:
  146. domain_kwargs['minimum_ttl'] = 60
  147. domain = serializer.save(**domain_kwargs)
  148. if domain_is_local:
  149. parent_domain = Domain.objects.get(name=parent_domain_name)
  150. # NOTE we need two change trackers here, as the first transaction must be committed to
  151. # pdns in order to have keys available for the delegation
  152. with PDNSChangeTracker():
  153. parent_domain.update_delegation(domain)
  154. except PDNSException as e:
  155. if not str(e).endswith(' already exists'):
  156. raise e
  157. ex = ValidationError(detail={
  158. "detail": "This domain name is unavailable.",
  159. "code": "domain-unavailable"}
  160. )
  161. ex.status_code = status.HTTP_400_BAD_REQUEST
  162. raise ex
  163. def send_dyn_dns_email():
  164. content_tmpl = get_template('emails/domain-dyndns/content.txt')
  165. subject_tmpl = get_template('emails/domain-dyndns/subject.txt')
  166. from_tmpl = get_template('emails/from.txt')
  167. context = {
  168. 'domain': domain_name,
  169. 'url': 'https://update.dedyn.io/',
  170. 'username': domain_name,
  171. 'password': self.request.auth.key
  172. }
  173. email = EmailMessage(subject_tmpl.render(context),
  174. content_tmpl.render(context),
  175. from_tmpl.render(context),
  176. [self.request.user.email])
  177. email.send()
  178. if domain.name.endswith('.dedyn.io'):
  179. send_dyn_dns_email()
  180. class DomainDetail(IdempotentDestroy, RetrieveUpdateDestroyAPIView):
  181. serializer_class = DomainSerializer
  182. permission_classes = (IsAuthenticated, IsOwner,)
  183. lookup_field = 'name'
  184. def perform_destroy(self, instance: Domain):
  185. with PDNSChangeTracker():
  186. instance.delete()
  187. parent_domain_name = instance.partition_name()[1]
  188. if parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES:
  189. parent_domain = Domain.objects.get(name=parent_domain_name)
  190. with PDNSChangeTracker():
  191. parent_domain.update_delegation(instance)
  192. def get_queryset(self):
  193. return Domain.objects.filter(owner=self.request.user.pk)
  194. def update(self, request, *args, **kwargs):
  195. try:
  196. return super().update(request, *args, **kwargs)
  197. except django.core.exceptions.ValidationError as e:
  198. raise ValidationError(detail={"detail": e.message})
  199. class RRsetDetail(IdempotentDestroy, DomainView, RetrieveUpdateDestroyAPIView):
  200. serializer_class = RRsetSerializer
  201. permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
  202. def get_queryset(self):
  203. return self.domain.rrset_set
  204. def get_object(self):
  205. queryset = self.filter_queryset(self.get_queryset())
  206. filter_kwargs = {k: self.kwargs[k] for k in ['subname', 'type']}
  207. obj = get_object_or_404(queryset, **filter_kwargs)
  208. # May raise a permission denied
  209. self.check_object_permissions(self.request, obj)
  210. return obj
  211. def get_serializer(self, *args, **kwargs):
  212. kwargs['domain'] = self.domain
  213. return super().get_serializer(*args, **kwargs)
  214. def update(self, request, *args, **kwargs):
  215. response = super().update(request, *args, **kwargs)
  216. if response.data is None:
  217. response.status_code = 204
  218. return response
  219. def perform_update(self, serializer):
  220. with PDNSChangeTracker():
  221. super().perform_update(serializer)
  222. def perform_destroy(self, instance):
  223. with PDNSChangeTracker():
  224. super().perform_destroy(instance)
  225. class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
  226. serializer_class = RRsetSerializer
  227. permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
  228. def get_queryset(self):
  229. rrsets = RRset.objects.filter(domain=self.domain)
  230. for filter_field in ('subname', 'type'):
  231. value = self.request.query_params.get(filter_field)
  232. if value is not None:
  233. # TODO consider moving this
  234. if filter_field == 'type' and value in RRset.RESTRICTED_TYPES:
  235. raise PermissionDenied("You cannot tinker with the %s RRset." % value)
  236. rrsets = rrsets.filter(**{'%s__exact' % filter_field: value})
  237. return rrsets
  238. def get_object(self):
  239. # For this view, the object we're operating on is the queryset that one can also GET. Serializing a queryset
  240. # is fine as per https://www.django-rest-framework.org/api-guide/serializers/#serializing-multiple-objects.
  241. # We skip checking object permissions here to avoid evaluating the queryset. The user can access all his RRsets
  242. # anyways.
  243. return self.filter_queryset(self.get_queryset())
  244. def get_serializer(self, *args, **kwargs):
  245. data = kwargs.get('data')
  246. if data and 'many' not in kwargs:
  247. if self.request.method == 'POST':
  248. kwargs['many'] = isinstance(data, list)
  249. elif self.request.method in ['PATCH', 'PUT']:
  250. kwargs['many'] = True
  251. return super().get_serializer(domain=self.domain, *args, **kwargs)
  252. def create(self, request, *args, **kwargs):
  253. response = super().create(request, *args, **kwargs)
  254. if not response.data:
  255. return Response(status=status.HTTP_204_NO_CONTENT)
  256. else:
  257. return response
  258. def perform_create(self, serializer):
  259. with PDNSChangeTracker():
  260. serializer.save(domain=self.domain)
  261. def perform_update(self, serializer):
  262. with PDNSChangeTracker():
  263. serializer.save(domain=self.domain)
  264. class Root(APIView):
  265. def get(self, request, *_):
  266. if self.request.user and self.request.user.is_authenticated:
  267. return Response({
  268. 'domains': reverse('domain-list', request=request),
  269. 'user': reverse('user', request=request),
  270. 'logout': reverse('token-destroy', request=request), # TODO change interface to token-destroy, too?
  271. })
  272. else:
  273. return Response({
  274. 'login': reverse('token-create', request=request),
  275. 'register': reverse('register', request=request),
  276. })
  277. class DynDNS12Update(APIView):
  278. authentication_classes = (auth.TokenAuthentication, auth.BasicTokenAuthentication, auth.URLParamAuthentication,)
  279. renderer_classes = [PlainTextRenderer]
  280. def _find_domain(self, request):
  281. if self.request.user.locked:
  282. # Error code from https://help.dyn.com/remote-access-api/return-codes/
  283. raise PermissionDenied('abuse')
  284. def find_domain_name(r):
  285. # 1. hostname parameter
  286. if 'hostname' in r.query_params and r.query_params['hostname'] != 'YES':
  287. return r.query_params['hostname']
  288. # 2. host_id parameter
  289. if 'host_id' in r.query_params:
  290. return r.query_params['host_id']
  291. # 3. http basic auth username
  292. try:
  293. domain_name = base64.b64decode(
  294. get_authorization_header(r).decode().split(' ')[1].encode()).decode().split(':')[0]
  295. if domain_name and '@' not in domain_name:
  296. return domain_name
  297. except IndexError:
  298. pass
  299. except UnicodeDecodeError:
  300. pass
  301. except binascii.Error:
  302. pass
  303. # 4. username parameter
  304. if 'username' in r.query_params:
  305. return r.query_params['username']
  306. # 5. only domain associated with this user account
  307. if len(r.user.domains.all()) == 1:
  308. return r.user.domains.all()[0].name
  309. if len(r.user.domains.all()) > 1:
  310. ex = ValidationError(detail={
  311. "detail": "Request does not specify domain unambiguously.",
  312. "code": "domain-ambiguous"
  313. })
  314. ex.status_code = status.HTTP_409_CONFLICT
  315. raise ex
  316. return None
  317. name = find_domain_name(request).lower()
  318. try:
  319. return self.request.user.domains.get(name=name)
  320. except Domain.DoesNotExist:
  321. return None
  322. @staticmethod
  323. def find_ip(request, params, version=4):
  324. if version == 4:
  325. look_for = '.'
  326. elif version == 6:
  327. look_for = ':'
  328. else:
  329. raise Exception
  330. # Check URL parameters
  331. for p in params:
  332. if p in request.query_params:
  333. if not len(request.query_params[p]):
  334. return None
  335. if look_for in request.query_params[p]:
  336. return request.query_params[p]
  337. # Check remote IP address
  338. client_ip = request.META.get('REMOTE_ADDR')
  339. if look_for in client_ip:
  340. return client_ip
  341. # give up
  342. return None
  343. def _find_ip_v4(self, request):
  344. return self.find_ip(request, ['myip', 'myipv4', 'ip'])
  345. def _find_ip_v6(self, request):
  346. return self.find_ip(request, ['myipv6', 'ipv6', 'myip', 'ip'], version=6)
  347. def get(self, request, *_):
  348. domain = self._find_domain(request)
  349. if domain is None:
  350. raise NotFound('nohost')
  351. ipv4 = self._find_ip_v4(request)
  352. ipv6 = self._find_ip_v6(request)
  353. data = [
  354. {'type': 'A', 'subname': '', 'ttl': 60, 'records': [ipv4] if ipv4 else []},
  355. {'type': 'AAAA', 'subname': '', 'ttl': 60, 'records': [ipv6] if ipv6 else []},
  356. ]
  357. instances = domain.rrset_set.filter(subname='', type__in=['A', 'AAAA']).all()
  358. serializer = RRsetSerializer(instances, domain=domain, data=data, many=True, partial=True)
  359. try:
  360. serializer.is_valid(raise_exception=True)
  361. except ValidationError as e:
  362. raise e
  363. with PDNSChangeTracker():
  364. serializer.save(domain=domain)
  365. return Response('good', content_type='text/plain')
  366. class DonationList(generics.CreateAPIView):
  367. serializer_class = DonationSerializer
  368. def perform_create(self, serializer):
  369. iban = serializer.validated_data['iban']
  370. obj = serializer.save()
  371. def send_donation_emails(donation):
  372. context = {
  373. 'donation': donation,
  374. 'creditoridentifier': settings.SEPA['CREDITOR_ID'],
  375. 'creditorname': settings.SEPA['CREDITOR_NAME'],
  376. 'complete_iban': iban
  377. }
  378. # internal desec notification
  379. content_tmpl = get_template('emails/donation/desec-content.txt')
  380. subject_tmpl = get_template('emails/donation/desec-subject.txt')
  381. attachment_tmpl = get_template('emails/donation/desec-attachment-jameica.txt')
  382. from_tmpl = get_template('emails/from.txt')
  383. email = EmailMessage(subject_tmpl.render(context),
  384. content_tmpl.render(context),
  385. from_tmpl.render(context),
  386. ['donation@desec.io'],
  387. attachments=[
  388. ('jameica-directdebit.xml',
  389. attachment_tmpl.render(context),
  390. 'text/xml')
  391. ])
  392. email.send()
  393. # donor notification
  394. if donation.email:
  395. content_tmpl = get_template('emails/donation/donor-content.txt')
  396. subject_tmpl = get_template('emails/donation/donor-subject.txt')
  397. email = EmailMessage(subject_tmpl.render(context),
  398. content_tmpl.render(context),
  399. from_tmpl.render(context),
  400. [donation.email])
  401. email.send()
  402. # send emails
  403. send_donation_emails(obj)
  404. class UserCreateView(views.UserCreateView):
  405. """
  406. Extends the djoser UserCreateView to record the remote IP address of any registration.
  407. """
  408. def perform_create(self, serializer):
  409. remote_ip = self.request.META.get('REMOTE_ADDR')
  410. lock = (
  411. ipaddress.ip_address(remote_ip) not in ipaddress.IPv6Network(os.environ['DESECSTACK_IPV6_SUBNET'])
  412. and (
  413. User.objects.filter(
  414. created__gte=timezone.now()-timedelta(hours=settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS),
  415. registration_remote_ip=remote_ip
  416. ).count() >= settings.ABUSE_BY_REMOTE_IP_LIMIT
  417. or
  418. User.objects.filter(
  419. created__gte=timezone.now() - timedelta(hours=settings.ABUSE_BY_EMAIL_HOSTNAME_PERIOD_HRS),
  420. email__endswith='@{0}'.format(serializer.validated_data['email'].split('@')[-1])
  421. ).count() >= settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT
  422. )
  423. )
  424. user = serializer.save(registration_remote_ip=remote_ip, lock=lock)
  425. if user.locked:
  426. send_account_lock_email(self.request, user)
  427. if not user.dyn:
  428. context = {'token': user.get_or_create_first_token()}
  429. send_token_email(context, user)
  430. signals.user_registered.send(sender=self.__class__, user=user, request=self.request)
  431. def unlock(request, email):
  432. # if this is a POST request we need to process the form data
  433. if request.method == 'POST':
  434. # create a form instance and populate it with data from the request:
  435. form = UnlockForm(request.POST)
  436. # check whether it's valid:
  437. if form.is_valid():
  438. User.objects.filter(email=email).update(locked=None)
  439. return HttpResponseRedirect(reverse('v1:unlock/done', request=request)) # TODO remove dependency on v1
  440. # if a GET (or any other method) we'll create a blank form
  441. else:
  442. form = UnlockForm()
  443. return render(request, 'unlock.html', {'form': form})
  444. def unlock_done(request):
  445. return render(request, 'unlock-done.html')