Browse Source

Merge pull request #15 from desec-io/captcha

feat(): captcha to mitigate abuse, closes #9
Nils Wisiol 8 năm trước cách đây
mục cha
commit
516e00f187

+ 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=

+ 2 - 0
.travis.yml

@@ -24,6 +24,8 @@ env:
    - DESECSTACK_IPV6_ADDRESS=fd80::1
    - DESECSTACK_WWW_CERTS=/dev/null
    - DESECSTACK_DBMASTER_CERTS=/dev/null
+   - DESECSTACK_NORECAPTCHA_SITE_KEY=9Fn33T5yGulkjhdidid
+   - DESECSTACK_NORECAPTCHA_SECRET_KEY=9Fn33T5yGulkjhoiwhetoi
 
 language: python
 

+ 18 - 0
api/desecapi/emails.py

@@ -0,0 +1,18 @@
+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, user):
+    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=[user.email], request=request),
+        'domainname': user.domains[0].name if user.domains.count() > 0 else 'deSEC dedyn.io'
+    })
+    email = EmailMessage(subject_tmpl.render(context),
+                         content_tmpl.render(context),
+                         from_tmpl.render(context),
+                         [user.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()

+ 15 - 0
api/desecapi/management/commands/privacy-chores.py

@@ -0,0 +1,15 @@
+from django.core.management import BaseCommand
+from desecapi.models import User
+from desecapi import settings
+from django.utils import timezone
+from datetime import timedelta
+
+
+class Command(BaseCommand):
+
+    def handle(self, *args, **kwargs):
+
+        users = User.objects.filter(created__lt=timezone.now()-timedelta(hours=settings.ABUSE_LOCK_ACCOUNT_BY_REGISTRATION_IP_PERIOD_HRS))
+        for u in users:
+            u.registration_remote_ip = ''
+            u.save() # TODO bulk update?

+ 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),
+        ),
+    ]

+ 20 - 0
api/desecapi/migrations/0011_user_limit_domains.py

@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10.3 on 2016-12-27 07:59
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0010_auto_20161219_1242'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='limit_domains',
+            field=models.IntegerField(blank=True, default=5, null=True),
+        ),
+    ]

+ 54 - 86
api/desecapi/models.py

@@ -4,14 +4,12 @@ from django.contrib.auth.models import (
     BaseUserManager, AbstractBaseUser
 )
 from django.utils import timezone
-import requests
-import json
-import subprocess
-import os
+from desecapi import pdns
 import datetime, time
 
+
 class MyUserManager(BaseUserManager):
-    def create_user(self, email, password=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.
@@ -21,6 +19,8 @@ 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)
@@ -48,6 +48,10 @@ class User(AbstractBaseUser):
     )
     is_active = models.BooleanField(default=True)
     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)
+    limit_domains = models.IntegerField(default=settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT,null=True,blank=True)
 
     objects = MyUserManager()
 
@@ -79,6 +83,12 @@ class User(AbstractBaseUser):
         # Simplest possible answer: All admins are staff
         return self.is_admin
 
+    def unlock(self):
+        self.captcha_required = False
+        for domain in self.domains.all():
+            domain.pdns_resync()
+        self.save()
+
 
 class Domain(models.Model):
     created = models.DateTimeField(auto_now_add=True)
@@ -89,90 +99,48 @@ class Domain(models.Model):
     dyn = models.BooleanField(default=False)
     owner = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='domains')
 
-    headers = {
-        'User-Agent': 'desecapi',
-        'X-API-Key': settings.POWERDNS_API_TOKEN,
-    }
+    def pdns_resync(self):
+        """
+        Make sure that pdns gets the latest information about this domain/zone.
+        Re-Syncing is relatively expensive and should not happen routinely.
+        """
+
+        # Create zone if needed
+        if not pdns.zone_exists(self.name):
+            pdns.create_zone(self.name)
+
+        # update zone to latest information
+        pdns.set_dyn_records(self.name, self.arecord, self.aaaarecord)
+
+    def pdns_sync(self):
+        """
+        Command pdns updates as indicated by the local changes.
+        """
+
+        if self.owner.captcha_required:
+            # suspend all updates
+            return
+
+        new_domain = self.id is None
+        changes_required = False
+
+        # if this zone is new, create it
+        if new_domain:
+            pdns.create_zone(self.name)
+
+        # for existing domains, see if records are changed
+        if not new_domain:
+            orig_domain = Domain.objects.get(id=self.id)
+            changes_required = self.arecord != orig_domain.arecord or self.aaaarecord != orig_domain.aaaarecord
+
+        # make changes if necessary
+        if changes_required:
+            pdns.set_dyn_records(self.name, self.arecord, self.aaaarecord)
 
     def save(self, *args, **kwargs):
-        if self.id is None:
-            self.pdnsCreate()
-            if self.arecord or self.aaaarecord:
-                self.pdnsUpdate()
-        else:
-            orig = Domain.objects.get(id=self.id)
-            if self.arecord != orig.arecord or self.aaaarecord != orig.aaaarecord:
-                self.pdnsUpdate()
         self.updated = timezone.now()
-        super(Domain, self).save(*args, **kwargs) # Call the "real" save() method.
-
-    def pdnsCreate(self):
-        payload = {
-            "name": self.name + ".",
-            "kind": "NATIVE",
-            "masters": [],
-            "nameservers": [
-                "ns1.desec.io.",
-                "ns2.desec.io."
-            ]
-        }
-        r = requests.post(settings.POWERDNS_API + '/zones', data=json.dumps(payload), headers=self.headers)
-        if r.status_code < 200 or r.status_code >= 300:
-            raise Exception(r.text)
-
-    def pdnsUpdate(self):
-        if self.arecord:
-            a = \
-                {
-                    "records": [
-                            {
-                                "type": "A",
-                                "name": self.name + ".",
-                                "disabled": False,
-                                "content": self.arecord,
-                            }
-                        ],
-                    "ttl": 60,
-                    "changetype": "REPLACE",
-                    "type": "A",
-                    "name": self.name + ".",
-                }
-        else:
-            a = \
-                {
-                    "changetype": "DELETE",
-                    "type": "A",
-                    "name": self.name + "."
-                }
-
-        if self.aaaarecord:
-            aaaa = \
-                {
-                    "records": [
-                            {
-                                "type": "AAAA",
-                                "name": self.name + ".",
-                                "disabled": False,
-                                "content": self.aaaarecord,
-                            }
-                        ],
-                    "ttl": 60,
-                    "changetype": "REPLACE",
-                    "type": "AAAA",
-                    "name": self.name + ".",
-                }
-        else:
-            aaaa = \
-                {
-                    "changetype": "DELETE",
-                    "type": "AAAA",
-                    "name": self.name + "."
-                }
-
-        payload = { "rrsets": [a, aaaa] }
-        r = requests.patch(settings.POWERDNS_API + '/zones/' + self.name, data=json.dumps(payload), headers=self.headers)
-        if r.status_code < 200 or r.status_code >= 300:
-            raise Exception(r)
+        self.pdns_sync()
+        super(Domain, self).save(*args, **kwargs)
 
     class Meta:
         ordering = ('created',)

+ 104 - 0
api/desecapi/pdns.py

@@ -0,0 +1,104 @@
+import requests
+import json
+from desecapi import settings
+
+
+headers = {
+    'User-Agent': 'desecapi',
+    'X-API-Key': settings.POWERDNS_API_TOKEN,
+}
+
+
+def normalize_hostname(name):
+    if '/' in name or '?' in name:
+        raise Exception('Invalid hostname ' + name)
+    return name if name.endswith('.') else name + '.'
+
+
+def _pdns_post(url, body):
+    r = requests.post(settings.POWERDNS_API + url, data=json.dumps(body), headers=headers)
+    if r.status_code < 200 or r.status_code >= 300:
+        raise Exception(r.text)
+    return r
+
+
+def _pdns_patch(url, body):
+    r = requests.patch(settings.POWERDNS_API + url, data=json.dumps(body), headers=headers)
+    if r.status_code < 200 or r.status_code >= 300:
+        raise Exception(r.text)
+    return r
+
+
+def _pdns_get(url):
+    r = requests.get(settings.POWERDNS_API + url, headers=headers)
+    if (r.status_code < 200 or r.status_code >= 300) and r.status_code != 404:
+        raise Exception(r.text)
+    return r
+
+
+def _delete_or_replace_rrset(name, type, value, ttl=60):
+    """
+    Return pdns API json to either replace or delete a record set, depending on value is empty or not.
+    """
+    if value != "":
+        return \
+            {
+                "records": [
+                    {
+                        "type": type,
+                        "name": name,
+                        "disabled": False,
+                        "content": value,
+                    }
+                ],
+                "ttl": ttl,
+                "changetype": "REPLACE",
+                "type": type,
+                "name": name,
+            }
+    else:
+        return \
+            {
+                "changetype": "DELETE",
+                "type": type,
+                "name": name
+            }
+
+
+def create_zone(name, kind='NATIVE'):
+    """
+    Commands pdns to create a zone with the given name.
+    """
+    payload = {
+        "name": normalize_hostname(name),
+        "kind": kind.upper(),
+        "masters": [],
+        "nameservers": [
+            "ns1.desec.io.",
+            "ns2.desec.io."
+        ]
+    }
+    _pdns_post('/zones', payload)
+
+
+def zone_exists(name):
+    """
+    Returns whether pdns knows a zone with the given name.
+    """
+    return _pdns_get('/zones/' + normalize_hostname(name)).status_code != 404
+
+
+def set_dyn_records(name, a, aaaa):
+    """
+    Commands pdns to set the A and AAAA record for the zone with the given name to the given record values.
+    Only supports one A, one AAAA record.
+    If a or aaaa is None, pdns will be commanded to delete the record.
+    """
+    name = normalize_hostname(name)
+
+    _pdns_patch('/zones/' + name, {
+        "rrsets": [
+            _delete_or_replace_rrset(name, 'a', a),
+            _delete_or_replace_rrset(name, 'aaaa', aaaa),
+        ]
+    })

+ 9 - 0
api/desecapi/settings.py

@@ -148,3 +148,12 @@ 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
+LIMIT_USER_DOMAIN_COUNT_DEFAULT = 5

+ 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 @@
+{{domainname}} 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>

+ 19 - 1
api/desecapi/tests/testdomains.py

@@ -150,7 +150,7 @@ class AuthenticatedDomainTests(APITestCase):
         response = self.client.get(url)
 
         httpretty.enable()
-        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + response.data['name'])
+        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + response.data['name'] + '.')
 
         response.data['arecord'] = '10.13.3.7'
         response = self.client.put(url, response.data)
@@ -189,3 +189,21 @@ class AuthenticatedDomainTests(APITestCase):
             response = self.client.post(url, data)
             self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
             self.assertEqual(len(mail.outbox), outboxlen)
+
+    def testLimitDomains(self):
+        httpretty.enable()
+        httpretty.register_uri(httpretty.POST, settings.POWERDNS_API + '/zones')
+
+        outboxlen = len(mail.outbox)
+
+        url = reverse('domain-list')
+        for i in range(settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2):
+            data = {'name': utils.generateDomainname(), 'dyn': True}
+            response = self.client.post(url, data)
+            self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+            self.assertEqual(len(mail.outbox), outboxlen+i+1)
+
+        data = {'name': utils.generateDomainname(), 'dyn': True}
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertEqual(len(mail.outbox), outboxlen + settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT-2)

+ 63 - 2
api/desecapi/tests/testdyndns12update.py

@@ -31,8 +31,12 @@ class DynDNS12UpdateTest(APITestCase):
         self.client.credentials(HTTP_AUTHORIZATION='Basic ' + base64.b64encode((self.username + ':' + self.password).encode()).decode())
 
         httpretty.enable()
+        httpretty.HTTPretty.allow_net_connect = False
         httpretty.register_uri(httpretty.POST, settings.POWERDNS_API + '/zones')
-        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + self.domain)
+        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + self.domain + '.')
+
+    def tearDown(self):
+        httpretty.disable()
 
     def assertIP(self, ipv4=None, ipv6=None):
         old_credentials = self.client._credentials['HTTP_AUTHORIZATION']
@@ -121,4 +125,61 @@ class DynDNS12UpdateTest(APITestCase):
                                    })
         self.assertEqual(response.status_code, status.HTTP_200_OK)
         self.assertEqual(response.data, 'good')
-        self.assertIP(ipv4='127.0.0.1')
+        self.assertIP(ipv4='127.0.0.1')
+
+    def testSuspendedUpdates(self):
+        self.owner.captcha_required = True
+        self.owner.save()
+
+        httpretty.reset()
+        httpretty.enable()
+        httpretty.HTTPretty.allow_net_connect = False
+
+        domain = self.owner.domains.all()[0]
+        domain.arecord = '10.1.1.1'
+        domain.save()
+
+        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + self.domain + '.')
+        httpretty.register_uri(httpretty.GET, settings.POWERDNS_API + '/zones/' + self.domain + '.', status=200)
+
+        self.owner.unlock()
+
+        self.assertEqual(httpretty.last_request().method, 'PATCH')
+        self.assertTrue((settings.POWERDNS_API + '/zones/' + self.domain + '.').endswith(httpretty.last_request().path))
+        self.assertTrue(self.domain in httpretty.last_request().parsed_body)
+        self.assertTrue('10.1.1.1' in httpretty.last_request().parsed_body)
+
+    def testSuspendedUpdatesDomainCreation(self):
+        self.owner.captcha_required = True
+        self.owner.save()
+
+        httpretty.reset()
+        httpretty.enable()
+        httpretty.HTTPretty.allow_net_connect = False
+
+        url = reverse('domain-list')
+        newdomain = utils.generateDynDomainname()
+        data = {'name': newdomain, 'dyn': True, 'arecord': '10.2.2.2'}
+        self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token)
+        response = self.client.post(url, data)
+        self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+        self.assertEqual(response.data['dyn'], True)
+
+        domain = self.owner.domains.all()[0]
+        domain.arecord = '10.1.1.1'
+        domain.save()
+
+        httpretty.register_uri(httpretty.POST, settings.POWERDNS_API + '/zones')
+        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + newdomain + '.')
+        httpretty.register_uri(httpretty.GET, settings.POWERDNS_API + '/zones/' + newdomain + '.', status=200)
+        httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + self.domain + '.')
+        httpretty.register_uri(httpretty.GET, settings.POWERDNS_API + '/zones/' + self.domain + '.', status=200)
+
+        self.owner.unlock()
+
+        self.assertEqual(httpretty.last_request().method, 'PATCH')
+        self.assertTrue(
+                (settings.POWERDNS_API + '/zones/' + self.domain + '.').endswith(httpretty.last_request().path) \
+                or (settings.POWERDNS_API + '/zones/' + newdomain + '.').endswith(httpretty.last_request().path)
+            )
+        self.assertTrue('10.2.2.2' in httpretty.last_request().parsed_body)

+ 1 - 1
api/desecapi/tests/testdynupdateauthentication.py

@@ -32,7 +32,7 @@ class DynUpdateAuthenticationTests(APITestCase):
 
             httpretty.enable()
             httpretty.register_uri(httpretty.POST, settings.POWERDNS_API + '/zones')
-            httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + self.domain)
+            httpretty.register_uri(httpretty.PATCH, settings.POWERDNS_API + '/zones/' + self.domain + '.')
 
     def testSuccessfulAuthentication(self):
         response = self.client.get(self.url)

+ 34 - 0
api/desecapi/tests/testprivacychores.py

@@ -0,0 +1,34 @@
+from django.core.management import call_command
+from django.test import TestCase
+from django.utils import timezone
+from desecapi.models import User, MyUserManager
+from .utils import utils
+from desecapi import settings
+from datetime import timedelta
+
+
+class PrivacyChoresCommandTest(TestCase):
+
+    def test_delete_registration_ip_for_old_users(self):
+        name1 = utils.generateUsername()
+        name2 = utils.generateUsername()
+
+        User(
+            email=name1,
+            registration_remote_ip='1.3.3.7',
+        ).save()
+        User(
+            email=name2,
+            registration_remote_ip='1.3.3.8',
+        ).save()
+        user2 = User.objects.get(email=name2)
+        user2.created = timezone.now()-timedelta(hours=settings.ABUSE_LOCK_ACCOUNT_BY_REGISTRATION_IP_PERIOD_HRS+1)
+        user2.save()
+
+        user_count = User.objects.all().count()
+
+        call_command('privacy-chores')
+
+        self.assertEqual(User.objects.all().count(), user_count)
+        self.assertEqual(User.objects.get(email=name1).registration_remote_ip, '1.3.3.7')
+        self.assertEqual(User.objects.get(email=name2).registration_remote_ip, '')

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

@@ -0,0 +1,112 @@
+from django.core.urlresolvers import reverse
+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):
+
+    def test_registration_successful(self):
+        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")
+
+    def test_multiple_registration_captcha_required_same_ip_short_time(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)
+
+        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 test_multiple_registration_no_captcha_required_different_ip(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 test_multiple_registration_no_captcha_required_same_ip_long_time(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 test_send_captcha_email_manually(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)
+
+        self.assertEqual(len(mail.outbox), outboxlen+1)

+ 5 - 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,11 +13,14 @@ 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)
 
 urlpatterns = [
+   url(r'^api/v1/auth/register/$', RegistrationView.as_view(), name='register'),
    url(r'^api/v1/auth/', include('djoser.urls.authtoken')),
    url(r'^api/v1/', include(apiurls)),
 ]

+ 71 - 10
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
@@ -18,6 +18,23 @@ from desecapi.authentication import BasicTokenAuthentication, URLParamAuthentica
 import base64
 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):
+    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
+    if x_forwarded_for:
+        ip = x_forwarded_for.split(',')[0]
+    else:
+        ip = request.META.get('REMOTE_ADDR')
+    return ip
 
 
 class DomainList(generics.ListCreateAPIView):
@@ -34,6 +51,11 @@ class DomainList(generics.ListCreateAPIView):
             ex.status_code = 409
             raise ex
 
+        if self.request.user.limit_domains is not None and self.request.user.domains.count() >= self.request.user.limit_domains:
+            ex = ValidationError(detail={"detail": "You reached the maximum number of domains allowed for your account.", "code": "domain-limit"})
+            ex.status_code = 403
+            raise ex
+
         obj = serializer.save(owner=self.request.user)
 
         def sendDynDnsEmail(domain):
@@ -189,21 +211,13 @@ class DynDNS12Update(APIView):
                 return request.query_params[p]
 
         # Check remote IP address
-        client_ip = self.get_client_ip(request)
+        client_ip = get_client_ip(request)
         if lookfor in client_ip:
             return client_ip
 
         # give up
         return ''
 
-    def get_client_ip(self, request):
-        x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
-        if x_forwarded_for:
-            ip = x_forwarded_for.split(',')[0]
-        else:
-            ip = request.META.get('REMOTE_ADDR')
-        return ip
-
     def findIPv4(self, request):
         return self.findIP(request, ['myip', 'myipv4', 'ip'])
 
@@ -267,3 +281,50 @@ class DonationList(generics.CreateAPIView):
         # send emails
         sendDonationEmails(obj)
 
+
+class RegistrationView(views.RegistrationView):
+    """
+    Extends the djoser RegistrationView to record the remote IP address of any registration.
+    """
+
+    def create(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        self.perform_create(serializer, get_client_ip(request))
+        headers = self.get_success_headers(serializer.data)
+        return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+    def perform_create(self, serializer, 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)
+        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.objects.get(email=email).unlock()
+            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