Browse Source

feat(api): rework user management

Nils Wisiol 5 years ago
parent
commit
7c4dc77ddc
52 changed files with 2173 additions and 971 deletions
  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_DBAPI_PASSWORD_desec=
 DESECSTACK_MINIMUM_TTL_DEFAULT=900
-DESECSTACK_NORECAPTCHA_SITE_KEY=
-DESECSTACK_NORECAPTCHA_SECRET_KEY=
 
 # nslord-related
 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_DBAPI_PASSWORD_desec=insecure
 DESECSTACK_MINIMUM_TTL_DEFAULT=
-DESECSTACK_NORECAPTCHA_SITE_KEY=
-DESECSTACK_NORECAPTCHA_SECRET_KEY=
 
 # nslord-related
 DESECSTACK_DBLORD_PASSWORD_pdns=insecure

+ 0 - 2
.travis.yml

@@ -30,8 +30,6 @@ env:
    - DESECSTACK_WWW_CERTS=./certs
    - DESECSTACK_DBMASTER_CERTS=./dbmastercerts
    - DESECSTACK_MINIMUM_TTL_DEFAULT=3600
-   - DESECSTACK_NORECAPTCHA_SITE_KEY=9Fn33T5yGulkjhdidid
-   - DESECSTACK_NORECAPTCHA_SECRET_KEY=9Fn33T5yGulkjhoiwhetoi
 
 services:
   - 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, ...)
 import os
+from datetime import timedelta
 
 BASE_DIR = os.path.dirname(os.path.dirname(__file__))
 
@@ -39,7 +40,6 @@ INSTALLED_APPS = (
     'django.contrib.auth',
     'django.contrib.contenttypes',
     'rest_framework',
-    'djoser',
     'desecapi',
     'corsheaders',
 )
@@ -95,20 +95,6 @@ REST_FRAMEWORK = {
     '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
 # No need to add Authorization to CORS_ALLOW_HEADERS (included by default)
 CORS_ORIGIN_ALLOW_ALL = True
@@ -138,9 +124,6 @@ EMAIL_USE_TLS = True
 DEFAULT_FROM_EMAIL = 'deSEC <support@desec.io>'
 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 = [name + '.' for name in os.environ['DESECSTACK_NS'].strip().split()]
 DEFAULT_NS_TTL = os.environ['DESECSTACK_NSLORD_DEFAULT_TTL']
@@ -165,27 +148,18 @@ SEPA = {
     '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'])
-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
+USER_ACTIVATION_REQUIRED = True
+VALIDITY_PERIOD_VERIFICATION_SIGNATURE = timedelta(hours=12)
 
 if DEBUG and not EMAIL_HOST:
     EMAIL_BACKEND = 'django.core.mail.backends.dummy.EmailBackend'
 
 if os.environ.get('DESECSTACK_E2E_TEST', "").upper() == "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
+    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
 PASSWORD_HASHERS = [
     'django.contrib.auth.hashers.MD5PasswordHasher',

+ 17 - 2
api/desecapi/authentication.py

@@ -1,8 +1,14 @@
 import base64
+
 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 rest_framework.authentication import TokenAuthentication as RestFrameworkTokenAuthentication
+from desecapi.serializers import EmailPasswordSerializer
 
 
 class TokenAuthentication(RestFrameworkTokenAuthentication):
@@ -94,3 +100,12 @@ class URLParamAuthentication(BaseAuthentication):
             raise exceptions.AuthenticationFailed('badauth')
 
         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.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):

+ 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(
             model_name='domain',
             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(
             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
 
-import datetime
+import json
+import logging
 import random
 import time
 import uuid
 from base64 import b64encode
+from datetime import datetime, timedelta
 from os import urandom
 
 import rest_framework.authtoken.models
 from django.conf import settings
 from django.contrib.auth.models import BaseUserManager, AbstractBaseUser
 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.db import models
 from django.db.models import Manager
+from django.template.loader import get_template
 from django.utils import timezone
+from django.utils.crypto import constant_time_compare
 from rest_framework.exceptions import APIException
 
 from desecapi import pdns
 
+logger = logging.getLogger(__name__)
+
 
 def validate_lower(value):
     if value != value.lower():
@@ -35,7 +43,7 @@ def validate_upper(value):
 
 
 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
         birth and password.
@@ -43,13 +51,9 @@ class MyUserManager(BaseUserManager):
         if not email:
             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.save(using=self._db)
         return user
@@ -74,10 +78,8 @@ class User(AbstractBaseUser):
     is_active = models.BooleanField(default=True)
     is_admin = models.BooleanField(default=False)
     registration_remote_ip = models.CharField(max_length=1024, blank=True)
-    locked = models.DateTimeField(null=True, blank=True)
     created = models.DateTimeField(auto_now_add=True)
     limit_domains = models.IntegerField(default=settings.LIMIT_USER_DOMAIN_COUNT_DEFAULT, null=True, blank=True)
-    dyn = models.BooleanField(default=False)
 
     objects = MyUserManager()
 
@@ -118,6 +120,48 @@ class User(AbstractBaseUser):
         # Simplest possible answer: All admins are staff
         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):
     key = models.CharField("Key", max_length=40, db_index=True, unique=True)
@@ -148,7 +192,7 @@ class Domain(models.Model):
                             unique=True,
                             validators=[validate_lower,
                                         RegexValidator(regex=r'^[a-z0-9_.-]*[a-z]$',
-                                                       message='Domain name malformed.',
+                                                       message='Invalid value (not a DNS name).',
                                                        code='invalid_domain_name')
                                         ])
     owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name='domains')
@@ -159,6 +203,12 @@ class Domain(models.Model):
     def 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):
         name = domain.name if isinstance(domain, Domain) else domain
         subname, _, parent_name = name.partition('.')
@@ -200,7 +250,7 @@ def get_default_value_created():
 
 
 def get_default_value_due():
-    return timezone.now() + datetime.timedelta(days=7)
+    return timezone.now() + timedelta(days=7)
 
 
 def get_default_value_mref():
@@ -313,3 +363,188 @@ class RR(models.Model):
 
     def __str__(self):
         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):
         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
+from base64 import urlsafe_b64decode, urlsafe_b64encode
 
+import psl_dns
 from django.core.validators import MinValueValidator
 from django.db.models import Model, Q
-from djoser import serializers as djoser_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.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):
-    value = serializers.ReadOnlyField(source='key')
+    auth_token = serializers.ReadOnlyField(source='key')
     # note this overrides the original "id" field, which is the db primary key
     id = serializers.ReadOnlyField(source='user_specific_id')
 
     class Meta:
         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):
@@ -28,7 +35,7 @@ class RequiredOnPartialUpdateCharField(serializers.CharField):
     This field is always required, even for partial updates (e.g. using PATCH).
     """
     def validate_empty_values(self, data):
-        if data is empty:
+        if data is serializers.empty:
             self.fail('required')
 
         return super().validate_empty_values(data)
@@ -67,19 +74,19 @@ class ReadOnlyOnUpdateValidator(Validator):
             raise serializers.ValidationError(self.message, code='read-only-on-update')
 
 
-class StringField(CharField):
+class StringField(serializers.CharField):
 
     def to_internal_value(self, data):
         return data
 
-    def run_validation(self, data=empty):
+    def run_validation(self, data=serializers.empty):
         data = super().run_validation(data)
         if not isinstance(data, str):
             raise serializers.ValidationError('Must be a string.', code='must-be-a-string')
         return data
 
 
-class RRsField(ListField):
+class RRsField(serializers.ListField):
 
     def __init__(self, **kwargs):
         super().__init__(child=StringField(), **kwargs)
@@ -156,7 +163,7 @@ class NonBulkOnlyDefault:
 
     def __call__(self):
         if self.is_many:
-            raise SkipField()
+            raise serializers.SkipField()
         if callable(self.default):
             return self.default()
         return self.default
@@ -177,7 +184,7 @@ class RRsetSerializer(ConditionalExistenceModelSerializer):
             '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:
             raise ValueError('RRsetSerializer() must be given a domain object (to validate uniqueness constraints).')
         self.domain = domain
@@ -291,7 +298,7 @@ class RRsetListSerializer(ListSerializer):
 
         if not self.allow_empty and len(data) == 0:
             if self.parent and self.partial:
-                raise SkipField()
+                raise serializers.SkipField()
             else:
                 self.fail('empty')
 
@@ -425,6 +432,7 @@ class RRsetListSerializer(ListSerializer):
 
 
 class DomainSerializer(serializers.ModelSerializer):
+    psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER)
 
     class Meta:
         model = Domain
@@ -440,6 +448,48 @@ class DomainSerializer(serializers.ModelSerializer):
         fields['name'].validators.append(ReadOnlyOnUpdateValidator())
         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):
 
@@ -456,28 +506,184 @@ class DonationSerializer(serializers.ModelSerializer):
         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
 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 }},
 please configure your device (or any other dynDNS client) to use
 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 desecapi.models import User, Domain, Token, RRset, RR
-from desecapi.views import DomainList
+from desecapi.serializers import DomainSerializer
 
 
 class DesecAPIClient(APIClient):
@@ -320,23 +320,6 @@ class MockPDNSTestCase(APITestCase):
         request['status'] = 422
         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
     def request_pdns_zone_delete(cls, name=None, ns='LORD'):
         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):
         if public_suffixes is None:
             public_suffixes = settings.LOCAL_PUBLIC_SUFFIXES | self.PUBLIC_SUFFIXES
@@ -861,7 +829,7 @@ class DomainOwnerTestCase(DesecTestCase):
 
     @staticmethod
     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):
         if side_effect_parameter is None:
@@ -872,18 +840,35 @@ class DomainOwnerTestCase(DesecTestCase):
         else:
             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):
-        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)
 
+
+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
     def setUpTestDataWithPdns(cls):
         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}
         if cls.DYN:
@@ -914,7 +899,7 @@ class DomainOwnerTestCase(DesecTestCase):
     def setUp(self):
         super().setUp()
         self.client.credentials(HTTP_AUTHORIZATION='Token ' + self.token.key)
-        self.setUpMockPatch()
+        PublicSuffixMockMixin.setUpMockPatch(self)
 
 
 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.status import HTTP_200_OK, HTTP_401_UNAUTHORIZED
 
@@ -51,8 +56,8 @@ class SignUpLoginTestCase(DesecTestCase):
     REGISTRATION_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):
         super().__init__(*args, **kwargs)
@@ -72,6 +77,15 @@ class SignUpLoginTestCase(DesecTestCase):
             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):
         response = self.client.post(self.LOGIN_ENDPOINT, {
             'email': self.EMAIL,
@@ -81,35 +95,32 @@ class SignUpLoginTestCase(DesecTestCase):
 
     def test_sign_up(self):
         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):
         self.sign_up()
+        self.activate()
         self.log_in()
 
     def test_log_in_twice(self):
         self.sign_up()
+        self.activate()
         self.log_in()
         self.log_in()
 
     def test_log_in_two_tokens(self):
-        self.sign_up()  # this may create a token
+        self.sign_up()
+        self.activate()
         for _ in range(2):
             Token.objects.create(user=User.objects.get(email=self.EMAIL))
         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):
 
     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.pdns_change_tracker import PDNSChangeTracker
-from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase, LockedDomainOwnerTestCase
+from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase
 
 
 class UnauthenticatedDomainTests(DesecTestCase):
@@ -166,13 +166,6 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
             response = self.client.post(url, {'name': name})
             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):
         for name in [
             ' ' + self.random_domain_name(),
@@ -181,14 +174,14 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
             self.assertResponse(
                 self.client.post(self.reverse('v1:domain-list'), {'name': name}),
                 status.HTTP_400_BAD_REQUEST,
-                {'name': ['Domain name malformed.']},
+                {'name': ['Invalid value (not a DNS name).']},
             )
 
     def test_create_public_suffixes(self):
         for name in self.PUBLIC_SUFFIXES:
             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):
         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
         name = 'test.s4.amazonaws.com'
         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
         # 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()
         response = self.client.post(self.reverse('v1:domain-list'), {'name': name})
         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):
         name = 'something.' + self.other_domain.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):
         name = self.random_domain_name()
@@ -278,56 +271,6 @@ class DomainOwnerTestCase1(DomainOwnerTestCase):
         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):
     DYN = True
 
@@ -361,15 +304,6 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
                 self.assertTrue(self.token.key 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):
         url = self.reverse('v1:domain-list')
         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)
 
         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)
 
     def test_domain_minimum_ttl(self):
@@ -392,21 +327,3 @@ class AutoDelegationDomainOwnerTests(DomainOwnerTestCase):
             response = self.client.post(url, {'name': name})
         self.assertStatus(response, status.HTTP_201_CREATED)
         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.versioning import NamespaceVersioning
 
 from desecapi import models
-from desecapi.emails import send_account_lock_email
 from desecapi.tests.base import 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):
         super().setUp()
         email = self.random_username()
@@ -36,119 +16,14 @@ class SingleRegistrationTestCase(RegistrationTestCase):
         )
         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):
         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 djoser.views import UserView
 from rest_framework.routers import SimpleRouter
 
 from desecapi import views
@@ -8,29 +7,23 @@ tokens_router = SimpleRouter()
 tokens_router.register(r'', views.TokenViewSet, base_name='token')
 
 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
-    path('token/login/', views.TokenCreateView.as_view(), name='login'),
-    path('token/logout/', views.TokenDestroyView.as_view(), name='logout'),
     path('tokens/', include(tokens_router.urls)),
-
-    # User home
-    path('me/', UserView.as_view(), name='user'),
 ]
 
 api_urls = [
     # API home
     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/<name>/', views.DomainDetail.as_view(), name='domain-detail'),
     path('domains/<name>/rrsets/', views.RRsetList.as_view(), name='rrsets'),
@@ -42,15 +35,17 @@ api_urls = [
             views.RRsetDetail.as_view(), name='rrset@'),
     path('domains/<name>/rrsets/<subname>/<type>/', views.RRsetDetail.as_view()),
 
-    # DynDNS update endpoint
+    # DynDNS update
     path('dyndns/update', views.DynDNS12Update.as_view(), name='dyndns12update'),
 
-    # Donation endpoints
+    # 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'

+ 283 - 197
api/desecapi/views.py

@@ -1,29 +1,20 @@
 import base64
 import binascii
-import ipaddress
-import os
-import re
-from datetime import timedelta
 
 import django.core.exceptions
-import djoser.views
-import psl_dns
 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.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.utils import timezone
-from djoser import views, signals
-from djoser.serializers import TokenSerializer as DjoserTokenSerializer
 from rest_framework import generics
 from rest_framework import mixins
 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.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.response import Response
 from rest_framework.reverse import reverse
@@ -31,17 +22,12 @@ from rest_framework.views import APIView
 from rest_framework.viewsets import GenericViewSet
 
 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.permissions import IsOwner, IsUnlocked, IsDomainOwner
+from desecapi.permissions import IsOwner, IsDomainOwner
 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:
@@ -67,37 +53,12 @@ class DomainView:
             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,
                    mixins.CreateModelMixin,
                    mixins.DestroyModelMixin,
                    mixins.ListModelMixin,
                    GenericViewSet):
-    serializer_class = TokenSerializer
+    serializer_class = serializers.TokenSerializer
     permission_classes = (IsAuthenticated, )
     lookup_field = 'user_specific_id'
 
@@ -109,96 +70,32 @@ class TokenViewSet(IdempotentDestroy,
 
 
 class DomainList(ListCreateAPIView):
-    serializer_class = DomainSerializer
+    serializer_class = serializers.DomainSerializer
     permission_classes = (IsAuthenticated, IsOwner,)
-    psl = psl_dns.PSL(resolver=settings.PSL_RESOLVER)
 
     def get_queryset(self):
         return Domain.objects.filter(owner=self.request.user.pk)
 
     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
-        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')
             subject_tmpl = get_template('emails/domain-dyndns/subject.txt')
             from_tmpl = get_template('emails/from.txt')
             context = {
-                'domain': domain_name,
+                'domain': domain.name,
                 'url': 'https://update.dedyn.io/',
-                'username': domain_name,
+                'username': domain.name,
                 'password': self.request.auth.key
             }
             email = EmailMessage(subject_tmpl.render(context),
@@ -207,21 +104,24 @@ class DomainList(ListCreateAPIView):
                                  [self.request.user.email])
             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):
-    serializer_class = DomainSerializer
+    serializer_class = serializers.DomainSerializer
     permission_classes = (IsAuthenticated, IsOwner,)
     lookup_field = 'name'
 
     def perform_destroy(self, instance: Domain):
         with PDNSChangeTracker():
             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():
                 parent_domain.update_delegation(instance)
 
@@ -236,8 +136,8 @@ class DomainDetail(IdempotentDestroy, 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):
         return self.domain.rrset_set
@@ -274,8 +174,8 @@ class RRsetDetail(IdempotentDestroy, DomainView, RetrieveUpdateDestroyAPIView):
 
 
 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):
         rrsets = RRset.objects.filter(domain=self.domain)
@@ -308,13 +208,6 @@ class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
                 kwargs['many'] = True
         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):
         with PDNSChangeTracker():
             serializer.save(domain=self.domain)
@@ -326,17 +219,24 @@ class RRsetList(DomainView, ListCreateAPIView, UpdateAPIView):
 
 class Root(APIView):
     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),
-                'user': reverse('user', request=request),
-                'logout': reverse('token-destroy', request=request),  # TODO change interface to token-destroy, too?
-            })
+            }
         else:
-            return Response({
-                'login': reverse('token-create', request=request),
+            routes = {
                 'register': reverse('register', request=request),
-            })
+                'login': reverse('login', request=request),
+                'reset-password': reverse('account-reset-password', request=request),
+            }
+        return Response(routes)
 
 
 class DynDNS12Update(APIView):
@@ -344,10 +244,6 @@ class DynDNS12Update(APIView):
     renderer_classes = [PlainTextRenderer]
 
     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):
             # 1. hostname parameter
             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()
-        serializer = RRsetSerializer(instances, domain=domain, data=data, many=True, partial=True)
+        serializer = serializers.RRsetSerializer(instances, domain=domain, data=data, many=True, partial=True)
         try:
             serializer.is_valid(raise_exception=True)
         except ValidationError as e:
@@ -453,7 +349,7 @@ class DynDNS12Update(APIView):
 
 
 class DonationList(generics.CreateAPIView):
-    serializer_class = DonationSerializer
+    serializer_class = serializers.DonationSerializer
 
     def perform_create(self, serializer):
         iban = serializer.validated_data['iban']
@@ -497,54 +393,244 @@ class DonationList(generics.CreateAPIView):
         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
 Django~=2.2.0
 django-cors-headers~=3.0.2
-django-nocaptcha-recaptcha==0.0.20  # updated manually
 djangorestframework~=3.9.3
-djangorestframework-bulk~=0.2.0
-djoser~=1.5.1
 httpretty~=0.9.0
 mysqlclient~=1.4.0
 psl-dns~=1.0rc2

+ 0 - 2
docker-compose.yml

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

+ 260 - 120
docs/authentication.rst

@@ -1,70 +1,79 @@
 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
 
@@ -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
-~~~~~~
+``````
 
 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 @- <<< \
-        '{"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
 ``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``
 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"
 
 
-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"
 
-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:
 
-``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``
-    :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``
     :Access mode: read-only
 
     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
 ````````````````````
 
-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 @- <<< \
-        '{"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

+ 18 - 6
docs/endpoint-reference.rst

@@ -7,15 +7,17 @@ for `User Registration and Management`_.
 +------------------------------------------------+------------+---------------------------------------------+
 | 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                 |
 |                                                +------------+---------------------------------------------+
@@ -23,6 +25,16 @@ for `User Registration and Management`_.
 +------------------------------------------------+------------+---------------------------------------------+
 | ...\ ``/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
 for `Domain Management`_ and `Retrieving and Manipulating DNS Information`_.

+ 1 - 0
docs/index.rst

@@ -1,4 +1,5 @@
 .. include:: introduction.rst
+.. include:: quickstart.rst
 .. include:: authentication.rst
 .. include:: domains.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 = {
     properties: {
-        dyn: { type: "boolean" },
+        created: {
+            type: "string",
+            format: "date-time"
+        },
         email: {
             type: "string",
             format: "email"
         },
+        id: { type: "integer" },
         limit_domains: { type: "integer" },
     },
-    required: ["dyn", "email", "limit_domains"]
+    required: ["created", "email", "id", "limit_domains"]
 };
 
 exports.domain = {
@@ -79,12 +83,12 @@ exports.rrsets = {
 
 exports.token = {
     properties: {
-        value: { type: "string" },
+        auth_token: { type: "string" },
         name: { type: "string" },
         created: { type: "string" },
         id: { type: "integer" },
     },
-    required: ["value", "name", "created", "id"]
+    required: ["auth_token", "name", "created", "id"]
 };
 
 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() {
             chakram.get('/' + version + '/').then(function (response) {
                 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);
                 return chakram.wait();
             });
@@ -47,8 +47,8 @@ describe("API v1", function () {
         })
 
         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}}
                 chakram.post('/domains/', {name: publicSuffix}, config)
                 // TODO verify behavior for non-existent local public suffixes
@@ -59,7 +59,7 @@ describe("API v1", function () {
     it("provides an index page", function () {
         chakram.get('/').then(function (response) {
             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();
         });
     });
@@ -72,15 +72,13 @@ describe("API v1", function () {
             email = require("uuid").v4() + '@e2etest.local';
             password = require("uuid").v4();
 
-            var response = chakram.post('/auth/users/', {
+            var response = chakram.post('/auth/', {
                 "email": email,
                 "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 () {
@@ -93,16 +91,16 @@ describe("API v1", function () {
             email = require("uuid").v4() + '@e2etest.local';
             password = require("uuid").v4();
 
-            var response = chakram.post('/auth/users/', {
+            var response = chakram.post('/auth/', {
                 "email": email,
                 "password": password,
             });
 
-            return expect(response).to.have.status(201);
+            return expect(response).to.have.status(202);
         });
 
         it("returns a token when logging in", function () {
-            return chakram.post('/auth/token/login/', {
+            return chakram.post('/auth/login/', {
                 "email": email,
                 "password": password,
             }).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;
 
             before(function () {
@@ -118,11 +116,11 @@ describe("API v1", function () {
                 email2 = require("uuid").v4() + '@e2etest.local';
                 password2 = require("uuid").v4();
 
-                return chakram.post('/auth/users/', {
+                return chakram.post('/auth/', {
                     "email": email2,
                     "password": password2,
                 }).then(function () {
-                    return chakram.post('/auth/token/login/', {
+                    return chakram.post('/auth/login/', {
                         "email": email2,
                         "password": password2,
                     }).then(function (response) {
@@ -132,7 +130,7 @@ describe("API v1", function () {
             });
 
             it("returns JSON of correct schema", function () {
-                var response = chakram.get('/auth/me/', {
+                var response = chakram.get('/auth/account/', {
                     headers: {'Authorization': 'Token ' + token2 }
                 });
                 expect(response).to.have.status(200);
@@ -140,95 +138,16 @@ describe("API v1", function () {
                 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';
@@ -237,10 +156,10 @@ describe("API v1", function () {
         var apiHomeSchema = {
             properties: {
                 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;
@@ -257,11 +176,11 @@ describe("API v1", function () {
             // register a user that we can login and work with
             password = require("uuid").v4();
 
-            return chakram.post('/auth/users/', {
+            return chakram.post('/auth/', {
                 "email": email,
                 "password": password,
             }).then(function () {
-                return chakram.post('/auth/token/login/', {
+                return chakram.post('/auth/login/', {
                     "email": email,
                     "password": password,
                 }).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
             password = require("uuid").v4();
 
-            return chakram.post('/auth/users/', {
+            return chakram.post('/auth/', {
                 "email": email,
                 "password": password,
             }).then(function () {
-                return chakram.post('/auth/token/login/', {
+                return chakram.post('/auth/login/', {
                     "email": email,
                     "password": password,
                 }).then(function (loginResponse) {