Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add U2F devices feature #215

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion two_factor/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
65 changes: 60 additions & 5 deletions two_factor/forms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from binascii import unhexlify
import json
from time import time

from django import forms
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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()))
Expand Down Expand Up @@ -152,7 +191,7 @@ class AuthenticationTokenForm(OTPAuthenticationFormMixin, Form):
# its own `<form>`.
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
Expand All @@ -161,16 +200,32 @@ 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
# IntegerField with a CharField.
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


Expand Down
29 changes: 28 additions & 1 deletion two_factor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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',
}
2 changes: 2 additions & 0 deletions two_factor/templates/two_factor/_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7/html5shiv.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/respond.js/1.3.0/respond.js"></script>
<![endif]-->
<script src="{% static 'two_factor/u2f-api.js' %}"></script>
</head>
<body>
<div class="alert alert-success"><p class="container">Provide a template named
Expand All @@ -21,5 +22,6 @@

<script src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/3.0.2/js/bootstrap.min.js"></script>
<script src="{% static 'two_factor/u2f-api.js' %}"></script>
</body>
</html>
11 changes: 10 additions & 1 deletion two_factor/templates/two_factor/core/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ <h1>{% block title %}{% trans "Login" %}{% endblock %}</h1>
enter one of these backup tokens to login to your account.{% endblocktrans %}</p>
{% endif %}

<form action="" method="post">{% csrf_token %}
<form action="" method="post" id="login-form">{% csrf_token %}
{% include "two_factor/_wizard_forms.html" %}

{# hidden submit button to enable [enter] key #}
Expand All @@ -49,4 +49,13 @@ <h1>{% block title %}{% trans "Login" %}{% endblock %}</h1>

{% include "two_factor/_wizard_actions.html" %}
</form>

<script>
var request = {{ wizard.form.sign_request|safe }};
u2f.sign(request.appId, request.challenge, request.registeredKeys, function(resp) {
var form = document.getElementById('login-form');
form['token-otp_token'].value = JSON.stringify(resp);
form.submit();
})
</script>
{% endblock %}
26 changes: 26 additions & 0 deletions two_factor/templates/two_factor/core/manage_keys.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{% extends "two_factor/_base_focus.html" %}
{% load i18n %}

{% block content %}
{{ block.super }}
<h1>U2F Keys</h1>
<a href="{% url 'two_factor:profile' %}"
class="btn btn-info">{% trans 'Back to profile' %}</a>
<table>
<tbody>
{% for key in object_list %}
<tr>
<td>{{ key.public_key }}</td>
<td>
<form method="post">{% csrf_token %}
<input name="key_id" type="hidden" value="{{ key.public_key }}">
<input type="submit" name="delete" value="X">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{% url 'two_factor:add_u2f_key' %}"
class="btn btn-info">{% trans 'Add another key' %}</a>
{% endblock %}
12 changes: 10 additions & 2 deletions two_factor/templates/two_factor/core/setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,20 @@ <h1>{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %
account.{% endblocktrans %}</p>
{% endif %}

<form action="" method="post">{% csrf_token %}
<form action="" method="post" id="auth-form">{% csrf_token %}
{% include "two_factor/_wizard_forms.html" %}

{# hidden submit button to enable [enter] key #}
<div style="margin-left: -9999px"><input type="submit" value=""/></div>

{% include "two_factor/_wizard_actions.html" %}
</form>

<script>
var request = {{ wizard.form.registration_request|safe }};
u2f.register(request.appId, request.registerRequests, request.registeredKeys, function(resp) {
var form = document.getElementById('auth-form');
form['u2f-token'].value = JSON.stringify(resp);
form.submit();
})
</script>
{% endblock %}
3 changes: 3 additions & 0 deletions two_factor/templates/two_factor/profile/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ <h1>{% block title %}{% trans "Account Security" %}{% endblock %}</h1>
<p>{% blocktrans with primary=default_device|device_action %}Primary method: {{ primary }}{% endblocktrans %}</p>
{% elif default_device_type == 'RemoteYubikeyDevice' %}
<p>{% blocktrans %}Tokens will be generated by your YubiKey.{% endblocktrans %}</p>
{% elif default_device_type == 'U2FDevice' %}
<p><a href="{% url 'two_factor:manage_keys' %}"
class="btn btn-info">{% trans "Manage U2F keys" %}</a></p>
{% endif %}

<h2>{% trans "Backup Phone Numbers" %}</h2>
Expand Down
23 changes: 22 additions & 1 deletion two_factor/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion two_factor/views/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Loading