浏览代码

feat(api): rework user management

Nils Wisiol 5 年之前
父节点
当前提交
7c4dc77ddc
共有 52 个文件被更改,包括 2173 次插入971 次删除
  1. 0 2
      .env.default
  2. 0 2
      .env.dev
  3. 0 2
      .travis.yml
  4. 7 33
      api/api/settings.py
  5. 0 6
      api/api/settings_quick_test.py
  6. 17 2
      api/desecapi/authentication.py
  7. 0 16
      api/desecapi/emails.py
  8. 0 6
      api/desecapi/forms.py
  9. 1 1
      api/desecapi/migrations/0003_validation.py
  10. 21 0
      api/desecapi/migrations/0005_user_model_cleanup.py
  11. 78 0
      api/desecapi/migrations/0006_authenticated_actions.py
  12. 0 7
      api/desecapi/mixins.py
  13. 248 13
      api/desecapi/models.py
  14. 0 13
      api/desecapi/permissions.py
  15. 240 34
      api/desecapi/serializers.py
  16. 0 21
      api/desecapi/templates/captcha-widget.html
  17. 14 0
      api/desecapi/templates/emails/activate-with-domain/content.txt
  18. 1 0
      api/desecapi/templates/emails/activate-with-domain/subject.txt
  19. 15 0
      api/desecapi/templates/emails/activate/content.txt
  20. 1 0
      api/desecapi/templates/emails/activate/subject.txt
  21. 0 37
      api/desecapi/templates/emails/captcha/content.txt
  22. 0 1
      api/desecapi/templates/emails/captcha/subject.txt
  23. 10 0
      api/desecapi/templates/emails/change-email-confirmation-old-email/content.txt
  24. 1 0
      api/desecapi/templates/emails/change-email-confirmation-old-email/subject.txt
  25. 21 0
      api/desecapi/templates/emails/change-email/content.txt
  26. 1 0
      api/desecapi/templates/emails/change-email/subject.txt
  27. 16 0
      api/desecapi/templates/emails/delete-user/content.txt
  28. 1 0
      api/desecapi/templates/emails/delete-user/subject.txt
  29. 0 6
      api/desecapi/templates/emails/domain-dyndns/content.txt
  30. 14 0
      api/desecapi/templates/emails/footer.txt
  31. 10 0
      api/desecapi/templates/emails/password-change-confirmation/content.txt
  32. 1 0
      api/desecapi/templates/emails/password-change-confirmation/subject.txt
  33. 12 0
      api/desecapi/templates/emails/reset-password/content.txt
  34. 1 0
      api/desecapi/templates/emails/reset-password/subject.txt
  35. 0 11
      api/desecapi/templates/unlock-done.html
  36. 0 15
      api/desecapi/templates/unlock.html
  37. 25 40
      api/desecapi/tests/base.py
  38. 26 15
      api/desecapi/tests/test_authentication.py
  39. 11 94
      api/desecapi/tests/test_domains.py
  40. 9 134
      api/desecapi/tests/test_registration.py
  41. 704 0
      api/desecapi/tests/test_user_management.py
  42. 15 20
      api/desecapi/urls/version_1.py
  43. 283 197
      api/desecapi/views.py
  44. 0 3
      api/requirements.txt
  45. 0 2
      docker-compose.yml
  46. 260 120
      docs/authentication.rst
  47. 18 6
      docs/endpoint-reference.rst
  48. 1 0
      docs/index.rst
  49. 55 0
      docs/quickstart.rst
  50. 8 4
      test/e2e/schemas.js
  51. 25 106
      test/e2e/spec/api_spec.js
  52. 2 2
      test/e2e/spec/dyndns_spec.js

+ 0 - 2
.env.default

@@ -23,8 +23,6 @@ DESECSTACK_API_SECRETKEY=
 DESECSTACK_API_PSL_RESOLVER=
 DESECSTACK_API_PSL_RESOLVER=
 DESECSTACK_DBAPI_PASSWORD_desec=
 DESECSTACK_DBAPI_PASSWORD_desec=
 DESECSTACK_MINIMUM_TTL_DEFAULT=900
 DESECSTACK_MINIMUM_TTL_DEFAULT=900
-DESECSTACK_NORECAPTCHA_SITE_KEY=
-DESECSTACK_NORECAPTCHA_SECRET_KEY=
 
 
 # nslord-related
 # nslord-related
 DESECSTACK_DBLORD_PASSWORD_pdns=
 DESECSTACK_DBLORD_PASSWORD_pdns=

+ 0 - 2
.env.dev

@@ -23,8 +23,6 @@ DESECSTACK_API_SECRETKEY=insecure
 DESECSTACK_API_PSL_RESOLVER=9.9.9.9
 DESECSTACK_API_PSL_RESOLVER=9.9.9.9
 DESECSTACK_DBAPI_PASSWORD_desec=insecure
 DESECSTACK_DBAPI_PASSWORD_desec=insecure
 DESECSTACK_MINIMUM_TTL_DEFAULT=
 DESECSTACK_MINIMUM_TTL_DEFAULT=
-DESECSTACK_NORECAPTCHA_SITE_KEY=
-DESECSTACK_NORECAPTCHA_SECRET_KEY=
 
 
 # nslord-related
 # nslord-related
 DESECSTACK_DBLORD_PASSWORD_pdns=insecure
 DESECSTACK_DBLORD_PASSWORD_pdns=insecure

+ 0 - 2
.travis.yml

@@ -30,8 +30,6 @@ env:
    - DESECSTACK_WWW_CERTS=./certs
    - DESECSTACK_WWW_CERTS=./certs
    - DESECSTACK_DBMASTER_CERTS=./dbmastercerts
    - DESECSTACK_DBMASTER_CERTS=./dbmastercerts
    - DESECSTACK_MINIMUM_TTL_DEFAULT=3600
    - DESECSTACK_MINIMUM_TTL_DEFAULT=3600
-   - DESECSTACK_NORECAPTCHA_SITE_KEY=9Fn33T5yGulkjhdidid
-   - DESECSTACK_NORECAPTCHA_SECRET_KEY=9Fn33T5yGulkjhoiwhetoi
 
 
 services:
 services:
   - docker
   - docker

+ 7 - 33
api/api/settings.py

@@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/1.7/ref/settings/
 
 
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
 import os
 import os
+from datetime import timedelta
 
 
 BASE_DIR = os.path.dirname(os.path.dirname(__file__))
 BASE_DIR = os.path.dirname(os.path.dirname(__file__))
 
 
@@ -39,7 +40,6 @@ INSTALLED_APPS = (
     'django.contrib.auth',
     'django.contrib.auth',
     'django.contrib.contenttypes',
     'django.contrib.contenttypes',
     'rest_framework',
     'rest_framework',
-    'djoser',
     'desecapi',
     'desecapi',
     'corsheaders',
     'corsheaders',
 )
 )
@@ -95,20 +95,6 @@ REST_FRAMEWORK = {
     'ALLOWED_VERSIONS': ['v1', 'v2'],
     'ALLOWED_VERSIONS': ['v1', 'v2'],
 }
 }
 
 
-# user management configuration
-DJOSER = {
-    'DOMAIN': 'desec.io',
-    'SITE_NAME': 'deSEC',
-    'LOGIN_AFTER_ACTIVATION': True,
-    'SEND_ACTIVATION_EMAIL': False,
-    'SERIALIZERS': {
-        'current_user': 'desecapi.serializers.UserSerializer',
-        'user': 'desecapi.serializers.UserSerializer',
-        'user_create': 'desecapi.serializers.UserCreateSerializer',
-    },
-    'TOKEN_MODEL': 'desecapi.models.Token',
-}
-
 # CORS
 # CORS
 # No need to add Authorization to CORS_ALLOW_HEADERS (included by default)
 # No need to add Authorization to CORS_ALLOW_HEADERS (included by default)
 CORS_ORIGIN_ALLOW_ALL = True
 CORS_ORIGIN_ALLOW_ALL = True
@@ -138,9 +124,6 @@ EMAIL_USE_TLS = True
 DEFAULT_FROM_EMAIL = 'deSEC <support@desec.io>'
 DEFAULT_FROM_EMAIL = 'deSEC <support@desec.io>'
 ADMINS = [(address.split("@")[0], address) for address in os.environ['DESECSTACK_API_ADMIN'].split()]
 ADMINS = [(address.split("@")[0], address) for address in os.environ['DESECSTACK_API_ADMIN'].split()]
 
 
-# use our own user model
-AUTH_USER_MODEL = 'desecapi.User'
-
 # default NS records
 # default NS records
 DEFAULT_NS = [name + '.' for name in os.environ['DESECSTACK_NS'].strip().split()]
 DEFAULT_NS = [name + '.' for name in os.environ['DESECSTACK_NS'].strip().split()]
 DEFAULT_NS_TTL = os.environ['DESECSTACK_NSLORD_DEFAULT_TTL']
 DEFAULT_NS_TTL = os.environ['DESECSTACK_NSLORD_DEFAULT_TTL']
@@ -165,27 +148,18 @@ SEPA = {
     'CREDITOR_NAME': os.environ['DESECSTACK_API_SEPA_CREDITOR_NAME'],
     'CREDITOR_NAME': os.environ['DESECSTACK_API_SEPA_CREDITOR_NAME'],
 }
 }
 
 
-# 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
+# user management
 MINIMUM_TTL_DEFAULT = int(os.environ['DESECSTACK_MINIMUM_TTL_DEFAULT'])
 MINIMUM_TTL_DEFAULT = int(os.environ['DESECSTACK_MINIMUM_TTL_DEFAULT'])
-ABUSE_BY_REMOTE_IP_LIMIT = 0
-ABUSE_BY_REMOTE_IP_PERIOD_HRS = 7*24
-ABUSE_BY_EMAIL_HOSTNAME_LIMIT = 0
-ABUSE_BY_EMAIL_HOSTNAME_PERIOD_HRS = 24
+AUTH_USER_MODEL = 'desecapi.User'
 LIMIT_USER_DOMAIN_COUNT_DEFAULT = 5
 LIMIT_USER_DOMAIN_COUNT_DEFAULT = 5
+USER_ACTIVATION_REQUIRED = True
+VALIDITY_PERIOD_VERIFICATION_SIGNATURE = timedelta(hours=12)
 
 
 if DEBUG and not EMAIL_HOST:
 if DEBUG and not EMAIL_HOST:
     EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend'
     EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend'
 
 
 if os.environ.get('DESECSTACK_E2E_TEST', "").upper() == "TRUE":
 if os.environ.get('DESECSTACK_E2E_TEST', "").upper() == "TRUE":
     DEBUG = True
     DEBUG = True
-    EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend'
-    ABUSE_BY_REMOTE_IP_LIMIT = 100
-    ABUSE_BY_REMOTE_IP_PERIOD_HRS = 0
-    ABUSE_BY_EMAIL_HOSTNAME_LIMIT = 100
-    ABUSE_BY_EMAIL_HOSTNAME_PERIOD_HRS = 0
     LIMIT_USER_DOMAIN_COUNT_DEFAULT = 5000
     LIMIT_USER_DOMAIN_COUNT_DEFAULT = 5000
+    USER_ACTIVATION_REQUIRED = False
+    EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend'

+ 0 - 6
api/api/settings_quick_test.py

@@ -13,12 +13,6 @@ DATABASES = {
 
 
 }
 }
 
 
-# abuse protection
-ABUSE_BY_REMOTE_IP_LIMIT = 1
-ABUSE_BY_REMOTE_IP_PERIOD_HRS = 48
-ABUSE_BY_EMAIL_HOSTNAME_LIMIT = 1
-ABUSE_BY_EMAIL_HOSTNAME_PERIOD_HRS = 24
-
 # avoid computationally expensive password hashing for tests
 # avoid computationally expensive password hashing for tests
 PASSWORD_HASHERS = [
 PASSWORD_HASHERS = [
     'django.contrib.auth.hashers.MD5PasswordHasher',
     'django.contrib.auth.hashers.MD5PasswordHasher',

+ 17 - 2
api/desecapi/authentication.py

@@ -1,8 +1,14 @@
 import base64
 import base64
+
 from rest_framework import exceptions, HTTP_HEADER_ENCODING
 from rest_framework import exceptions, HTTP_HEADER_ENCODING
-from rest_framework.authentication import BaseAuthentication, get_authorization_header
+from rest_framework.authentication import (
+    BaseAuthentication,
+    get_authorization_header,
+    TokenAuthentication as RestFrameworkTokenAuthentication,
+    BasicAuthentication)
+
 from desecapi.models import Token
 from desecapi.models import Token
-from rest_framework.authentication import TokenAuthentication as RestFrameworkTokenAuthentication
+from desecapi.serializers import EmailPasswordSerializer
 
 
 
 
 class TokenAuthentication(RestFrameworkTokenAuthentication):
 class TokenAuthentication(RestFrameworkTokenAuthentication):
@@ -94,3 +100,12 @@ class URLParamAuthentication(BaseAuthentication):
             raise exceptions.AuthenticationFailed('badauth')
             raise exceptions.AuthenticationFailed('badauth')
 
 
         return token.user, token
         return token.user, token
+
+
+class EmailPasswordPayloadAuthentication(BasicAuthentication):
+
+    def authenticate(self, request):
+        serializer = EmailPasswordSerializer(data=request.data)
+        if not serializer.is_valid():
+            return None, None
+        return self.authenticate_credentials(serializer.data['email'], serializer.data['password'], request)

+ 0 - 16
api/desecapi/emails.py

@@ -1,21 +1,5 @@
 from django.template.loader import get_template
 from django.template.loader import get_template
 from django.core.mail import EmailMessage
 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 = {
-        'url': reverse('unlock/byEmail', args=[user.email], request=request),
-        'domainname': user.domains[0].name if user.domains.count() > 0 else 'deSEC DNS'
-    }
-    email = EmailMessage(subject_tmpl.render(context),
-                         content_tmpl.render(context),
-                         from_tmpl.render(context),
-                         [user.email])
-    email.send()
 
 
 
 
 def send_token_email(context, user):
 def send_token_email(context, user):

+ 0 - 6
api/desecapi/forms.py

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

+ 1 - 1
api/desecapi/migrations/0003_validation.py

@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
         migrations.AlterField(
         migrations.AlterField(
             model_name='domain',
             model_name='domain',
             name='name',
             name='name',
-            field=models.CharField(max_length=191, unique=True, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_domain_name', message='Domain name malformed.', regex='^[a-z0-9_.-]*[a-z]$')]),
+            field=models.CharField(max_length=191, unique=True, validators=[desecapi.models.validate_lower, django.core.validators.RegexValidator(code='invalid_domain_name', message='Invalid value (not a DNS name).', regex='^[a-z0-9_.-]*[a-z]$')]),
         ),
         ),
         migrations.AlterField(
         migrations.AlterField(
             model_name='rrset',
             model_name='rrset',

+ 21 - 0
api/desecapi/migrations/0005_user_model_cleanup.py

@@ -0,0 +1,21 @@
+# Generated by Django 2.2.2 on 2019-06-28 18:23
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0004_domain_minimum_ttl'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='user',
+            name='dyn',
+        ),
+        migrations.RemoveField(
+            model_name='user',
+            name='locked',
+        ),
+    ]

+ 78 - 0
api/desecapi/migrations/0006_authenticated_actions.py

@@ -0,0 +1,78 @@
+# Generated by Django 2.2.1 on 2019-09-21 11:39
+
+import datetime
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0005_user_model_cleanup'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='AuthenticatedAction',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.PositiveIntegerField(default=lambda: int(datetime.timestamp(datetime.now())))),
+            ],
+            options={
+                'managed': False,
+            },
+        ),
+        migrations.CreateModel(
+            name='AuthenticatedUserAction',
+            fields=[
+                ('authenticatedaction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.AuthenticatedAction')),
+            ],
+            options={
+                'managed': False,
+            },
+            bases=('desecapi.authenticatedaction',),
+        ),
+        migrations.CreateModel(
+            name='AuthenticatedActivateUserAction',
+            fields=[
+                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.AuthenticatedUserAction')),
+                ('domain', models.CharField(max_length=191)),
+            ],
+            options={
+                'managed': False,
+            },
+            bases=('desecapi.authenticateduseraction',),
+        ),
+        migrations.CreateModel(
+            name='AuthenticatedChangeEmailUserAction',
+            fields=[
+                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.AuthenticatedUserAction')),
+                ('new_email', models.EmailField(max_length=254)),
+            ],
+            options={
+                'managed': False,
+            },
+            bases=('desecapi.authenticateduseraction',),
+        ),
+        migrations.CreateModel(
+            name='AuthenticatedDeleteUserAction',
+            fields=[
+                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.AuthenticatedUserAction')),
+            ],
+            options={
+                'managed': False,
+            },
+            bases=('desecapi.authenticateduseraction',),
+        ),
+        migrations.CreateModel(
+            name='AuthenticatedResetPasswordUserAction',
+            fields=[
+                ('authenticateduseraction_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='desecapi.AuthenticatedUserAction')),
+                ('new_password', models.CharField(max_length=128)),
+            ],
+            options={
+                'managed': False,
+            },
+            bases=('desecapi.authenticateduseraction',),
+        ),
+    ]

+ 0 - 7
api/desecapi/mixins.py

@@ -1,7 +0,0 @@
-class SetterMixin:
-    def __setattr__(self, attrname, val):
-        setter_func = 'setter_' + attrname
-        if attrname in self.__dict__ and callable(getattr(self, setter_func, None)):
-            super().__setattr__(attrname, getattr(self, setter_func)(val))
-        else:
-            super().__setattr__(attrname, val)

+ 248 - 13
api/desecapi/models.py

@@ -1,24 +1,32 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
-import datetime
+import json
+import logging
 import random
 import random
 import time
 import time
 import uuid
 import uuid
 from base64 import b64encode
 from base64 import b64encode
+from datetime import datetime, timedelta
 from os import urandom
 from os import urandom
 
 
 import rest_framework.authtoken.models
 import rest_framework.authtoken.models
 from django.conf import settings
 from django.conf import settings
 from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
 from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
+from django.core.mail import EmailMessage
+from django.core.signing import Signer
 from django.core.validators import RegexValidator
 from django.core.validators import RegexValidator
 from django.db import models
 from django.db import models
 from django.db.models import Manager
 from django.db.models import Manager
+from django.template.loader import get_template
 from django.utils import timezone
 from django.utils import timezone
+from django.utils.crypto import constant_time_compare
 from rest_framework.exceptions import APIException
 from rest_framework.exceptions import APIException
 
 
 from desecapi import pdns
 from desecapi import pdns
 
 
+logger = logging.getLogger(__name__)
+
 
 
 def validate_lower(value):
 def validate_lower(value):
     if value != value.lower():
     if value != value.lower():
@@ -35,7 +43,7 @@ def validate_upper(value):
 
 
 
 
 class MyUserManager(BaseUserManager):
 class MyUserManager(BaseUserManager):
-    def create_user(self, email, password=None, registration_remote_ip=None, lock=False, dyn=False):
+    def create_user(self, email, password, **extra_fields):
         """
         """
         Creates and saves a User with the given email, date of
         Creates and saves a User with the given email, date of
         birth and password.
         birth and password.
@@ -43,13 +51,9 @@ class MyUserManager(BaseUserManager):
         if not email:
         if not email:
             raise ValueError('Users must have an email address')
             raise ValueError('Users must have an email address')
 
 
-        user = self.model(
-            email=self.normalize_email(email),
-            registration_remote_ip=registration_remote_ip,
-            locked=timezone.now() if lock else None,
-            dyn=dyn,
-        )
-
+        email = self.normalize_email(email)
+        extra_fields.setdefault('registration_remote_ip')
+        user = self.model(email=email, **extra_fields)
         user.set_password(password)
         user.set_password(password)
         user.save(using=self._db)
         user.save(using=self._db)
         return user
         return user
@@ -74,10 +78,8 @@ class User(AbstractBaseUser):
     is_active = models.BooleanField(default=True)
     is_active = models.BooleanField(default=True)
     is_admin = models.BooleanField(default=False)
     is_admin = models.BooleanField(default=False)
     registration_remote_ip = models.CharField(max_length=1024, blank=True)
     registration_remote_ip = models.CharField(max_length=1024, blank=True)
-    locked = models.DateTimeField(null=True, blank=True)
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
     limit_domains = models.IntegerField(default=settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT, null=True, blank=True)
     limit_domains = models.IntegerField(default=settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT, null=True, blank=True)
-    dyn = models.BooleanField(default=False)
 
 
     objects = MyUserManager()
     objects = MyUserManager()
 
 
@@ -118,6 +120,48 @@ class User(AbstractBaseUser):
         # Simplest possible answer: All admins are staff
         # Simplest possible answer: All admins are staff
         return self.is_admin
         return self.is_admin
 
 
+    def activate(self):
+        self.is_active = True
+        self.save()
+
+    def change_email(self, email):
+        old_email = self.email
+        self.email = email
+        self.validate_unique()
+        self.save()
+
+        self.send_email('change-email-confirmation-old-email', recipient=old_email)
+
+    def change_password(self, raw_password):
+        self.set_password(raw_password)
+        self.save()
+        self.send_email('password-change-confirmation')
+
+    def send_email(self, reason, context=None, recipient=None):
+        context = context or {}
+        reasons = [
+            'activate',
+            'activate-with-domain',
+            'change-email',
+            'change-email-confirmation-old-email',
+            'password-change-confirmation',
+            'reset-password',
+            'delete-user',
+        ]
+        recipient = recipient or self.email
+        if reason not in reasons:
+            raise ValueError('Cannot send email to user {} without a good reason: {}'.format(self.email, reason))
+        content_tmpl = get_template('emails/{}/content.txt'.format(reason))
+        subject_tmpl = get_template('emails/{}/subject.txt'.format(reason))
+        from_tmpl = get_template('emails/from.txt')
+        footer_tmpl = get_template('emails/footer.txt')
+        email = EmailMessage(subject_tmpl.render(context).strip(),
+                             content_tmpl.render(context) + footer_tmpl.render(),
+                             from_tmpl.render(),
+                             [recipient])
+        logger.warning('Sending email for user account %s (reason: %s)', str(self.pk), reason)
+        email.send()
+
 
 
 class Token(rest_framework.authtoken.models.Token):
 class Token(rest_framework.authtoken.models.Token):
     key = models.CharField("Key", max_length=40, db_index=True, unique=True)
     key = models.CharField("Key", max_length=40, db_index=True, unique=True)
@@ -148,7 +192,7 @@ class Domain(models.Model):
                             unique=True,
                             unique=True,
                             validators=[validate_lower,
                             validators=[validate_lower,
                                         RegexValidator(regex=r'^[a-z0-9_.-]*[a-z]$',
                                         RegexValidator(regex=r'^[a-z0-9_.-]*[a-z]$',
-                                                       message='Domain name malformed.',
+                                                       message='Invalid value (not a DNS name).',
                                                        code='invalid_domain_name')
                                                        code='invalid_domain_name')
                                         ])
                                         ])
     owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='domains')
     owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='domains')
@@ -159,6 +203,12 @@ class Domain(models.Model):
     def keys(self):
     def keys(self):
         return pdns.get_keys(self)
         return pdns.get_keys(self)
 
 
+    def has_local_public_suffix(self):
+        return self.partition_name()[1] in settings.LOCAL_PUBLIC_SUFFIXES
+
+    def parent_domain_name(self):
+        return self.partition_name()[1]
+
     def partition_name(domain):
     def partition_name(domain):
         name = domain.name if isinstance(domain, Domain) else domain
         name = domain.name if isinstance(domain, Domain) else domain
         subname, _, parent_name = name.partition('.')
         subname, _, parent_name = name.partition('.')
@@ -200,7 +250,7 @@ def get_default_value_created():
 
 
 
 
 def get_default_value_due():
 def get_default_value_due():
-    return timezone.now() + datetime.timedelta(days=7)
+    return timezone.now() + timedelta(days=7)
 
 
 
 
 def get_default_value_mref():
 def get_default_value_mref():
@@ -313,3 +363,188 @@ class RR(models.Model):
 
 
     def __str__(self):
     def __str__(self):
         return '<RR %s>' % self.content
         return '<RR %s>' % self.content
+
+
+def authenticated_action_default_timestamp():
+    return int(datetime.timestamp(datetime.now()))
+
+
+class AuthenticatedAction(models.Model):
+    """
+    Represents a procedure call on a defined set of arguments.
+
+    Subclasses can define additional arguments by adding Django model fields and must define the action to be taken by
+    implementing the `act` method.
+
+    AuthenticatedAction provides the `mac` property that returns a Message Authentication Code (MAC) based on the
+    state. By default, the state contains the action's name (defined by the `name` property) and a timestamp; the
+    state can be extended by (carefully) overriding the `mac_state` method. Any AuthenticatedAction instance of
+    the same subclass and state will deterministically have the same MAC, effectively allowing authenticated
+    procedure calls by third parties according to the following protocol:
+
+    (1) Instantiate the AuthenticatedAction subclass representing the action to be taken with the desired state,
+    (2) provide information on how to instantiate the instance and the MAC to a third party,
+    (3) when provided with data that allows instantiation and a valid MAC, take the defined action, possibly with
+        additional parameters chosen by the third party that do not belong to the verified state.
+    """
+    created = models.PositiveIntegerField(default=lambda: int(datetime.timestamp(datetime.now())))
+
+    class Meta:
+        managed = False
+
+    def __init__(self, *args, **kwargs):
+        # silently ignore any value supplied for the mac value, that makes it easier to use with DRF serializers
+        kwargs.pop('mac', None)
+        super().__init__(*args, **kwargs)
+
+    @property
+    def name(self):
+        """
+        Returns a human-readable string containing the name of this action class that uniquely identifies this action.
+        """
+        return NotImplementedError
+
+    @property
+    def mac(self):
+        """
+        Deterministically generates a message authentication code (MAC) for this action, based on the state as defined
+        by `self.mac_state`. Identical state is guaranteed to yield identical MAC.
+        :return:
+        """
+        return Signer().signature(json.dumps(self.mac_state))
+
+    def check_mac(self, mac):
+        """
+        Checks if the message authentication code (MAC) provided by the first argument matches the MAC of this action.
+        Note that expiration is not verified by this method.
+        :param mac: Message Authentication Code
+        :return: True, if MAC is valid; False otherwise.
+        """
+        return constant_time_compare(
+            mac,
+            self.mac,
+        )
+
+    def check_expiration(self, validity_period: timedelta, check_time: datetime = None):
+        """
+        Checks if the action's timestamp is no older than the given validity period. Note that the message
+        authentication code itself is not verified by this method.
+        :param validity_period: How long after issuance the MAC of this action is considered valid.
+        :param check_time: Point in time for which to check the expiration. Defaults to datetime.now().
+        :return: True if valid, False if expired.
+        """
+        issue_time = datetime.fromtimestamp(self.created)
+        check_time = check_time or datetime.now()
+        return check_time - issue_time <= validity_period
+
+    @property
+    def mac_state(self):
+        """
+        Returns a list that defines the state of this action (used for MAC calculation).
+
+        Return value must be JSON-serializable.
+
+        Values not included in the return value will not be used for MAC calculation, i.e. the MAC will be independent
+        of them.
+
+        Use caution when overriding this method. You will usually want to append a value to the list returned by the
+        parent. Overriding the behavior altogether could result in reducing the state to fewer variables, resulting
+        in valid signatures when they were intended to be invalid. The suggested method for overriding is
+
+            @property
+            def mac_state:
+                return super().mac_state + [self.important_value, self.another_added_value]
+
+        :return: List of values to be signed.
+        """
+        # TODO consider adding a "last change" attribute of the user to the state to avoid code
+        #  re-use after the the state has been changed and changed back.
+        return [self.created, self.name]
+
+    def act(self):
+        """
+        Conduct the action represented by this class.
+        :return: None
+        """
+        raise NotImplementedError
+
+
+class AuthenticatedUserAction(AuthenticatedAction):
+    """
+    Abstract AuthenticatedAction involving an user instance, incorporating the user's id, email, password, and
+    is_active flag into the Message Authentication Code state.
+    """
+    user = models.ForeignKey(User, on_delete=models.DO_NOTHING)
+
+    class Meta:
+        managed = False
+
+    @property
+    def name(self):
+        raise NotImplementedError
+
+    @property
+    def mac_state(self):
+        return super().mac_state + [self.user.id, self.user.email, self.user.password, self.user.is_active]
+
+    def act(self):
+        raise NotImplementedError
+
+
+class AuthenticatedActivateUserAction(AuthenticatedUserAction):
+    domain = models.CharField(max_length=191)
+
+    class Meta:
+        managed = False
+
+    @property
+    def name(self):
+        return 'user/activate'
+
+    def act(self):
+        self.user.activate()
+
+
+class AuthenticatedChangeEmailUserAction(AuthenticatedUserAction):
+    new_email = models.EmailField()
+
+    class Meta:
+        managed = False
+
+    @property
+    def name(self):
+        return 'user/change_email'
+
+    @property
+    def mac_state(self):
+        return super().mac_state + [self.new_email]
+
+    def act(self):
+        self.user.change_email(self.new_email)
+
+
+class AuthenticatedResetPasswordUserAction(AuthenticatedUserAction):
+    new_password = models.CharField(max_length=128)
+
+    class Meta:
+        managed = False
+
+    @property
+    def name(self):
+        return 'user/reset_password'
+
+    def act(self):
+        self.user.change_password(self.new_password)
+
+
+class AuthenticatedDeleteUserAction(AuthenticatedUserAction):
+
+    class Meta:
+        managed = False
+
+    @property
+    def name(self):
+        return 'user/delete'
+
+    def act(self):
+        self.user.delete()

+ 0 - 13
api/desecapi/permissions.py

@@ -17,16 +17,3 @@ class IsDomainOwner(permissions.BasePermission):
 
 
     def has_object_permission(self, request, view, obj):
     def has_object_permission(self, request, view, obj):
         return obj.domain.owner == request.user
         return obj.domain.owner == request.user
-
-
-class IsUnlocked(permissions.BasePermission):
-    """
-    Allow non-safe methods only when account is not locked.
-    """
-    message = 'You cannot modify DNS data while your account is locked.'
-
-    def has_permission(self, request, view):
-        return bool(
-            request.method in permissions.SAFE_METHODS or
-            not request.user.locked
-        )

+ 240 - 34
api/desecapi/serializers.py

@@ -1,26 +1,33 @@
+import binascii
+import json
 import re
 import re
+from base64 import urlsafe_b64decode, urlsafe_b64encode
 
 
+import psl_dns
 from django.core.validators import MinValueValidator
 from django.core.validators import MinValueValidator
 from django.db.models import Model, Q
 from django.db.models import Model, Q
-from djoser import serializers as djoser_serializers
 from rest_framework import serializers
 from rest_framework import serializers
-from rest_framework.fields import empty, SkipField, ListField, CharField
+from rest_framework.exceptions import ValidationError
 from rest_framework.serializers import ListSerializer
 from rest_framework.serializers import ListSerializer
 from rest_framework.settings import api_settings
 from rest_framework.settings import api_settings
-from rest_framework.validators import UniqueTogetherValidator
+from rest_framework.validators import UniqueTogetherValidator, UniqueValidator, qs_filter
 
 
-from desecapi.models import Domain, Donation, User, RRset, Token, RR
+from api import settings
+# TODO organize imports
+from desecapi.models import Domain, Donation, User, RRset, Token, RR, AuthenticatedUserAction, \
+    AuthenticatedActivateUserAction, AuthenticatedChangeEmailUserAction, \
+    AuthenticatedDeleteUserAction, AuthenticatedResetPasswordUserAction, AuthenticatedAction
 
 
 
 
 class TokenSerializer(serializers.ModelSerializer):
 class TokenSerializer(serializers.ModelSerializer):
-    value = serializers.ReadOnlyField(source='key')
+    auth_token = serializers.ReadOnlyField(source='key')
     # note this overrides the original "id" field, which is the db primary key
     # note this overrides the original "id" field, which is the db primary key
     id = serializers.ReadOnlyField(source='user_specific_id')
     id = serializers.ReadOnlyField(source='user_specific_id')
 
 
     class Meta:
     class Meta:
         model = Token
         model = Token
-        fields = ('id', 'created', 'name', 'value',)
-        read_only_fields = ('created', 'value', 'id')
+        fields = ('id', 'created', 'name', 'auth_token',)
+        read_only_fields = ('created', 'auth_token', 'id')
 
 
 
 
 class RequiredOnPartialUpdateCharField(serializers.CharField):
 class RequiredOnPartialUpdateCharField(serializers.CharField):
@@ -28,7 +35,7 @@ class RequiredOnPartialUpdateCharField(serializers.CharField):
     This field is always required, even for partial updates (e.g. using PATCH).
     This field is always required, even for partial updates (e.g. using PATCH).
     """
     """
     def validate_empty_values(self, data):
     def validate_empty_values(self, data):
-        if data is empty:
+        if data is serializers.empty:
             self.fail('required')
             self.fail('required')
 
 
         return super().validate_empty_values(data)
         return super().validate_empty_values(data)
@@ -67,19 +74,19 @@ class ReadOnlyOnUpdateValidator(Validator):
             raise serializers.ValidationError(self.message, code='read-only-on-update')
             raise serializers.ValidationError(self.message, code='read-only-on-update')
 
 
 
 
-class StringField(CharField):
+class StringField(serializers.CharField):
 
 
     def to_internal_value(self, data):
     def to_internal_value(self, data):
         return data
         return data
 
 
-    def run_validation(self, data=empty):
+    def run_validation(self, data=serializers.empty):
         data = super().run_validation(data)
         data = super().run_validation(data)
         if not isinstance(data, str):
         if not isinstance(data, str):
             raise serializers.ValidationError('Must be a string.', code='must-be-a-string')
             raise serializers.ValidationError('Must be a string.', code='must-be-a-string')
         return data
         return data
 
 
 
 
-class RRsField(ListField):
+class RRsField(serializers.ListField):
 
 
     def __init__(self, **kwargs):
     def __init__(self, **kwargs):
         super().__init__(child=StringField(), **kwargs)
         super().__init__(child=StringField(), **kwargs)
@@ -156,7 +163,7 @@ class NonBulkOnlyDefault:
 
 
     def __call__(self):
     def __call__(self):
         if self.is_many:
         if self.is_many:
-            raise SkipField()
+            raise serializers.SkipField()
         if callable(self.default):
         if callable(self.default):
             return self.default()
             return self.default()
         return self.default
         return self.default
@@ -177,7 +184,7 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
             'subname': {'required': False, 'default': NonBulkOnlyDefault('')}
             'subname': {'required': False, 'default': NonBulkOnlyDefault('')}
         }
         }
 
 
-    def __init__(self, instance=None, data=empty, domain=None, **kwargs):
+    def __init__(self, instance=None, data=serializers.empty, domain=None, **kwargs):
         if domain is None:
         if domain is None:
             raise ValueError('RRsetSerializer() must be given a domain object (to validate uniqueness constraints).')
             raise ValueError('RRsetSerializer() must be given a domain object (to validate uniqueness constraints).')
         self.domain = domain
         self.domain = domain
@@ -291,7 +298,7 @@ class RRsetListSerializer(ListSerializer):
 
 
         if not self.allow_empty and len(data) == 0:
         if not self.allow_empty and len(data) == 0:
             if self.parent and self.partial:
             if self.parent and self.partial:
-                raise SkipField()
+                raise serializers.SkipField()
             else:
             else:
                 self.fail('empty')
                 self.fail('empty')
 
 
@@ -425,6 +432,7 @@ class RRsetListSerializer(ListSerializer):
 
 
 
 
 class DomainSerializer(serializers.ModelSerializer):
 class DomainSerializer(serializers.ModelSerializer):
+    psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER)
 
 
     class Meta:
     class Meta:
         model = Domain
         model = Domain
@@ -440,6 +448,48 @@ class DomainSerializer(serializers.ModelSerializer):
         fields['name'].validators.append(ReadOnlyOnUpdateValidator())
         fields['name'].validators.append(ReadOnlyOnUpdateValidator())
         return fields
         return fields
 
 
+    def validate_name(self, value):
+        # Check if domain is a public suffix
+        try:
+            public_suffix = self.psl.get_public_suffix(value)
+            is_public_suffix = self.psl.is_public_suffix(value)
+        except psl_dns.exceptions.UnsupportedRule as e:
+            # It would probably be fine to just create the domain (with the TLD acting as the
+            # public suffix and setting both public_suffix and is_public_suffix accordingly).
+            # However, in order to allow to investigate the situation, it's better not catch
+            # this exception. Our error handler turns it into a 503 error and makes sure
+            # admins are notified.
+            raise e
+
+        is_restricted_suffix = is_public_suffix and value not in settings.LOCAL_PUBLIC_SUFFIXES
+
+        # Generate a list of all domains connecting this one and its public suffix.
+        # If another user owns a zone with one of these names, then the requested
+        # domain is unavailable because it is part of the other user's zone.
+        private_components = value.rsplit(public_suffix, 1)[0].rstrip('.')
+        private_components = private_components.split('.') if private_components else []
+        private_components += [public_suffix]
+        private_domains = ['.'.join(private_components[i:]) for i in range(0, len(private_components) - 1)]
+        assert is_public_suffix or value == private_domains[0]
+
+        # Deny registration for non-local public suffixes and for domains covered by other users' zones
+        owner = self.context['request'].user
+        queryset = Domain.objects.filter(Q(name__in=private_domains) & ~Q(owner=owner))
+        if is_restricted_suffix or queryset.exists():
+            msg = 'This domain name is unavailable.'
+            raise serializers.ValidationError(msg, code='name_unavailable')
+
+        return value
+
+    def validate(self, attrs):  # TODO I believe this should be a permission, not a validation
+        # Check user's domain limit
+        owner = self.context['request'].user
+        if (owner.limit_domains is not None and
+                owner.domains.count() >= owner.limit_domains):
+            msg = 'You reached the maximum number of domains allowed for your account.'
+            raise serializers.ValidationError(msg, code='domain_limit')
+        return attrs
+
 
 
 class DonationSerializer(serializers.ModelSerializer):
 class DonationSerializer(serializers.ModelSerializer):
 
 
@@ -456,28 +506,184 @@ class DonationSerializer(serializers.ModelSerializer):
         return re.sub(r'[\s]', '', value)
         return re.sub(r'[\s]', '', value)
 
 
 
 
-class UserSerializer(djoser_serializers.UserSerializer):
-    locked = serializers.SerializerMethodField()
+class UserSerializer(serializers.ModelSerializer):
 
 
-    class Meta(djoser_serializers.UserSerializer.Meta):
-        fields = tuple(User.REQUIRED_FIELDS) + (
-            User.USERNAME_FIELD,
-            'dyn',
-            'limit_domains',
-            'locked',
-        )
-        read_only_fields = ('dyn', 'limit_domains', 'locked',)
+    class Meta:
+        model = User
+        fields = ('created', 'email', 'id', 'limit_domains', 'password',)
+        extra_kwargs = {
+            'password': {
+                'write_only': True,  # Do not expose password field
+            }
+        }
 
 
-    @staticmethod
-    def get_locked(obj):
-        return bool(obj.locked)
+    def create(self, validated_data):
+        return User.objects.create_user(**validated_data)
+
+
+class RegisterAccountSerializer(UserSerializer):
+    domain = serializers.CharField(required=False)  # TODO Needs more validation
+
+    class Meta:
+        model = UserSerializer.Meta.model
+        fields = ('email', 'password', 'domain')
+        extra_kwargs = UserSerializer.Meta.extra_kwargs
+
+    def create(self, validated_data):
+        validated_data.pop('domain', None)
+        return super().create(validated_data)
+
+
+class EmailSerializer(serializers.Serializer):
+    email = serializers.EmailField()
+
+
+class EmailPasswordSerializer(EmailSerializer):
+    password = serializers.CharField()
+
+
+class ChangeEmailSerializer(serializers.Serializer):
+    new_email = serializers.EmailField()
+
+    def validate_new_email(self, value):
+        if value == self.context['request'].user.email:
+            raise serializers.ValidationError('Email address unchanged.')
+        return value
+
+
+class CustomFieldNameUniqueValidator(UniqueValidator):
+    """
+    Does exactly what rest_framework's UniqueValidator does, however allows to further customize the
+    query that is used to determine the uniqueness.
+    More specifically, we allow that the field name the value is queried against is passed when initializing
+    this validator. (At the time of writing, UniqueValidator insists that the field's name is used for the
+    database query field; only how the lookup must match is allowed to be changed.)
+    """
+
+    def __init__(self, queryset, message=None, lookup='exact', lookup_field=None):
+        self.lookup_field = lookup_field
+        super().__init__(queryset, message, lookup)
+
+    def filter_queryset(self, value, queryset):
+        """
+        Filter the queryset to all instances matching the given value on the specified lookup field.
+        """
+        filter_kwargs = {'%s__%s' % (self.lookup_field or self.field_name, self.lookup): value}
+        return qs_filter(queryset, **filter_kwargs)
+
+
+class AuthenticatedActionSerializer(serializers.ModelSerializer):
+    mac = serializers.CharField()  # serializer read-write, but model read-only field
+
+    class Meta:
+        model = AuthenticatedAction
+        fields = ('mac', 'created')
+
+    @classmethod
+    def _pack_code(cls, unpacked_data):
+        return urlsafe_b64encode(json.dumps(unpacked_data).encode()).decode()
+
+    @classmethod
+    def _unpack_code(cls, packed_data):
+        try:
+            return json.loads(urlsafe_b64decode(packed_data.encode()).decode())
+        except (TypeError, UnicodeDecodeError, UnicodeEncodeError, json.JSONDecodeError, binascii.Error):
+            raise ValueError
+
+    def to_representation(self, instance: AuthenticatedUserAction):
+        # do the regular business
+        data = super().to_representation(instance)
+
+        # encode into single string
+        return {'code': self._pack_code(data)}
+
+    def to_internal_value(self, data):
+        data = data.copy()  # avoid side effect from .pop
+        try:
+            # decode from single string
+            unpacked_data = self._unpack_code(data.pop('code'))
+        except KeyError:
+            raise ValidationError({'code': ['No verification code.']})
+        except ValueError:
+            raise ValidationError({'code': ['Invalid verification code.']})
+
+        # add extra fields added by the user
+        unpacked_data.update(**data)
+
+        # do the regular business
+        return super().to_internal_value(unpacked_data)
+
+    def validate(self, attrs):
+        if not self.instance:
+            self.instance = self.Meta.model(**attrs)  # TODO This creates an attribute on self. Side-effect intended?
+
+        # check if expired
+        expired = not self.instance.check_expiration(settings.VALIDITY_PERIOD_VERIFICATION_SIGNATURE)
+        if expired:
+            raise ValidationError(detail='Code expired, please restart the process.', code='expired')
+
+        # check if MAC valid
+        mac_valid = self.instance.check_mac(attrs['mac'])
+        if not mac_valid:
+            raise ValidationError(detail='Bad signature.', code='bad_sig')
+
+        return attrs
+
+    def act(self):
+        self.instance.act()
+        return self.instance
+
+    def save(self, **kwargs):
+        raise ValueError
+
+
+class AuthenticatedUserActionSerializer(AuthenticatedActionSerializer):
+    user = serializers.PrimaryKeyRelatedField(
+        queryset=User.objects.all(),
+        error_messages={'does_not_exist': 'This user does not exist.'}
+    )
+
+    class Meta:
+        model = AuthenticatedUserAction
+        fields = AuthenticatedActionSerializer.Meta.fields + ('user',)
+
+
+class AuthenticatedActivateUserActionSerializer(AuthenticatedUserActionSerializer):
+
+    class Meta(AuthenticatedUserActionSerializer.Meta):
+        model = AuthenticatedActivateUserAction
+        fields = AuthenticatedUserActionSerializer.Meta.fields + ('domain',)
+        extra_kwargs = {
+            'domain': {'default': None, 'allow_null': True}
+        }
+
+
+class AuthenticatedChangeEmailUserActionSerializer(AuthenticatedUserActionSerializer):
+    new_email = serializers.EmailField(
+        validators=[
+            CustomFieldNameUniqueValidator(
+                queryset=User.objects.all(),
+                lookup_field='email',
+                message='You already have another account with this email address.',
+            )
+        ],
+        required=True,
+    )
+
+    class Meta(AuthenticatedUserActionSerializer.Meta):
+        model = AuthenticatedChangeEmailUserAction
+        fields = AuthenticatedUserActionSerializer.Meta.fields + ('new_email',)
+
+
+class AuthenticatedResetPasswordUserActionSerializer(AuthenticatedUserActionSerializer):
+    new_password = serializers.CharField(write_only=True)
+
+    class Meta(AuthenticatedUserActionSerializer.Meta):
+        model = AuthenticatedResetPasswordUserAction
+        fields = AuthenticatedUserActionSerializer.Meta.fields + ('new_password',)
 
 
 
 
-class UserCreateSerializer(djoser_serializers.UserCreateSerializer):
+class AuthenticatedDeleteUserActionSerializer(AuthenticatedUserActionSerializer):
 
 
-    class Meta(djoser_serializers.UserCreateSerializer.Meta):
-        fields = tuple(User.REQUIRED_FIELDS) + (
-            User.USERNAME_FIELD,
-            'password',
-            'dyn',
-        )
+    class Meta(AuthenticatedUserActionSerializer.Meta):
+        model = AuthenticatedDeleteUserAction

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

@@ -1,21 +0,0 @@
-<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>

+ 14 - 0
api/desecapi/templates/emails/activate-with-domain/content.txt

@@ -0,0 +1,14 @@
+Hi there,
+
+Thank you for registering with deSEC!
+
+As we may need to contact you in the future, you need to verify your
+email address before we can register your domain. To do so, please use
+the following link:
+
+{{ confirmation_link }}
+
+After that, please follow the instructions on the confirmation page.
+
+Stay secure,
+Nils

+ 1 - 0
api/desecapi/templates/emails/activate-with-domain/subject.txt

@@ -0,0 +1 @@
+Welcome to deSEC!

+ 15 - 0
api/desecapi/templates/emails/activate/content.txt

@@ -0,0 +1,15 @@
+Hi there,
+
+Thank you for registering with deSEC!
+
+As we may need to contact you in the future, you need to verify your
+email address before you can use your account. To do so, please use
+the following link:
+
+{{ confirmation_link }}
+
+After that, please follow the instructions on the confirmation page.
+You can also find our API docs here: https://desec.readthedocs.io/
+
+Stay secure,
+Nils

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

@@ -0,0 +1 @@
+Welcome to deSEC!

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

@@ -1,37 +0,0 @@
-Hello,
-
-Your activity on the deSEC DNS service (e.g. dedyn.io) looks
-suspiciously similar to the one 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 new 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.
-
-Stay secure,
-Nils
-
---
-deSEC
-Kyffhäuserstr. 5
-10781 Berlin
-Germany
-
-phone: +49-30-47384344
-
-Vorstandsvorsitzender: Nils Wisiol

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

@@ -1 +0,0 @@
-{{domainname}} Account Suspended

+ 10 - 0
api/desecapi/templates/emails/change-email-confirmation-old-email/content.txt

@@ -0,0 +1,10 @@
+Hi there,
+
+We're writing to let you know that the email address associated with
+your deSEC account has been changed to another address.
+
+If you did not expect that to happen, please contact us immediately at
+support@desec.io.
+
+Stay secure,
+The deSEC Team

+ 1 - 0
api/desecapi/templates/emails/change-email-confirmation-old-email/subject.txt

@@ -0,0 +1 @@
+[deSEC] Account email address changed

+ 21 - 0
api/desecapi/templates/emails/change-email/content.txt

@@ -0,0 +1,21 @@
+Hi,
+
+You requested to change the email address associated with your deSEC
+account from:
+
+{{ old_email }}
+
+to:
+
+{{ new_email }}
+
+As we may need to contact you under this address in the future, you
+need to verify your new email address before we can make the change.
+To do so, please use the following link:
+
+{{ confirmation_link }}
+
+After your confirmation, we will perform the change.
+
+Stay secure,
+The deSEC Team

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

@@ -0,0 +1 @@
+[deSEC] Confirmation required: Email address change

+ 16 - 0
api/desecapi/templates/emails/delete-user/content.txt

@@ -0,0 +1,16 @@
+Hi there,
+
+Sad to see you leave! 😢 We know there's always room for improvement.
+If your wish to leave deSEC is due to any shortcomings of our service,
+please shoot us an email so that we can improve whatever is not right.
+
+Otherwise, we will delete your account, including all related data.
+Before we do so, we need you to confirm once more that this is what you
+really, really want by clicking the following link:
+
+{{ confirmation_link }}
+
+Note that this action is irreversible! We cannot recover your account.
+
+Wherever you go, stay secure!
+The deSEC Team

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

@@ -0,0 +1 @@
+[deSEC] Confirmation required: Delete account

+ 0 - 6
api/desecapi/templates/emails/domain-dyndns/content.txt

@@ -4,12 +4,6 @@ And welcome to the deSEC dynDNS service! I'm Nils, CTO of deSEC.
 If you have any questions or concerns, please do not hestitate
 If you have any questions or concerns, please do not hestitate
 to contact me.
 to contact me.
 
 
-Note: If you find that your domain does not work, it may be
-because a captcha needs to be solved first. In this case, please
-double-check your mailbox for a separate email with captcha
-instructions. Don't forget your spam folder, especially when
-using a gmail address!
-
 To get started using your new dynDNS domain {{ domain }},
 To get started using your new dynDNS domain {{ domain }},
 please configure your device (or any other dynDNS client) to use
 please configure your device (or any other dynDNS client) to use
 the following credentials:
 the following credentials:

+ 14 - 0
api/desecapi/templates/emails/footer.txt

@@ -0,0 +1,14 @@
+
+-- 
+Like our community service? 💛
+Please consider donating at
+
+https://desec.io/
+
+deSEC e.V.
+Kyffhäuserstr. 5
+10781 Berlin
+Germany
+
+Vorstandsvorsitz: Nils Wisiol
+Registergericht: AG Berlin (Charlottenburg) VR 37525

+ 10 - 0
api/desecapi/templates/emails/password-change-confirmation/content.txt

@@ -0,0 +1,10 @@
+Hi,
+
+This is to let you know that the password for your deSEC account has
+been changed.
+
+If you did not expect that to happen, please contact us immediately at
+support@desec.io.
+
+Stay secure,
+The deSEC Team

+ 1 - 0
api/desecapi/templates/emails/password-change-confirmation/subject.txt

@@ -0,0 +1 @@
+[deSEC] Password changed

+ 12 - 0
api/desecapi/templates/emails/reset-password/content.txt

@@ -0,0 +1,12 @@
+Hi,
+
+We received a request to reset the password for your deSEC account. To
+ensure that this request is legitimate, we need you to confirm it using
+the following link:
+
+{{ confirmation_link }}
+
+After your confirmation, we will ask you to set a new password.
+
+Stay secure,
+The deSEC Team

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

@@ -0,0 +1 @@
+[deSEC] Confirmation required: Password reset

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

@@ -1,11 +0,0 @@
-<!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>

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

@@ -1,15 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
-    <meta charset="UTF-8">
-    <title>Unlock deSEC account</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>

+ 25 - 40
api/desecapi/tests/base.py

@@ -18,7 +18,7 @@ from rest_framework.test import APITestCase, APIClient
 from rest_framework.utils import json
 from rest_framework.utils import json
 
 
 from desecapi.models import User, Domain, Token, RRset, RR
 from desecapi.models import User, Domain, Token, RRset, RR
-from desecapi.views import DomainList
+from desecapi.serializers import DomainSerializer
 
 
 
 
 class DesecAPIClient(APIClient):
 class DesecAPIClient(APIClient):
@@ -320,23 +320,6 @@ class MockPDNSTestCase(APITestCase):
         request['status'] = 422
         request['status'] = 422
         return request
         return request
 
 
-    @classmethod
-    def request_pdns_zone_create_already_exists(cls, existing_domains=None):
-        existing_domains = cls._normalize_name(existing_domains)
-
-        def request_callback(r, _, response_headers):
-            body = json.loads(r.parsed_body)
-            if not existing_domains or body['name'] in existing_domains:
-                return [422, response_headers, json.dumps({'error': 'Domain \'%s\' already exists' % body['name']})]
-            else:
-                return [200, response_headers, '']
-
-        request = cls.request_pdns_zone_create_422()
-        # noinspection PyTypeChecker
-        request['body'] = request_callback
-        request.pop('status')
-        return request
-
     @classmethod
     @classmethod
     def request_pdns_zone_delete(cls, name=None, ns='LORD'):
     def request_pdns_zone_delete(cls, name=None, ns='LORD'):
         return {
         return {
@@ -832,22 +815,7 @@ class DesecTestCase(MockPDNSTestCase):
             ))
             ))
 
 
 
 
-class DomainOwnerTestCase(DesecTestCase):
-    """
-    This test case creates a domain owner, some domains for her and some domains that are owned by other users.
-    DomainOwnerTestCase.client is authenticated with the owner's token.
-    """
-    DYN = False
-    NUM_OWNED_DOMAINS = 2
-    NUM_OTHER_DOMAINS = 20
-
-    owner = None
-    my_domains = None
-    other_domains = None
-    my_domain = None
-    other_domain = None
-    token = None
-
+class PublicSuffixMockMixin():
     def _mock_get_public_suffix(self, domain_name, public_suffixes=None):
     def _mock_get_public_suffix(self, domain_name, public_suffixes=None):
         if public_suffixes is None:
         if public_suffixes is None:
             public_suffixes = settings.LOCAL_PUBLIC_SUFFIXES | self.PUBLIC_SUFFIXES
             public_suffixes = settings.LOCAL_PUBLIC_SUFFIXES | self.PUBLIC_SUFFIXES
@@ -861,7 +829,7 @@ class DomainOwnerTestCase(DesecTestCase):
 
 
     @staticmethod
     @staticmethod
     def _mock_is_public_suffix(name):
     def _mock_is_public_suffix(name):
-        return name == DomainList.psl.get_public_suffix(name)
+        return name == DomainSerializer.psl.get_public_suffix(name)
 
 
     def get_psl_context_manager(self, side_effect_parameter):
     def get_psl_context_manager(self, side_effect_parameter):
         if side_effect_parameter is None:
         if side_effect_parameter is None:
@@ -872,18 +840,35 @@ class DomainOwnerTestCase(DesecTestCase):
         else:
         else:
             side_effect = partial(self._mock_get_public_suffix, public_suffixes=[side_effect_parameter])
             side_effect = partial(self._mock_get_public_suffix, public_suffixes=[side_effect_parameter])
 
 
-        return mock.patch.object(DomainList.psl, 'get_public_suffix', side_effect=side_effect)
+        return mock.patch.object(DomainSerializer.psl, 'get_public_suffix', side_effect=side_effect)
 
 
     def setUpMockPatch(self):
     def setUpMockPatch(self):
-        mock.patch.object(DomainList.psl, 'get_public_suffix', side_effect=self._mock_get_public_suffix).start()
-        mock.patch.object(DomainList.psl, 'is_public_suffix', side_effect=self._mock_is_public_suffix).start()
+        mock.patch.object(DomainSerializer.psl, 'get_public_suffix', side_effect=self._mock_get_public_suffix).start()
+        mock.patch.object(DomainSerializer.psl, 'is_public_suffix', side_effect=self._mock_is_public_suffix).start()
         self.addCleanup(mock.patch.stopall)
         self.addCleanup(mock.patch.stopall)
 
 
+
+class DomainOwnerTestCase(DesecTestCase, PublicSuffixMockMixin):
+    """
+    This test case creates a domain owner, some domains for her and some domains that are owned by other users.
+    DomainOwnerTestCase.client is authenticated with the owner's token.
+    """
+    DYN = False
+    NUM_OWNED_DOMAINS = 2
+    NUM_OTHER_DOMAINS = 20
+
+    owner = None
+    my_domains = None
+    other_domains = None
+    my_domain = None
+    other_domain = None
+    token = None
+
     @classmethod
     @classmethod
     def setUpTestDataWithPdns(cls):
     def setUpTestDataWithPdns(cls):
         super().setUpTestDataWithPdns()
         super().setUpTestDataWithPdns()
 
 
-        cls.owner = cls.create_user(dyn=cls.DYN)
+        cls.owner = cls.create_user()
 
 
         domain_kwargs = {'suffix': cls.AUTO_DELEGATION_DOMAINS if cls.DYN else None}
         domain_kwargs = {'suffix': cls.AUTO_DELEGATION_DOMAINS if cls.DYN else None}
         if cls.DYN:
         if cls.DYN:
@@ -914,7 +899,7 @@ class DomainOwnerTestCase(DesecTestCase):
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key)
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key)
-        self.setUpMockPatch()
+        PublicSuffixMockMixin.setUpMockPatch(self)
 
 
 
 
 class LockedDomainOwnerTestCase(DomainOwnerTestCase):
 class LockedDomainOwnerTestCase(DomainOwnerTestCase):

+ 26 - 15
api/desecapi/tests/test_authentication.py

@@ -1,3 +1,8 @@
+import base64
+import json
+import re
+
+from django.core import mail
 from rest_framework import status
 from rest_framework import status
 from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
 from rest_framework.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
 
 
@@ -51,8 +56,8 @@ class SignUpLoginTestCase(DesecTestCase):
     REGISTRATION_ENDPOINT = None
     REGISTRATION_ENDPOINT = None
     LOGIN_ENDPOINT = None
     LOGIN_ENDPOINT = None
 
 
-    REGISTRATION_STATUS = status.HTTP_201_CREATED
-    LOGIN_STATUS = status.HTTP_201_CREATED
+    REGISTRATION_STATUS = status.HTTP_202_ACCEPTED
+    LOGIN_STATUS = status.HTTP_200_OK
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -72,6 +77,15 @@ class SignUpLoginTestCase(DesecTestCase):
             self.REGISTRATION_STATUS
             self.REGISTRATION_STATUS
         )
         )
 
 
+    def activate(self):
+        total = 1
+        self.assertEqual(len(mail.outbox), total, "Expected %i message in the outbox, but found %i." %
+                         (total, len(mail.outbox)))
+        email = mail.outbox[-1]
+        self.assertTrue('Welcome' in email.subject)
+        confirmation_link = re.search(r'following link:\s+([^\s]*)', email.body).group(1)
+        self.client.get(confirmation_link)
+
     def log_in(self):
     def log_in(self):
         response = self.client.post(self.LOGIN_ENDPOINT, {
         response = self.client.post(self.LOGIN_ENDPOINT, {
             'email': self.EMAIL,
             'email': self.EMAIL,
@@ -81,35 +95,32 @@ class SignUpLoginTestCase(DesecTestCase):
 
 
     def test_sign_up(self):
     def test_sign_up(self):
         self.sign_up()
         self.sign_up()
+        self.assertFalse(User.objects.get(email=self.EMAIL).is_active)
+
+    def test_activate(self):
+        self.sign_up()
+        self.activate()
+        self.assertTrue(User.objects.get(email=self.EMAIL).is_active)
 
 
     def test_log_in(self):
     def test_log_in(self):
         self.sign_up()
         self.sign_up()
+        self.activate()
         self.log_in()
         self.log_in()
 
 
     def test_log_in_twice(self):
     def test_log_in_twice(self):
         self.sign_up()
         self.sign_up()
+        self.activate()
         self.log_in()
         self.log_in()
         self.log_in()
         self.log_in()
 
 
     def test_log_in_two_tokens(self):
     def test_log_in_two_tokens(self):
-        self.sign_up()  # this may create a token
+        self.sign_up()
+        self.activate()
         for _ in range(2):
         for _ in range(2):
             Token.objects.create(user=User.objects.get(email=self.EMAIL))
             Token.objects.create(user=User.objects.get(email=self.EMAIL))
         self.log_in()
         self.log_in()
 
 
 
 
-class URLSignUpLoginTestCase(SignUpLoginTestCase):
-
-    REGISTRATION_ENDPOINT = '/api/v1/auth/users/'
-    LOGIN_ENDPOINT = '/api/v1/auth/token/login/'
-
-
-class LegacyURLSignUpLoginTestCase(SignUpLoginTestCase):
-
-    REGISTRATION_ENDPOINT = '/api/v1/auth/users/create/'
-    LOGIN_ENDPOINT = '/api/v1/auth/token/create/'
-
-
 class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
 class TokenAuthenticationTestCase(DynDomainOwnerTestCase):
 
 
     def _get_domains(self):
     def _get_domains(self):

+ 11 - 94
api/desecapi/tests/test_domains.py

@@ -8,7 +8,7 @@ from rest_framework import status
 
 
 from desecapi.models import Domain
 from desecapi.models import Domain
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.pdns_change_tracker import PDNSChangeTracker
-from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase, LockedDomainOwnerTestCase
+from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase
 
 
 
 
 class UnauthenticatedDomainTests(DesecTestCase):
 class UnauthenticatedDomainTests(DesecTestCase):
@@ -166,13 +166,6 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
             response = self.client.post(url, {'name': name})
             response = self.client.post(url, {'name': name})
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
             self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
-    def test_create_pdns_known_domain(self):
-        url = self.reverse('v1:domain-list')
-        name = self.random_domain_name()
-        with self.assertPdnsRequests(self.request_pdns_zone_create_already_exists(existing_domains=[name])):
-            response = self.client.post(url, {'name': name})
-            self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-
     def test_create_domain_with_whitespace(self):
     def test_create_domain_with_whitespace(self):
         for name in [
         for name in [
             ' ' + self.random_domain_name(),
             ' ' + self.random_domain_name(),
@@ -181,14 +174,14 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
             self.assertResponse(
             self.assertResponse(
                 self.client.post(self.reverse('v1:domain-list'), {'name': name}),
                 self.client.post(self.reverse('v1:domain-list'), {'name': name}),
                 status.HTTP_400_BAD_REQUEST,
                 status.HTTP_400_BAD_REQUEST,
-                {'name': ['Domain name malformed.']},
+                {'name': ['Invalid value (not a DNS name).']},
             )
             )
 
 
     def test_create_public_suffixes(self):
     def test_create_public_suffixes(self):
         for name in self.PUBLIC_SUFFIXES:
         for name in self.PUBLIC_SUFFIXES:
             response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
             response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
-            self.assertStatus(response, status.HTTP_409_CONFLICT)
-            self.assertEqual(response.data['code'], 'domain-unavailable')
+            self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+            self.assertEqual(response.data['name'][0].code, 'name_unavailable')
 
 
     def test_create_domain_under_public_suffix_with_private_parent(self):
     def test_create_domain_under_public_suffix_with_private_parent(self):
         name = 'amazonaws.com'
         name = 'amazonaws.com'
@@ -199,8 +192,8 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
         # If amazonaws.com is owned by another user, we cannot register test.s4.amazonaws.com
         # If amazonaws.com is owned by another user, we cannot register test.s4.amazonaws.com
         name = 'test.s4.amazonaws.com'
         name = 'test.s4.amazonaws.com'
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
-        self.assertStatus(response, status.HTTP_409_CONFLICT)
-        self.assertEqual(response.data['code'], 'domain-unavailable')
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['name'][0].code, 'name_unavailable')
 
 
         # s3.amazonaws.com is a public suffix. Therefore, test.s3.amazonaws.com can be
         # s3.amazonaws.com is a public suffix. Therefore, test.s3.amazonaws.com can be
         # registered even if the parent zone amazonaws.com is owned by another user
         # registered even if the parent zone amazonaws.com is owned by another user
@@ -222,13 +215,13 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
         name = '*.' + self.random_domain_name()
         name = '*.' + self.random_domain_name()
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
         self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
-        self.assertTrue("Domain name malformed." in response.data['name'][0])
+        self.assertTrue("Invalid value (not a DNS name)." in response.data['name'][0])
 
 
     def test_create_domain_other_parent(self):
     def test_create_domain_other_parent(self):
         name = 'something.' + self.other_domain.name
         name = 'something.' + self.other_domain.name
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
-        self.assertStatus(response, status.HTTP_409_CONFLICT)
-        self.assertIn('domain name is unavailable.', response.data['detail'])
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['name'][0].code, 'name_unavailable')
 
 
     def test_create_domain_atomicity(self):
     def test_create_domain_atomicity(self):
         name = self.random_domain_name()
         name = self.random_domain_name()
@@ -278,56 +271,6 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
         self.assertEqual(response.data['minimum_ttl'], settings.MINIMUM_TTL_DEFAULT)
         self.assertEqual(response.data['minimum_ttl'], settings.MINIMUM_TTL_DEFAULT)
 
 
 
 
-class LockedDomainOwnerTestCase1(LockedDomainOwnerTestCase):
-
-    def test_create_domains(self):
-        name = self.random_domain_name()
-        with self.assertPdnsRequests(self.requests_desec_domain_creation(name)):
-            self.assertStatus(
-                self.client.post(self.reverse('v1:domain-list'), {'name': name}),
-                status.HTTP_201_CREATED
-            )
-
-    def test_update_domains(self):
-        url = self.reverse('v1:domain-detail', name=self.my_domain.name)
-        name = self.random_domain_name()
-
-        for method in [self.client.patch, self.client.put]:
-            with PDNSChangeTracker():
-                response = method(url, {'name': name})
-                self.assertStatus(response, status.HTTP_400_BAD_REQUEST)  # TODO fix docs, consider to change code
-
-        with self.assertPdnsRequests(self.requests_desec_domain_deletion(name=self.my_domain.name)):
-            response = self.client.delete(url)
-            self.assertStatus(response, status.HTTP_204_NO_CONTENT)
-
-    def test_create_rr_sets(self):
-        data = {'records': ['1.2.3.4'], 'ttl': 60, 'type': 'A'}
-        response = self.client.post_rr_set(self.my_domain.name, **data)
-        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
-
-    def test_update_rr_sets(self):
-        type_ = 'A'
-        for subname in ['', '*', 'asdf', 'asdf.adsf.asdf']:
-            data = {'records': ['1.2.3.4'], 'ttl': 60}
-            response = self.client.put_rr_set(self.my_domain.name, subname, type_, data)
-            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
-
-            for patch_request in [
-                {'records': ['1.2.3.4'], 'ttl': 60},
-                {'records': [], 'ttl': 60},
-                {'records': []},
-                {'ttl': 60},
-                {},
-            ]:
-                response = self.client.patch_rr_set(self.my_domain.name, subname, type_, patch_request)
-                self.assertStatus(response, status.HTTP_403_FORBIDDEN)
-
-            # Try DELETE
-            response = self.client.delete_rr_set(self.my_domain.name, subname, type_)
-            self.assertStatus(response, status.HTTP_403_FORBIDDEN)
-
-
 class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
 class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
     DYN = True
     DYN = True
 
 
@@ -361,15 +304,6 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
                 self.assertTrue(self.token.key in email)
                 self.assertTrue(self.token.key in email)
                 self.assertFalse(self.user.plain_password in email)
                 self.assertFalse(self.user.plain_password in email)
 
 
-    def test_create_regular_domains(self):
-        for name in [
-            self.random_domain_name(),
-            'very.long.domain.' + self.random_domain_name()
-        ]:
-            response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
-            self.assertStatus(response, status.HTTP_409_CONFLICT)
-            self.assertEqual(response.data['code'], 'domain-illformed')
-
     def test_domain_limit(self):
     def test_domain_limit(self):
         url = self.reverse('v1:domain-list')
         url = self.reverse('v1:domain-list')
         user_quota = settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT - self.NUM_OWNED_DOMAINS
         user_quota = settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT - self.NUM_OWNED_DOMAINS
@@ -382,7 +316,8 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
                 self.assertEqual(len(mail.outbox), i + 1)
                 self.assertEqual(len(mail.outbox), i + 1)
 
 
         response = self.client.post(url, {'name': self.random_domain_name(self.AUTO_DELEGATION_DOMAINS)})
         response = self.client.post(url, {'name': self.random_domain_name(self.AUTO_DELEGATION_DOMAINS)})
-        self.assertStatus(response, status.HTTP_403_FORBIDDEN)
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertEqual(response.data['non_field_errors'][0].code, 'domain_limit')
         self.assertEqual(len(mail.outbox), user_quota)
         self.assertEqual(len(mail.outbox), user_quota)
 
 
     def test_domain_minimum_ttl(self):
     def test_domain_minimum_ttl(self):
@@ -392,21 +327,3 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
             response = self.client.post(url, {'name': name})
             response = self.client.post(url, {'name': name})
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertStatus(response, status.HTTP_201_CREATED)
         self.assertEqual(response.data['minimum_ttl'], 60)
         self.assertEqual(response.data['minimum_ttl'], 60)
-
-
-class LockedAutoDelegationDomainOwnerTests(LockedDomainOwnerTestCase):
-    DYN = True
-
-    def test_create_domain(self):
-        name = self.random_domain_name(suffix=self.AUTO_DELEGATION_DOMAINS)
-        with self.assertPdnsRequests(self.requests_desec_domain_creation_auto_delegation(name)):
-            self.assertStatus(
-                self.client.post(self.reverse('v1:domain-list'), {'name': name}),
-                status.HTTP_201_CREATED
-            )
-
-    def test_create_rrset(self):
-        self.assertStatus(
-            self.client.post_rr_set(self.my_domain.name, type='A', records=['1.1.1.1']),
-            status.HTTP_403_FORBIDDEN
-        )

+ 9 - 134
api/desecapi/tests/test_registration.py

@@ -1,31 +1,11 @@
-from datetime import timedelta
-
-from django.conf import settings
-from django.core import mail
-from django.test import RequestFactory
-from django.utils import timezone
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
-from rest_framework.versioning import NamespaceVersioning
 
 
 from desecapi import models
 from desecapi import models
-from desecapi.emails import send_account_lock_email
 from desecapi.tests.base import DesecTestCase
 from desecapi.tests.base import DesecTestCase
 
 
 
 
 class RegistrationTestCase(DesecTestCase):
 class RegistrationTestCase(DesecTestCase):
 
 
-    def assertRegistration(self, remote_addr='', status=201, **kwargs):
-        url = reverse('v1:register')
-        post_kwargs = {}
-        if remote_addr:
-            post_kwargs['REMOTE_ADDR'] = remote_addr
-        response = self.client.post(url, kwargs, **post_kwargs)
-        self.assertStatus(response, status)
-        return response
-
-
-class SingleRegistrationTestCase(RegistrationTestCase):
-
     def setUp(self):
     def setUp(self):
         super().setUp()
         super().setUp()
         email = self.random_username()
         email = self.random_username()
@@ -36,119 +16,14 @@ class SingleRegistrationTestCase(RegistrationTestCase):
         )
         )
         self.user = models.User.objects.get(email=email)
         self.user = models.User.objects.get(email=email)
 
 
+    def assertRegistration(self, remote_addr='', status=202, **kwargs):
+        url = reverse('v1:register')
+        post_kwargs = {}
+        if remote_addr:
+            post_kwargs['REMOTE_ADDR'] = remote_addr
+        response = self.client.post(url, kwargs, **post_kwargs)
+        self.assertStatus(response, status)
+        return response
+
     def test_registration_successful(self):
     def test_registration_successful(self):
         self.assertEqual(self.user.registration_remote_ip, "1.3.3.7")
         self.assertEqual(self.user.registration_remote_ip, "1.3.3.7")
-
-    def test_token_email(self):
-        self.assertEqual(len(mail.outbox), 1 if not self.user.locked else 2)
-        self.assertTrue(self.user.get_or_create_first_token() in mail.outbox[-1].body)
-
-    def test_send_captcha_email_manually(self):
-        # TODO see if this can be replaced by a method of self.client
-        r = RequestFactory().request(HTTP_HOST=settings.ALLOWED_HOSTS[0])
-        r.version = 'v1'
-        r.versioning_scheme = NamespaceVersioning()
-        # end TODO
-
-        mail.outbox = []
-        send_account_lock_email(r, self.user)
-        self.assertEqual(len(mail.outbox), 1)
-
-
-class MultipleRegistrationTestCase(RegistrationTestCase):
-
-    def _registrations(self):
-        return []
-
-    def setUp(self):
-        super().setUp()
-        self.users = []
-        for (ip, hours_ago, email_host) in self._registrations():
-            email = self.random_username(email_host)
-            ip = ip or self.random_ip()
-            self.assertRegistration(
-                email=email,
-                password=self.random_password(),
-                dyn=True,
-                remote_addr=ip,
-            )
-            user = models.User.objects.get(email=email)
-            self.assertEqual(user.registration_remote_ip, ip)
-            user.created = timezone.now() - timedelta(hours=hours_ago)
-            user.save()
-            self.users.append(user)
-
-
-class MultipleRegistrationSameIPShortTime(MultipleRegistrationTestCase):
-
-    NUM_REGISTRATIONS = 3
-
-    def _registrations(self):
-        return [('1.3.3.7', 0, None) for _ in range(self.NUM_REGISTRATIONS)]
-
-    def test_is_locked(self):
-        self.assertIsNone(self.users[0].locked)
-        for i in range(1, self.NUM_REGISTRATIONS):
-            self.assertIsNotNone(self.users[i].locked)
-
-
-class MultipleRegistrationDifferentIPShortTime(MultipleRegistrationTestCase):
-
-    NUM_REGISTRATIONS = 10
-
-    def _registrations(self):
-        return [('1.3.3.%s' % i, 0, None) for i in range(self.NUM_REGISTRATIONS)]
-
-    def test_is_not_locked(self):
-        for user in self.users:
-            self.assertIsNone(user.locked)
-
-
-class MultipleRegistrationSameIPLongTime(MultipleRegistrationTestCase):
-
-    NUM_REGISTRATIONS = 10
-
-    def _registrations(self):
-        return [
-            ('1.3.3.7', settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS, None)
-            for _ in range(self.NUM_REGISTRATIONS)
-        ]
-
-    def test_is_not_locked(self):
-        for user in self.users:
-            self.assertIsNone(user.locked)
-
-
-class MultipleRegistrationSameEmailHostShortTime(MultipleRegistrationTestCase):
-
-    NUM_REGISTRATIONS = settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT + 3
-
-    def _registrations(self):
-        host = self.random_domain_name()
-        return [
-            (None, 0, host)
-            for _ in range(self.NUM_REGISTRATIONS)
-        ]
-
-    def test_is_locked(self):
-        for i in range(self.NUM_REGISTRATIONS):
-            if i < settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT:
-                self.assertIsNone(self.users[i].locked)
-            else:
-                self.assertIsNotNone(self.users[i].locked)
-
-
-class MultipleRegistrationsSameEmailHostLongTime(MultipleRegistrationTestCase):
-
-    NUM_REGISTRATIONS = settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT + 3
-
-    def _registrations(self):
-        host = self.random_domain_name()
-        return [
-            (self.random_ip(), settings.ABUSE_BY_EMAIL_HOSTNAME_PERIOD_HRS + 1, host)
-            for _ in range(self.NUM_REGISTRATIONS)
-        ]
-
-    def test_is_not_locked(self):
-        for user in self.users:
-            self.assertIsNone(user.locked)

+ 704 - 0
api/desecapi/tests/test_user_management.py

@@ -0,0 +1,704 @@
+"""
+This module tests deSEC's user management.
+
+The tests are separated into two categories, where
+(a) the client has an associated user account and
+(b) does not have an associated user account.
+
+This involves testing five separate endpoints:
+(1) Registration endpoint,
+(2) Reset password endpoint,
+(3) Change email address endpoint,
+(4) delete user endpoint, and
+(5) verify endpoint.
+"""
+import base64
+import json
+import re
+import time
+from unittest import mock
+
+from django.core import mail
+from django.test import override_settings
+from rest_framework import status
+from rest_framework.reverse import reverse
+from rest_framework.serializers import ValidationError
+from rest_framework.test import APIClient
+
+from api import settings
+from desecapi.models import Domain, User
+from desecapi.serializers import AuthenticatedUserAction
+from desecapi.tests.base import DesecTestCase, PublicSuffixMockMixin
+
+
+class UserManagementClient(APIClient):
+
+    def register(self, email, password, **kwargs):
+        return self.post(reverse('v1:register'), {
+            'email': email,
+            'password': password,
+            **kwargs
+        })
+
+    def login_user(self, email, password):
+        return self.post(reverse('v1:login'), {
+            'email': email,
+            'password': password,
+        })
+
+    def reset_password(self, email):
+        return self.post(reverse('v1:account-reset-password'), {
+            'email': email,
+        })
+
+    def change_email(self, email, password, **payload):
+        payload['email'] = email
+        payload['password'] = password
+        return self.post(reverse('v1:account-change-email'), payload)
+
+    def change_email_token_auth(self, token, **payload):
+        return self.post(reverse('v1:account-change-email'), payload, HTTP_AUTHORIZATION='Token {}'.format(token))
+
+    def delete_account(self, email, password):
+        return self.post(reverse('v1:account-delete'), {
+            'email': email,
+            'password': password,
+        })
+
+    def view_account(self, token):
+        return self.get(reverse('v1:account'), HTTP_AUTHORIZATION='Token {}'.format(token))
+
+    def verify(self, url, **kwargs):
+        return self.post(url, kwargs) if kwargs else self.get(url)
+
+
+class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
+
+    client_class = UserManagementClient
+    password = None
+    token = None
+
+    def register_user(self, email=None, password=None, **kwargs):
+        email = email if email is not None else self.random_username()
+        password = password if password is not None else self.random_password()
+        return email.strip(), password, self.client.register(email, password, **kwargs)
+
+    def login_user(self, email, password):
+        response = self.client.login_user(email, password)
+        token = response.data.get('auth_token')
+        return token, response
+
+    def reset_password(self, email):
+        return self.client.reset_password(email)
+
+    def change_email(self, new_email):
+        return self.client.change_email(self.email, self.password, new_email=new_email)
+
+    def delete_account(self, email, password):
+        return self.client.delete_account(email, password)
+
+    def assertContains(self, response, text, count=None, status_code=200, msg_prefix='', html=False):
+        msg_prefix += '\nResponse: %s' % response.data
+        super().assertContains(response, text, count, status_code, msg_prefix, html)
+
+    def assertPassword(self, email, password):
+        password = password.strip()
+        self.assertTrue(User.objects.get(email=email).check_password(password),
+                        'Expected user password to be "%s" (potentially trimmed), but check failed.' % password)
+
+    def assertUserExists(self, email):
+        try:
+            User.objects.get(email=email)
+        except User.DoesNotExist:
+            self.fail('Expected user %s to exist, but did not.' % email)
+
+    def assertUserDoesNotExist(self, email):
+        # noinspection PyTypeChecker
+        with self.assertRaises(User.DoesNotExist):
+            User.objects.get(email=email)
+
+    def assertNoEmailSent(self):
+        self.assertFalse(mail.outbox, "Expected no email to be sent, but %i were sent. First subject line is '%s'." %
+                         (len(mail.outbox), mail.outbox[0].subject if mail.outbox else '<n/a>'))
+
+    def assertEmailSent(self, subject_contains='', body_contains='', recipient=None, reset=True, pattern=None):
+        total = 1
+        self.assertEqual(len(mail.outbox), total, "Expected %i message in the outbox, but found %i." %
+                         (total, len(mail.outbox)))
+        email = mail.outbox[-1]
+        self.assertTrue(subject_contains in email.subject,
+                        "Expected '%s' in the email subject, but found '%s'" %
+                        (subject_contains, email.subject))
+        self.assertTrue(body_contains in email.body,
+                        "Expected '%s' in the email body, but found '%s'" %
+                        (body_contains, email.body))
+        if recipient is not None:
+            if isinstance(recipient, list):
+                self.assertListEqual(recipient, email.recipients())
+            else:
+                self.assertIn(recipient, email.recipients())
+        body = email.body
+        if reset:
+            mail.outbox = []
+        return body if not pattern else re.search(pattern, body).group(1)
+
+    def assertRegistrationEmail(self, recipient, reset=True):
+        return self.assertEmailSent(
+            subject_contains='deSEC',
+            body_contains='Thank you for registering with deSEC!',
+            recipient=[recipient],
+            reset=reset,
+            pattern=r'following link:\s+([^\s]*)',
+        )
+
+    def assertResetPasswordEmail(self, recipient, reset=True):
+        return self.assertEmailSent(
+            subject_contains='Password reset',
+            body_contains='We received a request to reset the password for your deSEC account.',
+            recipient=[recipient],
+            reset=reset,
+            pattern=r'following link:\s+([^\s]*)',
+        )
+
+    def assertChangeEmailVerificationEmail(self, recipient, reset=True):
+        return self.assertEmailSent(
+            subject_contains='Confirmation required: Email address change',
+            body_contains='You requested to change the email address associated',
+            recipient=[recipient],
+            reset=reset,
+            pattern=r'following link:\s+([^\s]*)',
+        )
+
+    def assertChangeEmailNotificationEmail(self, recipient, reset=True):
+        return self.assertEmailSent(
+            subject_contains='Account email address changed',
+            body_contains='We\'re writing to let you know that the email address associated with',
+            recipient=[recipient],
+            reset=reset,
+        )
+
+    def assertDeleteAccountEmail(self, recipient, reset=True):
+        return self.assertEmailSent(
+            subject_contains='Confirmation required: Delete account',
+            body_contains='confirm once more',
+            recipient=[recipient],
+            reset=reset,
+            pattern=r'following link:\s+([^\s]*)',
+        )
+
+    def assertRegistrationSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Welcome! Please check your mailbox.",
+            status_code=status.HTTP_202_ACCEPTED
+        )
+
+    def assertLoginSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="auth_token",
+            status_code=status.HTTP_200_OK
+        )
+
+    def assertRegistrationFailurePasswordRequiredResponse(self, response):
+        self.assertContains(
+            response=response,
+            text="This field may not be blank",
+            status_code=status.HTTP_400_BAD_REQUEST
+        )
+        self.assertEqual(response.data['password'][0].code, 'blank')
+
+    def assertRegistrationFailureDomainUnavailableResponse(self, response, domain, reason):
+        self.assertContains(
+            response=response,
+            text=("The requested domain {} could not be registered (reason: {}). "
+                  "Please start over and sign up again.".format(domain, reason)),
+            status_code=status.HTTP_400_BAD_REQUEST,
+            msg_prefix=str(response.data)
+        )
+
+    def assertRegistrationVerificationSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Success! Please log in at",
+            status_code=status.HTTP_200_OK
+        )
+
+    def assertRegistrationWithDomainVerificationSuccessResponse(self, response, domain=None):
+        if domain and domain.endswith('.dedyn.io'):
+            text = 'Success! Here is the password'
+        else:
+            text = 'Success! Please check the docs for the next steps'
+        return self.assertContains(
+            response=response,
+            text=text,
+            status_code=status.HTTP_200_OK
+        )
+
+    def assertResetPasswordSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Please check your mailbox for further password reset instructions.",
+            status_code=status.HTTP_202_ACCEPTED
+        )
+
+    def assertResetPasswordVerificationSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Success! Your password has been changed.",
+            status_code=status.HTTP_200_OK
+        )
+
+    def assertChangeEmailSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Please check your mailbox to confirm email address change.",
+            status_code=status.HTTP_202_ACCEPTED
+        )
+
+    def assert401InvalidPasswordResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Invalid password.",
+            status_code=status.HTTP_401_UNAUTHORIZED
+        )
+
+    def assertChangeEmailFailureAddressTakenResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="You already have another account with this email address.",
+            status_code=status.HTTP_400_BAD_REQUEST
+        )
+
+    def assertChangeEmailFailureSameAddressResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Email address unchanged.",
+            status_code=status.HTTP_400_BAD_REQUEST
+        )
+
+    def assertChangeEmailVerificationSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Success! Your email address has been changed to",
+            status_code=status.HTTP_200_OK
+        )
+
+    def assertChangeEmailVerificationFailureChangePasswordResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="This field is not allowed for action ",
+            status_code=status.HTTP_400_BAD_REQUEST
+        )
+
+    def assertDeleteAccountSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Please check your mailbox for further account deletion instructions.",
+            status_code=status.HTTP_202_ACCEPTED
+        )
+
+    def assertDeleteAccountVerificationSuccessResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="All your data has been deleted. Bye bye, see you soon! <3",
+            status_code=status.HTTP_200_OK
+        )
+
+    def assertVerificationFailureInvalidCodeResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="Bad signature.",
+            status_code=status.HTTP_400_BAD_REQUEST
+        )
+
+    def assertVerificationFailureUnknownUserResponse(self, response):
+        return self.assertContains(
+            response=response,
+            text="This user does not exist.",
+            status_code=status.HTTP_400_BAD_REQUEST
+        )
+
+    def _test_registration(self, email=None, password=None, **kwargs):
+        email, password, response = self.register_user(email, password, **kwargs)
+        self.assertRegistrationSuccessResponse(response)
+        self.assertUserExists(email)
+        self.assertFalse(User.objects.get(email=email).is_active)
+        self.assertPassword(email, password)
+        confirmation_link = self.assertRegistrationEmail(email)
+        self.assertRegistrationVerificationSuccessResponse(self.client.verify(confirmation_link))
+        self.assertTrue(User.objects.get(email=email).is_active)
+        self.assertPassword(email, password)
+        return email, password
+
+    def _test_registration_with_domain(self, email=None, password=None, domain=None, expect_failure_reason=None):
+        domain = domain or self.random_domain_name()
+
+        email, password, response = self.register_user(email, password, domain=domain)
+        self.assertRegistrationSuccessResponse(response)
+        self.assertUserExists(email)
+        self.assertFalse(User.objects.get(email=email).is_active)
+        self.assertPassword(email, password)
+
+        confirmation_link = self.assertRegistrationEmail(email)
+        if expect_failure_reason is None:
+            if domain.endswith('.dedyn.io'):
+                cm = self.requests_desec_domain_creation_auto_delegation(domain)
+            else:
+                cm = self.requests_desec_domain_creation(domain)
+            with self.assertPdnsRequests(cm[:-1]):
+                response = self.client.verify(confirmation_link)
+            self.assertRegistrationWithDomainVerificationSuccessResponse(response, domain)
+            self.assertTrue(User.objects.get(email=email).is_active)
+            self.assertPassword(email, password)
+            self.assertTrue(Domain.objects.filter(name=domain, owner__email=email).exists())
+            return email, password, domain
+        else:
+            domain_exists = Domain.objects.filter(name=domain).exists()
+            response = self.client.verify(confirmation_link)
+            self.assertRegistrationFailureDomainUnavailableResponse(response, domain, expect_failure_reason)
+            self.assertUserDoesNotExist(email)
+            self.assertEqual(Domain.objects.filter(name=domain).exists(), domain_exists)
+
+    def _test_login(self):
+        token, response = self.login_user(self.email, self.password)
+        self.assertLoginSuccessResponse(response)
+        return token
+
+    def _test_reset_password(self, email, new_password=None, **kwargs):
+        new_password = new_password or self.random_password()
+        self.assertResetPasswordSuccessResponse(self.reset_password(email))
+        confirmation_link = self.assertResetPasswordEmail(email)
+        self.assertResetPasswordVerificationSuccessResponse(
+            self.client.verify(confirmation_link, new_password=new_password, **kwargs))
+        self.assertPassword(email, new_password)
+        return new_password
+
+    def _test_change_email(self):
+        old_email = self.email
+        new_email = ' {} '.format(self.random_username())  # test trimming
+        self.assertChangeEmailSuccessResponse(self.change_email(new_email))
+        new_email = new_email.strip()
+        confirmation_link = self.assertChangeEmailVerificationEmail(new_email)
+        self.assertChangeEmailVerificationSuccessResponse(self.client.verify(confirmation_link))
+        self.assertChangeEmailNotificationEmail(old_email)
+        self.assertUserExists(new_email)
+        self.assertUserDoesNotExist(old_email)
+        self.email = new_email
+        return self.email
+
+    def _test_delete_account(self, email, password):
+        self.assertDeleteAccountSuccessResponse(self.delete_account(email, password))
+        confirmation_link = self.assertDeleteAccountEmail(email)
+        self.assertDeleteAccountVerificationSuccessResponse(self.client.verify(confirmation_link))
+        self.assertUserDoesNotExist(email)
+
+
+class UserLifeCycleTestCase(UserManagementTestCase):
+
+    def test_life_cycle(self):
+        self.email, self.password = self._test_registration()
+        self.password = self._test_reset_password(self.email)
+        mail.outbox = []
+        self.token = self._test_login()
+        email = self._test_change_email()
+        self._test_delete_account(email, self.password)
+
+
+class NoUserAccountTestCase(UserLifeCycleTestCase):
+
+    def test_home(self):
+        self.assertResponse(self.client.get(reverse('v1:root')), status.HTTP_200_OK)
+
+    def test_registration(self):
+        self._test_registration()
+
+    def test_registration_trim_email(self):
+        user_email = ' {} '.format(self.random_username())
+        email, new_password = self._test_registration(user_email)
+        self.assertEqual(email, user_email.strip())
+
+    def test_registration_with_domain(self):
+        PublicSuffixMockMixin.setUpMockPatch(self)
+        with self.get_psl_context_manager('.'):
+            _, _, domain = self._test_registration_with_domain()
+            self._test_registration_with_domain(domain=domain, expect_failure_reason='unique')
+            self._test_registration_with_domain(domain='töö--', expect_failure_reason='invalid_domain_name')
+
+        with self.get_psl_context_manager('co.uk'):
+            self._test_registration_with_domain(domain='co.uk', expect_failure_reason='name_unavailable')
+        with self.get_psl_context_manager('dedyn.io'):
+            self._test_registration_with_domain(domain=self.random_domain_name(suffix='dedyn.io'))
+
+    def test_registration_known_account(self):
+        email, _ = self._test_registration()
+        self.assertRegistrationSuccessResponse(self.register_user(email, self.random_password())[2])
+        self.assertNoEmailSent()
+
+    def test_registration_password_required(self):
+        email = self.random_username()
+        self.assertRegistrationFailurePasswordRequiredResponse(
+            response=self.register_user(email=email, password='')[2]
+        )
+        self.assertNoEmailSent()
+        self.assertUserDoesNotExist(email)
+
+    def test_registration_spam_protection(self):
+        email = self.random_username()
+        self.assertRegistrationSuccessResponse(
+            response=self.register_user(email=email)[2]
+        )
+        self.assertRegistrationEmail(email)
+        for _ in range(5):
+            self.assertRegistrationSuccessResponse(
+                response=self.register_user(email=email)[2]
+            )
+            self.assertNoEmailSent()
+
+
+class OtherUserAccountTestCase(UserManagementTestCase):
+
+    def setUp(self):
+        super().setUp()
+        self.other_email, self.other_password = self._test_registration()
+
+    def test_reset_password_unknown_user(self):
+        self.assertResetPasswordSuccessResponse(
+            response=self.reset_password(self.random_username())
+        )
+        self.assertNoEmailSent()
+
+
+class HasUserAccountTestCase(UserManagementTestCase):
+
+    def __init__(self, methodName: str = ...) -> None:
+        super().__init__(methodName)
+        self.email = None
+        self.password = None
+
+    def setUp(self):
+        super().setUp()
+        self.email, self.password = self._test_registration()
+        self.token = self._test_login()
+
+    def _start_reset_password(self):
+        self.assertResetPasswordSuccessResponse(
+            response=self.reset_password(self.email)
+        )
+        return self.assertResetPasswordEmail(self.email)
+
+    def _start_change_email(self):
+        new_email = self.random_username()
+        self.assertChangeEmailSuccessResponse(
+            response=self.change_email(new_email)
+        )
+        return self.assertChangeEmailVerificationEmail(new_email), new_email
+
+    def _start_delete_account(self):
+        self.assertDeleteAccountSuccessResponse(self.delete_account(self.email, self.password))
+        return self.assertDeleteAccountEmail(self.email)
+
+    def _finish_reset_password(self, confirmation_link, expect_success=True):
+        new_password = self.random_password()
+        response = self.client.verify(confirmation_link, new_password=new_password)
+        if expect_success:
+            self.assertResetPasswordVerificationSuccessResponse(response=response)
+        else:
+            self.assertVerificationFailureInvalidCodeResponse(response)
+        return new_password
+
+    def _finish_change_email(self, confirmation_link, expect_success=True):
+        response = self.client.verify(confirmation_link)
+        if expect_success:
+            self.assertChangeEmailVerificationSuccessResponse(response)
+            self.assertChangeEmailNotificationEmail(self.email)
+        else:
+            self.assertVerificationFailureInvalidCodeResponse(response)
+
+    def _finish_delete_account(self, confirmation_link):
+        self.assertDeleteAccountVerificationSuccessResponse(self.client.verify(confirmation_link))
+        self.assertUserDoesNotExist(self.email)
+
+    def test_view_account(self):
+        response = self.client.view_account(self.token)
+        self.assertEqual(response.status_code, 200)
+        self.assertTrue('created' in response.data)
+        self.assertEqual(response.data['email'], self.email)
+        self.assertEqual(response.data['id'], User.objects.get(email=self.email).pk)
+        self.assertEqual(response.data['limit_domains'], settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT)
+
+    def test_view_account_read_only(self):
+        # Should this test ever be removed (to allow writeable fields), make sure to
+        # add new tests for each read-only field individually (such as limit_domains)!
+        for method in [self.client.put, self.client.post, self.client.delete]:
+            response = method(
+                reverse('v1:account'),
+                {'limit_domains': 99},
+                HTTP_AUTHORIZATION='Token {}'.format(self.token)
+            )
+            self.assertResponse(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+    def test_reset_password(self):
+        self._test_reset_password(self.email)
+
+    def test_reset_password_inactive_user(self):
+        user = User.objects.get(email=self.email)
+        user.is_active = False
+        user.save()
+        self.assertResetPasswordSuccessResponse(self.reset_password(self.email))
+        self.assertNoEmailSent()
+
+    def test_reset_password_multiple_times(self):
+        for _ in range(3):
+            self._test_reset_password(self.email)
+            mail.outbox = []
+
+    def test_reset_password_during_change_email_interleaved(self):
+        reset_password_verification_code = self._start_reset_password()
+        change_email_verification_code, new_email = self._start_change_email()
+        new_password = self._finish_reset_password(reset_password_verification_code)
+        self._finish_change_email(change_email_verification_code, expect_success=False)
+
+        self.assertUserExists(self.email)
+        self.assertUserDoesNotExist(new_email)
+        self.assertPassword(self.email, new_password)
+
+    def test_reset_password_during_change_email_nested(self):
+        change_email_verification_code, new_email = self._start_change_email()
+        reset_password_verification_code = self._start_reset_password()
+        new_password = self._finish_reset_password(reset_password_verification_code)
+        self._finish_change_email(change_email_verification_code, expect_success=False)
+
+        self.assertUserExists(self.email)
+        self.assertUserDoesNotExist(new_email)
+        self.assertPassword(self.email, new_password)
+
+    def test_reset_password_validation_unknown_user(self):
+        confirmation_link = self._start_reset_password()
+        self._test_delete_account(self.email, self.password)
+        self.assertVerificationFailureUnknownUserResponse(
+            response=self.client.verify(confirmation_link)
+        )
+        self.assertNoEmailSent()
+
+    def test_change_email(self):
+        self._test_change_email()
+
+    def test_change_email_requires_password(self):
+        # Make sure that the account's email address cannot be changed with a token (password required)
+        new_email = self.random_username()
+        response = self.client.change_email_token_auth(self.token, new_email=new_email)
+        self.assertContains(response, 'You do not have permission', status_code=status.HTTP_403_FORBIDDEN)
+        self.assertNoEmailSent()
+
+    def test_change_email_multiple_times(self):
+        for _ in range(3):
+            self._test_change_email()
+
+    def test_change_email_user_exists(self):
+        known_email, _ = self._test_registration()
+        # We send a verification link to the new email and check account existence only later, upon verification
+        self.assertChangeEmailSuccessResponse(
+            response=self.change_email(known_email)
+        )
+
+    def test_change_email_verification_user_exists(self):
+        new_email = self.random_username()
+        self.assertChangeEmailSuccessResponse(self.change_email(new_email))
+        confirmation_link = self.assertChangeEmailVerificationEmail(new_email)
+        new_email, new_password = self._test_registration(new_email)
+        self.assertChangeEmailFailureAddressTakenResponse(
+            response=self.client.verify(confirmation_link)
+        )
+        self.assertUserExists(self.email)
+        self.assertPassword(self.email, self.password)
+        self.assertUserExists(new_email)
+        self.assertPassword(new_email, new_password)
+
+    def test_change_email_verification_change_password(self):
+        new_email = self.random_username()
+        self.assertChangeEmailSuccessResponse(self.change_email(new_email))
+        confirmation_link = self.assertChangeEmailVerificationEmail(new_email)
+        response = self.client.verify(confirmation_link, new_password=self.random_password())
+        self.assertStatus(response, status.HTTP_405_METHOD_NOT_ALLOWED)
+
+    def test_change_email_same_email(self):
+        self.assertChangeEmailFailureSameAddressResponse(
+            response=self.change_email(self.email)
+        )
+        self.assertUserExists(self.email)
+
+    def test_change_email_during_reset_password_interleaved(self):
+        change_email_verification_code, new_email = self._start_change_email()
+        reset_password_verification_code = self._start_reset_password()
+        self._finish_change_email(change_email_verification_code)
+        self._finish_reset_password(reset_password_verification_code, expect_success=False)
+
+        self.assertUserExists(new_email)
+        self.assertUserDoesNotExist(self.email)
+        self.assertPassword(new_email, self.password)
+
+    def test_change_email_during_reset_password_nested(self):
+        reset_password_verification_code = self._start_reset_password()
+        change_email_verification_code, new_email = self._start_change_email()
+        self._finish_change_email(change_email_verification_code)
+        self._finish_reset_password(reset_password_verification_code, expect_success=False)
+
+        self.assertUserExists(new_email)
+        self.assertUserDoesNotExist(self.email)
+        self.assertPassword(new_email, self.password)
+
+    def test_change_email_nested(self):
+        verification_code_1, new_email_1 = self._start_change_email()
+        verification_code_2, new_email_2 = self._start_change_email()
+
+        self._finish_change_email(verification_code_2)
+        self.assertUserDoesNotExist(self.email)
+        self.assertUserDoesNotExist(new_email_1)
+        self.assertUserExists(new_email_2)
+
+        self._finish_change_email(verification_code_1, expect_success=False)
+        self.assertUserDoesNotExist(self.email)
+        self.assertUserDoesNotExist(new_email_1)
+        self.assertUserExists(new_email_2)
+
+    def test_change_email_interleaved(self):
+        verification_code_1, new_email_1 = self._start_change_email()
+        verification_code_2, new_email_2 = self._start_change_email()
+
+        self._finish_change_email(verification_code_1)
+        self.assertUserDoesNotExist(self.email)
+        self.assertUserExists(new_email_1)
+        self.assertUserDoesNotExist(new_email_2)
+
+        self._finish_change_email(verification_code_2, expect_success=False)
+        self.assertUserDoesNotExist(self.email)
+        self.assertUserExists(new_email_1)
+        self.assertUserDoesNotExist(new_email_2)
+
+    def test_change_email_validation_unknown_user(self):
+        confirmation_link, new_email = self._start_change_email()
+        self._test_delete_account(self.email, self.password)
+        self.assertVerificationFailureUnknownUserResponse(
+            response=self.client.verify(confirmation_link)
+        )
+        self.assertNoEmailSent()
+
+    def test_delete_account_validation_unknown_user(self):
+        confirmation_link = self._start_delete_account()
+        self._test_delete_account(self.email, self.password)
+        self.assertVerificationFailureUnknownUserResponse(
+            response=self.client.verify(confirmation_link)
+        )
+        self.assertNoEmailSent()
+
+    def test_reset_password_password_strip(self):
+        password = ' %s ' % self.random_password()
+        self._test_reset_password(self.email, password)
+        self.assertPassword(self.email, password.strip())
+        self.assertPassword(self.email, password)
+
+    def test_reset_password_no_code_override(self):
+        password = self.random_password()
+        self._test_reset_password(self.email, password, code='foobar')
+        self.assertPassword(self.email, password)

+ 15 - 20
api/desecapi/urls/version_1.py

@@ -1,5 +1,4 @@
 from django.urls import include, path, re_path
 from django.urls import include, path, re_path
-from djoser.views import UserView
 from rest_framework.routers import SimpleRouter
 from rest_framework.routers import SimpleRouter
 
 
 from desecapi import views
 from desecapi import views
@@ -8,29 +7,23 @@ tokens_router = SimpleRouter()
 tokens_router.register(r'', views.TokenViewSet, base_name='token')
 tokens_router.register(r'', views.TokenViewSet, base_name='token')
 
 
 auth_urls = [
 auth_urls = [
-    # Old user management
-    # TODO deprecated, remove
-    path('users/create/', views.UserCreateView.as_view(), name='user-create'),  # deprecated
-    path('token/create/', views.TokenCreateView.as_view(), name='token-create'),  # deprecated
-    path('token/destroy/', views.TokenDestroyView.as_view(), name='token-destroy'),  # deprecated
-
-    # New user management
-    path('users/', views.UserCreateView.as_view(), name='register'),
+    # User management
+    path('', views.AccountCreateView.as_view(), name='register'),
+    path('account/', views.AccountView.as_view(), name='account'),
+    path('account/delete/', views.AccountDeleteView.as_view(), name='account-delete'),
+    path('account/change-email/', views.AccountChangeEmailView.as_view(), name='account-change-email'),
+    path('account/reset-password/', views.AccountResetPasswordView.as_view(), name='account-reset-password'),
+    path('login/', views.AccountLoginView.as_view(), name='login'),
 
 
     # Token management
     # Token management
-    path('token/login/', views.TokenCreateView.as_view(), name='login'),
-    path('token/logout/', views.TokenDestroyView.as_view(), name='logout'),
     path('tokens/', include(tokens_router.urls)),
     path('tokens/', include(tokens_router.urls)),
-
-    # User home
-    path('me/', UserView.as_view(), name='user'),
 ]
 ]
 
 
 api_urls = [
 api_urls = [
     # API home
     # API home
     path('', views.Root.as_view(), name='root'),
     path('', views.Root.as_view(), name='root'),
 
 
-    # Domain and RRSet endpoints
+    # Domain and RRSet management
     path('domains/', views.DomainList.as_view(), name='domain-list'),
     path('domains/', views.DomainList.as_view(), name='domain-list'),
     path('domains/<name>/', views.DomainDetail.as_view(), name='domain-detail'),
     path('domains/<name>/', views.DomainDetail.as_view(), name='domain-detail'),
     path('domains/<name>/rrsets/', views.RRsetList.as_view(), name='rrsets'),
     path('domains/<name>/rrsets/', views.RRsetList.as_view(), name='rrsets'),
@@ -42,15 +35,17 @@ api_urls = [
             views.RRsetDetail.as_view(), name='rrset@'),
             views.RRsetDetail.as_view(), name='rrset@'),
     path('domains/<name>/rrsets/<subname>/<type>/', views.RRsetDetail.as_view()),
     path('domains/<name>/rrsets/<subname>/<type>/', views.RRsetDetail.as_view()),
 
 
-    # DynDNS update endpoint
+    # DynDNS update
     path('dyndns/update', views.DynDNS12Update.as_view(), name='dyndns12update'),
     path('dyndns/update', views.DynDNS12Update.as_view(), name='dyndns12update'),
 
 
-    # Donation endpoints
+    # Donation
     path('donation/', views.DonationList.as_view(), name='donation'),
     path('donation/', views.DonationList.as_view(), name='donation'),
 
 
-    # Unlock endpoints
-    path('unlock/user/<email>', views.unlock, name='unlock/byEmail'),
-    path('unlock/done', views.unlock_done, name='unlock/done'),
+    # Authenticated Actions
+    path('v/activate-account/<code>/', views.AuthenticatedActivateUserActionView.as_view(), name='confirm-activate-account'),
+    path('v/change-email/<code>/', views.AuthenticatedChangeEmailUserActionView.as_view(), name='confirm-change-email'),
+    path('v/reset-password/<code>/', views.AuthenticatedResetPasswordUserActionView.as_view(), name='confirm-reset-password'),
+    path('v/delete-account/<code>/', views.AuthenticatedDeleteUserActionView.as_view(), name='confirm-delete-account'),
 ]
 ]
 
 
 app_name = 'desecapi'
 app_name = 'desecapi'

+ 283 - 197
api/desecapi/views.py

@@ -1,29 +1,20 @@
 import base64
 import base64
 import binascii
 import binascii
-import ipaddress
-import os
-import re
-from datetime import timedelta
 
 
 import django.core.exceptions
 import django.core.exceptions
-import djoser.views
-import psl_dns
 from django.conf import settings
 from django.conf import settings
-from django.contrib.auth import user_logged_in, user_logged_out
+from django.contrib.auth import user_logged_in
 from django.core.mail import EmailMessage
 from django.core.mail import EmailMessage
-from django.db.models import Q
-from django.http import Http404, HttpResponseRedirect
-from django.shortcuts import render
+from django.http import Http404
 from django.template.loader import get_template
 from django.template.loader import get_template
-from django.utils import timezone
-from djoser import views, signals
-from djoser.serializers import TokenSerializer as DjoserTokenSerializer
 from rest_framework import generics
 from rest_framework import generics
 from rest_framework import mixins
 from rest_framework import mixins
 from rest_framework import status
 from rest_framework import status
-from rest_framework.authentication import get_authorization_header
+from rest_framework.authentication import get_authorization_header, BaseAuthentication
 from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError)
 from rest_framework.exceptions import (NotFound, PermissionDenied, ValidationError)
-from rest_framework.generics import ListCreateAPIView, RetrieveUpdateDestroyAPIView, UpdateAPIView, get_object_or_404
+from rest_framework.generics import (
+    GenericAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView, UpdateAPIView, get_object_or_404
+)
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
@@ -31,17 +22,12 @@ from rest_framework.views import APIView
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import GenericViewSet
 
 
 import desecapi.authentication as auth
 import desecapi.authentication as auth
-from desecapi.emails import send_account_lock_email, send_token_email
-from desecapi.forms import UnlockForm
-from desecapi.models import Domain, User, RRset, Token
-from desecapi.pdns import PDNSException
+from desecapi import serializers
+from desecapi.models import Domain, User, RRset, Token, AuthenticatedActivateUserAction, AuthenticatedChangeEmailUserAction, AuthenticatedDeleteUserAction, \
+    AuthenticatedResetPasswordUserAction
 from desecapi.pdns_change_tracker import PDNSChangeTracker
 from desecapi.pdns_change_tracker import PDNSChangeTracker
-from desecapi.permissions import IsOwner, IsUnlocked, IsDomainOwner
+from desecapi.permissions import IsOwner, IsDomainOwner
 from desecapi.renderers import PlainTextRenderer
 from desecapi.renderers import PlainTextRenderer
-from desecapi.serializers import DomainSerializer, RRsetSerializer, DonationSerializer, TokenSerializer
-
-patternDyn = re.compile(r'^[A-Za-z-][A-Za-z0-9_-]*\.dedyn\.io$')
-patternNonDyn = re.compile(r'^([A-Za-z0-9-][A-Za-z0-9_-]*\.)*[A-Za-z]+$')
 
 
 
 
 class IdempotentDestroy:
 class IdempotentDestroy:
@@ -67,37 +53,12 @@ class DomainView:
             raise Http404
             raise Http404
 
 
 
 
-class TokenCreateView(djoser.views.TokenCreateView):
-
-    def _action(self, serializer):
-        user = serializer.user
-        token = Token(user=user, name="login")
-        token.save()
-        user_logged_in.send(sender=user.__class__, request=self.request, user=user)
-        token_serializer_class = DjoserTokenSerializer
-        return Response(
-            data=token_serializer_class(token).data,
-            status=status.HTTP_201_CREATED,
-        )
-
-
-class TokenDestroyView(djoser.views.TokenDestroyView):
-
-    def post(self, request):
-        _, token = auth.TokenAuthentication().authenticate(request)
-        token.delete()
-        user_logged_out.send(
-            sender=request.user.__class__, request=request, user=request.user
-        )
-        return Response(status=status.HTTP_204_NO_CONTENT)
-
-
 class TokenViewSet(IdempotentDestroy,
 class TokenViewSet(IdempotentDestroy,
                    mixins.CreateModelMixin,
                    mixins.CreateModelMixin,
                    mixins.DestroyModelMixin,
                    mixins.DestroyModelMixin,
                    mixins.ListModelMixin,
                    mixins.ListModelMixin,
                    GenericViewSet):
                    GenericViewSet):
-    serializer_class = TokenSerializer
+    serializer_class = serializers.TokenSerializer
     permission_classes = (IsAuthenticated, )
     permission_classes = (IsAuthenticated, )
     lookup_field = 'user_specific_id'
     lookup_field = 'user_specific_id'
 
 
@@ -109,96 +70,32 @@ class TokenViewSet(IdempotentDestroy,
 
 
 
 
 class DomainList(ListCreateAPIView):
 class DomainList(ListCreateAPIView):
-    serializer_class = DomainSerializer
+    serializer_class = serializers.DomainSerializer
     permission_classes = (IsAuthenticated, IsOwner,)
     permission_classes = (IsAuthenticated, IsOwner,)
-    psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER)
 
 
     def get_queryset(self):
     def get_queryset(self):
         return Domain.objects.filter(owner=self.request.user.pk)
         return Domain.objects.filter(owner=self.request.user.pk)
 
 
     def perform_create(self, serializer):
     def perform_create(self, serializer):
-        domain_name = serializer.validated_data['name']
-
-        pattern = patternDyn if self.request.user.dyn else patternNonDyn
-        if pattern.match(domain_name) is None:
-            ex = ValidationError(detail={
-                "detail": "This domain name is not well-formed, by policy.",
-                "code": "domain-illformed"}
-            )
-            ex.status_code = status.HTTP_409_CONFLICT
-            raise ex
-
-        # Check if domain is a public suffix
-        try:
-            public_suffix = self.psl.get_public_suffix(domain_name)
-            is_public_suffix = self.psl.is_public_suffix(domain_name)
-        except psl_dns.exceptions.UnsupportedRule as e:
-            # It would probably be fine to just create the domain (with the TLD acting as the
-            # public suffix and setting both public_suffix and is_public_suffix accordingly).
-            # However, in order to allow to investigate the situation, it's better not catch
-            # this exception. Our error handler turns it into a 503 error and makes sure
-            # admins are notified.
-            raise e
-
-        is_restricted_suffix = is_public_suffix and domain_name not in settings.LOCAL_PUBLIC_SUFFIXES
-
-        # Generate a list of all domains connecting this one and its public suffix.
-        # If another user owns a zone with one of these names, then the requested
-        # domain is unavailable because it is part of the other user's zone.
-        private_components = domain_name.rsplit(public_suffix, 1)[0].rstrip('.')
-        private_components = private_components.split('.') if private_components else []
-        private_components += [public_suffix]
-        private_domains = ['.'.join(private_components[i:]) for i in range(0, len(private_components) - 1)]
-        assert is_public_suffix or domain_name == private_domains[0]
-
-        # Deny registration for non-local public suffixes and for domains covered by other users' zones
-        queryset = Domain.objects.filter(Q(name__in=private_domains) & ~Q(owner=self.request.user))
-        if is_restricted_suffix or queryset.exists():
-            ex = ValidationError(detail={"detail": "This domain name is unavailable.", "code": "domain-unavailable"})
-            ex.status_code = status.HTTP_409_CONFLICT
-            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 = status.HTTP_403_FORBIDDEN
-            raise ex
-
-        parent_domain_name = Domain.partition_name(domain_name)[1]
+        _, parent_domain_name = Domain.partition_name(serializer.validated_data['name'])
         domain_is_local = parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES
         domain_is_local = parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES
-        try:
-            with PDNSChangeTracker():
-                domain_kwargs = {'owner': self.request.user}
-                if domain_is_local:
-                    domain_kwargs['minimum_ttl'] = 60
-                domain = serializer.save(**domain_kwargs)
-            if domain_is_local:
-                parent_domain = Domain.objects.get(name=parent_domain_name)
-                # NOTE we need two change trackers here, as the first transaction must be committed to
-                # pdns in order to have keys available for the delegation
-                with PDNSChangeTracker():
-                    parent_domain.update_delegation(domain)
-        except PDNSException as e:
-            if not str(e).endswith(' already exists'):
-                raise e
-            ex = ValidationError(detail={
-                "detail": "This domain name is unavailable.",
-                "code": "domain-unavailable"}
-            )
-            ex.status_code = status.HTTP_400_BAD_REQUEST
-            raise ex
+        domain_kwargs = {'owner': self.request.user}
+        if domain_is_local:
+            domain_kwargs['minimum_ttl'] = 60
+        with PDNSChangeTracker():
+            domain = serializer.save(**domain_kwargs)
+
+        PDNSChangeTracker.track(lambda: self.auto_delegate(domain))
 
 
-        def send_dyn_dns_email():
+        # Send dyn email
+        if domain.name.endswith('.dedyn.io'):
             content_tmpl = get_template('emails/domain-dyndns/content.txt')
             content_tmpl = get_template('emails/domain-dyndns/content.txt')
             subject_tmpl = get_template('emails/domain-dyndns/subject.txt')
             subject_tmpl = get_template('emails/domain-dyndns/subject.txt')
             from_tmpl = get_template('emails/from.txt')
             from_tmpl = get_template('emails/from.txt')
             context = {
             context = {
-                'domain': domain_name,
+                'domain': domain.name,
                 'url': 'https://update.dedyn.io/',
                 'url': 'https://update.dedyn.io/',
-                'username': domain_name,
+                'username': domain.name,
                 'password': self.request.auth.key
                 'password': self.request.auth.key
             }
             }
             email = EmailMessage(subject_tmpl.render(context),
             email = EmailMessage(subject_tmpl.render(context),
@@ -207,21 +104,24 @@ class DomainList(ListCreateAPIView):
                                  [self.request.user.email])
                                  [self.request.user.email])
             email.send()
             email.send()
 
 
-        if domain.name.endswith('.dedyn.io'):
-            send_dyn_dns_email()
+    @staticmethod
+    def auto_delegate(domain: Domain):
+        parent_domain_name = domain.partition_name()[1]
+        if parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES:
+            parent_domain = Domain.objects.get(name=parent_domain_name)
+            parent_domain.update_delegation(domain)
 
 
 
 
 class DomainDetail(IdempotentDestroy, RetrieveUpdateDestroyAPIView):
 class DomainDetail(IdempotentDestroy, RetrieveUpdateDestroyAPIView):
-    serializer_class = DomainSerializer
+    serializer_class = serializers.DomainSerializer
     permission_classes = (IsAuthenticated, IsOwner,)
     permission_classes = (IsAuthenticated, IsOwner,)
     lookup_field = 'name'
     lookup_field = 'name'
 
 
     def perform_destroy(self, instance: Domain):
     def perform_destroy(self, instance: Domain):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
             instance.delete()
             instance.delete()
-        parent_domain_name = instance.partition_name()[1]
-        if parent_domain_name in settings.LOCAL_PUBLIC_SUFFIXES:
-            parent_domain = Domain.objects.get(name=parent_domain_name)
+        if instance.has_local_public_suffix():
+            parent_domain = Domain.objects.get(name=instance.parent_domain_name())
             with PDNSChangeTracker():
             with PDNSChangeTracker():
                 parent_domain.update_delegation(instance)
                 parent_domain.update_delegation(instance)
 
 
@@ -236,8 +136,8 @@ class DomainDetail(IdempotentDestroy, RetrieveUpdateDestroyAPIView):
 
 
 
 
 class RRsetDetail(IdempotentDestroy, DomainView, RetrieveUpdateDestroyAPIView):
 class RRsetDetail(IdempotentDestroy, DomainView, RetrieveUpdateDestroyAPIView):
-    serializer_class = RRsetSerializer
-    permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
+    serializer_class = serializers.RRsetSerializer
+    permission_classes = (IsAuthenticated, IsDomainOwner,)
 
 
     def get_queryset(self):
     def get_queryset(self):
         return self.domain.rrset_set
         return self.domain.rrset_set
@@ -274,8 +174,8 @@ class RRsetDetail(IdempotentDestroy, DomainView, RetrieveUpdateDestroyAPIView):
 
 
 
 
 class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
 class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
-    serializer_class = RRsetSerializer
-    permission_classes = (IsAuthenticated, IsDomainOwner, IsUnlocked,)
+    serializer_class = serializers.RRsetSerializer
+    permission_classes = (IsAuthenticated, IsDomainOwner,)
 
 
     def get_queryset(self):
     def get_queryset(self):
         rrsets = RRset.objects.filter(domain=self.domain)
         rrsets = RRset.objects.filter(domain=self.domain)
@@ -308,13 +208,6 @@ class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
                 kwargs['many'] = True
                 kwargs['many'] = True
         return super().get_serializer(domain=self.domain, *args, **kwargs)
         return super().get_serializer(domain=self.domain, *args, **kwargs)
 
 
-    def create(self, request, *args, **kwargs):
-        response = super().create(request, *args, **kwargs)
-        if not response.data:
-            return Response(status=status.HTTP_204_NO_CONTENT)
-        else:
-            return response
-
     def perform_create(self, serializer):
     def perform_create(self, serializer):
         with PDNSChangeTracker():
         with PDNSChangeTracker():
             serializer.save(domain=self.domain)
             serializer.save(domain=self.domain)
@@ -326,17 +219,24 @@ class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
 
 
 class Root(APIView):
 class Root(APIView):
     def get(self, request, *_):
     def get(self, request, *_):
-        if self.request.user and self.request.user.is_authenticated:
-            return Response({
+        if self.request.user.is_authenticated:
+            routes = {
+                'account': {
+                    'show': reverse('account', request=request),
+                    'delete': reverse('account-delete', request=request),
+                    'change-email': reverse('account-change-email', request=request),
+                    'reset-password': reverse('account-reset-password', request=request),
+                },
+                'tokens': reverse('token-list', request=request),
                 'domains': reverse('domain-list', request=request),
                 'domains': reverse('domain-list', request=request),
-                'user': reverse('user', request=request),
-                'logout': reverse('token-destroy', request=request),  # TODO change interface to token-destroy, too?
-            })
+            }
         else:
         else:
-            return Response({
-                'login': reverse('token-create', request=request),
+            routes = {
                 'register': reverse('register', request=request),
                 'register': reverse('register', request=request),
-            })
+                'login': reverse('login', request=request),
+                'reset-password': reverse('account-reset-password', request=request),
+            }
+        return Response(routes)
 
 
 
 
 class DynDNS12Update(APIView):
 class DynDNS12Update(APIView):
@@ -344,10 +244,6 @@ class DynDNS12Update(APIView):
     renderer_classes = [PlainTextRenderer]
     renderer_classes = [PlainTextRenderer]
 
 
     def _find_domain(self, request):
     def _find_domain(self, request):
-        if self.request.user.locked:
-            # Error code from https://help.dyn.com/remote-access-api/return-codes/
-            raise PermissionDenied('abuse')
-
         def find_domain_name(r):
         def find_domain_name(r):
             # 1. hostname parameter
             # 1. hostname parameter
             if 'hostname' in r.query_params and r.query_params['hostname'] != 'YES':
             if 'hostname' in r.query_params and r.query_params['hostname'] != 'YES':
@@ -440,7 +336,7 @@ class DynDNS12Update(APIView):
         ]
         ]
 
 
         instances = domain.rrset_set.filter(subname='', type__in=['A', 'AAAA']).all()
         instances = domain.rrset_set.filter(subname='', type__in=['A', 'AAAA']).all()
-        serializer = RRsetSerializer(instances, domain=domain, data=data, many=True, partial=True)
+        serializer = serializers.RRsetSerializer(instances, domain=domain, data=data, many=True, partial=True)
         try:
         try:
             serializer.is_valid(raise_exception=True)
             serializer.is_valid(raise_exception=True)
         except ValidationError as e:
         except ValidationError as e:
@@ -453,7 +349,7 @@ class DynDNS12Update(APIView):
 
 
 
 
 class DonationList(generics.CreateAPIView):
 class DonationList(generics.CreateAPIView):
-    serializer_class = DonationSerializer
+    serializer_class = serializers.DonationSerializer
 
 
     def perform_create(self, serializer):
     def perform_create(self, serializer):
         iban = serializer.validated_data['iban']
         iban = serializer.validated_data['iban']
@@ -497,54 +393,244 @@ class DonationList(generics.CreateAPIView):
         send_donation_emails(obj)
         send_donation_emails(obj)
 
 
 
 
-class UserCreateView(views.UserCreateView):
+class AccountCreateView(generics.CreateAPIView):
+    serializer_class = serializers.RegisterAccountSerializer
+
+    def create(self, request, *args, **kwargs):
+        # Create user and send trigger email verification.
+        # Alternative would be to create user once email is verified, but this could be abused for bulk email.
+
+        serializer = self.get_serializer(data=request.data)
+        activation_required = settings.USER_ACTIVATION_REQUIRED
+        try:
+            serializer.is_valid(raise_exception=True)
+        except ValidationError as e:
+            # Hide existing users
+            email_detail = e.detail.pop('email', [])
+            email_detail = [detail for detail in email_detail if detail.code != 'unique']
+            if email_detail:
+                e.detail['email'] = email_detail
+            if e.detail:
+                raise e
+        else:
+            ip = self.request.META.get('REMOTE_ADDR')
+            user = serializer.save(is_active=(not activation_required), registration_remote_ip=ip)
+
+            domain = serializer.validated_data.get('domain')
+            if domain or activation_required:
+                action = AuthenticatedActivateUserAction(user=user, domain=domain)
+                verification_code = serializers.AuthenticatedActivateUserActionSerializer(action).data['code']
+                user.send_email('activate-with-domain' if domain else 'activate', context={
+                    'confirmation_link': reverse('confirm-activate-account', request=request, args=[verification_code])
+                })
+
+        # This request is unauthenticated, so don't expose whether we did anything.
+        message = 'Welcome! Please check your mailbox.' if activation_required else 'Welcome!'
+        return Response(data={'detail': message}, status=status.HTTP_202_ACCEPTED)
+
+
+class AccountView(generics.RetrieveAPIView):
+    permission_classes = (IsAuthenticated,)
+    serializer_class = serializers.UserSerializer
+
+    def get_object(self):
+        return self.request.user
+
+
+class AccountDeleteView(GenericAPIView):
+    authentication_classes = (auth.EmailPasswordPayloadAuthentication,)
+    permission_classes = (IsAuthenticated,)
+
+    def post(self, request, *args, **kwargs):
+        action = AuthenticatedDeleteUserAction(user=self.request.user)
+        verification_code = serializers.AuthenticatedDeleteUserActionSerializer(action).data['code']
+        request.user.send_email('delete-user', context={
+            'confirmation_link': reverse('confirm-delete-account', request=request, args=[verification_code])
+        })
+
+        return Response(data={'detail': 'Please check your mailbox for further account deletion instructions.'},
+                        status=status.HTTP_202_ACCEPTED)
+
+
+class AccountLoginView(GenericAPIView):
+    authentication_classes = (auth.EmailPasswordPayloadAuthentication,)
+    permission_classes = (IsAuthenticated,)
+
+    def post(self, request, *args, **kwargs):
+        user = self.request.user
+
+        token = Token.objects.create(user=user, name="login")
+        user_logged_in.send(sender=user.__class__, request=self.request, user=user)
+
+        data = serializers.TokenSerializer(token).data
+        return Response(data)
+
+
+class AccountChangeEmailView(GenericAPIView):
+    authentication_classes = (auth.EmailPasswordPayloadAuthentication,)
+    permission_classes = (IsAuthenticated,)
+    serializer_class = serializers.ChangeEmailSerializer
+
+    def post(self, request, *args, **kwargs):
+        # Check password and extract email
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        new_email = serializer.validated_data['new_email']
+
+        action = AuthenticatedChangeEmailUserAction(user=request.user, new_email=new_email)
+        verification_code = serializers.AuthenticatedChangeEmailUserActionSerializer(action).data['code']
+        request.user.send_email('change-email', recipient=new_email, context={
+            'confirmation_link': reverse('confirm-change-email', request=request, args=[verification_code]),
+            'old_email': request.user.email,
+            'new_email': new_email,
+        })
+
+        # At this point, we know that we are talking to the user, so we can tell that we sent an email.
+        return Response(data={'detail': 'Please check your mailbox to confirm email address change.'},
+                        status=status.HTTP_202_ACCEPTED)
+
+
+class AccountResetPasswordView(GenericAPIView):
+    serializer_class = serializers.EmailSerializer
+
+    def post(self, request, *args, **kwargs):
+        serializer = self.get_serializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+        try:
+            email = serializer.validated_data['email']
+            user = User.objects.get(email=email, is_active=True)
+        except User.DoesNotExist:
+            pass
+        else:
+            action = AuthenticatedResetPasswordUserAction(user=user)
+            verification_code = serializers.AuthenticatedResetPasswordUserActionSerializer(action).data['code']
+            user.send_email('reset-password', context={
+                'confirmation_link': reverse('confirm-reset-password', request=request, args=[verification_code])
+            })
+
+        # This request is unauthenticated, so don't expose whether we did anything.
+        return Response(data={'detail': 'Please check your mailbox for further password reset instructions. '
+                                        'If you did not receive an email, please contact support.'},
+                        status=status.HTTP_202_ACCEPTED)
+
+
+class AuthenticatedActionView(GenericAPIView):
     """
     """
-    Extends the djoser UserCreateView to record the remote IP address of any registration.
+    Abstract class. Deserializes the given payload according the serializers specified by the view extending
+    this class. If the `serializer.is_valid`, `act` is called on the action object.
     """
     """
 
 
-    def perform_create(self, serializer):
-        remote_ip = self.request.META.get('REMOTE_ADDR')
-        lock = (
-                ipaddress.ip_address(remote_ip) not in ipaddress.IPv6Network(os.environ['DESECSTACK_IPV6_SUBNET'])
-                and (
-                    User.objects.filter(
-                        created__gte=timezone.now()-timedelta(hours=settings.ABUSE_BY_REMOTE_IP_PERIOD_HRS),
-                        registration_remote_ip=remote_ip
-                    ).count() >= settings.ABUSE_BY_REMOTE_IP_LIMIT
-                    or
-                    User.objects.filter(
-                        created__gte=timezone.now() - timedelta(hours=settings.ABUSE_BY_EMAIL_HOSTNAME_PERIOD_HRS),
-                        email__endswith='@{0}'.format(serializer.validated_data['email'].split('@')[-1])
-                    ).count() >= settings.ABUSE_BY_EMAIL_HOSTNAME_LIMIT
-                )
+    class AuthenticatedActionAuthenticator(BaseAuthentication):
+        """
+        Authenticates a request based on whether the serializer determines the validity of the given verification code
+        and additional data (using `serializer.is_valid()`). The serializer's input data will be determined by (a) the
+        view's 'code' kwarg and (b) the request payload for POST requests. Request methods other than GET and POST will
+        fail authentication regardless of other conditions.
+
+        If the request is valid, the AuthenticatedAction instance will be attached to the view as `authenticated_action`
+        attribute.
+
+        Note that this class will raise ValidationError instead of AuthenticationFailed, usually resulting in status
+        400 instead of 403.
+        """
+
+        def __init__(self, view):
+            super().__init__()
+            self.view = view
+
+        def authenticate(self, request):
+            data = {**request.data, 'code': self.view.kwargs['code']}  # order crucial to avoid override from payload!
+            serializer = self.view.serializer_class(data=data, context=self.view.get_serializer_context())
+            serializer.is_valid(raise_exception=True)
+            self.view.authenticated_action = serializer.instance
+
+            return self.view.authenticated_action.user, None
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.authenticated_action = None
+
+    def get_authenticators(self):
+        return [self.AuthenticatedActionAuthenticator(self)]
+
+    def get(self, request, *args, **kwargs):
+        return self.take_action()
+
+    def post(self, request, *args, **kwargs):
+        return self.take_action()
+
+    def finalize(self):
+        raise NotImplementedError
+
+    def take_action(self):
+        # execute the action
+        self.authenticated_action.act()
+
+        return self.finalize()
+
+
+class AuthenticatedActivateUserActionView(AuthenticatedActionView):
+    http_method_names = ['get']
+    serializer_class = serializers.AuthenticatedActivateUserActionSerializer
+
+    def finalize(self):
+        action = self.authenticated_action
+
+        if not action.domain:
+            return Response({
+                'detail': 'Success! Please log in at {}.'.format(self.request.build_absolute_uri(reverse('v1:login')))
+            })
+
+        serializer = serializers.DomainSerializer(
+            data={'name': action.domain},
+            context=self.get_serializer_context()
+        )
+        try:
+            serializer.is_valid(raise_exception=True)
+        except ValidationError as e:  # e.g. domain name unavailable
+            action.user.delete()
+            reasons = ', '.join([detail.code for detail in e.detail.get('name', [])])
+            raise ValidationError(
+                f'The requested domain {action.domain} could not be registered (reason: {reasons}). '
+                f'Please start over and sign up again.'
             )
             )
+        domain = PDNSChangeTracker.track(lambda: serializer.save(owner=action.user))
+
+        if domain.parent_domain_name() in settings.LOCAL_PUBLIC_SUFFIXES:
+            PDNSChangeTracker.track(lambda: DomainList.auto_delegate(domain))
+            token = Token.objects.create(user=action.user, name='dyndns')
+            return Response({
+                'detail': 'Success! Here is the password ("auth_token") to configure your router (or any other dynDNS '
+                          'client). This password is different from your account password for security reasons.',
+                **serializers.TokenSerializer(token).data,
+            })
+        else:
+            return Response({
+                'detail': 'Success! Please check the docs for the next steps, https://desec.readthedocs.io/.'
+            })
+
 
 
-        user = serializer.save(registration_remote_ip=remote_ip, lock=lock)
-        if user.locked:
-            send_account_lock_email(self.request, user)
-        if not user.dyn:
-            context = {'token': user.get_or_create_first_token()}
-            send_token_email(context, user)
-        signals.user_registered.send(sender=self.__class__, user=user, request=self.request)
+class AuthenticatedChangeEmailUserActionView(AuthenticatedActionView):
+    http_method_names = ['get']
+    serializer_class = serializers.AuthenticatedChangeEmailUserActionSerializer
 
 
+    def finalize(self):
+        return Response({
+            'detail': f'Success! Your email address has been changed to {self.authenticated_action.user.email}.'
+        })
 
 
-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():
-            User.objects.filter(email=email).update(locked=None)
 
 
-            return HttpResponseRedirect(reverse('v1:unlock/done', request=request))  # TODO remove dependency on v1
+class AuthenticatedResetPasswordUserActionView(AuthenticatedActionView):
+    http_method_names = ['post']
+    serializer_class = serializers.AuthenticatedResetPasswordUserActionSerializer
 
 
-    # if a GET (or any other method) we'll create a blank form
-    else:
-        form = UnlockForm()
+    def finalize(self):
+        return Response({'detail': 'Success! Your password has been changed.'})
 
 
-    return render(request, 'unlock.html', {'form': form})
 
 
+class AuthenticatedDeleteUserActionView(AuthenticatedActionView):
+    http_method_names = ['get']
+    serializer_class = serializers.AuthenticatedDeleteUserActionSerializer
 
 
-def unlock_done(request):
-    return render(request, 'unlock-done.html')
+    def finalize(self):
+        return Response({'detail': 'All your data has been deleted. Bye bye, see you soon! <3'})

+ 0 - 3
api/requirements.txt

@@ -1,10 +1,7 @@
 coverage~=4.5.3
 coverage~=4.5.3
 Django~=2.2.0
 Django~=2.2.0
 django-cors-headers~=3.0.2
 django-cors-headers~=3.0.2
-django-nocaptcha-recaptcha==0.0.20  # updated manually
 djangorestframework~=3.9.3
 djangorestframework~=3.9.3
-djangorestframework-bulk~=0.2.0
-djoser~=1.5.1
 httpretty~=0.9.0
 httpretty~=0.9.0
 mysqlclient~=1.4.0
 mysqlclient~=1.4.0
 psl-dns~=1.0rc2
 psl-dns~=1.0rc2

+ 0 - 2
docker-compose.yml

@@ -122,8 +122,6 @@ services:
     - DESECSTACK_NSLORD_DEFAULT_TTL
     - DESECSTACK_NSLORD_DEFAULT_TTL
     - DESECSTACK_NSMASTER_APIKEY
     - DESECSTACK_NSMASTER_APIKEY
     - DESECSTACK_MINIMUM_TTL_DEFAULT
     - DESECSTACK_MINIMUM_TTL_DEFAULT
-    - DESECSTACK_NORECAPTCHA_SITE_KEY
-    - DESECSTACK_NORECAPTCHA_SECRET_KEY
     networks:
     networks:
     - rearapi1
     - rearapi1
     - rearapi2
     - rearapi2

+ 260 - 120
docs/authentication.rst

@@ -1,70 +1,79 @@
 User Registration and Management
 User Registration and Management
 --------------------------------
 --------------------------------
 
 
-Getting Started
-~~~~~~~~~~~~~~~
+Manage Account
+~~~~~~~~~~~~~~
 
 
-Access to the domain management API is granted to registered and logged in users only. User accounts
-can register free of charge through the API, providing an email address and a
-password. To register an user account, issue a ``POST`` request like this::
+Access to the domain management API is granted to registered and logged in
+users only. Users can register an account free of charge through the API as
+described below.
 
 
-    curl -X POST https://desec.io/api/v1/auth/users/ \
-        --header "Content-Type: application/json" --data @- <<< \
-        '{"email": "anemailaddress@example.com", "password": "yourpassword"}'
+Obtain a Captcha
+```````````````````
 
 
-Your email address is required for account recovery, in case you forgot your
-password, for contacting support, etc. It is deSEC's policy to require users
-to provide a valid email address so that support requests can be verified.
-**If you provide an invalid email address, we will not be able to help you
-if you need support.**
+Before registering a user account, you need to solve a captcha. You will have
+to send the captcha ID and solution along with your registration request. To
+obtain a captcha, issue a ``POST`` request as follows::
 
 
-Note that while we do not enforce restrictions on your password, please do not
-choose a weak one.
+    curl -X POST https://desec.io/api/v1/captcha/
 
 
-Once a user account has been registered, you will be able to log in. Log in is
-done by asking the API for a token that can be used to authorize subsequent DNS
-management requests. To obtain such a token, send your email address and password to the
-``/auth/token/login/`` endpoint::
+The response body will be a JSON object with an ``id`` and a ``challenge``
+field. The value of the ``id`` field is the one that you need to fill into the
+corresponding field of the account registration request. The value of the
+``challenge`` field is the base64-encoded PNG representation of the captcha
+itself. You can display it by directing your browser to the URL
+``data:image/png;base64,<challenge>``, after replacing ``<challenge>`` with
+the value of the ``challenge`` response field.
 
 
-    curl -X POST https://desec.io/api/v1/auth/token/login/ \
-        --header "Content-Type: application/json" --data @- <<< \
-        '{"email": "anemailaddress@example.com", "password": "yourpassword"}'
+Captchas expire after 24 hours. IDs are also invalidated after using them in
+a registration request. This means that if you send an incorrect solution,
+you will have to obtain a fresh captcha and try again.
 
 
-The API will reply with a token like::
 
 
-    {
-        "auth_token": "i+T3b1h/OI+H9ab8tRS98stGtURe"
-    }
+Register Account
+````````````````
 
 
-Most interactions with the API require authentication of the domain owner using
-this token. To authenticate, the token is transmitted via the HTTP
-``Authorization`` header, as shown in the examples in this document.
+You can register an account by sending a ``POST`` request containing your
+email address, a password, and a captcha ID and solution (see `Obtain a
+Captcha`_), like this::
 
 
-Additionally, the API provides you with the ``/auth/tokens/`` endpoint which you can
-use to create and destroy additional tokens (see below). Such token can be used
-to authenticate devices independently of your current login session, such as
-routers. They can be revoked individually.
+    curl -X POST https://desec.io/api/v1/auth/ \
+        --header "Content-Type: application/json" --data @- <<EOF
+        {
+          "email": "youremailaddress@example.com",
+          "password": "yourpassword",
+          "captcha": {
+            "id": "00010203-0405-0607-0809-0a0b0c0d0e0f",
+            "solution": "12H45"
+          }
+        }
+    EOF
 
 
+Please consider the following when registering an account:
 
 
-Registration
-~~~~~~~~~~~~
+- Surrounding whitespace is stripped automatically from passwords.
 
 
-The API provides an endpoint to register new user accounts. New accounts
-require an email address and a password.
+- We do not enforce restrictions on your password. However, to maintain a high
+  level of security, make sure to choose a strong password. It is best to
+  generate a long random string consisting of at least 16 alphanumeric
+  characters, and use a password manager instead of attempting to remember it.
 
 
-Your email address is required for account recovery, in case you forgot your
-password, for contacting support, etc. It is deSEC's policy to require users
-to provide a valid email address so that support requests can be verified.
-**If you provide an invalid email address, we will not be able to help you
-if you need support.**
+- Your email address is required for account recovery in case you forgot your
+  password, for contacting support, etc. We also send out announcements for
+  technical changes occasionally. It is thus deSEC's policy to require users
+  provide a valid email address.
 
 
-Note that while we do not enforce restrictions on your password, please do not
-choose a weak one.
+When attempting to register a user account, the server will reply with ``202
+Accepted``. In case there already is an account for that email address,
+nothing else will be done. Otherwise, you will receive an email with a
+verification link of the form
+``https://desec.io/api/v1/v/activate-account/<code>/``. To activate your
+account, send a ``GET`` request using this link (i.e., you can simply click
+it). The link expires after 12 hours.
 
 
-Upon successful registration, the server will reply with ``201 Created`` and
-send you a welcome email. If there is a problem with your email or password,
-the server will reply with ``400 Bad Request`` and give a human-readable
-error message that may look like::
+If there is a problem with your email address, your password, or the proposed
+captcha solution, the server will reply with ``400 Bad Request`` and give a
+human-readable error message that may look like::
 
 
     HTTP/1.1 400 Bad Request
     HTTP/1.1 400 Bad Request
 
 
@@ -74,46 +83,61 @@ error message that may look like::
         ]
         ]
     }
     }
 
 
-Your password information will be stored on our servers using `Django's default
-method, PBKDF2 <https://docs.djangoproject.com/en/2.1/topics/auth/passwords/>`_.
 
 
+Zone Creation during Account Registration
+*****************************************
 
 
-Preventing Abuse
-````````````````
+**Note:** The following functionality is intended for internal deSEC use only.
+Availability of this functionality may change without notice.
 
 
-We enforce some limits on user creation requests to make abuse harder. In cases
-where our heuristic suspects abuse, the server will still reply with
-``201 Created`` but will send you an (additional) email asking to solve a
-Google ReCaptcha. We implemented this as privacy-friendly as possible, but
-recommend solving the captcha using some additional privacy measures such as an
-anonymous browser-tab, VPN, etc. Before solving the captcha, the account will
-be locked, that is, it will be possible to log in; however, most operations on
-the API will be limited to read-only.
+Along with your account creation request, you can provide a domain name as
+follows::
+
+    curl -X POST https://desec.io/api/v1/auth/ \
+        --header "Content-Type: application/json" --data @- <<EOF
+        {
+          "email": "youremailaddress@example.com",
+          "password": "yourpassword",
+          "captcha": {
+            "id": "00010203-0405-0607-0809-0a0b0c0d0e0f",
+            "solution": "12H45"
+          },
+          "domain": "example.org"
+        }
+    EOF
+
+If the ``domain`` field is present in the request payload, a DNS zone will be
+created for this domain name once you activate your account using the
+verification link that we will send to your email address. If the zone cannot
+be created (for example, because the domain name is unavailable), your account
+will be deleted, and you can start over with a fresh registration.
 
 
 
 
 Log In
 Log In
-~~~~~~
+``````
 
 
 All interactions with the API that require authentication must be authenticated
 All interactions with the API that require authentication must be authenticated
-using a token that identifies the user and authorizes the request. The process
-of obtaining such a token is what we call log in.
+using a token that identifies the user and authorizes the request. Logging in
+is the process of obtaining such a token.
 
 
-To obtain an authentication token, log in by sending your email address and
-password to the token create endpoint of the API::
+In order to log in, you need to confirm your email address first. Afterwards,
+you can ask the API for a token that can be used to authorize subsequent DNS
+management requests. To obtain such a token, send a ``POST`` request with your
+email address and password to the ``/auth/login/`` endpoint::
 
 
-    curl -X POST https://desec.io/api/v1/auth/token/login/ \
+    curl -X POST https://desec.io/api/v1/auth/login/ \
         --header "Content-Type: application/json" --data @- <<< \
         --header "Content-Type: application/json" --data @- <<< \
-        '{"email": "anemailaddress@example.com", "password": "yourpassword"}'
+        '{"email": "youremailaddress@example.com", "password": "yourpassword"}'
 
 
 If email address and password match our records, the server will reply with
 If email address and password match our records, the server will reply with
 ``201 Created`` and send you the token as part of the response body::
 ``201 Created`` and send you the token as part of the response body::
 
 
-    {
-        "auth_token": "i+T3b1h/OI+H9ab8tRS98stGtURe"
-    }
+    {"auth_token": "i+T3b1h/OI+H9ab8tRS98stGtURe"}
+
+In case of credential mismatch, the server replies with ``401 Unauthorized``.
 
 
-Note that every time you POST to this endpoint, a *new* Token will be created,
-while old tokens *remain valid*.
+**Note:** Every time you send a ``POST`` request to this endpoint, an
+additional token will be created. Existing tokens will *remain valid*.
 
 
 To authorize subsequent requests with the new token, set the HTTP ``Authorization``
 To authorize subsequent requests with the new token, set the HTTP ``Authorization``
 header to the token value, prefixed with ``Token``::
 header to the token value, prefixed with ``Token``::
@@ -122,88 +146,204 @@ header to the token value, prefixed with ``Token``::
         --header "Authorization: Token i+T3b1h/OI+H9ab8tRS98stGtURe"
         --header "Authorization: Token i+T3b1h/OI+H9ab8tRS98stGtURe"
 
 
 
 
-Log Out
-~~~~~~~
+Retrieve Account Information
+````````````````````````````
 
 
-To invalidate an authentication token (log out), send a ``POST`` request to
-the token destroy endpoint, using the token in question in the ``Authorization``
-header::
+To request information about your account, send a ``GET`` request to the
+``/auth/account/`` endpoint::
 
 
-    curl -X POST https://desec.io/api/v1/auth/token/logout/ \
+    curl -X GET https://desec.io/api/v1/auth/account/ \
         --header "Authorization: Token i+T3b1h/OI+H9ab8tRS98stGtURe"
         --header "Authorization: Token i+T3b1h/OI+H9ab8tRS98stGtURe"
 
 
-The server will delete the token and respond with ``204 No Content``.
-
-
-Manage Account
-~~~~~~~~~~~~~~
-
-Field Reference
-```````````````
-
-A JSON object representing a user has the following structure::
+A JSON object representing your user account will be returned::
 
 
     {
     {
-        "dyn": false,
-        "email": "address@example.com",
-        "limit_domains": 5,
-        "locked": false
+        "created": "2019-10-16T18:09:17.715702Z",
+        "email": "youremailaddress@example.com",
+        "id": 127,
+        "limit_domains": 5
     }
     }
 
 
 Field details:
 Field details:
 
 
-``dyn``
-    :Access mode: read-only (deprecated)
+``created``
+    :Access mode: read-only
 
 
-    Indicates whether the account is restricted to dynDNS domains under
-    dedyn.io.
+    Registration timestamp.
 
 
 ``email``
 ``email``
-    :Access mode: read, write
+    :Access mode: read-only
+
+    Email address associated with the account.
+
+``id``
+    :Access mode: read-only
 
 
-    Email address associated with the account.  This address must be valid
-    in order to submit support requests to deSEC.
+    User ID.
 
 
 ``limit_domains``
 ``limit_domains``
     :Access mode: read-only
     :Access mode: read-only
 
 
     Maximum number of DNS zones the user can create.
     Maximum number of DNS zones the user can create.
 
 
-``locked``
-    :Access mode: read-only
 
 
-    Indicates whether the account is locked.  If so, domains put in
-    read-only mode.  Changes are not propagated in the DNS system.
+Password Reset
+``````````````
 
 
+In case you forget your password, you can reset it. To do so, send a
+``POST`` request with your email address to the
+``/auth/account/reset-password/`` endpoint::
 
 
-Retrieve Account Information
-````````````````````````````
+    curl -X POST https://desec.io/api/v1/auth/account/reset-password/ \
+        --header "Content-Type: application/json" --data @- <<< \
+        '{"email": "youremailaddress@example.com"}'
 
 
-To request information about your account, send a ``GET`` request to the
-``auth/me/`` endpoint::
+The server will reply with ``202 Accepted``. If there is no account associated
+with this email address, nothing else will be done. Otherwise, you will receive
+an email with a URL of the form
+``https://desec.io/api/v1/v/reset-password/<code>/``. To perform the actual
+password reset, send a ``POST`` request to this URL, with the new password in
+the payload::
 
 
-    curl -X GET https://desec.io/api/v1/auth/me/ \
-        --header "Authorization: Token i+T3b1h/OI+H9ab8tRS98stGtURe"
+    curl -X POST https://desec.io/api/v1/v/reset-password/<code>/ \
+        --header "Content-Type: application/json" --data @- <<< \
+        '{"new_password": "yournewpassword"}'
+
+This URL expires after 12 hours. It is also invalidated by certain other
+account-related activities, such as changing your email address.
+
+Once the password was reset successfully, we will send you an email informing
+you of the event.
+
+Password Change
+```````````````
+
+To change your password, please follow the instructions for `Password Reset`_.
 
 
 
 
 Change Email Address
 Change Email Address
 ````````````````````
 ````````````````````
 
 
-You can change your account email address by sending a ``PUT`` request to the
-``auth/me/`` endpoint::
+To change the email address associated with your account, send a ``POST``
+request with your email address, your password, and your new email address to
+the ``/auth/account/change-email/`` endpoint::
+
+    curl -X POST https://desec.io/api/v1/auth/account/change-email/ \
+        --header "Content-Type: application/json" --data @- <<EOF
+        {
+          "email": "youremailaddress@example.com",
+          "password": "yourpassword",
+          "new_email": "anotheremailaddress@example.net"
+        }
+    EOF
+
+If the correct password has been provided, the server will reply with ``202
+Accepted``. In case there already is an account for the email address given in
+the ``new_email`` field, nothing else will be done. Otherwise, we will send
+an email to the new email address for verification purposes. It will contain a
+link of the form ``https://desec.io/api/v1/v/change-email/<code>/``. To perform
+the actual change, send a ``GET`` request using this link (i.e., you can simply
+click the link).
+
+The link expires after 12 hours. It is also invalidated by certain other
+account-related activities, such as changing your password.
 
 
-    curl -X PUT https://desec.io/api/v1/auth/me/ \
-        --header "Authorization: Token i+T3b1h/OI+H9ab8tRS98stGtURe" \
+Once the email address was changed successfully, we will send a message to the
+old email address for informational purposes.
+
+
+Delete Account
+``````````````
+
+To delete your account, send a ``POST`` request with your email address and
+password to the ``/auth/account/delete/`` endpoint::
+
+    curl -X POST https://desec.io/api/v1/auth/account/delete/ \
         --header "Content-Type: application/json" --data @- <<< \
         --header "Content-Type: application/json" --data @- <<< \
-        '{"email": "new-email@example.com"}'
+        '{"email": "youremailaddress@example.com", "password": "yourpassword"}'
 
 
-Please note that our email support only acts upon requests that originate from
-the email address associated with the deSEC user in question.  It is therefore
-required that you provide a valid email address.  However, we do not
-automatically verify the validity of the address provided.
+You will receive an email with a link of the form
+``https://desec.io/api/v1/v/delete-account/<code>/``. To actually delete your
+account, send a ``GET`` request using this link (i.e., you can simply click
+the link).
 
 
-**If you provide an invalid email address and forget your account password and
-tokens, we will not be able to help you, and access will be lost permanently.**
+The link expires after 12 hours. It is also invalidated by certain other
+account-related activities, such as changing your email address or password.
+
+
+Log Out
+```````
+
+To invalidate an authentication token (log out), please see `Delete Tokens`_.
+
+
+Security Considerations
+```````````````````````
+
+Confirmation Codes
+    Some account-related activities require the user to explicitly reaffirm her
+    intent. For this purpose, we send a link with a confirmation code to the
+    user's email address. Although clients generally should consider these
+    codes opaque, we would like to give some insights into how they work.
+
+    The code is a base64-encoded JSON representation of the user's intent.
+    The representation carries a timestamp of when the intent was expressed,
+    the user ID, and also any extra parameters that were submitted along with
+    the intent. An example of such a parameter is the new email address in the
+    context of a `change email address`_ operation. Parameters that are
+    unknown at the time when the code is generated are not included in the
+    code and must be provided via ``POST`` request payload when using the
+    code. A typical example of this is the new password in a `password reset`_
+    operation, as it is only provided when the code is being used (and not at
+    the time when the code is requested).
+
+    To ensure integrity, we also include a message authentication code (MAC)
+    using `Django's signature implementation
+    <https://docs.djangoproject.com/en/2.2/_modules/django/core/signing/#Signer>`_.
+    When a confirmation code is used, we recompute the MAC based on the data
+    incorporated in the code, and only perform the requested action if the MAC
+    is reproduced identically. Codes are also checked for freshness using the
+    timestamp, and rejected if older than allowed.
+
+    In order to prevent race conditions, we add additional data to the MAC
+    input such that codes are only valid as long as the user state is not
+    modified (e.g. by performing another sensitive account operation). This is
+    achieved by mixing a) the account operation type (e.g. password reset), b)
+    the account's activation status, c) the account's current email address,
+    and d) the user's password hash into the MAC input. If any of these
+    parameters happens to change before a code is applied, the MAC will be
+    rendered invalid, and the operation will fail. This measure blocks
+    scenarios such as using an old email address change code after a more
+    recent password change.
+
+    This approach allows us to securely authenticate sensitive user operations
+    without keeping a list of requested operations on the server. This is both
+    an operational and a privacy advantage. For example, if the user expresses
+    her intent to change the account email address, we do not store that new
+    address on the server until the confirmation code is used (from which the
+    new address is then extracted).
+
+Email verification
+    Operations that require verification of a new email address (such as when
+    registering first), the server response does not depend on whether another
+    user is already using that address. This is to prevent clients from
+    telling whether a certain email address is registered with deSEC or not.
+
+    Verification emails will only be sent out if the email address is not yet
+    associated with an account. Otherwise, nothing will happen.
+
+    Also, accounts are created on the server side when the registration
+    request is received (and kept in inactive state). That is, state exists
+    on the server even before the email address is confirmed. Confirmation
+    merely activates the existing account. The purpose of this is to avoid
+    running the risk of sending out large numbers of emails to the same
+    address when a client decides to send multiple registration requests for
+    the same address. In this case, no emails will be sent after the first
+    one.
+
+Password Security
+    Password information is stored using `Django's default method, PBKDF2
+    <https://docs.djangoproject.com/en/2.1/topics/auth/passwords/>`_.
 
 
 
 
 Manage Tokens
 Manage Tokens

+ 18 - 6
docs/endpoint-reference.rst

@@ -7,15 +7,17 @@ for `User Registration and Management`_.
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
 | Endpoint ``/api/v1``...                        | Methods    | Use case                                    |
 | Endpoint ``/api/v1``...                        | Methods    | Use case                                    |
 +================================================+============+=============================================+
 +================================================+============+=============================================+
-| ...\ ``/auth/me/``                             | ``GET``    | Retrieve user account information           |
-|                                                +------------+---------------------------------------------+
-|                                                | ``PUT``    | Change account email address                |
+| ...\ ``/auth/``                                | ``POST``   | Register user account                       |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/account/``                        | ``GET``    | Retrieve user account information           |
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/users/``                          | ``POST``   | Create user account                         |
+| ...\ ``/auth/account/change-email/``           | ``POST``   | Request account email address change        |
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/token/login/``                    | ``POST``   | Log in and request authentication token     |
+| ...\ ``/auth/account/reset-password/``         | ``POST``   | Request password reset                      |
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
-| ...\ ``/auth/token/logout/``                   | ``POST``   | Log out and destroy authentication token    |
+| ...\ ``/auth/account/delete/``                 | ``POST``   | Request account deletion                    |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/auth/login/``                          | ``POST``   | Log in and request authentication token     |
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
 | ...\ ``/auth/tokens/``                         | ``GET``    | Retrieve all current tokens                 |
 | ...\ ``/auth/tokens/``                         | ``GET``    | Retrieve all current tokens                 |
 |                                                +------------+---------------------------------------------+
 |                                                +------------+---------------------------------------------+
@@ -23,6 +25,16 @@ for `User Registration and Management`_.
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
 | ...\ ``/auth/tokens/:id/``                     | ``DELETE`` | Delete token                                |
 | ...\ ``/auth/tokens/:id/``                     | ``DELETE`` | Delete token                                |
 +------------------------------------------------+------------+---------------------------------------------+
 +------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/captcha/``                             | ``POST``   | Obtain captcha                              |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/activate-account/:code/``            | ``GET``    | Confirm email address for new account       |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/reset-password/:code/``              | ``POST``   | Confirm password reset                      |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/change-email/:code/``                | ``GET``    | Confirm email address change                |
++------------------------------------------------+------------+---------------------------------------------+
+| ...\ ``/v/delete-account/:code/``              | ``GET``    | Confirm account deletion                    |
++------------------------------------------------+------------+---------------------------------------------+
 
 
 The following table summarizes basic information about the deSEC API endpoints used
 The following table summarizes basic information about the deSEC API endpoints used
 for `Domain Management`_ and `Retrieving and Manipulating DNS Information`_.
 for `Domain Management`_ and `Retrieving and Manipulating DNS Information`_.

+ 1 - 0
docs/index.rst

@@ -1,4 +1,5 @@
 .. include:: introduction.rst
 .. include:: introduction.rst
+.. include:: quickstart.rst
 .. include:: authentication.rst
 .. include:: authentication.rst
 .. include:: domains.rst
 .. include:: domains.rst
 .. include:: rrsets.rst
 .. include:: rrsets.rst

+ 55 - 0
docs/quickstart.rst

@@ -0,0 +1,55 @@
+Quickstart
+----------
+
+To use our domain management API, you need to register an account with deSEC.
+Here's a quick intro how to get started:
+
+#. `Obtain a captcha`_ and solve it::
+
+    curl -X POST https://desec.io/api/v1/captcha/
+
+   Note down the captcha ID from the response body, and figure out the
+   solution from the ``challenge`` field. It's a base64-encoded PNG image
+   which you can display by directing your browser to the URL
+   ``data:image/png;base64,<challenge>``, after replacing ``<challenge>`` with
+   the value of the ``challenge`` response field
+
+#. `Register account`_::
+
+    curl -X POST https://desec.io/api/v1/auth/ \
+        --header "Content-Type: application/json" --data @- <<EOF
+        {
+          "email": "youremailaddress@example.com",
+          "password": "yourpassword",
+          "captcha": {
+            "id": "00010203-0405-0607-0809-0a0b0c0d0e0f",
+            "solution": "12H45"
+          }
+        }
+    EOF
+
+   Before activating your account, we need to verify your email address. To
+   that end, we will send you an email, containing a validation link of the
+   form ``https://desec.io/api/v1/v/activate-account/<code>/``. To confirm
+   your address and activate your account, simply click the link.
+
+
+#. `Log in`_::
+
+    curl -X POST https://desec.io/api/v1/auth/login/ \
+        --header "Content-Type: application/json" --data @- <<< \
+        '{"email": "youremailaddress@example.com", "password": "yourpassword"}'
+
+   The response body will contain an ``auth_token`` which is used to
+   authenticate requests to the DNS management endpoints as demonstrated in
+   the next step.
+
+#. Create a DNS zone::
+
+    curl -X POST https://desec.io/api/v1/domains/ \
+        --header "Authorization: Token {token}" \
+        --header "Content-Type: application/json" --data @- <<< \
+        '{"name": "example.com"}'
+
+#. Yay! Keep browsing the `Domain Management`_ section of the docs to see how
+   to continue.

+ 8 - 4
test/e2e/schemas.js

@@ -9,14 +9,18 @@ exports.rootNoLogin = {
 
 
 exports.user = {
 exports.user = {
     properties: {
     properties: {
-        dyn: { type: "boolean" },
+        created: {
+            type: "string",
+            format: "date-time"
+        },
         email: {
         email: {
             type: "string",
             type: "string",
             format: "email"
             format: "email"
         },
         },
+        id: { type: "integer" },
         limit_domains: { type: "integer" },
         limit_domains: { type: "integer" },
     },
     },
-    required: ["dyn", "email", "limit_domains"]
+    required: ["created", "email", "id", "limit_domains"]
 };
 };
 
 
 exports.domain = {
 exports.domain = {
@@ -79,12 +83,12 @@ exports.rrsets = {
 
 
 exports.token = {
 exports.token = {
     properties: {
     properties: {
-        value: { type: "string" },
+        auth_token: { type: "string" },
         name: { type: "string" },
         name: { type: "string" },
         created: { type: "string" },
         created: { type: "string" },
         id: { type: "integer" },
         id: { type: "integer" },
     },
     },
-    required: ["value", "name", "created", "id"]
+    required: ["auth_token", "name", "created", "id"]
 };
 };
 
 
 exports.tokens = {
 exports.tokens = {

+ 25 - 106
test/e2e/spec/api_spec.js

@@ -23,7 +23,7 @@ describe("API Versioning", function () {
         it("maintains the requested version " + version, function() {
         it("maintains the requested version " + version, function() {
             chakram.get('/' + version + '/').then(function (response) {
             chakram.get('/' + version + '/').then(function (response) {
                 expect(response).to.have.schema(schemas.rootNoLogin);
                 expect(response).to.have.schema(schemas.rootNoLogin);
-                let regex = new RegExp('http://[^/]+/api/' + version + '/auth/users/', 'g')
+                let regex = new RegExp('http://[^/]+/api/' + version + '/auth/', 'g')
                 expect(response.body.login).to.match(regex);
                 expect(response.body.login).to.match(regex);
                 return chakram.wait();
                 return chakram.wait();
             });
             });
@@ -47,8 +47,8 @@ describe("API v1", function () {
         })
         })
 
 
         let credentials = {"email":"admin@e2etest.local", "password": "password"};
         let credentials = {"email":"admin@e2etest.local", "password": "password"};
-        return chakram.post('/auth/users/', credentials).then(function() {
-            chakram.post('/auth/token/login/', credentials).then(function (response) {
+        return chakram.post('/auth/', credentials).then(function() {
+            chakram.post('/auth/login/', credentials).then(function (response) {
                 let config = {headers: {'Authorization': 'Token ' + response.body.auth_token}}
                 let config = {headers: {'Authorization': 'Token ' + response.body.auth_token}}
                 chakram.post('/domains/', {name: publicSuffix}, config)
                 chakram.post('/domains/', {name: publicSuffix}, config)
                 // TODO verify behavior for non-existent local public suffixes
                 // TODO verify behavior for non-existent local public suffixes
@@ -59,7 +59,7 @@ describe("API v1", function () {
     it("provides an index page", function () {
     it("provides an index page", function () {
         chakram.get('/').then(function (response) {
         chakram.get('/').then(function (response) {
             expect(response).to.have.schema(schemas.rootNoLogin);
             expect(response).to.have.schema(schemas.rootNoLogin);
-            expect(response.body.login).to.match(/http:\/\/[^\/]+\/api\/v1\/auth\/users\//);
+            expect(response.body.login).to.match(/http:\/\/[^\/]+\/api\/v1\/auth\//);
             return chakram.wait();
             return chakram.wait();
         });
         });
     });
     });
@@ -72,15 +72,13 @@ describe("API v1", function () {
             email = require("uuid").v4() + '@e2etest.local';
             email = require("uuid").v4() + '@e2etest.local';
             password = require("uuid").v4();
             password = require("uuid").v4();
 
 
-            var response = chakram.post('/auth/users/', {
+            var response = chakram.post('/auth/', {
                 "email": email,
                 "email": email,
                 "password": password,
                 "password": password,
             });
             });
 
 
-            return expect(response).to.have.status(201);
+            return expect(response).to.have.status(202);
         });
         });
-
-        it("locks new users that look suspicious");
     });
     });
 
 
     describe("user account", function () {
     describe("user account", function () {
@@ -93,16 +91,16 @@ describe("API v1", function () {
             email = require("uuid").v4() + '@e2etest.local';
             email = require("uuid").v4() + '@e2etest.local';
             password = require("uuid").v4();
             password = require("uuid").v4();
 
 
-            var response = chakram.post('/auth/users/', {
+            var response = chakram.post('/auth/', {
                 "email": email,
                 "email": email,
                 "password": password,
                 "password": password,
             });
             });
 
 
-            return expect(response).to.have.status(201);
+            return expect(response).to.have.status(202);
         });
         });
 
 
         it("returns a token when logging in", function () {
         it("returns a token when logging in", function () {
-            return chakram.post('/auth/token/login/', {
+            return chakram.post('/auth/login/', {
                 "email": email,
                 "email": email,
                 "password": password,
                 "password": password,
             }).then(function (loginResponse) {
             }).then(function (loginResponse) {
@@ -110,7 +108,7 @@ describe("API v1", function () {
             });
             });
         });
         });
 
 
-        describe("auth/me/ endpoint", function () {
+        describe("auth/account/ endpoint", function () {
             var email2, password2, token2;
             var email2, password2, token2;
 
 
             before(function () {
             before(function () {
@@ -118,11 +116,11 @@ describe("API v1", function () {
                 email2 = require("uuid").v4() + '@e2etest.local';
                 email2 = require("uuid").v4() + '@e2etest.local';
                 password2 = require("uuid").v4();
                 password2 = require("uuid").v4();
 
 
-                return chakram.post('/auth/users/', {
+                return chakram.post('/auth/', {
                     "email": email2,
                     "email": email2,
                     "password": password2,
                     "password": password2,
                 }).then(function () {
                 }).then(function () {
-                    return chakram.post('/auth/token/login/', {
+                    return chakram.post('/auth/login/', {
                         "email": email2,
                         "email": email2,
                         "password": password2,
                         "password": password2,
                     }).then(function (response) {
                     }).then(function (response) {
@@ -132,7 +130,7 @@ describe("API v1", function () {
             });
             });
 
 
             it("returns JSON of correct schema", function () {
             it("returns JSON of correct schema", function () {
-                var response = chakram.get('/auth/me/', {
+                var response = chakram.get('/auth/account/', {
                     headers: {'Authorization': 'Token ' + token2 }
                     headers: {'Authorization': 'Token ' + token2 }
                 });
                 });
                 expect(response).to.have.status(200);
                 expect(response).to.have.status(200);
@@ -140,95 +138,16 @@ describe("API v1", function () {
                 return chakram.wait();
                 return chakram.wait();
             });
             });
 
 
-            it("allows changing email address", function () {
-                let email3 = require("uuid").v4() + '@e2etest.local';
-
-                return chakram.put('/auth/me/',
-                    {'email': email3},
-                    {headers: {'Authorization': 'Token ' + token2}}
-                ).then(function (response) {
-                    expect(response).to.have.status(200);
-                    expect(response).to.have.schema(schemas.user);
-                    expect(response.body.email).to.equal(email3);
-                });
-            });
-        });
-
-        describe("token management (djoser)", function () {
-
-            var token1, token2;
-
-            function createTwoTokens() {
-                return chakram.waitFor([
-                    chakram.post('/auth/token/login/', {
-                        "email": email,
-                        "password": password,
-                    }).then(function (loginResponse) {
-                        expect(loginResponse).to.have.status(201);
-                        expect(loginResponse.body.auth_token).to.match(schemas.TOKEN_REGEX);
-                        token1 = loginResponse.body.auth_token;
-                        expect(token1).to.not.equal(token2);
-                    }),
-                    chakram.post('/auth/token/login/', {
-                        "email": email,
-                        "password": password,
-                    }).then(function (loginResponse) {
-                        expect(loginResponse).to.have.status(201);
-                        expect(loginResponse.body.auth_token).to.match(schemas.TOKEN_REGEX);
-                        token2 = loginResponse.body.auth_token;
-                        expect(token2).to.not.equal(token1);
-                    })
-                ]);
-            }
-
-            function deleteToken(token) {
-                var response = chakram.post('/auth/token/logout/', null, {
-                    headers: {'Authorization': 'Token ' + token}
-                });
-
-                return expect(response).to.have.status(204);
-            }
-
-            it("can create additional tokens", createTwoTokens);
-
-            describe("additional tokens", function () {
-
-                before(createTwoTokens);
-
-                it("can be used for login (1)", function () {
-                    return expect(chakram.get('/domains/', {
-                        headers: {'Authorization': 'Token ' + token1 }
-                    })).to.have.status(200);
-                });
-
-                it("can be used for login (2)", function () {
-                    return expect(chakram.get('/domains/', {
-                        headers: {'Authorization': 'Token ' + token2 }
-                    })).to.have.status(200);
-                });
-
-                describe("and one deleted", function () {
-
-                    before(function () {
-                        var response = chakram.post('/auth/token/logout/', undefined,
-                            { headers: {'Authorization': 'Token ' + token1 } }
-                        );
-
-                        return expect(response).to.have.status(204);
-                    });
-
-                    it("leaves the other untouched", function () {
-                        return expect(chakram.get('/domains/', {
-                            headers: {'Authorization': 'Token ' + token2 }
-                        })).to.have.status(200);
-                    });
-
+            it("allows triggering change email process", function () {
+                return chakram.post('/auth/account/change-email/', {
+                    "email": email2,
+                    "password": password2,
+                    "new_email": require("uuid").v4() + '@e2etest.local',
+                }).then(function (response) {
+                    expect(response).to.have.status(202);
                 });
                 });
-
             });
             });
-
         });
         });
-
     });
     });
 
 
     var email = require("uuid").v4() + '@e2etest.local';
     var email = require("uuid").v4() + '@e2etest.local';
@@ -237,10 +156,10 @@ describe("API v1", function () {
         var apiHomeSchema = {
         var apiHomeSchema = {
             properties: {
             properties: {
                 domains: {type: "string"},
                 domains: {type: "string"},
-                logout: {type: "string"},
-                user: {type: "string"},
+                tokens: {type: "string"},
+                account: {type: "object"},
             },
             },
-            required: ["domains", "logout", "user"]
+            required: ["domains", "tokens", "account"]
         };
         };
 
 
         var password, token;
         var password, token;
@@ -257,11 +176,11 @@ describe("API v1", function () {
             // register a user that we can login and work with
             // register a user that we can login and work with
             password = require("uuid").v4();
             password = require("uuid").v4();
 
 
-            return chakram.post('/auth/users/', {
+            return chakram.post('/auth/', {
                 "email": email,
                 "email": email,
                 "password": password,
                 "password": password,
             }).then(function () {
             }).then(function () {
-                return chakram.post('/auth/token/login/', {
+                return chakram.post('/auth/login/', {
                     "email": email,
                     "email": email,
                     "password": password,
                     "password": password,
                 }).then(function (loginResponse) {
                 }).then(function (loginResponse) {

+ 2 - 2
test/e2e/spec/dyndns_spec.js

@@ -24,11 +24,11 @@ describe("dyndns service", function () {
             // register a user that we can login and work with
             // register a user that we can login and work with
             password = require("uuid").v4();
             password = require("uuid").v4();
 
 
-            return chakram.post('/auth/users/', {
+            return chakram.post('/auth/', {
                 "email": email,
                 "email": email,
                 "password": password,
                 "password": password,
             }).then(function () {
             }).then(function () {
-                return chakram.post('/auth/token/login/', {
+                return chakram.post('/auth/login/', {
                     "email": email,
                     "email": email,
                     "password": password,
                     "password": password,
                 }).then(function (loginResponse) {
                 }).then(function (loginResponse) {