Browse Source

feat(api,webapp): adds audio CAPTCHA for a11y

Nils Wisiol 4 years ago
parent
commit
3119c3da93

+ 1 - 1
api/desecapi/metrics.py

@@ -18,7 +18,7 @@ def set_histogram(name, *args, **kwargs):
 
 
 #models.py metrics
 #models.py metrics
 
 
-set_counter('desecapi_captcha_content_created', 'number of times captcha content created')
+set_counter('desecapi_captcha_content_created', 'number of times captcha content created', ['kind'])
 set_counter('desecapi_autodelegation_created', 'number of autodelegations added')
 set_counter('desecapi_autodelegation_created', 'number of autodelegations added')
 set_counter('desecapi_autodelegation_deleted', 'number of autodelegations deleted')
 set_counter('desecapi_autodelegation_deleted', 'number of autodelegations deleted')
 set_histogram('desecapi_messages_queued', 'number of emails queued', ['reason', 'user', 'lane'], buckets=[0, 1, float("inf")])
 set_histogram('desecapi_messages_queued', 'number of emails queued', ['reason', 'user', 'lane'], buckets=[0, 1, float("inf")])

+ 23 - 0
api/desecapi/migrations/0011_captcha_kind.py

@@ -0,0 +1,23 @@
+# Generated by Django 3.1.4 on 2020-12-29 14:08
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('desecapi', '0010_token_expiration'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='captcha',
+            name='kind',
+            field=models.CharField(choices=[('image', 'Image'), ('audio', 'Audio')], default='image', max_length=24),
+        ),
+        migrations.AlterField(
+            model_name='captcha',
+            name='content',
+            field=models.CharField(default='', max_length=24),
+        ),
+    ]

+ 24 - 8
api/desecapi/models.py

@@ -897,20 +897,36 @@ class AuthenticatedRenewDomainBasicUserAction(AuthenticatedDomainBasicUserAction
         self.domain.save(update_fields=['renewal_state', 'renewal_changed'])
         self.domain.save(update_fields=['renewal_state', 'renewal_changed'])
 
 
 
 
-def captcha_default_content():
-    alphabet = (string.ascii_uppercase + string.digits).translate({ord(c): None for c in 'IO0'})
-    content = ''.join([secrets.choice(alphabet) for _ in range(5)])
-    metrics.get('desecapi_captcha_content_created').inc()
+def captcha_default_content(kind: str) -> str:
+    if kind == Captcha.Kind.IMAGE:
+        alphabet = (string.ascii_uppercase + string.digits).translate({ord(c): None for c in 'IO0'})
+        length = 5
+    elif kind == Captcha.Kind.AUDIO:
+        alphabet = string.digits
+        length = 8
+    else:
+        raise ValueError(f'Unknown Captcha kind: {kind}')
+
+    content = ''.join([secrets.choice(alphabet) for _ in range(length)])
+    metrics.get('desecapi_captcha_content_created').labels(kind).inc()
     return content
     return content
 
 
 
 
 class Captcha(ExportModelOperationsMixin('Captcha'), models.Model):
 class Captcha(ExportModelOperationsMixin('Captcha'), models.Model):
+
+    class Kind(models.TextChoices):
+        IMAGE = 'image'
+        AUDIO = 'audio'
+
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
     created = models.DateTimeField(auto_now_add=True)
     created = models.DateTimeField(auto_now_add=True)
-    content = models.CharField(
-        max_length=24,
-        default=captcha_default_content,
-    )
+    content = models.CharField(max_length=24, default="")
+    kind = models.CharField(choices=Kind.choices, default=Kind.IMAGE, max_length=24)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if not self.content:
+            self.content = captcha_default_content(self.kind)
 
 
     def verify(self, solution: str):
     def verify(self, solution: str):
         age = timezone.now() - self.created
         age = timezone.now() - self.created

+ 9 - 3
api/desecapi/serializers.py

@@ -4,10 +4,11 @@ import json
 import re
 import re
 from base64 import urlsafe_b64decode, urlsafe_b64encode, b64encode
 from base64 import urlsafe_b64decode, urlsafe_b64encode, b64encode
 
 
+import django.core.exceptions
+from captcha.audio import AudioCaptcha
 from captcha.image import ImageCaptcha
 from captcha.image import ImageCaptcha
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.auth.models import AnonymousUser
 from django.contrib.auth.password_validation import validate_password
 from django.contrib.auth.password_validation import validate_password
-import django.core.exceptions
 from django.core.validators import MinValueValidator
 from django.core.validators import MinValueValidator
 from django.db.models import Model, Q
 from django.db.models import Model, Q
 from django.utils import timezone
 from django.utils import timezone
@@ -25,11 +26,16 @@ class CaptchaSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = models.Captcha
         model = models.Captcha
-        fields = ('id', 'challenge') if not settings.DEBUG else ('id', 'challenge', 'content')
+        fields = ('id', 'challenge', 'kind') if not settings.DEBUG else ('id', 'challenge', 'kind', 'content')
 
 
     def get_challenge(self, obj: models.Captcha):
     def get_challenge(self, obj: models.Captcha):
         # TODO Does this need to be stored in the object instance, in case this method gets called twice?
         # TODO Does this need to be stored in the object instance, in case this method gets called twice?
-        challenge = ImageCaptcha().generate(obj.content).getvalue()
+        if obj.kind == models.Captcha.Kind.IMAGE:
+            challenge = ImageCaptcha().generate(obj.content).getvalue()
+        elif obj.kind == models.Captcha.Kind.AUDIO:
+            challenge = AudioCaptcha().generate(obj.content)
+        else:
+            raise ValueError(f'Unknown captcha type {obj.kind}')
         return b64encode(challenge)
         return b64encode(challenge)
 
 
 
 

+ 42 - 16
api/desecapi/tests/test_captcha.py

@@ -3,22 +3,22 @@ from io import BytesIO
 from unittest import mock
 from unittest import mock
 
 
 from PIL import Image
 from PIL import Image
-from django.utils import timezone
 from django.test import TestCase
 from django.test import TestCase
+from django.utils import timezone
 from rest_framework import status
 from rest_framework import status
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
 from rest_framework.test import APIClient
 from rest_framework.test import APIClient
 
 
 from api import settings
 from api import settings
 from desecapi.models import Captcha
 from desecapi.models import Captcha
-from desecapi.serializers import CaptchaSerializer, CaptchaSolutionSerializer
+from desecapi.serializers import CaptchaSolutionSerializer
 from desecapi.tests.base import DesecTestCase
 from desecapi.tests.base import DesecTestCase
 
 
 
 
 class CaptchaClient(APIClient):
 class CaptchaClient(APIClient):
 
 
     def obtain(self, **kwargs):
     def obtain(self, **kwargs):
-        return self.post(reverse('v1:captcha'))
+        return self.post(reverse('v1:captcha'), data=kwargs)
 
 
 
 
 class CaptchaModelTestCase(TestCase):
 class CaptchaModelTestCase(TestCase):
@@ -30,14 +30,6 @@ class CaptchaModelTestCase(TestCase):
         self.assertNotEqual(captcha[0].content, '')
         self.assertNotEqual(captcha[0].content, '')
         self.assertNotEqual(captcha[0].content, captcha[1].content)
         self.assertNotEqual(captcha[0].content, captcha[1].content)
 
 
-    def test_valid_png(self):
-        for _ in range(10):
-            # use the show method on the Image object to see the actual image during test run
-            # This also allows an impression of how the CAPTCHAs will look like.
-            serializer = CaptchaSerializer(instance=Captcha())
-            challenge = b64decode(serializer.data['challenge'])
-            Image.open(BytesIO(challenge))  # .show()
-
     def test_verify_solution(self):
     def test_verify_solution(self):
         for _ in range(10):
         for _ in range(10):
             c = self.captcha_class.objects.create()
             c = self.captcha_class.objects.create()
@@ -50,6 +42,7 @@ class CaptchaWorkflowTestCase(DesecTestCase):
     client_class = CaptchaClient
     client_class = CaptchaClient
     captcha_class = Captcha
     captcha_class = Captcha
     serializer_class = CaptchaSolutionSerializer
     serializer_class = CaptchaSolutionSerializer
+    kind = None
 
 
     def verify(self, id, solution):
     def verify(self, id, solution):
         """
         """
@@ -62,29 +55,62 @@ class CaptchaWorkflowTestCase(DesecTestCase):
         # use the serializer to validate the solution; id is validated implicitly on DB lookup
         # use the serializer to validate the solution; id is validated implicitly on DB lookup
         return self.serializer_class(data={'id': id, 'solution': solution}).is_valid()
         return self.serializer_class(data={'id': id, 'solution': solution}).is_valid()
 
 
+    def obtain(self):
+        if self.kind is None:
+            return self.client.obtain()
+        else:
+            return self.client.obtain(kind=self.kind)
+
     def test_obtain(self):
     def test_obtain(self):
-        response = self.client.obtain()
+        response = self.obtain()
         self.assertContains(response, 'id', status_code=status.HTTP_201_CREATED)
         self.assertContains(response, 'id', status_code=status.HTTP_201_CREATED)
         self.assertContains(response, 'challenge', status_code=status.HTTP_201_CREATED)
         self.assertContains(response, 'challenge', status_code=status.HTTP_201_CREATED)
         self.assertTrue('content' not in response.data)
         self.assertTrue('content' not in response.data)
-        self.assertTrue(len(response.data) == 2)
+        self.assertTrue(len(response.data) == 3)
         self.assertEqual(self.captcha_class.objects.all().count(), 1)
         self.assertEqual(self.captcha_class.objects.all().count(), 1)
         # use the value of f'<img src="data:image/png;base64,{response.data["challenge"].decode()}" />'
         # use the value of f'<img src="data:image/png;base64,{response.data["challenge"].decode()}" />'
         # to display the CAPTCHA in a browser
         # to display the CAPTCHA in a browser
 
 
     def test_verify_correct(self):
     def test_verify_correct(self):
-        id = self.client.obtain().data['id']
+        id = self.obtain().data['id']
         correct_solution = Captcha.objects.get(id=id).content
         correct_solution = Captcha.objects.get(id=id).content
         self.assertTrue(self.verify(id, correct_solution))
         self.assertTrue(self.verify(id, correct_solution))
 
 
     def test_verify_incorrect(self):
     def test_verify_incorrect(self):
-        id = self.client.obtain().data['id']
+        id = self.obtain().data['id']
         wrong_solution = 'most certainly wrong!'
         wrong_solution = 'most certainly wrong!'
         self.assertFalse(self.verify(id, wrong_solution))
         self.assertFalse(self.verify(id, wrong_solution))
 
 
     def test_expired(self):
     def test_expired(self):
-        id = self.client.obtain().data['id']
+        id = self.obtain().data['id']
         correct_solution = Captcha.objects.get(id=id).content
         correct_solution = Captcha.objects.get(id=id).content
 
 
         with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + settings.CAPTCHA_VALIDITY_PERIOD):
         with mock.patch('desecapi.models.timezone.now', return_value=timezone.now() + settings.CAPTCHA_VALIDITY_PERIOD):
             self.assertFalse(self.verify(id, correct_solution))
             self.assertFalse(self.verify(id, correct_solution))
+
+
+class ImageCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
+    kind = 'image'
+
+    def test_length(self):
+        self.assertTrue(5000 < len(self.obtain().data['challenge']) < 50000)
+
+    def test_parses(self):
+        for _ in range(10):
+            # use the show method on the Image object to see the actual image during test run
+            # This also allows an impression of how the CAPTCHAs will look like.
+            cap = self.obtain().data
+            challenge = b64decode(cap['challenge'])
+            Image.open(BytesIO(challenge))  # .show()
+
+
+class AudioCaptchaWorkflowTestCase(CaptchaWorkflowTestCase):
+    kind = 'audio'
+
+    def test_length(self):
+        self.assertTrue(10**5 < len(self.obtain().data['challenge']) < 10**6)
+
+    def test_parses(self):
+        for _ in range(10):
+            challenge = b64decode(self.obtain().data['challenge'])
+            self.assertTrue(b'WAVE' in challenge)

+ 1 - 1
test/e2e/spec/www_spec.js

@@ -156,7 +156,7 @@ describe("www/nginx", function () {
         it("has security headers", function () {
         it("has security headers", function () {
             var response = chakram.get('https://www/');
             var response = chakram.get('https://www/');
             expect(response).to.have.header('Strict-Transport-Security', 'max-age=31536000; includeSubdomains; preload');
             expect(response).to.have.header('Strict-Transport-Security', 'max-age=31536000; includeSubdomains; preload');
-            expect(response).to.have.header('Content-Security-Policy', "default-src 'self'; frame-src 'none'; connect-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'; block-all-mixed-content; form-action 'none';");
+            expect(response).to.have.header('Content-Security-Policy', "default-src 'self'; frame-src 'none'; connect-src 'self'; font-src 'self'; img-src 'self' data:; media-src data:; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'; block-all-mixed-content; form-action 'none';");
             expect(response).to.have.header('X-Frame-Options', 'deny');
             expect(response).to.have.header('X-Frame-Options', 'deny');
             expect(response).to.have.header('X-Content-Type-Options', 'nosniff');
             expect(response).to.have.header('X-Content-Type-Options', 'nosniff');
             expect(response).to.have.header('Referrer-Policy', 'strict-origin-when-cross-origin');
             expect(response).to.have.header('Referrer-Policy', 'strict-origin-when-cross-origin');

+ 30 - 10
webapp/src/views/ResetPassword.vue

@@ -77,21 +77,33 @@
                                             class="uppercase"
                                             class="uppercase"
                                             ref="captchaField"
                                             ref="captchaField"
                                             tabindex="3"
                                             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>
-                                  <v-col cols="8" sm="auto">
+                                  <v-col cols="12" sm="auto">
                                     <v-progress-circular
                                     <v-progress-circular
-                                            indeterminate
-                                            v-if="captchaWorking"
+                                          indeterminate
+                                          v-if="captchaWorking"
                                     ></v-progress-circular>
                                     ></v-progress-circular>
                                     <img
                                     <img
-                                            v-if="captcha && !captchaWorking"
-                                            :src="'data:image/png;base64,'+captcha.challenge"
-                                            alt="Passwords can also be reset by sending an email to our support."
+                                          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'"
                                     >
                                     >
-                                  </v-col>
-                                  <v-col cols="4" sm="auto">
-                                    <v-btn text outlined @click="getCaptcha(true)" :disabled="captchaWorking"><v-icon>mdi-refresh</v-icon></v-btn>
+                                      <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-col>
                                 </v-row>
                                 </v-row>
                               </v-container>
                               </v-container>
@@ -142,6 +154,7 @@
       captchaSolution: '',
       captchaSolution: '',
       captcha_rules: [v => !!v || 'Please enter the text displayed in the picture so we are (somewhat) convinced you are human'],
       captcha_rules: [v => !!v || 'Please enter the text displayed in the picture so we are (somewhat) convinced you are human'],
       captcha_errors: [],
       captcha_errors: [],
+      captcha_kind: 'image',
     }),
     }),
     async mounted() {
     async mounted() {
       if ('email' in this.$route.params && this.$route.params.email !== undefined) {
       if ('email' in this.$route.params && this.$route.params.email !== undefined) {
@@ -155,7 +168,7 @@
         this.captchaWorking = true;
         this.captchaWorking = true;
         this.captchaSolution = "";
         this.captchaSolution = "";
         try {
         try {
-          this.captcha = (await HTTP.post('captcha/')).data;
+          this.captcha = (await HTTP.post('captcha/', {kind: this.captcha_kind})).data;
           if(focus) {
           if(focus) {
             this.$refs.captchaField.focus()
             this.$refs.captchaField.focus()
           }
           }
@@ -214,6 +227,13 @@
         this.working = false;
         this.working = false;
       },
       },
     },
     },
+    watch: {
+      captcha_kind: function (oldKind, newKind) {
+        if (oldKind !== newKind) {
+          this.getCaptcha();
+        }
+      },
+    },
   };
   };
 </script>
 </script>
 
 

+ 25 - 7
webapp/src/views/SignUp.vue

@@ -85,7 +85,7 @@
 
 
               <v-container class="pa-0">
               <v-container class="pa-0">
                 <v-row dense align="center" class="text-center">
                 <v-row dense align="center" class="text-center">
-                  <v-col cols="12" sm="">
+                  <v-col cols="8" sm="">
                     <v-text-field
                     <v-text-field
                         v-model="captchaSolution"
                         v-model="captchaSolution"
                         label="Type CAPTCHA text here"
                         label="Type CAPTCHA text here"
@@ -100,21 +100,33 @@
                         class="uppercase"
                         class="uppercase"
                         ref="captchaField"
                         ref="captchaField"
                         tabindex="4"
                         tabindex="4"
+                        :hint="captcha_kind === 'image' ? 'Can\'t see? Hear an audio CAPTCHA instead.' : 'Trouble hearing? Switch to an image CAPTCHA.'"
                     />
                     />
                   </v-col>
                   </v-col>
-                  <v-col cols="8" sm="auto">
+                  <v-col cols="12" sm="auto">
                     <v-progress-circular
                     <v-progress-circular
                           indeterminate
                           indeterminate
                           v-if="captchaWorking"
                           v-if="captchaWorking"
                     ></v-progress-circular>
                     ></v-progress-circular>
                     <img
                     <img
-                          v-if="captcha && !captchaWorking"
+                          v-if="captcha && !captchaWorking && captcha_kind === 'image'"
                           :src="'data:image/png;base64,'+captcha.challenge"
                           :src="'data:image/png;base64,'+captcha.challenge"
                           alt="Sign up is also possible by sending an email to our support."
                           alt="Sign up is also possible by sending an email to our support."
+                    />
+                    <audio controls
+                          v-if="captcha && !captchaWorking && captcha_kind === 'audio'"
                     >
                     >
-                  </v-col>
-                  <v-col cols="4" sm="auto">
-                    <v-btn text outlined @click="getCaptcha(true)" :disabled="captchaWorking"><v-icon>mdi-refresh</v-icon></v-btn>
+                      <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-col>
                 </v-row>
                 </v-row>
               </v-container>
               </v-container>
@@ -186,6 +198,7 @@
       captchaSolution: '',
       captchaSolution: '',
       captcha_rules: [v => !!v || 'Please enter the text displayed in the picture so we are (somewhat) convinced you are human'],
       captcha_rules: [v => !!v || 'Please enter the text displayed in the picture so we are (somewhat) convinced you are human'],
       captcha_errors: [],
       captcha_errors: [],
+      captcha_kind: 'image',
 
 
       /* terms field */
       /* terms field */
       terms: false,
       terms: false,
@@ -217,7 +230,7 @@
         this.captchaWorking = true;
         this.captchaWorking = true;
         this.captchaSolution = "";
         this.captchaSolution = "";
         try {
         try {
-          this.captcha = (await HTTP.post('captcha/')).data;
+          this.captcha = (await HTTP.post('captcha/', {kind: this.captcha_kind})).data;
           if(focus) {
           if(focus) {
             this.$refs.captchaField.focus()
             this.$refs.captchaField.focus()
           }
           }
@@ -302,6 +315,11 @@
           this.$refs.domainField.validate();
           this.$refs.domainField.validate();
         })
         })
       },
       },
+      captcha_kind: function (oldKind, newKind) {
+        if (oldKind !== newKind) {
+          this.getCaptcha();
+        }
+      },
     },
     },
   };
   };
 </script>
 </script>

+ 1 - 1
www/conf/sites-available/90-desec.static.location

@@ -3,7 +3,7 @@
 #####
 #####
 location / {
 location / {
     add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload" always;
     add_header Strict-Transport-Security "max-age=31536000; includeSubdomains; preload" always;
-    add_header Content-Security-Policy "default-src 'self'; frame-src 'none'; connect-src 'self'; font-src 'self'; img-src 'self' data:; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'; block-all-mixed-content; form-action 'none';" always;
+    add_header Content-Security-Policy "default-src 'self'; frame-src 'none'; connect-src 'self'; font-src 'self'; img-src 'self' data:; media-src data:; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; base-uri 'self'; frame-ancestors 'none'; block-all-mixed-content; form-action 'none';" always;
     add_header X-Frame-Options "deny" always;
     add_header X-Frame-Options "deny" always;
     add_header X-Content-Type-Options "nosniff" always;
     add_header X-Content-Type-Options "nosniff" always;
     add_header Referrer-Policy "strict-origin-when-cross-origin" always;
     add_header Referrer-Policy "strict-origin-when-cross-origin" always;