views.py 22 KB

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