views.py 23 KB

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