فهرست منبع

feat(api): allow postponing captcha to AuthAction, closes #489

Peter Thomassen 4 سال پیش
والد
کامیت
e2c2440f28

+ 23 - 0
api/desecapi/migrations/0013_user_needs_captcha.py

@@ -0,0 +1,23 @@
+# Generated by Django 3.1.5 on 2021-01-19 15:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0012_rrset_label_length'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='user',
+            name='needs_captcha',
+            field=models.BooleanField(default=False),
+        ),
+        migrations.AlterField(
+            model_name='user',
+            name='needs_captcha',
+            field=models.BooleanField(default=True),
+        ),
+    ]

+ 2 - 0
api/desecapi/models.py

@@ -99,6 +99,7 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
     is_admin = models.BooleanField(default=False)
     created = models.DateTimeField(auto_now_add=True)
     limit_domains = models.IntegerField(default=_limit_domains_default.__func__, null=True, blank=True)
+    needs_captcha = models.BooleanField(default=True)
 
     objects = MyUserManager()
 
@@ -134,6 +135,7 @@ class User(ExportModelOperationsMixin('User'), AbstractBaseUser):
 
     def activate(self):
         self.is_active = True
+        self.needs_captcha = False
         self.save()
 
     def change_email(self, email):

+ 16 - 4
api/desecapi/serializers.py

@@ -12,7 +12,7 @@ from django.core.validators import MinValueValidator
 from django.db.models import Model, Q
 from django.utils import timezone
 from netfields import rest_framework as netfields_rf
-from rest_framework import serializers
+from rest_framework import fields, serializers
 from rest_framework.settings import api_settings
 from rest_framework.validators import UniqueTogetherValidator, UniqueValidator, qs_filter
 
@@ -632,7 +632,7 @@ class UserSerializer(serializers.ModelSerializer):
 
 class RegisterAccountSerializer(UserSerializer):
     domain = serializers.CharField(required=False, validators=models.validate_domain_name)
-    captcha = CaptchaSolutionSerializer(required=True)
+    captcha = CaptchaSolutionSerializer(required=False)
 
     class Meta:
         model = UserSerializer.Meta.model
@@ -650,7 +650,10 @@ class RegisterAccountSerializer(UserSerializer):
 
     def create(self, validated_data):
         validated_data.pop('domain', None)
-        validated_data.pop('captcha', None)
+        # If validated_data['captcha'] exists, the captcha was also validated, so we can set the user to verified
+        if 'captcha' in validated_data:
+            validated_data.pop('captcha')
+            validated_data['needs_captcha'] = False
         return super().create(validated_data)
 
 
@@ -775,14 +778,23 @@ class AuthenticatedBasicUserActionSerializer(AuthenticatedActionSerializer):
 
 
 class AuthenticatedActivateUserActionSerializer(AuthenticatedBasicUserActionSerializer):
+    captcha = CaptchaSolutionSerializer(required=False)
 
     class Meta(AuthenticatedBasicUserActionSerializer.Meta):
         model = models.AuthenticatedActivateUserAction
-        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('domain',)
+        fields = AuthenticatedBasicUserActionSerializer.Meta.fields + ('captcha', 'domain',)
         extra_kwargs = {
             'domain': {'default': None, 'allow_null': True}
         }
 
+    def validate(self, attrs):
+        try:
+            attrs.pop('captcha')  # remove captcha from internal value to avoid passing to Meta.model(**kwargs)
+        except KeyError:
+            if attrs['user'].needs_captcha:
+                raise serializers.ValidationError({'captcha': fields.Field.default_error_messages['required']})
+        return attrs
+
 
 class AuthenticatedChangeEmailUserActionSerializer(AuthenticatedBasicUserActionSerializer):
     new_email = serializers.EmailField(

+ 2 - 2
api/desecapi/tests/base.py

@@ -699,9 +699,9 @@ class DesecTestCase(MockPDNSTestCase):
         return token
 
     @classmethod
-    def create_user(cls, **kwargs):
+    def create_user(cls, needs_captcha=False, **kwargs):
         kwargs.setdefault('email', cls.random_username())
-        user = User(**kwargs)
+        user = User(needs_captcha=needs_captcha, **kwargs)
         user.plain_password = cls.random_string(length=12)
         user.set_password(user.plain_password)
         user.save()

+ 30 - 9
api/desecapi/tests/test_user_management.py

@@ -38,11 +38,17 @@ from desecapi.tests.base import DesecTestCase, DomainOwnerTestCase, PublicSuffix
 
 class UserManagementClient(APIClient):
 
-    def register(self, email, password, captcha_id, captcha_solution, **kwargs):
+    def register(self, email, password, captcha=None, **kwargs):
+        try:
+            captcha_id, captcha_solution = captcha
+        except TypeError:
+            pass
+        else:
+            kwargs['captcha'] = {'id': captcha_id, 'solution': captcha_solution}
+
         return self.post(reverse('v1:register'), {
             'email': email,
             'password': password,
-            'captcha': {'id': captcha_id, 'solution': captcha_solution},
             **kwargs
         })
 
@@ -98,10 +104,10 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
         solution = Captcha.objects.get(id=id).content
         return id, solution
 
-    def register_user(self, email=None, password=None, **kwargs):
+    def register_user(self, email=None, password=None, late_captcha=False, **kwargs):
         email = email if email is not None else self.random_username()
-        captcha_id, captcha_solution = self.get_captcha()
-        return email.strip(), password, self.client.register(email, password, captcha_id, captcha_solution, **kwargs)
+        captcha = None if late_captcha else self.get_captcha()
+        return email.strip(), password, self.client.register(email, password, captcha, **kwargs)
 
     def login_user(self, email, password):
         return self.client.login_user(email, password)
@@ -284,6 +290,10 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
             status_code=status.HTTP_200_OK
         )
 
+    def assertRegistrationVerificationFailureResponse(self, response):
+        self.assertEqual(response.data['captcha'][0], "This field is required.")
+        self.assertStatus(response, status.HTTP_400_BAD_REQUEST)
+
     def assertRegistrationWithDomainVerificationSuccessResponse(self, response, domain=None, email=None):
         self.assertNoEmailSent()  # do not send email in any case
         text = 'Success! Please check the docs for the next steps'
@@ -393,16 +403,24 @@ class UserManagementTestCase(DesecTestCase, PublicSuffixMockMixin):
             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)
+    def _test_registration(self, email=None, password=None, late_captcha=True, **kwargs):
+        email, password, response = self.register_user(email, password, late_captcha, **kwargs)
         self.assertRegistrationSuccessResponse(response)
         self.assertUserExists(email)
         self.assertFalse(User.objects.get(email=email).is_active)
+        self.assertEqual(User.objects.get(email=email).needs_captcha, late_captcha)
         self.assertPassword(email, password)
         confirmation_link = self.assertRegistrationEmail(email)
         self.assertConfirmationLinkRedirect(confirmation_link)
-        self.assertRegistrationVerificationSuccessResponse(self.client.verify(confirmation_link))
+        response = self.client.verify(confirmation_link)
+        if late_captcha:
+            self.assertRegistrationVerificationFailureResponse(response)
+            captcha_id, captcha_solution = self.get_captcha()
+            data = {'captcha': {'id': captcha_id, 'solution': captcha_solution}}
+            response = self.client.verify(confirmation_link, data=data)
+        self.assertRegistrationVerificationSuccessResponse(response)
         self.assertTrue(User.objects.get(email=email).is_active)
+        self.assertFalse(User.objects.get(email=email).needs_captcha)
         self.assertPassword(email, password)
         return email, password
 
@@ -585,9 +603,12 @@ class NoUserAccountTestCase(UserLifeCycleTestCase):
         password = self.random_password()
         captcha_id, _ = self.get_captcha()
         self.assertRegistrationFailureCaptchaInvalidResponse(
-            self.client.register(email, password, captcha_id, 'this is most definitely not a correct CAPTCHA solution')
+            self.client.register(email, password, (captcha_id, 'this is most definitely not a CAPTCHA solution'))
         )
 
+    def test_registration_late_captcha(self):
+        self._test_registration(password=self.random_password(), late_captcha=True)
+
 
 class OtherUserAccountTestCase(UserManagementTestCase):
 

+ 9 - 2
docs/auth/account.rst

@@ -70,7 +70,13 @@ Please consider the following when registering an account:
 - 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.
+  provide an email address and to confirm its validity by clicking a
+  verification link sent to that address.
+
+- To facilitate automatic sign-ups, the ``captcha`` field in the registration
+  request can be omitted; in this case, the field is required later when
+  completing email verification. In case we find this to cause adverse effects
+  on our systems, we may adopt a captcha-on-registration policy at any time.
 
 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,
@@ -78,7 +84,8 @@ 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, click on that link (which will direct you to our frontend) or send a
-``POST`` request on the command line. The link expires after 12 hours.
+``POST`` request on the command line. (If a captcha was not provided during
+registration, it has to be provided now.) The link expires after 12 hours.
 
 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

+ 111 - 0
webapp/src/components/ActivateAccountActionHandler.vue

@@ -1,14 +1,125 @@
+<template>
+  <div>
+    <div class="text-center" v-if="captcha_required && !success">
+      <v-container class="pa-0">
+        <v-row dense align="center" class="text-center">
+          <v-col cols="12" sm="">
+            <v-text-field
+                    v-model="payload.captcha.solution"
+                    label="Type CAPTCHA text here"
+                    prepend-icon="mdi-account-check"
+                    outline
+                    required
+                    :disabled="working"
+                    :rules="captcha_rules"
+                    :error-messages="captcha_errors"
+                    @change="captcha_errors=[]"
+                    @keypress="captcha_errors=[]"
+                    class="uppercase"
+                    ref="captchaField"
+                    tabindex="3"
+                    :hint="captcha_kind === 'image' ? 'Can\'t see? Hear an audio CAPTCHA instead.' : 'Trouble hearing? Switch to an image CAPTCHA.'"
+            />
+          </v-col>
+          <v-col cols="12" sm="auto">
+            <v-progress-circular
+                  indeterminate
+                  v-if="captchaWorking"
+            ></v-progress-circular>
+            <img
+                  v-if="captcha && !captchaWorking && captcha_kind === 'image'"
+                  :src="'data:image/png;base64,'+captcha.challenge"
+                  alt="Passwords can also be reset by sending an email to our support."
+            />
+            <audio controls
+                  v-if="captcha && !captchaWorking && captcha_kind === 'audio'"
+            >
+              <source :src="'data:audio/wav;base64,'+captcha.challenge" type="audio/wav"/>
+            </audio>
+            <br/>
+            <v-btn-toggle>
+              <v-btn text outlined @click="getCaptcha(true)" :disabled="captchaWorking"><v-icon>mdi-refresh</v-icon></v-btn>
+            </v-btn-toggle>
+            &nbsp;
+            <v-btn-toggle v-model="captcha_kind">
+              <v-btn text outlined value="image" aria-label="Switch to Image CAPTCHA" :disabled="captchaWorking"><v-icon>mdi-eye</v-icon></v-btn>
+              <v-btn text outlined value="audio" aria-label="Switch to Audio CAPTCHA" :disabled="captchaWorking"><v-icon>mdi-ear-hearing</v-icon></v-btn>
+            </v-btn-toggle>
+          </v-col>
+        </v-row>
+      </v-container>
+        <v-btn
+                depressed
+                color="primary"
+                type="submit"
+                :disabled="working || !valid"
+                :loading="working"
+                tabindex="2"
+        >Submit</v-btn>
+    </div>
+    <v-alert type="success" v-if="success">
+      <p>{{ this.response.data.detail }}</p>
+    </v-alert>
+  </div>
+</template>
+
 <script>
+  import axios from 'axios';
   import GenericActionHandler from "./GenericActionHandler"
 
+  const HTTP = axios.create({
+    baseURL: '/api/v1/',
+    headers: {},
+  });
+
   export default {
     name: 'ActivateAccountActionHandler',
     extends: GenericActionHandler,
     data: () => ({
       auto_submit: true,
+      captchaWorking: false,
       LOCAL_PUBLIC_SUFFIXES: process.env.VUE_APP_LOCAL_PUBLIC_SUFFIXES.split(' '),
+      captcha: null,
+      captcha_required: false,
+
+      /* captcha field */
+      captchaSolution: '',
+      captcha_rules: [v => !!v || 'Please enter the text displayed in the picture so we are (somewhat) convinced you are human'],
+      captcha_errors: [],
+      captcha_kind: 'image',
     }),
+    computed: {
+      captcha_error: function () {
+        return this.error && this.response.data.captcha !== undefined
+      }
+    },
+    methods: {
+      async getCaptcha() {
+        this.captchaWorking = true;
+        this.captchaSolution = "";
+        try {
+          this.captcha = (await HTTP.post('captcha/', {kind: this.captcha_kind})).data;
+          this.payload.captcha.id = this.captcha.id;
+          this.$refs.captchaField.focus()
+        } finally {
+          this.captchaWorking = false;
+        }
+      },
+    },
     watch: {
+      captcha_error(value) {
+        if(value) {
+          this.$emit('clearerrors');
+          this.captcha_required = true;
+          this.payload.captcha = {};
+          this.getCaptcha();
+        }
+      },
+      captcha_kind: function (oldKind, newKind) {
+        if (oldKind !== newKind) {
+          this.getCaptcha();
+        }
+      },
       success(value) {
         if(value) {
           let domain = this.response.data.domain;

+ 3 - 0
webapp/src/components/GenericActionHandler.vue

@@ -32,6 +32,9 @@
       working: Boolean,
     },
     computed: {
+      error: function () {
+        return this.response.status >= 400 && this.response.status < 500
+      },
       success: function () {
         return this.response.status >= 200 && this.response.status < 300
       }

+ 6 - 2
webapp/src/views/Confirmation.vue

@@ -41,6 +41,7 @@
                       :working="this.working"
                       ref="actionHandler"
                       @autosubmit="confirm"
+                      @clearerrors="clearErrors"
               ></div>
             </v-form>
             <h2 class="title">Keep deSEC Going</h2>
@@ -69,8 +70,7 @@
 
   const HTTP = axios.create({
     baseURL: '/api/v1/',
-    headers: {
-    },
+    headers: {'Content-Type': 'application/json'},
   });
 
   export default {
@@ -95,6 +95,7 @@
     },
     methods: {
       async confirm() {
+        this.post_response = {}
         this.errors = []
         this.working = true
         let action = this.$route.params.action
@@ -107,6 +108,9 @@
         }
         this.working = false
       },
+      clearErrors() {
+        this.errors = []
+      }
     },
     filters: {
       replace: function (value, a, b) {