Pārlūkot izejas kodu

feat(api): lock account on abuse suspicion

This code suspects a bot if the same remote IP address registeres more
than one user account in under 48 hours.

The account can be unlocked by solving a Google ReCAPTCHA.
Nils Wisiol 8 gadi atpakaļ
vecāks
revīzija
00c9644e0f

+ 2 - 0
.env.default

@@ -16,6 +16,8 @@ DESECSTACK_API_EMAIL_HOST_PASSWORD=
 DESECSTACK_API_EMAIL_PORT=
 DESECSTACK_API_SECRETKEY=
 DESECSTACK_DBAPI_PASSWORD_desec=
+DESECSTACK_NORECAPTCHA_SITE_KEY=
+DESECSTACK_NORECAPTCHA_SECRET_KEY=
 
 # nslord-related
 DESECSTACK_DBLORD_PASSWORD_pdns=

+ 17 - 0
api/desecapi/emails.py

@@ -0,0 +1,17 @@
+from django.template.loader import get_template
+from django.template import Context
+from django.core.mail import EmailMessage
+from rest_framework.reverse import reverse
+
+def send_account_lock_email(request, email):
+    content_tmpl = get_template('emails/captcha/content.txt')
+    subject_tmpl = get_template('emails/captcha/subject.txt')
+    from_tmpl = get_template('emails/from.txt')
+    context = Context({
+        'url': reverse('unlock/byEmail', args=[email], request=request),
+    })
+    email = EmailMessage(subject_tmpl.render(context),
+                         content_tmpl.render(context),
+                         from_tmpl.render(context),
+                         [email])
+    email.send()

+ 6 - 0
api/desecapi/forms.py

@@ -0,0 +1,6 @@
+from django import forms
+from nocaptcha_recaptcha.fields import NoReCaptchaField
+
+
+class UnlockForm(forms.Form):
+    captcha = NoReCaptchaField()

+ 30 - 0
api/desecapi/migrations/0010_auto_20161219_1242.py

@@ -0,0 +1,30 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.3 on 2016-12-19 12:42
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0009_auto_20161201_1548'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='captcha_required',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='registration_remote_ip',
+            field=models.CharField(blank=True, max_length=1024),
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='created',
+            field=models.DateTimeField(auto_now_add=True),
+        ),
+    ]

+ 4 - 1
api/desecapi/models.py

@@ -10,8 +10,9 @@ import subprocess
 import os
 import datetime, time
 
+
 class MyUserManager(BaseUserManager):
-    def create_user(self, email, password=None, registration_remote_ip=None):
+    def create_user(self, email, password=None, registration_remote_ip=None, captcha_required=False):
         """
         Creates and saves a User with the given email, date of
         birth and password.
@@ -22,6 +23,7 @@ class MyUserManager(BaseUserManager):
         user = self.model(
             email=self.normalize_email(email),
             registration_remote_ip=registration_remote_ip,
+            captcha_required=captcha_required,
         )
 
         user.set_password(password)
@@ -51,6 +53,7 @@ class User(AbstractBaseUser):
     is_admin = models.BooleanField(default=False)
     registration_remote_ip = models.CharField(max_length=1024, blank=True)
     captcha_required = models.BooleanField(default=False)
+    created = models.DateTimeField(auto_now_add=True)
 
     objects = MyUserManager()
 

+ 8 - 0
api/desecapi/settings.py

@@ -148,3 +148,11 @@ POWERDNS_API_TOKEN = os.environ['DESECSTACK_NSLORD_APIKEY']
 SEPA = {
     'CREDITOR_ID': os.environ['DESECSTACK_API_SEPA_CREDITOR_ID'],
 }
+
+# recaptcha
+NORECAPTCHA_SITE_KEY = os.environ['DESECSTACK_NORECAPTCHA_SITE_KEY']
+NORECAPTCHA_SECRET_KEY = os.environ['DESECSTACK_NORECAPTCHA_SECRET_KEY']
+NORECAPTCHA_WIDGET_TEMPLATE = 'captcha-widget.html'
+
+# abuse protection
+ABUSE_LOCK_ACCOUNT_BY_REGISTRATION_IP_PERIOD_HRS = 48

+ 21 - 0
api/desecapi/templates/captcha-widget.html

@@ -0,0 +1,21 @@
+<div class="g-recaptcha" {% for attr in gtag_attrs.items %}{{ attr.0 }}="{{ attr.1 }}" {% endfor %}data-sitekey="{{ site_key }}"></div>
+<noscript>
+    <div style="width: 302px; height: 352px;">
+        <div style="width: 302px; height: 352px; position: relative;">
+            <div style="width: 302px; height: 352px; position: absolute;">
+                <iframe src="{{ fallback_url }}?k={{ site_key }}"
+                        frameborder="0" scrolling="no"
+                        style="width: 302px; height:352px; border-style: none;">
+                </iframe>
+            </div>
+            <div style="width: 250px; height: 80px; position: absolute; border-style: none;
+                  bottom: 21px; left: 25px; margin: 0px; padding: 0px; right: 25px;">
+                <textarea id="g-recaptcha-response" name="g-recaptcha-response"
+                          class="g-recaptcha-response"
+                          style="width: 250px; height: 80px; border: 1px solid #c1c1c1;
+                         margin: 0px; padding: 0px; resize: none;" value="">
+                </textarea>
+            </div>
+        </div>
+    </div>
+</noscript>

+ 37 - 0
api/desecapi/templates/emails/captcha/content.txt

@@ -0,0 +1,37 @@
+Hello,
+
+your activity on the deSEC dedyn.io dynamic DNS service looks
+suspiciously similar to those of bots. As mass registrations
+by bots have been a problem for us in the past, we need to
+ask you to prove you are not a bot by solving a CAPTCHA. Sorry
+to bother you with that!
+
+Please go to
+
+  {{url}}
+
+and unlock your account for future use. Until unlocked, your
+account will be kept in read-only mode, that is, you won't be able
+to register any domains or update IP addresses (or any other
+records). Accounts that are not unlocked are subject to deletion
+after two weeks.
+
+If you need mass registrations for a legit purpose, please
+contact us directly. Just reply to this email, we will be happy
+to get in touch with you.
+
+As we use Google Recaptcha for our CAPTCHA, we recommend using
+your browser's anonymous mode to solve it.
+
+Cheers,
+Nils
+
+--
+deSEC GbR
+Maybachufer 9
+12047 Berlin
+Germany
+
+phone: +49-30-47384344
+
+Vertreten durch: Jan Binger, Peter Thomassen, Nils Wisiol

+ 1 - 0
api/desecapi/templates/emails/captcha/subject.txt

@@ -0,0 +1 @@
+deSEC dedyn.io Account Suspended

+ 11 - 0
api/desecapi/templates/unlock-done.html

@@ -0,0 +1,11 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Title</title>
+    <script src="https://www.google.com/recaptcha/api.js" async defer></script>
+</head>
+<body>
+    done.
+</body>
+</html>

+ 15 - 0
api/desecapi/templates/unlock.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Title</title>
+    <script src="https://www.google.com/recaptcha/api.js" async defer></script>
+</head>
+<body>
+    <form action="" method="post">
+        {% csrf_token %}
+        {{ form }}
+        <input type="submit" value="Submit" />
+    </form>
+</body>
+</html>

+ 96 - 0
api/desecapi/tests/testregistration.py

@@ -3,6 +3,11 @@ from rest_framework import status
 from rest_framework.test import APITestCase
 from .utils import utils
 from desecapi import models
+from datetime import timedelta
+from django.utils import timezone
+from django.core import mail
+from desecapi.emails import send_account_lock_email
+from desecapi import settings
 
 
 class RegistrationTest(APITestCase):
@@ -14,3 +19,94 @@ class RegistrationTest(APITestCase):
         user = models.User.objects.get(email=data['email'])
         self.assertEqual(user.email, data['email'])
         self.assertEqual(user.registration_remote_ip, "1.3.3.7")
+
+    def testMultipleRegistrationCaptchaRequiredSameIpShortTime(self):
+        outboxlen = len(mail.outbox)
+
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        self.assertEqual(user.email, data['email'])
+        self.assertEqual(user.registration_remote_ip, "1.3.3.7")
+        self.assertEqual(user.captcha_required, False)
+        print(user.created)
+
+        self.assertEqual(len(mail.outbox), outboxlen)
+
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        self.assertEqual(user.email, data['email'])
+        self.assertEqual(user.registration_remote_ip, "1.3.3.7")
+        self.assertEqual(user.captcha_required, True)
+
+        self.assertEqual(len(mail.outbox), outboxlen + 1)
+
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.7")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        self.assertEqual(user.email, data['email'])
+        self.assertEqual(user.registration_remote_ip, "1.3.3.7")
+        self.assertEqual(user.captcha_required, True)
+
+        self.assertEqual(len(mail.outbox), outboxlen + 2)
+
+    def testMultipleRegistrationNoCaptchaRequiredDifferentIp(self):
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.8")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        self.assertEqual(user.email, data['email'])
+        self.assertEqual(user.registration_remote_ip, "1.3.3.8")
+        self.assertEqual(user.captcha_required, False)
+
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.9")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        self.assertEqual(user.email, data['email'])
+        self.assertEqual(user.registration_remote_ip, "1.3.3.9")
+        self.assertEqual(user.captcha_required, False)
+
+    def testMultipleRegistrationNoCaptchaRequiredSameIpLongTime(self):
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        self.assertEqual(user.email, data['email'])
+        self.assertEqual(user.registration_remote_ip, "1.3.3.10")
+        self.assertEqual(user.captcha_required, False)
+
+        #fake registration time
+        user.created = timezone.now() - timedelta(hours=settings.ABUSE_LOCK_ACCOUNT_BY_REGISTRATION_IP_PERIOD_HRS+1)
+        user.save()
+
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        self.assertEqual(user.email, data['email'])
+        self.assertEqual(user.registration_remote_ip, "1.3.3.10")
+        self.assertEqual(user.captcha_required, False)
+
+    def testSendCaptchaEmailManually(self):
+        outboxlen = len(mail.outbox)
+
+        url = reverse('register')
+        data = {'email': utils.generateUsername(), 'password': utils.generateRandomString(size=12)}
+        response = self.client.post(url, data, REMOTE_ADDR="1.3.3.10")
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        user = models.User.objects.get(email=data['email'])
+        send_account_lock_email(None, user.email)
+
+        self.assertEqual(len(mail.outbox), outboxlen+1)

+ 4 - 0
api/desecapi/urls.py

@@ -2,6 +2,8 @@ from django.conf.urls import include, url
 from django.contrib import admin
 from desecapi.views import *
 from rest_framework.urlpatterns import format_suffix_patterns
+from desecapi import views
+
 
 apiurls = [
     url(r'^$', Root.as_view(), name='root'),
@@ -11,6 +13,8 @@ apiurls = [
     url(r'^dns$', DnsQuery.as_view(), name='dns-query'),
     url(r'^dyndns/update$', DynDNS12Update.as_view(), name='dyndns12update'),
     url(r'^donation/', DonationList.as_view(), name='donation'),
+    url(r'^unlock/user/(?P<email>.+)$', views.unlock, name='unlock/byEmail'),
+    url(r'^unlock/done', views.unlock_done, name='unlock/done'),
 ]
 
 apiurls = format_suffix_patterns(apiurls)

+ 40 - 2
api/desecapi/views.py

@@ -1,6 +1,6 @@
 from __future__ import unicode_literals
 from django.core.mail import EmailMessage
-from desecapi.models import Domain
+from desecapi.models import Domain, User
 from desecapi.serializers import DomainSerializer, DonationSerializer
 from rest_framework import generics
 from desecapi.permissions import IsOwner
@@ -20,6 +20,12 @@ from desecapi import settings
 from rest_framework.exceptions import ValidationError
 from djoser import views, signals
 from rest_framework import status
+from datetime import datetime, timedelta
+from django.utils import timezone
+from desecapi.forms import UnlockForm
+from django.shortcuts import render
+from django.http import HttpResponseRedirect
+from desecapi.emails import send_account_lock_email
 
 
 def get_client_ip(request):
@@ -284,6 +290,38 @@ class RegistrationView(views.RegistrationView):
         return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
 
     def perform_create(self, serializer, remote_ip):
-        user = serializer.save(registration_remote_ip=remote_ip)
+        captcha = User.objects.filter(
+                created__gte=timezone.now()-timedelta(hours=settings.ABUSE_LOCK_ACCOUNT_BY_REGISTRATION_IP_PERIOD_HRS),
+                registration_remote_ip=remote_ip
+            ).exists()
+        user = serializer.save(registration_remote_ip=remote_ip, captcha_required=captcha)
+        if captcha:
+            send_account_lock_email(self.request, user.email)
         signals.user_registered.send(sender=self.__class__, user=user, request=self.request)
 
+
+def unlock(request, email):
+    # if this is a POST request we need to process the form data
+    if request.method == 'POST':
+        # create a form instance and populate it with data from the request:
+        form = UnlockForm(request.POST)
+        # check whether it's valid:
+        if form.is_valid():
+            try:
+                user = User.objects.get(email=email)
+                user.captcha_required = False
+                user.save()
+            except User.DoesNotExist:
+                pass # fail silently, otherwise people can find out if email addresses are registered with us
+
+            return HttpResponseRedirect(reverse('unlock/done'))
+
+    # if a GET (or any other method) we'll create a blank form
+    else:
+        form = UnlockForm()
+
+    return render(request, 'unlock.html', {'form': form})
+
+
+def unlock_done(request):
+    return render(request, 'unlock-done.html')

+ 1 - 0
api/requirements.txt

@@ -18,3 +18,4 @@ pycparser==2.13
 requests==2.7.0
 six==1.9.0
 uwsgi==2.0.14
+django-nocaptcha-recaptcha==0.0.19

+ 2 - 0
docker-compose.yml

@@ -107,6 +107,8 @@ services:
     - DESECSTACK_API_SECRETKEY
     - DESECSTACK_DBAPI_PASSWORD_desec
     - DESECSTACK_NSLORD_APIKEY
+    - DESECSTACK_NORECAPTCHA_SITE_KEY
+    - DESECSTACK_NORECAPTCHA_SECRET_KEY
     networks:
     - back1
     - front # TODO change to back2 when https://github.com/docker/docker/issues/27101 is fixed