diff --git a/two_factor/admin.py b/two_factor/admin.py index 60a46f714..b783aac4f 100644 --- a/two_factor/admin.py +++ b/two_factor/admin.py @@ -6,7 +6,7 @@ from django.shortcuts import resolve_url from django.utils.http import is_safe_url -from .models import PhoneDevice +from .models import PhoneDevice, U2FDevice from .utils import monkeypatch_method @@ -75,4 +75,9 @@ class PhoneDeviceAdmin(admin.ModelAdmin): raw_id_fields = ('user',) +class U2FDeviceAdmin(admin.ModelAdmin): + pass + + admin.site.register(PhoneDevice, PhoneDeviceAdmin) +admin.site.register(U2FDevice, U2FDeviceAdmin) diff --git a/two_factor/forms.py b/two_factor/forms.py index 57dbc90ff..db3d80651 100644 --- a/two_factor/forms.py +++ b/two_factor/forms.py @@ -1,4 +1,5 @@ from binascii import unhexlify +import json from time import time from django import forms @@ -9,11 +10,13 @@ from django_otp.plugins.otp_totp.models import TOTPDevice from .models import ( - PhoneDevice, get_available_methods, get_available_phone_methods, + PhoneDevice, U2FDevice, get_available_methods, get_available_phone_methods, ) from .utils import totp_digits from .validators import validate_international_phonenumber +from u2flib_server import u2f + try: from otp_yubikey.models import RemoteYubikeyDevice, YubikeyDevice except ImportError: @@ -25,9 +28,9 @@ class MethodForm(forms.Form): initial='generator', widget=forms.RadioSelect) - def __init__(self, **kwargs): + def __init__(self, disabled_methods=None, **kwargs): super(MethodForm, self).__init__(**kwargs) - self.fields['method'].choices = get_available_methods() + self.fields['method'].choices = get_available_methods(disabled_methods=disabled_methods) class PhoneNumberMethodForm(ModelForm): @@ -83,6 +86,42 @@ def clean_token(self): self.device.public_id = self.cleaned_data['token'][:-32] return super(YubiKeyDeviceForm, self).clean_token() +class U2FDeviceForm(DeviceValidationForm): + token = forms.CharField(label=_("Token")) + + def __init__(self, user, device, request, **kwargs): + super(U2FDeviceForm, self).__init__(device, **kwargs) + self.request = request + self.user = user + self.u2f_device = None + self.appId = '{scheme}://{host}'.format(scheme='https' if self.request.is_secure() else 'http', host=self.request.get_host()) + + if self.data: + self.registration_request = self.request.session['u2f_registration_request'] + else: + self.registration_request = u2f.begin_registration(self.appId, [key.to_json() for key in self.request.user.u2f_keys.all()]) + self.request.session['u2f_registration_request'] = self.registration_request + + def clean_token(self): + response = self.cleaned_data['token'] + try: + request = self.request.session['u2f_registration_request'] + u2f_device, attestation_cert = u2f.complete_registration(request, response) + self.u2f_device = u2f_device + if U2FDevice.objects.filter(public_key=self.u2f_device['publicKey']).count() > 0: + raise forms.ValidationError("U2F device already exists in database: "+str(e)) + except ValueError as e: + raise forms.ValidationError("U2F device could not be verified: "+str(e)) + return response + + def save(self): + self.full_clean() + name = None + if len(self.request.user.u2f_keys.all()) == 0: + name = "default" + else: + name = "key" + return U2FDevice.objects.create(name=name, public_key=self.u2f_device['publicKey'], key_handle=self.u2f_device['keyHandle'], app_id=self.u2f_device['appId'], user=self.user) class TOTPDeviceForm(forms.Form): token = forms.IntegerField(label=_("Token"), min_value=0, max_value=int('9' * totp_digits())) @@ -152,7 +191,7 @@ class AuthenticationTokenForm(OTPAuthenticationFormMixin, Form): # its own `
`. use_required_attribute = False - def __init__(self, user, initial_device, **kwargs): + def __init__(self, user, initial_device, request, **kwargs): """ `initial_device` is either the user's default device, or the backup device when the user chooses to enter a backup token. The token will @@ -161,6 +200,9 @@ def __init__(self, user, initial_device, **kwargs): """ super(AuthenticationTokenForm, self).__init__(**kwargs) self.user = user + self.request = request + self.initial_device = initial_device + self.appId = '{scheme}://{host}'.format(scheme='https' if self.request.is_secure() else 'http', host=self.request.get_host()) # YubiKey generates a OTP of 44 characters (not digits). So if the # user's primary device is a YubiKey, replace the otp_token @@ -168,9 +210,22 @@ def __init__(self, user, initial_device, **kwargs): if RemoteYubikeyDevice and YubikeyDevice and \ isinstance(initial_device, (RemoteYubikeyDevice, YubikeyDevice)): self.fields['otp_token'] = forms.CharField(label=_('YubiKey')) + elif isinstance(initial_device, U2FDevice): + self.fields['otp_token'] = forms.CharField(label=_('Token')) + if self.data: + self.sign_request = self.request.session['u2f_sign_request'] + else: + self.sign_request = u2f.begin_authentication(self.appId, [key.to_json() for key in user.u2f_keys.all()]) + self.request.session['u2f_sign_request'] = self.sign_request def clean(self): - self.clean_otp(self.user) + if isinstance(self.initial_device, U2FDevice): + response = json.loads(self.cleaned_data['otp_token']) + request = self.request.session['u2f_sign_request'] + try: + device, login_counter, _ = u2f.complete_authentication(request, response) + except ValueError: + self.add_error('__all__', 'U2F validation failed -- bad signature.') return self.cleaned_data diff --git a/two_factor/models.py b/two_factor/models.py index cd01bd696..6ede2ecee 100644 --- a/two_factor/models.py +++ b/two_factor/models.py @@ -40,13 +40,17 @@ def get_available_yubikey_methods(): methods = [] if yubiotp and 'otp_yubikey' in settings.INSTALLED_APPS: methods.append(('yubikey', _('YubiKey'))) + methods.append(('u2f', _('FIDO U2F'))) return methods -def get_available_methods(): +def get_available_methods(disabled_methods=None): methods = [('generator', _('Token generator'))] methods.extend(get_available_phone_methods()) methods.extend(get_available_yubikey_methods()) + print('### disabled_methods: {}'.format(disabled_methods)) + if disabled_methods: + methods = [method for method in methods if method[0] not in disabled_methods] return methods @@ -114,3 +118,26 @@ def generate_challenge(self): make_call(device=self, token=token) else: send_sms(device=self, token=token) + +class U2FDevice(Device): + """ + Model for U2F authentication + """ + class Meta: + app_label = 'two_factor' + + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='u2f_keys') + created_at = models.DateTimeField(auto_now_add=True) + last_used_at = models.DateTimeField(null=True) + + public_key = models.TextField(unique=True) + key_handle = models.TextField() + app_id = models.TextField() + + def to_json(self): + return { + 'publicKey': self.public_key, + 'keyHandle': self.key_handle, + 'appId': self.app_id, + 'version': 'U2F_V2', + } diff --git a/two_factor/templates/two_factor/_base.html b/two_factor/templates/two_factor/_base.html index 2f76b3368..dbf632bf4 100644 --- a/two_factor/templates/two_factor/_base.html +++ b/two_factor/templates/two_factor/_base.html @@ -8,6 +8,7 @@ +

Provide a template named @@ -21,5 +22,6 @@ + diff --git a/two_factor/templates/two_factor/core/login.html b/two_factor/templates/two_factor/core/login.html index 5203b8e71..ae29203c2 100644 --- a/two_factor/templates/two_factor/core/login.html +++ b/two_factor/templates/two_factor/core/login.html @@ -23,7 +23,7 @@

{% block title %}{% trans "Login" %}{% endblock %}

enter one of these backup tokens to login to your account.{% endblocktrans %}

{% endif %} - {% csrf_token %} + {% csrf_token %} {% include "two_factor/_wizard_forms.html" %} {# hidden submit button to enable [enter] key #} @@ -49,4 +49,13 @@

{% block title %}{% trans "Login" %}{% endblock %}

{% include "two_factor/_wizard_actions.html" %} + + {% endblock %} diff --git a/two_factor/templates/two_factor/core/manage_keys.html b/two_factor/templates/two_factor/core/manage_keys.html new file mode 100644 index 000000000..8edb1cbc3 --- /dev/null +++ b/two_factor/templates/two_factor/core/manage_keys.html @@ -0,0 +1,26 @@ +{% extends "two_factor/_base_focus.html" %} +{% load i18n %} + +{% block content %} +{{ block.super }} +

U2F Keys

+{% trans 'Back to profile' %} + + + {% for key in object_list %} + + + + + {% endfor %} + +
{{ key.public_key }} +
{% csrf_token %} + + +
+
+{% trans 'Add another key' %} +{% endblock %} diff --git a/two_factor/templates/two_factor/core/setup.html b/two_factor/templates/two_factor/core/setup.html index efa0df82b..d05077043 100644 --- a/two_factor/templates/two_factor/core/setup.html +++ b/two_factor/templates/two_factor/core/setup.html @@ -45,12 +45,20 @@

{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock % account.{% endblocktrans %}

{% endif %} -
{% csrf_token %} + {% csrf_token %} {% include "two_factor/_wizard_forms.html" %} - {# hidden submit button to enable [enter] key #}
{% include "two_factor/_wizard_actions.html" %}
+ + {% endblock %} diff --git a/two_factor/templates/two_factor/profile/profile.html b/two_factor/templates/two_factor/profile/profile.html index d67b05252..da40ccbde 100644 --- a/two_factor/templates/two_factor/profile/profile.html +++ b/two_factor/templates/two_factor/profile/profile.html @@ -11,6 +11,9 @@

{% block title %}{% trans "Account Security" %}{% endblock %}

{% blocktrans with primary=default_device|device_action %}Primary method: {{ primary }}{% endblocktrans %}

{% elif default_device_type == 'RemoteYubikeyDevice' %}

{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}

+ {% elif default_device_type == 'U2FDevice' %} +

{% trans "Manage U2F keys" %}

{% endif %}

{% trans "Backup Phone Numbers" %}

diff --git a/two_factor/urls.py b/two_factor/urls.py index 18391a265..f7efaba10 100644 --- a/two_factor/urls.py +++ b/two_factor/urls.py @@ -2,7 +2,10 @@ from two_factor.views import ( BackupTokensView, DisableView, LoginView, PhoneDeleteView, PhoneSetupView, - ProfileView, QRGeneratorView, SetupCompleteView, SetupView, + ProfileView, QRGeneratorView, SetupCompleteView, SetupView, ManageKeysView, +) +from two_factor.forms import ( + U2FDeviceForm, ) core = [ @@ -41,6 +44,24 @@ view=PhoneDeleteView.as_view(), name='phone_delete', ), + url( + regex=r'^account/two_factor/manage_keys/$', + view=ManageKeysView.as_view(), + name='manage_keys', + ), + url( + regex=r'^account/two_factor/add_u2f_key/$', + view=SetupView.as_view( + disabled_methods=( + 'call', + 'sms', + 'yubikey', + 'generator', + ), + force=True, + ), + name='add_u2f_key' + ), ] profile = [ diff --git a/two_factor/views/__init__.py b/two_factor/views/__init__.py index c2abab1e4..d7818308e 100644 --- a/two_factor/views/__init__.py +++ b/two_factor/views/__init__.py @@ -1,6 +1,6 @@ from .core import ( BackupTokensView, LoginView, PhoneDeleteView, PhoneSetupView, - QRGeneratorView, SetupCompleteView, SetupView, + QRGeneratorView, SetupCompleteView, SetupView, ManageKeysView, ) from .mixins import OTPRequiredMixin from .profile import DisableView, ProfileView diff --git a/two_factor/views/core.py b/two_factor/views/core.py index 180fe3c11..1d1185b5b 100644 --- a/two_factor/views/core.py +++ b/two_factor/views/core.py @@ -12,13 +12,14 @@ from django.contrib.auth.forms import AuthenticationForm from django.contrib.sites.shortcuts import get_current_site from django.forms import Form -from django.http import Http404, HttpResponse +from django.http import Http404, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect, resolve_url +from django.utils.decorators import classonlymethod from django.utils.http import is_safe_url from django.utils.module_loading import import_string from django.views.decorators.cache import never_cache from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic import DeleteView, FormView, TemplateView +from django.views.generic import DeleteView, FormView, TemplateView, ListView from django.views.generic.base import View from django_otp.decorators import otp_required from django_otp.plugins.otp_static.models import StaticDevice, StaticToken @@ -30,9 +31,9 @@ from ..forms import ( AuthenticationTokenForm, BackupTokenForm, DeviceValidationForm, MethodForm, - PhoneNumberForm, PhoneNumberMethodForm, TOTPDeviceForm, YubiKeyDeviceForm, + PhoneNumberForm, PhoneNumberMethodForm, TOTPDeviceForm, YubiKeyDeviceForm, U2FDeviceForm, ) -from ..models import PhoneDevice, get_available_phone_methods +from ..models import PhoneDevice, U2FDevice, get_available_phone_methods from ..utils import backup_phones, default_device, get_otpauth_url from .utils import IdempotentSessionWizardView, class_view_decorator @@ -130,6 +131,7 @@ def get_form_kwargs(self, step=None): return { 'user': self.get_user(), 'initial_device': self.get_device(step), + 'request': self.request, } return {} @@ -227,6 +229,7 @@ class SetupView(IdempotentSessionWizardView): ('call', PhoneNumberForm), ('validation', DeviceValidationForm), ('yubikey', YubiKeyDeviceForm), + ('u2f', U2FDeviceForm), ) condition_dict = { 'generator': lambda self: self.get_method() == 'generator', @@ -234,10 +237,14 @@ class SetupView(IdempotentSessionWizardView): 'sms': lambda self: self.get_method() == 'sms', 'validation': lambda self: self.get_method() in ('sms', 'call'), 'yubikey': lambda self: self.get_method() == 'yubikey', + 'u2f': lambda self: self.get_method() == 'u2f', } idempotent_dict = { 'yubikey': False, + 'u2f': False, } + disabled_methods = None + force = False def get_method(self): method_data = self.storage.validated_step_data.get('method', {}) @@ -247,7 +254,7 @@ def get(self, request, *args, **kwargs): """ Start the setup wizard. Redirect if already enabled. """ - if default_device(self.request.user): + if default_device(self.request.user) and not self.force: return redirect(self.success_url) return super(SetupView, self).get(request, *args, **kwargs) @@ -292,6 +299,10 @@ def done(self, form_list, **kwargs): form = [form for form in form_list if isinstance(form, TOTPDeviceForm)][0] device = form.save() + elif self.get_method() == 'u2f': + form = [form for form in form_list if isinstance(form, U2FDeviceForm)][0] + device = form.save() + # PhoneNumberForm / YubiKeyDeviceForm elif self.get_method() in ('call', 'sms', 'yubikey'): device = self.get_device() @@ -305,6 +316,10 @@ def done(self, form_list, **kwargs): def get_form_kwargs(self, step=None): kwargs = {} + if step == 'method': + kwargs.update({ + 'disabled_methods': self.disabled_methods, + }) if step == 'generator': kwargs.update({ 'key': self.get_key(step), @@ -314,6 +329,12 @@ def get_form_kwargs(self, step=None): kwargs.update({ 'device': self.get_device() }) + if step == 'u2f': + kwargs.update({ + 'user': self.request.user, + 'device': self.get_device(), + 'request': self.request, + }) metadata = self.get_form_metadata(step) if metadata: kwargs.update({ @@ -340,7 +361,7 @@ def get_device(self, **kwargs): if method == 'yubikey': kwargs['public_id'] = self.storage.validated_step_data\ - .get('yubikey', {}).get('token', '')[:-32] + .get(method, {}).get('token', '')[:-32] try: kwargs['service'] = ValidationService.objects.get(name='default') except ValidationService.DoesNotExist: @@ -349,6 +370,8 @@ def get_device(self, **kwargs): raise KeyError("Multiple ValidationService found with name 'default'") return RemoteYubikeyDevice(**kwargs) + if method == 'u2f': + return U2FDevice(**kwargs) def get_key(self, step): self.storage.extra_data.setdefault('keys', {}) if step in self.storage.extra_data['keys']: @@ -382,6 +405,10 @@ def get_form_metadata(self, step): self.storage.extra_data.setdefault('forms', {}) return self.storage.extra_data['forms'].get(step, None) + @classonlymethod + def as_view(cls, *args, **kwargs): + return super(IdempotentSessionWizardView, cls).as_view(*args, **kwargs) + @class_view_decorator(never_cache) @class_view_decorator(otp_required) @@ -563,3 +590,25 @@ def get(self, request, *args, **kwargs): resp = HttpResponse(content_type=content_type) img.save(resp) return resp + +@class_view_decorator(never_cache) +@class_view_decorator(login_required) +class ManageKeysView(ListView): + template_name = 'two_factor/core/manage_keys.html' + def get_queryset(self): + return self.request.user.u2f_keys.all() + + def post(self, request): + assert 'delete' in self.request.POST + key = U2FDevice.objects.get(public_key=self.request.POST['key_id']) + key.delete() + keys = self.request.user.u2f_keys.all() + if U2FDevice.objects.filter(name='default').count() == 0: + if len(keys) > 0: + keys[0].name = 'default' + keys[0].save() + + if len(keys) == 0: + return HttpResponseRedirect(reverse('two_factor:profile')) + return HttpResponseRedirect(reverse('two_factor:manage_keys')) +