Skip to content

Commit

Permalink
Fixed #36 --Support for YubiKeys
Browse files Browse the repository at this point in the history
YubiKeys offer a OTP, similar to token generator application. The key is
generated by a hardware device, generating a 44 character OTP, which is to be
by YubiCloud (or locally if needed be).
  • Loading branch information
Bouke committed May 10, 2014
1 parent 130ea65 commit 90feb30
Show file tree
Hide file tree
Showing 9 changed files with 218 additions and 26 deletions.
9 changes: 7 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ Complete Two-Factor Authentication for Django. Built on top of the one-time
password framework django-otp_ and Django's built-in authentication framework
``django.contrib.auth`` for providing the easiest integration into most Django
projects. Inspired by the user experience of Google's Two-Step Authentication,
allowing users to authenticate through call, text messages (SMS) or by using a
token generator app like Google Authenticator.
allowing users to authenticate through call, text messages (SMS), by using a
token generator app like Google Authenticator or a YubiKey_ hardware token
generator (optional).

I would love to hear your feedback on this package. If you run into
problems, please file an issue on GitHub, or contribute to the project by
Expand Down Expand Up @@ -80,6 +81,9 @@ Be sure to remove any other login routes, otherwise the two-factor
authentication might be circumvented. The admin interface should be
automatically patched to use the new login method.

Support for YubiKey_ is disabled by default, but enabling is easy. Please
refer to the documentation for instructions.

Contribute
==========
* Submit issues to the `issue tracker`_ on Github
Expand Down Expand Up @@ -124,3 +128,4 @@ The project is licensed under the MIT license.
.. _issue tracker: https://github.com/Bouke/django-two-factor-auth/issues
.. _source code: https://github.com/Bouke/django-two-factor-auth
.. _readthedocs.org: http://django-two-factor-auth.readthedocs.org/
.. _Yubikey: https://www.yubico.com/products/yubikey-hardware/
30 changes: 30 additions & 0 deletions docs/installation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,33 @@ Add the routes to your url configuration::
Be sure to remove any other login routes, otherwise the two-factor
authentication might be circumvented. The admin interface should be
automatically patched to use the new login method.

Yubikey
-------

In order to support Yubikeys, you have to install a plugin for `django-otp`::

pip install django-otp-yubikey

Add the following app to the ``INSTALLED_APPS``::

INSTALLED_APPS = (
...
'otp_yubikey',
)

This plugin also requires adding a validation service, through wich YubiKeys
will be verified. Normally, you'd use the YubiCloud for this. In the Django
admin, navigate to ``YubiKey validation services`` and add an item. Django
Two-Factor Authentication will identify the validation service with the
name ``default``. The other fields can be left empty, but you might want to
consider requesting an API ID along with API key and using SSL for
communicating with YubiCloud.

You could also do this using this snippet::

manage.py shell
>>> from otp_yubikey.models import ValidationService
>>> ValidationService.objects.create(name='default', use_ssl=True,
... param_sl='', param_timeout='')
<ValidationService: default>
8 changes: 8 additions & 0 deletions tests/settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import os
from django.core.urlresolvers import reverse_lazy

try:
import otp_yubikey
except ImportError:
otp_yubikey = None

BASE_DIR = os.path.dirname(__file__)

SECRET_KEY = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
Expand All @@ -18,6 +23,9 @@
'tests',
]

if otp_yubikey:
INSTALLED_APPS += ['otp_yubikey']

MIDDLEWARE_CLASSES = (
'django.middleware.common.CommonMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
Expand Down
94 changes: 84 additions & 10 deletions tests/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,14 @@
from urllib import urlencode

try:
from unittest.mock import patch, Mock, ANY, call
import unittest2 as unittest
except ImportError:
from mock import patch, Mock, ANY, call
import unittest

try:
from django.contrib.auth import get_user_model
from unittest.mock import patch, Mock, ANY, call
except ImportError:
from django.contrib.auth.models import User
else:
User = get_user_model()
from mock import patch, Mock, ANY, call

import django
from django import forms
Expand All @@ -35,10 +33,22 @@
from django.test.utils import override_settings
from django.utils import translation, six

try:
from django.contrib.auth import get_user_model
except ImportError:
from django.contrib.auth.models import User
else:
User = get_user_model()

from django_otp import DEVICE_ID_SESSION_KEY, devices_for_user
from django_otp.oath import totp
from django_otp.util import random_hex

try:
from otp_yubikey.models import ValidationService, RemoteYubikeyDevice
except ImportError:
ValidationService = RemoteYubikeyDevice = None

import qrcode.image.svg

from two_factor.admin import patch_admin, unpatch_admin
Expand Down Expand Up @@ -258,7 +268,7 @@ def test_setup_generator(self):
data={'setup_view-current_step': 'generator',
'generator-token': '123456'})
self.assertEqual(response.context_data['wizard']['form'].errors,
{'token': ['Please enter a valid token.']})
{'token': ['Entered token is not valid.']})

key = response.context_data['keys'].get('generator')
bin_key = unhexlify(key.encode())
Expand Down Expand Up @@ -299,7 +309,7 @@ def test_setup_phone_call(self, fake):
response = self._post(data={'setup_view-current_step': 'validation',
'validation-token': '666'})
self.assertEqual(response.context_data['wizard']['form'].errors,
{'token': ['Entered token is not valid']})
{'token': ['Entered token is not valid.']})

# submitting correct token should finish the setup
token = fake.return_value.make_call.call_args[1]['token']
Expand Down Expand Up @@ -336,7 +346,7 @@ def test_setup_phone_sms(self, fake):
response = self._post(data={'setup_view-current_step': 'validation',
'validation-token': '666'})
self.assertEqual(response.context_data['wizard']['form'].errors,
{'token': ['Entered token is not valid']})
{'token': ['Entered token is not valid.']})

# submitting correct token should finish the setup
token = fake.return_value.send_sms.call_args[1]['token']
Expand Down Expand Up @@ -518,7 +528,7 @@ def test_setup(self, fake):
response = self._post({'phone_setup_view-current_step': 'validation',
'validation-token': '123456'})
self.assertEqual(response.context_data['wizard']['form'].errors,
{'token': ['Entered token is not valid']})
{'token': ['Entered token is not valid.']})

response = self._post({'phone_setup_view-current_step': 'validation',
'validation-token': totp(device.bin_key)})
Expand Down Expand Up @@ -841,3 +851,67 @@ def test_status_mutiple(self):
call_command('status', '[email protected]', '[email protected]', stdout=stdout)
self.assertEqual(stdout.getvalue(), '[email protected]: enabled\n'
'[email protected]: disabled\n')


@unittest.skipUnless(ValidationService, 'No YubiKey support')
class YubiKeyTest(UserMixin, TestCase):
@patch('otp_yubikey.models.RemoteYubikeyDevice.verify_token')
def test_setup(self, verify_token):
user = self.create_user()
self.login_user()
verify_token.return_value = [True, False] # only first try is valid

# Should be able to select YubiKey method
response = self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'welcome'})
self.assertContains(response, 'YubiKey')

# Without ValidationService it won't work
with self.assertRaisesMessage(KeyError, "No ValidationService found with name 'default'"):
self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'method',
'method-method': 'yubikey'})

# With a ValidationService, should be able to input a YubiKey
ValidationService.objects.create(name='default', param_sl='', param_timeout='')

response = self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'method',
'method-method': 'yubikey'})
self.assertContains(response, 'YubiKey:')

# Should call verify_token and create the device on finish
token = 'jlvurcgekuiccfcvgdjffjldedjjgugk'
response = self.client.post(reverse('two_factor:setup'),
data={'setup_view-current_step': 'yubikey',
'yubikey-token': token})
self.assertRedirects(response, reverse('two_factor:setup_complete'))
verify_token.assert_called_with(token)

yubikeys = user.remoteyubikeydevice_set.all()
self.assertEqual(len(yubikeys), 1)
self.assertEqual(yubikeys[0].name, 'default')

@patch('otp_yubikey.models.RemoteYubikeyDevice.verify_token')
def test_login(self, verify_token):
user = self.create_user()
verify_token.return_value = [True, False] # only first try is valid
service = ValidationService.objects.create(name='default', param_sl='', param_timeout='')
user.remoteyubikeydevice_set.create(service=service, name='default')

# Input type should be text, not numbers like other tokens
response = self.client.post(reverse('two_factor:login'),
data={'auth-username': '[email protected]',
'auth-password': 'secret',
'login_view-current_step': 'auth'})
self.assertContains(response, 'YubiKey:')
self.assertIsInstance(response.context_data['wizard']['form'].fields['otp_token'],
forms.CharField)

# Should call verify_token
token = 'cjikftknbiktlitnbltbitdncgvrbgic'
response = self.client.post(reverse('two_factor:login'),
data={'token-otp_token': token,
'login_view-current_step': 'token'})
self.assertRedirects(response, str(settings.LOGIN_REDIRECT_URL))
verify_token.assert_called_with(token)
34 changes: 32 additions & 2 deletions two_factor/forms.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from binascii import unhexlify
from time import time

from django import forms
from django.forms import ModelForm, Form
from django.utils.translation import ugettext_lazy as _

from django_otp.forms import OTPAuthenticationFormMixin
from django_otp.oath import totp
from django_otp.plugins.otp_totp.models import TOTPDevice
try:
from otp_yubikey.models import RemoteYubikeyDevice, YubikeyDevice
except ImportError:
RemoteYubikeyDevice = YubikeyDevice = None

from .models import (PhoneDevice, get_available_phone_methods,
get_available_methods)
from .utils import default_device


class MethodForm(forms.Form):
Expand Down Expand Up @@ -42,22 +49,38 @@ class Meta:
class DeviceValidationForm(forms.Form):
token = forms.IntegerField(label=_("Token"), min_value=1, max_value=999999)

error_messages = {
'invalid_token': _('Entered token is not valid.'),
}

def __init__(self, device, **args):
super(DeviceValidationForm, self).__init__(**args)
self.device = device

def clean_token(self):
token = self.cleaned_data['token']
if not self.device.verify_token(token):
raise forms.ValidationError(_('Entered token is not valid'))
raise forms.ValidationError(self.error_messages['invalid_token'])
return token


class YubiKeyDeviceForm(DeviceValidationForm):
token = forms.CharField(label=_("YubiKey"))

error_messages = {
'invalid_token': _("The YubiKey could not be verified."),
}

def clean_token(self):
self.device.public_id = self.cleaned_data['token'][:-32]
return super(YubiKeyDeviceForm, self).clean_token()


class TOTPDeviceForm(forms.Form):
token = forms.IntegerField(label=_("Token"), min_value=0, max_value=999999)

error_messages = {
'invalid_token': _("Please enter a valid token."),
'invalid_token': _('Entered token is not valid.'),
}

def __init__(self, key, user, metadata=None, **kwargs):
Expand Down Expand Up @@ -113,6 +136,13 @@ def __init__(self, user, **kwargs):
super(AuthenticationTokenForm, self).__init__(**kwargs)
self.user = user

# 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(default_device(user), (RemoteYubikeyDevice, YubikeyDevice)):
self.fields['otp_token'] = forms.CharField(label=_('YubiKey'))

def clean(self):
self.clean_otp(self.user)
return self.cleaned_data
Expand Down
13 changes: 13 additions & 0 deletions two_factor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
from django_otp.oath import totp
from django_otp.util import hex_validator, random_hex

try:
import yubiotp
except ImportError:
yubiotp = None

from .gateways import make_call, send_sms


Expand All @@ -35,9 +40,17 @@ def get_available_phone_methods():
return methods


def get_available_yubikey_methods():
methods = []
if yubiotp and 'otp_yubikey' in settings.INSTALLED_APPS:
methods.append(('yubikey', _('YubiKey')))
return methods


def get_available_methods():
methods = [('generator', _('Token generator'))]
methods.extend(get_available_phone_methods())
methods.extend(get_available_yubikey_methods())
return methods


Expand Down
4 changes: 4 additions & 0 deletions two_factor/templates/two_factor/core/setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ <h1>{% block title %}{% trans "Enable Two-Factor Authentication" %}{% endblock %
<p>{% blocktrans %}We sent you a text message, please enter the tokens we
sent.{% endblocktrans %}</p>
{% endif %}
{% elif wizard.steps.current == 'yubikey' %}
<p>{% blocktrans %}To identify and verify your YubiKey, please insert a
token in the field below. Your YubiKey will be linked to your
account.{% endblocktrans %}</p>
{% endif %}

<form action="" method="post">{% csrf_token %}
Expand Down
8 changes: 5 additions & 3 deletions two_factor/templates/two_factor/profile/profile.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
<h1>{% block title %}{% trans "Account Security" %}{% endblock %}</h1>

{% if default_device %}
{% if default_device_type == 'PhoneDevice' %}
<p>{% blocktrans with primary=default_device|device_action %}Primary method: {{ primary }}{% endblocktrans %}</p>
{% else %}
{% if default_device_type == 'TOTPDevice' %}
<p>{% trans "Tokens will be generated by your token generator." %}</p>
{% elif default_device_type == 'PhoneDevice' %}
<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>
{% endif %}

<h2>{% trans "Backup Phone Numbers" %}</h2>
Expand Down
Loading

0 comments on commit 90feb30

Please sign in to comment.