views.py 21 KB

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