diff --git a/src/pretix/base/forms/user.py b/src/pretix/base/forms/user.py index 1fbb00d88..983716e9a 100644 --- a/src/pretix/base/forms/user.py +++ b/src/pretix/base/forms/user.py @@ -115,5 +115,5 @@ class User2FADeviceAddForm(forms.Form): name = forms.CharField(label=_('Device name'), max_length=64) devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=( ('totp', _('Smartphone with the Authenticator application')), - ('u2f', _('U2F-compatible hardware token (e.g. Yubikey)')), + ('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')), )) diff --git a/src/pretix/base/migrations/0134_auto_20190909_1042.py b/src/pretix/base/migrations/0134_auto_20190909_1042.py new file mode 100644 index 000000000..dbeacf639 --- /dev/null +++ b/src/pretix/base/migrations/0134_auto_20190909_1042.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.4 on 2019-09-09 10:42 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0133_auto_20190830_1513'), + ] + + operations = [ + migrations.CreateModel( + name='WebAuthnDevice', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('confirmed', models.BooleanField(default=True)), + ('credential_id', models.CharField(max_length=255, null=True)), + ('rp_id', models.CharField(max_length=255, null=True)), + ('icon_url', models.CharField(max_length=255, null=True)), + ('ukey', models.TextField(null=True)), + ('pub_key', models.TextField(null=True)), + ('sign_count', models.IntegerField(default=0)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 82263b51c..22175ed9f 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -1,5 +1,5 @@ from ..settings import GlobalSettingsObject_SettingsStore -from .auth import U2FDevice, User +from .auth import U2FDevice, User, WebAuthnDevice from .base import CachedFile, LoggedModel, cachedfile_name from .checkin import Checkin, CheckinList from .devices import Device diff --git a/src/pretix/base/models/auth.py b/src/pretix/base/models/auth.py index d14e5038a..5d87306b0 100644 --- a/src/pretix/base/models/auth.py +++ b/src/pretix/base/models/auth.py @@ -1,5 +1,9 @@ +import binascii +import json from datetime import timedelta +from urllib.parse import urlparse +import webauthn from django.conf import settings from django.contrib.auth.models import ( AbstractBaseUser, BaseUserManager, PermissionsMixin, @@ -13,6 +17,9 @@ from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django_otp.models import Device from django_scopes import scopes_disabled +from u2flib_server.utils import ( + pub_key_from_der, websafe_decode, websafe_encode, +) from pretix.base.i18n import language from pretix.helpers.urls import build_absolute_uri @@ -380,3 +387,49 @@ class StaffSessionAuditLog(models.Model): class U2FDevice(Device): json_data = models.TextField() + + @property + def webauthnuser(self): + d = json.loads(self.json_data) + # We manually need to convert the pubkey from DER format (used in our + # former U2F implementation) to the format required by webauthn. This + # is based on the following example: + # https://www.w3.org/TR/webauthn/#sctn-encoded-credPubKey-examples + pub_key = pub_key_from_der(websafe_decode(d['publicKey'].replace('+', '-').replace('/', '_'))) + pub_key = binascii.unhexlify( + 'A5010203262001215820{:064x}225820{:064x}'.format( + pub_key.public_numbers().x, pub_key.public_numbers().y + ) + ) + return webauthn.WebAuthnUser( + d['keyHandle'], + self.user.email, + str(self.user), + settings.SITE_URL, + d['keyHandle'], + websafe_encode(pub_key), + 1, + urlparse(settings.SITE_URL).netloc + ) + + +class WebAuthnDevice(Device): + credential_id = models.CharField(max_length=255, null=True, blank=True) + rp_id = models.CharField(max_length=255, null=True, blank=True) + icon_url = models.CharField(max_length=255, null=True, blank=True) + ukey = models.TextField(null=True) + pub_key = models.TextField(null=True) + sign_count = models.IntegerField(default=0) + + @property + def webauthnuser(self): + return webauthn.WebAuthnUser( + self.ukey, + self.user.email, + str(self.user), + settings.SITE_URL, + self.credential_id, + self.pub_key, + self.sign_count, + urlparse(settings.SITE_URL).netloc + ) diff --git a/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html b/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html index 6c5c73aa8..f5996d536 100644 --- a/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html +++ b/src/pretix/control/templates/pretixcontrol/auth/login_2fa.html @@ -4,7 +4,7 @@ {% load static %} {% load compress %} {% block content %} -
+ {% csrf_token %}

{% trans "Welcome back!" %}

@@ -12,14 +12,14 @@

+ type="text" required="required" autofocus="autofocus" id="webauthn-response">
-
- {% trans "U2F failed. Check that the correct authentication device is correctly plugged in." %} +
+ {% trans "WebAuthn failed. Check that the correct authentication device is correctly plugged in." %}
{% if jsondata %}

- {% trans "Alternatively, connect your U2F device. If it has a button, touch it now. You might have to unplug the device and plug it back in again." %} + {% trans "Alternatively, connect your WebAuthn device. If it has a button, touch it now. You might have to unplug the device and plug it back in again." %}

{% endif %}
@@ -29,14 +29,14 @@
{% if jsondata %} - {% endif %} {% compress js %} - - + + {% endcompress %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/user/2fa_confirm_u2f.html b/src/pretix/control/templates/pretixcontrol/user/2fa_confirm_webauthn.html similarity index 63% rename from src/pretix/control/templates/pretixcontrol/user/2fa_confirm_u2f.html rename to src/pretix/control/templates/pretixcontrol/user/2fa_confirm_webauthn.html index 1f9980043..25cbdcd2b 100644 --- a/src/pretix/control/templates/pretixcontrol/user/2fa_confirm_u2f.html +++ b/src/pretix/control/templates/pretixcontrol/user/2fa_confirm_webauthn.html @@ -6,13 +6,13 @@ {% block title %}{% trans "Add a two-factor authentication device" %}{% endblock %} {% block content %}

{% trans "Add a two-factor authentication device" %}

-

+

- {% trans "Please connect your U2F device. If it has a button, touch it now. You might have to unplug the device and plug it back in again." %} + {% trans "Please connect your WebAuthn device. If it has a button, touch it now. You might have to unplug the device and plug it back in again." %}

-
+ {% csrf_token %} - +

+
{% trans "Device registration failed." %}
- {% compress js %} - - + + {% endcompress %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/user/2fa_main.html b/src/pretix/control/templates/pretixcontrol/user/2fa_main.html index b9db919b3..cab829244 100644 --- a/src/pretix/control/templates/pretixcontrol/user/2fa_main.html +++ b/src/pretix/control/templates/pretixcontrol/user/2fa_main.html @@ -78,6 +78,8 @@ {% if d.devicetype == "totp" %} + {% elif d.devicetype == "webauthn" %} + {% elif d.devicetype == "u2f" %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/user/reauth.html b/src/pretix/control/templates/pretixcontrol/user/reauth.html index f1a942204..7c68486c7 100644 --- a/src/pretix/control/templates/pretixcontrol/user/reauth.html +++ b/src/pretix/control/templates/pretixcontrol/user/reauth.html @@ -4,7 +4,7 @@ {% load compress %} {% load static %} {% block content %} -
{% if jsondata %} -
- {% trans "U2F failed. Check that the correct authentication device is correctly plugged in." %} +
+ {% trans "WebAuthn failed. Check that the correct authentication device is correctly plugged in." %}

- {% trans "Alternatively, you can use your U2F device." %} + {% trans "Alternatively, you can use your WebAuthn device." %}

{% endif %}
@@ -37,14 +37,14 @@
{% if jsondata %} - {% endif %} {% compress js %} - - + + {% endcompress %} {% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 10b3c4981..3b82cbb25 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -62,8 +62,8 @@ urlpatterns = [ name='user.settings.2fa.regenemergency'), url(r'^settings/2fa/totp/(?P[0-9]+)/confirm', user.User2FADeviceConfirmTOTPView.as_view(), name='user.settings.2fa.confirm.totp'), - url(r'^settings/2fa/u2f/(?P[0-9]+)/confirm', user.User2FADeviceConfirmU2FView.as_view(), - name='user.settings.2fa.confirm.u2f'), + url(r'^settings/2fa/webauthn/(?P[0-9]+)/confirm', user.User2FADeviceConfirmWebAuthnView.as_view(), + name='user.settings.2fa.confirm.webauthn'), url(r'^settings/2fa/(?P[^/]+)/(?P[0-9]+)/delete', user.User2FADeviceDeleteView.as_view(), name='user.settings.2fa.delete'), url(r'^organizers/$', organizer.OrganizerList.as_view(), name='organizers'), diff --git a/src/pretix/control/views/auth.py b/src/pretix/control/views/auth.py index 1d18c3cd4..c2f09940d 100644 --- a/src/pretix/control/views/auth.py +++ b/src/pretix/control/views/auth.py @@ -1,7 +1,9 @@ +import json import logging import time from urllib.parse import quote +import webauthn from django.conf import settings from django.contrib import messages from django.contrib.auth import ( @@ -17,15 +19,13 @@ from django.utils.http import is_safe_url from django.utils.translation import ugettext_lazy as _ from django.views.generic import TemplateView from django_otp import match_token -from u2flib_server import u2f -from u2flib_server.jsapi import DeviceRegistration -from u2flib_server.utils import rand_bytes from pretix.base.forms.auth import ( LoginForm, PasswordForgotForm, PasswordRecoverForm, RegistrationForm, ) -from pretix.base.models import TeamInvite, U2FDevice, User +from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice from pretix.base.services.mail import SendMailException +from pretix.helpers.webauthn import generate_challenge logger = logging.getLogger(__name__) @@ -302,7 +302,7 @@ class Recover(TemplateView): def get_u2f_appid(request): - return '%s://%s' % ('https' if request.is_secure() else 'http', request.get_host()) + return settings.SITE_URL class Login2FAView(TemplateView): @@ -333,15 +333,41 @@ class Login2FAView(TemplateView): token = request.POST.get('token', '').strip().replace(' ', '') valid = False - if '_u2f_challenge' in self.request.session and token.startswith('{'): - devices = [DeviceRegistration.wrap(device.json_data) - for device in U2FDevice.objects.filter(confirmed=True, user=self.user)] - challenge = self.request.session.pop('_u2f_challenge') + if 'webauthn_challenge' in self.request.session and token.startswith('{'): + challenge = self.request.session['webauthn_challenge'] + + resp = json.loads(self.request.POST.get("token")) try: - u2f.verify_authenticate(devices, challenge, token, [self.app_id]) - valid = True - except Exception: - logger.exception('U2F login failed') + devices = [WebAuthnDevice.objects.get(user=self.user, credential_id=resp.get("id"))] + except WebAuthnDevice.DoesNotExist: + devices = U2FDevice.objects.filter(user=self.user) + + for d in devices: + try: + wu = d.webauthnuser + + if isinstance(d, U2FDevice): + # RP_ID needs to be appId for U2F devices, but we can't + # set it that way in U2FDevice.webauthnuser, since that + # breaks the frontend part. + wu.rp_id = settings.SITE_URL + + webauthn_assertion_response = webauthn.WebAuthnAssertionResponse( + wu, + resp, + challenge, + settings.SITE_URL, + uv_required=False # User Verification + ) + sign_count = webauthn_assertion_response.verify() + except Exception: + logger.exception('U2F login failed') + else: + if isinstance(d, WebAuthnDevice): + d.sign_count = sign_count + d.save() + valid = True + break else: valid = match_token(self.user, token) @@ -359,18 +385,25 @@ class Login2FAView(TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data() - - devices = [DeviceRegistration.wrap(device.json_data) - for device in U2FDevice.objects.filter(confirmed=True, user=self.user)] + if 'webauthn_challenge' in self.request.session: + del self.request.session['webauthn_challenge'] + challenge = generate_challenge(32) + self.request.session['webauthn_challenge'] = challenge + devices = [ + device.webauthnuser for device in WebAuthnDevice.objects.filter(confirmed=True, user=self.user) + ] + [ + device.webauthnuser for device in U2FDevice.objects.filter(confirmed=True, user=self.user) + ] if devices: - challenge = u2f.start_authenticate(devices, challenge=rand_bytes(32)) - self.request.session['_u2f_challenge'] = challenge.json - ctx['jsondata'] = challenge.json - else: - if '_u2f_challenge' in self.request.session: - del self.request.session['_u2f_challenge'] - ctx['jsondata'] = None - + webauthn_assertion_options = webauthn.WebAuthnAssertionOptions( + devices, + challenge + ) + ad = webauthn_assertion_options.assertion_dict + ad['extensions'] = { + 'appid': get_u2f_appid(self.request) + } + ctx['jsondata'] = json.dumps(ad) return ctx def get(self, request, *args, **kwargs): diff --git a/src/pretix/control/views/user.py b/src/pretix/control/views/user.py index 2b91707ab..a6d13423d 100644 --- a/src/pretix/control/views/user.py +++ b/src/pretix/control/views/user.py @@ -1,9 +1,12 @@ import base64 +import json import logging +import os import time from collections import defaultdict -from urllib.parse import quote +from urllib.parse import quote, urlparse +import webauthn from django.conf import settings from django.contrib import messages from django.contrib.auth import update_session_auth_hash @@ -19,13 +22,10 @@ from django.views import View from django.views.generic import FormView, ListView, TemplateView, UpdateView from django_otp.plugins.otp_static.models import StaticDevice from django_otp.plugins.otp_totp.models import TOTPDevice -from u2flib_server import u2f -from u2flib_server.jsapi import DeviceRegistration -from u2flib_server.utils import rand_bytes from pretix.base.forms.user import User2FADeviceAddForm, UserSettingsForm from pretix.base.models import ( - Event, LogEntry, NotificationSetting, U2FDevice, User, + Event, LogEntry, NotificationSetting, U2FDevice, User, WebAuthnDevice, ) from pretix.base.models.auth import StaffSession from pretix.base.notifications import get_all_notification_types @@ -34,8 +34,9 @@ from pretix.control.permissions import ( AdministratorPermissionRequiredMixin, StaffMemberRequiredMixin, ) from pretix.control.views.auth import get_u2f_appid +from pretix.helpers.webauthn import generate_challenge, generate_ukey -REAL_DEVICE_TYPES = (TOTPDevice, U2FDevice) +REAL_DEVICE_TYPES = (TOTPDevice, WebAuthnDevice, U2FDevice) logger = logging.getLogger(__name__) @@ -52,23 +53,45 @@ class RecentAuthenticationRequiredMixin: class ReauthView(TemplateView): template_name = 'pretixcontrol/user/reauth.html' - @property - def app_id(self): - return get_u2f_appid(self.request) - def post(self, request, *args, **kwargs): password = request.POST.get("password", "") valid = False - if '_u2f_challenge' in self.request.session and password.startswith('{'): - devices = [DeviceRegistration.wrap(device.json_data) - for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user)] - challenge = self.request.session.pop('_u2f_challenge') + if 'webauthn_challenge' in self.request.session and password.startswith('{'): + challenge = self.request.session['webauthn_challenge'] + + resp = json.loads(password) try: - u2f.verify_authenticate(devices, challenge, password, [self.app_id]) - valid = True - except Exception: - logger.exception('U2F login failed') + devices = [WebAuthnDevice.objects.get(user=self.request.user, credential_id=resp.get("id"))] + except WebAuthnDevice.DoesNotExist: + devices = U2FDevice.objects.filter(user=self.request.user) + + for d in devices: + try: + wu = d.webauthnuser + + if isinstance(d, U2FDevice): + # RP_ID needs to be appId for U2F devices, but we can't + # set it that way in U2FDevice.webauthnuser, since that + # breaks the frontend part. + wu.rp_id = settings.SITE_URL + + webauthn_assertion_response = webauthn.WebAuthnAssertionResponse( + wu, + resp, + challenge, + settings.SITE_URL, + uv_required=False # User Verification + ) + sign_count = webauthn_assertion_response.verify() + except Exception: + logger.exception('U2F login failed') + else: + if isinstance(d, WebAuthnDevice): + d.sign_count = sign_count + d.save() + valid = True + break valid = valid or request.user.check_password(password) @@ -85,18 +108,25 @@ class ReauthView(TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data() - - devices = [DeviceRegistration.wrap(device.json_data) - for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user)] + if 'webauthn_challenge' in self.request.session: + del self.request.session['webauthn_challenge'] + challenge = generate_challenge(32) + self.request.session['webauthn_challenge'] = challenge + devices = [ + device.webauthnuser for device in WebAuthnDevice.objects.filter(confirmed=True, user=self.request.user) + ] + [ + device.webauthnuser for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user) + ] if devices: - challenge = u2f.start_authenticate(devices, challenge=rand_bytes(32)) - self.request.session['_u2f_challenge'] = challenge.json - ctx['jsondata'] = challenge.json - else: - if '_u2f_challenge' in self.request.session: - del self.request.session['_u2f_challenge'] - ctx['jsondata'] = None - + webauthn_assertion_options = webauthn.WebAuthnAssertionOptions( + devices, + challenge + ) + ad = webauthn_assertion_options.assertion_dict + ad['extensions'] = { + 'appid': get_u2f_appid(self.request) + } + ctx['jsondata'] = json.dumps(ad) return ctx @@ -200,6 +230,8 @@ class User2FAMainView(RecentAuthenticationRequiredMixin, TemplateView): obj.devicetype = 'totp' elif dt == U2FDevice: obj.devicetype = 'u2f' + elif dt == WebAuthnDevice: + obj.devicetype = 'webauthn' ctx['devices'] += objs return ctx @@ -212,11 +244,12 @@ class User2FADeviceAddView(RecentAuthenticationRequiredMixin, FormView): def form_valid(self, form): if form.cleaned_data['devicetype'] == 'totp': dev = TOTPDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name']) - elif form.cleaned_data['devicetype'] == 'u2f': + elif form.cleaned_data['devicetype'] == 'webauthn': if not self.request.is_secure(): - messages.error(self.request, _('U2F devices are only available if pretix is served via HTTPS.')) + messages.error(self.request, + _('Security devices are only available if pretix is served via HTTPS.')) return self.get(self.request, self.args, self.kwargs) - dev = U2FDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name']) + dev = WebAuthnDevice.objects.create(user=self.request.user, confirmed=False, name=form.cleaned_data['name']) return redirect(reverse('control:user.settings.2fa.confirm.' + form.cleaned_data['devicetype'], kwargs={ 'device': dev.pk })) @@ -233,6 +266,8 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView): def device(self): if self.kwargs['devicetype'] == 'totp': return get_object_or_404(TOTPDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True) + elif self.kwargs['devicetype'] == 'webauthn': + return get_object_or_404(WebAuthnDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True) elif self.kwargs['devicetype'] == 'u2f': return get_object_or_404(U2FDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=True) @@ -262,35 +297,94 @@ class User2FADeviceDeleteView(RecentAuthenticationRequiredMixin, TemplateView): return redirect(reverse('control:user.settings.2fa')) -class User2FADeviceConfirmU2FView(RecentAuthenticationRequiredMixin, TemplateView): - template_name = 'pretixcontrol/user/2fa_confirm_u2f.html' - - @property - def app_id(self): - return get_u2f_appid(self.request) +class User2FADeviceConfirmWebAuthnView(RecentAuthenticationRequiredMixin, TemplateView): + template_name = 'pretixcontrol/user/2fa_confirm_webauthn.html' @cached_property def device(self): - return get_object_or_404(U2FDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=False) + return get_object_or_404(WebAuthnDevice, user=self.request.user, pk=self.kwargs['device'], confirmed=False) def get_context_data(self, **kwargs): ctx = super().get_context_data() ctx['device'] = self.device - devices = [DeviceRegistration.wrap(device.json_data) - for device in U2FDevice.objects.filter(confirmed=True, user=self.request.user)] - enroll = u2f.start_register(self.app_id, devices) - self.request.session['_u2f_enroll'] = enroll.json - ctx['jsondata'] = enroll.json + if 'webauthn_register_ukey' in self.request.session: + del self.request.session['webauthn_register_ukey'] + if 'webauthn_challenge' in self.request.session: + del self.request.session['webauthn_challenge'] + + challenge = generate_challenge(32) + ukey = generate_ukey() + + self.request.session['webauthn_challenge'] = challenge + self.request.session['webauthn_register_ukey'] = ukey + + make_credential_options = webauthn.WebAuthnMakeCredentialOptions( + challenge, + urlparse(settings.SITE_URL).netloc, + urlparse(settings.SITE_URL).netloc, + ukey, + self.request.user.email, + str(self.request.user), + settings.SITE_URL + ) + ctx['jsondata'] = json.dumps(make_credential_options.registration_dict) return ctx def post(self, request, *args, **kwargs): try: - binding, cert = u2f.complete_register(self.request.session.pop('_u2f_enroll'), - request.POST.get('token'), - [self.app_id]) - self.device.json_data = binding.json + challenge = self.request.session['webauthn_challenge'] + ukey = self.request.session['webauthn_register_ukey'] + resp = json.loads(self.request.POST.get("token")) + trust_anchor_dir = os.path.normpath(os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '../../static/webauthn_trusted_attestation_roots' # currently does not exist + )) + # We currently do not check attestation certificates, since there's no real risk + # and we do not have any policies specifying what devices can be used. (Also, we + # didn't get it to work.) + # Read more: https://fidoalliance.org/fido-technotes-the-truth-about-attestation/ + trusted_attestation_cert_required = False + self_attestation_permitted = True + none_attestation_permitted = True + + webauthn_registration_response = webauthn.WebAuthnRegistrationResponse( + urlparse(settings.SITE_URL).netloc, + settings.SITE_URL, + resp, + challenge, + trust_anchor_dir, + trusted_attestation_cert_required, + self_attestation_permitted, + none_attestation_permitted, + uv_required=False + ) + webauthn_credential = webauthn_registration_response.verify() + + # Check that the credentialId is not yet registered to any other user. + # If registration is requested for a credential that is already registered + # to a different user, the Relying Party SHOULD fail this registration + # ceremony, or it MAY decide to accept the registration, e.g. while deleting + # the older registration. + credential_id_exists = WebAuthnDevice.objects.filter( + credential_id=webauthn_credential.credential_id + ).first() + if credential_id_exists: + messages.error(request, _('This security device is already registered.')) + return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={ + 'device': self.device.pk + })) + + webauthn_credential.credential_id = str(webauthn_credential.credential_id, "utf-8") + webauthn_credential.public_key = str(webauthn_credential.public_key, "utf-8") + + self.device.credential_id = webauthn_credential.credential_id + self.device.ukey = ukey + self.device.pub_key = webauthn_credential.public_key + self.device.sign_count = webauthn_credential.sign_count + self.device.rp_id = urlparse(settings.SITE_URL).netloc + self.device.icon_url = settings.SITE_URL self.device.confirmed = True self.device.save() self.request.user.log_action('pretix.user.settings.2fa.device.added', user=self.request.user, data={ @@ -320,8 +414,8 @@ class User2FADeviceConfirmU2FView(RecentAuthenticationRequiredMixin, TemplateVie return redirect(reverse('control:user.settings.2fa')) except Exception: messages.error(request, _('The registration could not be completed. Please try again.')) - logger.exception('U2F registration failed') - return redirect(reverse('control:user.settings.2fa.confirm.u2f', kwargs={ + logger.exception('WebAuthn registration failed') + return redirect(reverse('control:user.settings.2fa.confirm.webauthn', kwargs={ 'device': self.device.pk })) diff --git a/src/pretix/helpers/webauthn.py b/src/pretix/helpers/webauthn.py new file mode 100644 index 000000000..c92f572d3 --- /dev/null +++ b/src/pretix/helpers/webauthn.py @@ -0,0 +1,24 @@ +import random +import string + + +def generate_challenge(challenge_len): + return ''.join([ + random.SystemRandom().choice(string.ascii_letters + string.digits) + for i in range(challenge_len) + ]) + + +def generate_ukey(): + """ + Its value's id member is required, and contains an identifier + for the account, specified by the Relying Party. This is not meant + to be displayed to the user, but is used by the Relying Party to + control the number of credentials - an authenticator will never + contain more than one credential for a given Relying Party under + the same id. + A unique identifier for the entity. For a relying party entity, + sets the RP ID. For a user account entity, this will be an + arbitrary string specified by the relying party. + """ + return generate_challenge(20) diff --git a/src/pretix/static/pretixcontrol/js/base64js.js b/src/pretix/static/pretixcontrol/js/base64js.js new file mode 100644 index 000000000..69fcb107b --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/base64js.js @@ -0,0 +1,118 @@ +var lookup = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' + +;(function (exports) { + 'use strict' + + var Arr = (typeof Uint8Array !== 'undefined') + ? Uint8Array + : Array + + var PLUS = '+'.charCodeAt(0) + var SLASH = '/'.charCodeAt(0) + var NUMBER = '0'.charCodeAt(0) + var LOWER = 'a'.charCodeAt(0) + var UPPER = 'A'.charCodeAt(0) + var PLUS_URL_SAFE = '-'.charCodeAt(0) + var SLASH_URL_SAFE = '_'.charCodeAt(0) + + function decode (elt) { + var code = elt.charCodeAt(0) + if (code === PLUS || code === PLUS_URL_SAFE) return 62 // '+' + if (code === SLASH || code === SLASH_URL_SAFE) return 63 // '/' + if (code < NUMBER) return -1 // no match + if (code < NUMBER + 10) return code - NUMBER + 26 + 26 + if (code < UPPER + 26) return code - UPPER + if (code < LOWER + 26) return code - LOWER + 26 + } + + function b64ToByteArray (b64) { + var i, j, l, tmp, placeHolders, arr + + if (b64.length % 4 > 0) { + throw new Error('Invalid string. Length must be a multiple of 4') + } + + // the number of equal signs (place holders) + // if there are two placeholders, than the two characters before it + // represent one byte + // if there is only one, then the three characters before it represent 2 bytes + // this is just a cheap hack to not do indexOf twice + var len = b64.length + placeHolders = b64.charAt(len - 2) === '=' ? 2 : b64.charAt(len - 1) === '=' ? 1 : 0 + + // base64 is 4/3 + up to two characters of the original data + arr = new Arr(b64.length * 3 / 4 - placeHolders) + + // if there are placeholders, only get up to the last complete 4 chars + l = placeHolders > 0 ? b64.length - 4 : b64.length + + var L = 0 + + function push (v) { + arr[L++] = v + } + + for (i = 0, j = 0; i < l; i += 4, j += 3) { + tmp = (decode(b64.charAt(i)) << 18) | (decode(b64.charAt(i + 1)) << 12) | (decode(b64.charAt(i + 2)) << 6) | decode(b64.charAt(i + 3)) + push((tmp & 0xFF0000) >> 16) + push((tmp & 0xFF00) >> 8) + push(tmp & 0xFF) + } + + if (placeHolders === 2) { + tmp = (decode(b64.charAt(i)) << 2) | (decode(b64.charAt(i + 1)) >> 4) + push(tmp & 0xFF) + } else if (placeHolders === 1) { + tmp = (decode(b64.charAt(i)) << 10) | (decode(b64.charAt(i + 1)) << 4) | (decode(b64.charAt(i + 2)) >> 2) + push((tmp >> 8) & 0xFF) + push(tmp & 0xFF) + } + + return arr + } + + function uint8ToBase64 (uint8) { + var i + var extraBytes = uint8.length % 3 // if we have 1 byte left, pad 2 bytes + var output = '' + var temp, length + + function encode (num) { + return lookup.charAt(num) + } + + function tripletToBase64 (num) { + return encode(num >> 18 & 0x3F) + encode(num >> 12 & 0x3F) + encode(num >> 6 & 0x3F) + encode(num & 0x3F) + } + + // go through the array every three bytes, we'll deal with trailing stuff later + for (i = 0, length = uint8.length - extraBytes; i < length; i += 3) { + temp = (uint8[i] << 16) + (uint8[i + 1] << 8) + (uint8[i + 2]) + output += tripletToBase64(temp) + } + + // pad the end with zeros, but make sure to not forget the extra bytes + switch (extraBytes) { + case 1: + temp = uint8[uint8.length - 1] + output += encode(temp >> 2) + output += encode((temp << 4) & 0x3F) + output += '==' + break + case 2: + temp = (uint8[uint8.length - 2] << 8) + (uint8[uint8.length - 1]) + output += encode(temp >> 10) + output += encode((temp >> 4) & 0x3F) + output += encode((temp << 2) & 0x3F) + output += '=' + break + default: + break + } + + return output + } + + exports.toByteArray = b64ToByteArray + exports.fromByteArray = uint8ToBase64 +}(typeof exports === 'undefined' ? (this.base64js = {}) : exports)) diff --git a/src/pretix/static/pretixcontrol/js/ui/u2f-api.js b/src/pretix/static/pretixcontrol/js/ui/u2f-api.js deleted file mode 100644 index 8328ec60e..000000000 --- a/src/pretix/static/pretixcontrol/js/ui/u2f-api.js +++ /dev/null @@ -1,760 +0,0 @@ -//Copyright 2014-2015 Google Inc. All rights reserved. -// Extended by Raphael Michel 2017 for compatibility with Firefox 57 -//Use of this source code is governed by a BSD-style -//license that can be found in the LICENSE file or at -//https://developers.google.com/open-source/licenses/bsd - -/** - * @fileoverview The U2F api. - */ -'use strict'; - - -var build_u2f_object = function () { - /** - * Namespace for the U2F api. - * @type {Object} - */ - var u2f = u2f || {}; - - /** - * FIDO U2F Javascript API Version - * @number - */ - var js_api_version; - - /** - * The U2F extension id - * @const {string} - */ - // The Chrome packaged app extension ID. - // Uncomment this if you want to deploy a server instance that uses - // the package Chrome app and does not require installing the U2F Chrome extension. - u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; - // The U2F Chrome extension ID. - // Uncomment this if you want to deploy a server instance that uses - // the U2F Chrome extension to authenticate. - // u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; - - - /** - * Message types for messsages to/from the extension - * @const - * @enum {string} - */ - u2f.MessageTypes = { - 'U2F_REGISTER_REQUEST': 'u2f_register_request', - 'U2F_REGISTER_RESPONSE': 'u2f_register_response', - 'U2F_SIGN_REQUEST': 'u2f_sign_request', - 'U2F_SIGN_RESPONSE': 'u2f_sign_response', - 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', - 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' - }; - - - /** - * Response status codes - * @const - * @enum {number} - */ - u2f.ErrorCodes = { - 'OK': 0, - 'OTHER_ERROR': 1, - 'BAD_REQUEST': 2, - 'CONFIGURATION_UNSUPPORTED': 3, - 'DEVICE_INELIGIBLE': 4, - 'TIMEOUT': 5 - }; - - - /** - * A message for registration requests - * @typedef {{ - * type: u2f.MessageTypes, - * appId: ?string, - * timeoutSeconds: ?number, - * requestId: ?number - * }} - */ - u2f.U2fRequest; - - - /** - * A message for registration responses - * @typedef {{ - * type: u2f.MessageTypes, - * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), - * requestId: ?number - * }} - */ - u2f.U2fResponse; - - - /** - * An error object for responses - * @typedef {{ - * errorCode: u2f.ErrorCodes, - * errorMessage: ?string - * }} - */ - u2f.Error; - - /** - * Data object for a single sign request. - * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} - */ - u2f.Transport; - - - /** - * Data object for a single sign request. - * @typedef {Array} - */ - u2f.Transports; - - /** - * Data object for a single sign request. - * @typedef {{ - * version: string, - * challenge: string, - * keyHandle: string, - * appId: string - * }} - */ - u2f.SignRequest; - - - /** - * Data object for a sign response. - * @typedef {{ - * keyHandle: string, - * signatureData: string, - * clientData: string - * }} - */ - u2f.SignResponse; - - - /** - * Data object for a registration request. - * @typedef {{ - * version: string, - * challenge: string - * }} - */ - u2f.RegisterRequest; - - - /** - * Data object for a registration response. - * @typedef {{ - * version: string, - * keyHandle: string, - * transports: Transports, - * appId: string - * }} - */ - u2f.RegisterResponse; - - - /** - * Data object for a registered key. - * @typedef {{ - * version: string, - * keyHandle: string, - * transports: ?Transports, - * appId: ?string - * }} - */ - u2f.RegisteredKey; - - - /** - * Data object for a get API register response. - * @typedef {{ - * js_api_version: number - * }} - */ - u2f.GetJsApiVersionResponse; - - - //Low level MessagePort API support - - /** - * Sets up a MessagePort to the U2F extension using the - * available mechanisms. - * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback - */ - u2f.getMessagePort = function (callback) { - if (typeof chrome != 'undefined' && chrome.runtime) { - // The actual message here does not matter, but we need to get a reply - // for the callback to run. Thus, send an empty signature request - // in order to get a failure response. - var msg = { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - signRequests: [] - }; - chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function () { - if (!chrome.runtime.lastError) { - // We are on a whitelisted origin and can talk directly - // with the extension. - u2f.getChromeRuntimePort_(callback); - } else { - // chrome.runtime was available, but we couldn't message - // the extension directly, use iframe - u2f.getIframePort_(callback); - } - }); - } else if (u2f.isAndroidChrome_()) { - u2f.getAuthenticatorPort_(callback); - } else if (u2f.isIosChrome_()) { - u2f.getIosPort_(callback); - } else { - // chrome.runtime was not available at all, which is normal - // when this origin doesn't have access to any extensions. - u2f.getIframePort_(callback); - } - }; - - /** - * Detect chrome running on android based on the browser's useragent. - * @private - */ - u2f.isAndroidChrome_ = function () { - var userAgent = navigator.userAgent; - return userAgent.indexOf('Chrome') != -1 && - userAgent.indexOf('Android') != -1; - }; - - /** - * Detect chrome running on iOS based on the browser's platform. - * @private - */ - u2f.isIosChrome_ = function () { - return $.inArray(navigator.platform, ["iPhone", "iPad", "iPod"]) > -1; - }; - - /** - * Connects directly to the extension via chrome.runtime.connect. - * @param {function(u2f.WrappedChromeRuntimePort_)} callback - * @private - */ - u2f.getChromeRuntimePort_ = function (callback) { - var port = chrome.runtime.connect(u2f.EXTENSION_ID, - {'includeTlsChannelId': true}); - setTimeout(function () { - callback(new u2f.WrappedChromeRuntimePort_(port)); - }, 0); - }; - - /** - * Return a 'port' abstraction to the Authenticator app. - * @param {function(u2f.WrappedAuthenticatorPort_)} callback - * @private - */ - u2f.getAuthenticatorPort_ = function (callback) { - setTimeout(function () { - callback(new u2f.WrappedAuthenticatorPort_()); - }, 0); - }; - - /** - * Return a 'port' abstraction to the iOS client app. - * @param {function(u2f.WrappedIosPort_)} callback - * @private - */ - u2f.getIosPort_ = function (callback) { - setTimeout(function () { - callback(new u2f.WrappedIosPort_()); - }, 0); - }; - - /** - * A wrapper for chrome.runtime.Port that is compatible with MessagePort. - * @param {Port} port - * @constructor - * @private - */ - u2f.WrappedChromeRuntimePort_ = function (port) { - this.port_ = port; - }; - - /** - * Format and return a sign request compliant with the JS API version supported by the extension. - * @param {Array} signRequests - * @param {number} timeoutSeconds - * @param {number} reqId - * @return {Object} - */ - u2f.formatSignRequest_ = - function (appId, challenge, registeredKeys, timeoutSeconds, reqId) { - if (js_api_version === undefined || js_api_version < 1.1) { - // Adapt request to the 1.0 JS API - var signRequests = []; - for (var i = 0; i < registeredKeys.length; i++) { - signRequests[i] = { - version: registeredKeys[i].version, - challenge: challenge, - keyHandle: registeredKeys[i].keyHandle, - appId: appId - }; - } - return { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - signRequests: signRequests, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - } - // JS 1.1 API - return { - type: u2f.MessageTypes.U2F_SIGN_REQUEST, - appId: appId, - challenge: challenge, - registeredKeys: registeredKeys, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - }; - - /** - * Format and return a register request compliant with the JS API version supported by the extension.. - * @param {Array} signRequests - * @param {Array} signRequests - * @param {number} timeoutSeconds - * @param {number} reqId - * @return {Object} - */ - u2f.formatRegisterRequest_ = - function (appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { - if (js_api_version === undefined || js_api_version < 1.1) { - // Adapt request to the 1.0 JS API - for (var i = 0; i < registerRequests.length; i++) { - registerRequests[i].appId = appId; - } - var signRequests = []; - for (var i = 0; i < registeredKeys.length; i++) { - signRequests[i] = { - version: registeredKeys[i].version, - challenge: registerRequests[0], - keyHandle: registeredKeys[i].keyHandle, - appId: appId - }; - } - return { - type: u2f.MessageTypes.U2F_REGISTER_REQUEST, - signRequests: signRequests, - registerRequests: registerRequests, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - } - // JS 1.1 API - return { - type: u2f.MessageTypes.U2F_REGISTER_REQUEST, - appId: appId, - registerRequests: registerRequests, - registeredKeys: registeredKeys, - timeoutSeconds: timeoutSeconds, - requestId: reqId - }; - }; - - - /** - * Posts a message on the underlying channel. - * @param {Object} message - */ - u2f.WrappedChromeRuntimePort_.prototype.postMessage = function (message) { - this.port_.postMessage(message); - }; - - - /** - * Emulates the HTML 5 addEventListener interface. Works only for the - * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. - * @param {string} eventName - * @param {function({data: Object})} handler - */ - u2f.WrappedChromeRuntimePort_.prototype.addEventListener = - function (eventName, handler) { - var name = eventName.toLowerCase(); - if (name == 'message' || name == 'onmessage') { - this.port_.onMessage.addListener(function (message) { - // Emulate a minimal MessageEvent object - handler({'data': message}); - }); - } else { - console.error('WrappedChromeRuntimePort only supports onMessage'); - } - }; - - /** - * Wrap the Authenticator app with a MessagePort interface. - * @constructor - * @private - */ - u2f.WrappedAuthenticatorPort_ = function () { - this.requestId_ = -1; - this.requestObject_ = null; - } - - /** - * Launch the Authenticator intent. - * @param {Object} message - */ - u2f.WrappedAuthenticatorPort_.prototype.postMessage = function (message) { - var intentUrl = - u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + - ';S.request=' + encodeURIComponent(JSON.stringify(message)) + - ';end'; - document.location = intentUrl; - }; - - /** - * Tells what type of port this is. - * @return {String} port type - */ - u2f.WrappedAuthenticatorPort_.prototype.getPortType = function () { - return "WrappedAuthenticatorPort_"; - }; - - - /** - * Emulates the HTML 5 addEventListener interface. - * @param {string} eventName - * @param {function({data: Object})} handler - */ - u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function (eventName, handler) { - var name = eventName.toLowerCase(); - if (name == 'message') { - var self = this; - /* Register a callback to that executes when - * chrome injects the response. */ - window.addEventListener( - 'message', self.onRequestUpdate_.bind(self, handler), false); - } else { - console.error('WrappedAuthenticatorPort only supports message'); - } - }; - - /** - * Callback invoked when a response is received from the Authenticator. - * @param function({data: Object}) callback - * @param {Object} message message Object - */ - u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = - function (callback, message) { - var messageObject = JSON.parse(message.data); - var intentUrl = messageObject['intentURL']; - - var errorCode = messageObject['errorCode']; - var responseObject = null; - if (messageObject.hasOwnProperty('data')) { - responseObject = /** @type {Object} */ ( - JSON.parse(messageObject['data'])); - } - - callback({'data': responseObject}); - }; - - /** - * Base URL for intents to Authenticator. - * @const - * @private - */ - u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = - 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; - - /** - * Wrap the iOS client app with a MessagePort interface. - * @constructor - * @private - */ - u2f.WrappedIosPort_ = function () { - }; - - /** - * Launch the iOS client app request - * @param {Object} message - */ - u2f.WrappedIosPort_.prototype.postMessage = function (message) { - var str = JSON.stringify(message); - var url = "u2f://auth?" + encodeURI(str); - location.replace(url); - }; - - /** - * Tells what type of port this is. - * @return {String} port type - */ - u2f.WrappedIosPort_.prototype.getPortType = function () { - return "WrappedIosPort_"; - }; - - /** - * Emulates the HTML 5 addEventListener interface. - * @param {string} eventName - * @param {function({data: Object})} handler - */ - u2f.WrappedIosPort_.prototype.addEventListener = function (eventName, handler) { - var name = eventName.toLowerCase(); - if (name !== 'message') { - console.error('WrappedIosPort only supports message'); - } - }; - - /** - * Sets up an embedded trampoline iframe, sourced from the extension. - * @param {function(MessagePort)} callback - * @private - */ - u2f.getIframePort_ = function (callback) { - // Create the iframe - var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; - var iframe = document.createElement('iframe'); - iframe.src = iframeOrigin + '/u2f-comms.html'; - iframe.setAttribute('style', 'display:none'); - document.body.appendChild(iframe); - - var channel = new MessageChannel(); - var ready = function (message) { - if (message.data == 'ready') { - channel.port1.removeEventListener('message', ready); - callback(channel.port1); - } else { - console.error('First event on iframe port was not "ready"'); - } - }; - channel.port1.addEventListener('message', ready); - channel.port1.start(); - - iframe.addEventListener('load', function () { - // Deliver the port to the iframe and initialize - iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); - }); - }; - - - //High-level JS API - - /** - * Default extension response timeout in seconds. - * @const - */ - u2f.EXTENSION_TIMEOUT_SEC = 30; - - /** - * A singleton instance for a MessagePort to the extension. - * @type {MessagePort|u2f.WrappedChromeRuntimePort_} - * @private - */ - u2f.port_ = null; - - /** - * Callbacks waiting for a port - * @type {Array} - * @private - */ - u2f.waitingForPort_ = []; - - /** - * A counter for requestIds. - * @type {number} - * @private - */ - u2f.reqCounter_ = 0; - - /** - * A map from requestIds to client callbacks - * @type {Object.} - * @private - */ - u2f.callbackMap_ = {}; - - /** - * Creates or retrieves the MessagePort singleton to use. - * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback - * @private - */ - u2f.getPortSingleton_ = function (callback) { - if (u2f.port_) { - callback(u2f.port_); - } else { - if (u2f.waitingForPort_.length == 0) { - u2f.getMessagePort(function (port) { - u2f.port_ = port; - u2f.port_.addEventListener('message', - /** @type {function(Event)} */ (u2f.responseHandler_)); - - // Careful, here be async callbacks. Maybe. - while (u2f.waitingForPort_.length) - u2f.waitingForPort_.shift()(u2f.port_); - }); - } - u2f.waitingForPort_.push(callback); - } - }; - - /** - * Handles response messages from the extension. - * @param {MessageEvent.} message - * @private - */ - u2f.responseHandler_ = function (message) { - var response = message.data; - var reqId = response['requestId']; - if (!reqId || !u2f.callbackMap_[reqId]) { - console.error('Unknown or missing requestId in response.'); - return; - } - var cb = u2f.callbackMap_[reqId]; - delete u2f.callbackMap_[reqId]; - cb(response['responseData']); - }; - - /** - * Dispatches an array of sign requests to available U2F tokens. - * If the JS API version supported by the extension is unknown, it first sends a - * message to the extension to find out the supported API version and then it sends - * the sign request. - * @param {string=} appId - * @param {string=} challenge - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.SignResponse))} callback - * @param {number=} opt_timeoutSeconds - */ - u2f.sign = function (appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { - if (js_api_version === undefined) { - // Send a message to get the extension to JS API version, then send the actual sign request. - u2f.getApiVersion( - function (response) { - js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; - console.log("Extension JS API Version: ", js_api_version); - u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); - }); - } else { - // We know the JS API version. Send the actual sign request in the supported API version. - u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); - } - }; - - /** - * Dispatches an array of sign requests to available U2F tokens. - * @param {string=} appId - * @param {string=} challenge - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.SignResponse))} callback - * @param {number=} opt_timeoutSeconds - */ - u2f.sendSignRequest = function (appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { - u2f.getPortSingleton_(function (port) { - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? - opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); - var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); - port.postMessage(req); - }); - }; - - /** - * Dispatches register requests to available U2F tokens. An array of sign - * requests identifies already registered tokens. - * If the JS API version supported by the extension is unknown, it first sends a - * message to the extension to find out the supported API version and then it sends - * the register request. - * @param {string=} appId - * @param {Array} registerRequests - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.RegisterResponse))} callback - * @param {number=} opt_timeoutSeconds - */ - u2f.register = function (appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { - if (js_api_version === undefined) { - // Send a message to get the extension to JS API version, then send the actual register request. - u2f.getApiVersion( - function (response) { - js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; - console.log("Extension JS API Version: ", js_api_version); - u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, - callback, opt_timeoutSeconds); - }); - } else { - // We know the JS API version. Send the actual register request in the supported API version. - u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, - callback, opt_timeoutSeconds); - } - }; - - /** - * Dispatches register requests to available U2F tokens. An array of sign - * requests identifies already registered tokens. - * @param {string=} appId - * @param {Array} registerRequests - * @param {Array} registeredKeys - * @param {function((u2f.Error|u2f.RegisterResponse))} callback - * @param {number=} opt_timeoutSeconds - */ - u2f.sendRegisterRequest = function (appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { - u2f.getPortSingleton_(function (port) { - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? - opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); - var req = u2f.formatRegisterRequest_( - appId, registeredKeys, registerRequests, timeoutSeconds, reqId); - port.postMessage(req); - }); - }; - - - /** - * Dispatches a message to the extension to find out the supported - * JS API version. - * If the user is on a mobile phone and is thus using Google Authenticator instead - * of the Chrome extension, don't send the request and simply return 0. - * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback - * @param {number=} opt_timeoutSeconds - */ - u2f.getApiVersion = function (callback, opt_timeoutSeconds) { - u2f.getPortSingleton_(function (port) { - // If we are using Android Google Authenticator or iOS client app, - // do not fire an intent to ask which JS API version to use. - if (port.getPortType) { - var apiVersion; - switch (port.getPortType()) { - case 'WrappedIosPort_': - case 'WrappedAuthenticatorPort_': - apiVersion = 1.1; - break; - - default: - apiVersion = 0; - break; - } - callback({'js_api_version': apiVersion}); - return; - } - var reqId = ++u2f.reqCounter_; - u2f.callbackMap_[reqId] = callback; - var req = { - type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, - timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? - opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), - requestId: reqId - }; - port.postMessage(req); - }); - }; - - return u2f; -}; - -try { - var u2f = build_u2f_object(); -} catch (TypeError) { - // Firefox 57 sets the u2f object itself, we are not allowed to override it, - // so fail silently. -} \ No newline at end of file diff --git a/src/pretix/static/pretixcontrol/js/ui/u2f.js b/src/pretix/static/pretixcontrol/js/ui/u2f.js deleted file mode 100644 index 7dc7c7856..000000000 --- a/src/pretix/static/pretixcontrol/js/ui/u2f.js +++ /dev/null @@ -1,40 +0,0 @@ -/*global $,u2f */ -$(function () { - $("#u2f-progress").hide(); - if ($("#u2f-enroll").length) { - var request = JSON.parse($.trim($("#u2f-enroll").html())); - $("#u2f-progress").show(); - setTimeout(function () { - var appId = request.registerRequests[0].appId; - u2f.register(appId, request.registerRequests, [], function (data) { - if (data.errorCode) { - $("#u2f-error").removeClass("sr-only"); - $("#u2f-progress").remove(); - } else { - $('#u2f-response').val(JSON.stringify(data)); - $('#u2f-form').submit(); - } - }, 300); - }, 100); - } else if ($("#u2f-login").length) { - var request = JSON.parse($.trim($("#u2f-login").html())); - $("#u2f-progress").show(); - setTimeout(function () { - var firstr = request.authenticateRequests[0]; - var appId = firstr.appId; - var registeredKeys = []; - var reqs = request.authenticateRequests; - for (var i = 0; i < reqs.length; i++) { - registeredKeys.push({version: reqs[i].version, keyHandle: reqs[i].keyHandle}); - } - u2f.sign(appId, firstr.challenge, registeredKeys, function (data) { - if (data.errorCode && data.errorCode != 5) { - $("#u2f-error").removeClass("sr-only"); - } else { - $('#u2f-response, #id_password').val(JSON.stringify(data)); - $('#u2f-form').submit(); - } - }, 300); - }, 100); - } -}); diff --git a/src/pretix/static/pretixcontrol/js/ui/webauthn.js b/src/pretix/static/pretixcontrol/js/ui/webauthn.js new file mode 100644 index 000000000..5ec7c951b --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/webauthn.js @@ -0,0 +1,179 @@ +/*global $,u2f */ + +function b64enc(buf) { + return base64js.fromByteArray(buf) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function b64RawEnc(buf) { + return base64js.fromByteArray(buf) + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +function hexEncode(buf) { + return Array.from(buf) + .map(function(x) { + return ("0" + x.toString(16)).substr(-2); + }) + .join(""); +} + +async function fetch_json(url, options) { + const response = await fetch(url, options); + const body = await response.json(); + if (body.fail) + throw body.fail; + return body; +} + +/** + * Transforms items in the credentialCreateOptions generated on the server + * into byte arrays expected by the navigator.credentials.create() call + * @param {Object} credentialCreateOptionsFromServer + */ +const transformCredentialCreateOptions = function (credentialCreateOptionsFromServer) { + let {challenge, user} = credentialCreateOptionsFromServer; + user.id = Uint8Array.from( + atob(credentialCreateOptionsFromServer.user.id), c => c.charCodeAt(0)); + + challenge = Uint8Array.from( + atob(credentialCreateOptionsFromServer.challenge), c => c.charCodeAt(0)); + + const transformedCredentialCreateOptions = Object.assign( + {}, credentialCreateOptionsFromServer, + {challenge, user}); + + return transformedCredentialCreateOptions; +}; + + +/** + * Transforms the binary data in the credential into base64 strings + * for posting to the server. + * @param {PublicKeyCredential} newAssertion + */ +const transformNewAssertionForServer = (newAssertion) => { + const attObj = new Uint8Array( + newAssertion.response.attestationObject); + const clientDataJSON = new Uint8Array( + newAssertion.response.clientDataJSON); + const rawId = new Uint8Array( + newAssertion.rawId); + + const registrationClientExtensions = newAssertion.getClientExtensionResults(); + + return { + id: newAssertion.id, + rawId: b64enc(rawId), + type: newAssertion.type, + attObj: b64enc(attObj), + clientData: b64enc(clientDataJSON), + registrationClientExtensions: JSON.stringify(registrationClientExtensions) + }; +}; + + +const transformCredentialRequestOptions = (credentialRequestOptionsFromServer) => { + let {challenge, allowCredentials} = credentialRequestOptionsFromServer; + + challenge = Uint8Array.from( + atob(challenge), c => c.charCodeAt(0)); + + allowCredentials = allowCredentials.map(credentialDescriptor => { + let {id} = credentialDescriptor; + id = id.replace(/\_/g, "/").replace(/\-/g, "+"); + id = Uint8Array.from(atob(id), c => c.charCodeAt(0)); + return Object.assign({}, credentialDescriptor, {id}); + }); + + const transformedCredentialRequestOptions = Object.assign( + {}, + credentialRequestOptionsFromServer, + {challenge, allowCredentials}); + + return transformedCredentialRequestOptions; +}; + +/** + * Encodes the binary data in the assertion into strings for posting to the server. + * @param {PublicKeyCredential} newAssertion + */ +const transformAssertionForServer = (newAssertion) => { + const authData = new Uint8Array(newAssertion.response.authenticatorData); + const clientDataJSON = new Uint8Array(newAssertion.response.clientDataJSON); + const rawId = new Uint8Array(newAssertion.rawId); + const sig = new Uint8Array(newAssertion.response.signature); + const assertionClientExtensions = newAssertion.getClientExtensionResults(); + + return { + id: newAssertion.id, + rawId: b64enc(rawId), + type: newAssertion.type, + authData: b64RawEnc(authData), + clientData: b64RawEnc(clientDataJSON), + signature: hexEncode(sig), + assertionClientExtensions: JSON.stringify(assertionClientExtensions) + }; +}; + +const startRegister = async (e) => { + const publicKeyCredentialCreateOptions = transformCredentialCreateOptions(JSON.parse($("#webauthn-enroll").text())); + + // request the authenticator(s) to create a new credential keypair. + let credential; + try { + credential = await navigator.credentials.create({ + publicKey: publicKeyCredentialCreateOptions + }); + } catch (err) { + $("#webauthn-error").removeClass("sr-only"); + return console.error("Error creating credential:", err); + } + + // we now have a new credential! We now need to encode the byte arrays + // in the credential into strings, for posting to our server. + const newAssertionForServer = transformNewAssertionForServer(credential); + + $("#webauthn-response").val(JSON.stringify(newAssertionForServer)); + $("#webauthn-form").submit(); +}; + + +const startLogin = async (e) => { + const transformedCredentialRequestOptions = transformCredentialRequestOptions(JSON.parse($("#webauthn-login").text())); + console.log(transformedCredentialRequestOptions); + + // request the authenticator to create an assertion signature using the + // credential private key + let assertion; + try { + assertion = await navigator.credentials.get({ + publicKey: transformedCredentialRequestOptions, + }); + } catch (err) { + $("#webauthn-error").removeClass("sr-only"); + return console.error("Error when creating credential:", err); + } + + // we now have an authentication assertion! encode the byte arrays contained + // in the assertion data as strings for posting to the server + const transformedAssertionForServer = transformAssertionForServer(assertion); + + // post the assertion to the server for verification. + $("#webauthn-response, #id_password").val(JSON.stringify(transformedAssertionForServer)); + $("#webauthn-form").submit(); +}; + +$(function () { + $("#webauthn-progress").hide(); + if ($("#webauthn-enroll").length) { + $("#webauthn-progress").show(); + startRegister(); + } else if ($("#webauthn-login").length) { + $("#webauthn-progress").show(); + startLogin(); + } +}); diff --git a/src/requirements/production.txt b/src/requirements/production.txt index ab0235c16..4122bf7a3 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -17,6 +17,7 @@ django-libsass libsass django-otp==0.5.* python-u2flib-server==4.* +webauthn==0.4.* django-formtools==2.1 celery==4.3.* kombu==4.5.* diff --git a/src/setup.py b/src/setup.py index 4a96e81a2..4ca6ac89f 100644 --- a/src/setup.py +++ b/src/setup.py @@ -104,6 +104,7 @@ setup( 'django-libsass', 'libsass', 'django-otp==0.5.*', + 'webauthn==0.4.*', 'python-u2flib-server==4.*', 'django-formtools==2.1', 'celery==4.3.*', diff --git a/src/tests/control/test_auth.py b/src/tests/control/test_auth.py index 0d03935ef..3e28bba1d 100644 --- a/src/tests/control/test_auth.py +++ b/src/tests/control/test_auth.py @@ -11,7 +11,6 @@ from django.test import TestCase, override_settings from django.utils.timezone import now from django_otp.oath import TOTP from django_otp.plugins.otp_totp.models import TOTPDevice -from u2flib_server.jsapi import JSONDict from pretix.base.models import U2FDevice, User @@ -268,10 +267,14 @@ class Login2FAFormTest(TestCase): raise Exception("Failed") m = self.monkeypatch - m.setattr("u2flib_server.u2f.verify_authenticate", fail) - m.setattr("u2flib_server.u2f.start_authenticate", - lambda *args, **kwargs: JSONDict({'authenticateRequests': []})) - d = U2FDevice.objects.create(user=self.user, name='test', json_data="{}") + m.setattr("webauthn.WebAuthnAssertionResponse.verify", fail) + d = U2FDevice.objects.create( + user=self.user, name='test', + json_data='{"appId": "https://local.pretix.eu", "keyHandle": ' + '"j9Rkpon1J5U3eDQMM8YqAvwEapt-m87V8qdCaImiAqmvTJ' + '-sBvnACIKKM6J_RVXF4jPtY0LGyjbHi14sxsoC5g", "publ' + 'icKey": "BP5KRLUGvcHbqkCc7eJNXZ9caVXLSk4wjsq' + 'L-pLEQcNqVp2E4OeDUIxI0ZLOXry9JSrLn1aAGcGowXiIyB7ynj0"}') response = self.client.get('/control/login/2fa') assert 'token' in response.content.decode() @@ -285,10 +288,15 @@ class Login2FAFormTest(TestCase): def test_u2f_valid(self): m = self.monkeypatch - m.setattr("u2flib_server.u2f.verify_authenticate", lambda *args, **kwargs: True) - m.setattr("u2flib_server.u2f.start_authenticate", - lambda *args, **kwargs: JSONDict({'authenticateRequests': []})) - d = U2FDevice.objects.create(user=self.user, name='test', json_data="{}") + m.setattr("webauthn.WebAuthnAssertionResponse.verify", lambda *args, **kwargs: 1) + + d = U2FDevice.objects.create( + user=self.user, name='test', + json_data='{"appId": "https://local.pretix.eu", "keyHandle": ' + '"j9Rkpon1J5U3eDQMM8YqAvwEapt-m87V8qdCaImiAqmvTJ' + '-sBvnACIKKM6J_RVXF4jPtY0LGyjbHi14sxsoC5g", "publ' + 'icKey": "BP5KRLUGvcHbqkCc7eJNXZ9caVXLSk4wjsq' + 'L-pLEQcNqVp2E4OeDUIxI0ZLOXry9JSrLn1aAGcGowXiIyB7ynj0"}') response = self.client.get('/control/login/2fa') assert 'token' in response.content.decode() diff --git a/src/tests/control/test_user.py b/src/tests/control/test_user.py index a4c2a43ea..0a3ddbd70 100644 --- a/src/tests/control/test_user.py +++ b/src/tests/control/test_user.py @@ -6,9 +6,11 @@ from django_otp.oath import TOTP from django_otp.plugins.otp_static.models import StaticDevice from django_otp.plugins.otp_totp.models import TOTPDevice from tests.base import SoupTest, extract_form_fields -from u2flib_server.jsapi import JSONDict +from webauthn import WebAuthnCredential -from pretix.base.models import Event, Organizer, U2FDevice, User +from pretix.base.models import ( + Event, Organizer, U2FDevice, User, WebAuthnDevice, +) from pretix.testutils.mock import mocker_context @@ -181,27 +183,33 @@ class UserSettings2FATest(SoupTest): self.client.post('/control/settings/2fa/u2f/{}/delete'.format(d.pk)) assert not U2FDevice.objects.exists() + def test_delete_webauthn(self): + d = WebAuthnDevice.objects.create(user=self.user, name='Test') + self.client.get('/control/settings/2fa/webauthn/{}/delete'.format(d.pk)) + self.client.post('/control/settings/2fa/webauthn/{}/delete'.format(d.pk)) + assert not WebAuthnDevice.objects.exists() + def test_delete_totp(self): d = TOTPDevice.objects.create(user=self.user, name='Test') self.client.get('/control/settings/2fa/totp/{}/delete'.format(d.pk)) self.client.post('/control/settings/2fa/totp/{}/delete'.format(d.pk)) assert not TOTPDevice.objects.exists() - def test_create_u2f_require_https(self): + def test_create_webauthn_require_https(self): r = self.client.post('/control/settings/2fa/add', { - 'devicetype': 'u2f', + 'devicetype': 'webauthn', 'name': 'Foo' }) assert 'alert-danger' in r.content.decode() - def test_create_u2f(self): + def test_create_webauthn(self): with mocker_context() as mocker: mocker.patch('django.http.request.HttpRequest.is_secure') self.client.post('/control/settings/2fa/add', { - 'devicetype': 'u2f', + 'devicetype': 'webauthn', 'name': 'Foo' }) - d = U2FDevice.objects.first() + d = WebAuthnDevice.objects.first() assert d.name == 'Foo' assert not d.confirmed @@ -246,35 +254,38 @@ class UserSettings2FATest(SoupTest): d.refresh_from_db() assert not d.confirmed - def test_confirm_u2f_failed(self): + def test_confirm_webauthn_failed(self): with mocker_context() as mocker: mocker.patch('django.http.request.HttpRequest.is_secure') self.client.post('/control/settings/2fa/add', { - 'devicetype': 'u2f', + 'devicetype': 'webauthn', 'name': 'Foo' }, follow=True) - d = U2FDevice.objects.first() - r = self.client.post('/control/settings/2fa/u2f/{}/confirm'.format(d.pk), { + d = WebAuthnDevice.objects.first() + r = self.client.post('/control/settings/2fa/webauthn/{}/confirm'.format(d.pk), { 'token': 'FOO' }, follow=True) assert 'alert-danger' in r.content.decode() d.refresh_from_db() assert not d.confirmed - def test_confirm_u2f_success(self): + def test_confirm_webauthn_success(self): with mocker_context() as mocker: mocker.patch('django.http.request.HttpRequest.is_secure') self.client.post('/control/settings/2fa/add', { - 'devicetype': 'u2f', + 'devicetype': 'webauthn', 'name': 'Foo' }, follow=True) m = self.monkeypatch - m.setattr("u2flib_server.u2f.complete_register", lambda *args, **kwargs: (JSONDict({}), None)) + m.setattr("webauthn.WebAuthnRegistrationResponse.verify", + lambda *args, **kwargs: WebAuthnCredential( + '', '', b'asd', b'foo', 1 + )) - d = U2FDevice.objects.first() - r = self.client.post('/control/settings/2fa/u2f/{}/confirm'.format(d.pk), { - 'token': 'FOO', + d = WebAuthnDevice.objects.first() + r = self.client.post('/control/settings/2fa/webauthn/{}/confirm'.format(d.pk), { + 'token': '{}', 'activate': 'on' }, follow=True) d.refresh_from_db() @@ -282,7 +293,6 @@ class UserSettings2FATest(SoupTest): assert 'alert-success' in r.content.decode() self.user.refresh_from_db() assert self.user.require_2fa - m.undo()