Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Tests for LoginView and BackupTokensView
  • Loading branch information
Bouke committed Nov 18, 2013
1 parent e4ba43d commit bfcbfaa
Show file tree
Hide file tree
Showing 15 changed files with 206 additions and 88 deletions.
2 changes: 1 addition & 1 deletion .coveragerc
@@ -1,6 +1,6 @@
[run]
source = two_factor
omit = */migrations/*
omit = */migrations/*,two_factor/compat.py

[report]
precision = 2
2 changes: 1 addition & 1 deletion .gitignore
@@ -1,5 +1,5 @@
example/settings_private.py
*.egg-info
/.coverage

/.tox/
/htmlcov/
7 changes: 4 additions & 3 deletions README.rst
Expand Up @@ -72,16 +72,17 @@ automatically patched to use the new login method.

Settings
========
``TWO_FACTOR_SMS_GATEWAY`` (default: ``two_factor.gateways.fake.Fake``)
``TWO_FACTOR_SMS_GATEWAY`` (default: ``None``)
Which module should be used for sending text messages.

``TWO_FACTOR_CALL_GATEWAY`` (default: ``two_factor.gateways.fake.Fake``)
``TWO_FACTOR_CALL_GATEWAY`` (default: ``None``)
Which module should be used for making calls.

Gateway ``two_factor.gateways.fake.Fake``
-----------------------------------------
Prints the tokens to the logger. You will have to set the message level of the
``two_factor`` logger to ``INFO`` for them to appear in the console.
``two_factor`` logger to ``INFO`` for them to appear in the console. Useful for
local development.
::

LOGGING = {
Expand Down
1 change: 1 addition & 0 deletions requirements_dev.txt
Expand Up @@ -15,3 +15,4 @@ django_otp
coverage
flake8
tox
mock
6 changes: 6 additions & 0 deletions tests/settings.py
@@ -1,4 +1,6 @@
import os
from django.core.urlresolvers import reverse_lazy

BASE_DIR = os.path.dirname(__file__)

SECRET_KEY = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'
Expand Down Expand Up @@ -28,6 +30,10 @@

ROOT_URLCONF = 'tests.urls'

LOGOUT_URL = reverse_lazy('logout')
LOGIN_URL = reverse_lazy('two_factor:login')
LOGIN_REDIRECT_URL = reverse_lazy('two_factor:profile')

CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
Expand Down
166 changes: 132 additions & 34 deletions tests/tests.py
@@ -1,65 +1,116 @@
from binascii import unhexlify
try:
from unittest.mock import patch
except ImportError:
from mock import patch

from django.conf import settings
from django.contrib.auth.models import User
from django.core.urlresolvers import reverse
from django.test import TestCase
from django.test.utils import override_settings
from django_otp import DEVICE_ID_SESSION_KEY
from django_otp.oath import totp
from django_otp.util import random_hex


class LoginTest(TestCase):
def _post(self, data=None):
return self.client.post(reverse('two_factor:login'), data=data)

def test_form(self):
response = self.client.get(reverse('two_factor:login'))
self.assertContains(response, 'Username:')

def test_invalid_login(self):
response = self.client.post(
reverse('two_factor:login'),
data={'auth-username': 'unknown', 'auth-password': 'secret',
'login_view-current_step': 'auth'})
response = self._post({'auth-username': 'unknown',
'auth-password': 'secret',
'login_view-current_step': 'auth'})
self.assertContains(response, 'Please enter a correct username '
'and password.')

def test_valid_login(self):
User.objects.create_user('bouke', None, 'secret')
response = self.client.post(
reverse('two_factor:login'),
data={'auth-username': 'bouke', 'auth-password': 'secret',
'login_view-current_step': 'auth'})
self.assertContains(response, '', status_code=302)
response = self._post({'auth-username': 'bouke',
'auth-password': 'secret',
'login_view-current_step': 'auth'})
self.assertRedirects(response, str(settings.LOGIN_REDIRECT_URL))

def test_with_generator(self):
user = User.objects.create_user('bouke', None, 'secret')
device = user.totpdevice_set.create(name='default',
key=random_hex().decode())

response = self.client.post(
reverse('two_factor:login'),
data={'auth-username': 'bouke', 'auth-password': 'secret',
'login_view-current_step': 'auth'})
response = self._post({'auth-username': 'bouke',
'auth-password': 'secret',
'login_view-current_step': 'auth'})
self.assertContains(response, 'Token:')

response = self.client.post(
reverse('two_factor:login'),
data={'token-otp_token': '123456',
'login_view-current_step': 'token'})
self.assertContains(response, 'Please enter your OTP token')
response = self._post({'token-otp_token': '123456',
'login_view-current_step': 'token'})
self.assertEqual(response.context_data['wizard']['form'].errors,
{'__all__': ['Please enter your OTP token']})

response = self.client.post(
reverse('two_factor:login'),
data={'token-otp_token': totp(device.bin_key),
'login_view-current_step': 'token'})
self.assertContains(response, '', status_code=302)
response = self._post({'token-otp_token': totp(device.bin_key),
'login_view-current_step': 'token'})
self.assertRedirects(response, str(settings.LOGIN_REDIRECT_URL))

self.assertEqual(device.persistent_id,
self.client.session.get(DEVICE_ID_SESSION_KEY))


class SetupTest(TestCase):
@patch('two_factor.gateways.fake.Fake')
@override_settings(
TWO_FACTOR_SMS_GATEWAY='two_factor.gateways.fake.Fake',
TWO_FACTOR_CALL_GATEWAY='two_factor.gateways.fake.Fake',
)
def test_with_backup_phone(self, fake):
user = User.objects.create_user('bouke', None, 'secret')
user.totpdevice_set.create(name='default', key=random_hex().decode())
device = user.phonedevice_set.create(name='backup', number='123456789',
method='sms',
key=random_hex().decode())

# Backup phones should be listed on the login form
response = self._post({'auth-username': 'bouke',
'auth-password': 'secret',
'login_view-current_step': 'auth'})
self.assertContains(response, 'Text Message to 123456789')

# Ask for challenge on invalid device
response = self._post({'auth-username': 'bouke',
'auth-password': 'secret',
'challenge_device': 'MALICIOUS/INPUT/666'})
self.assertContains(response, 'Text Message to 123456789')

# Ask for SMS challenge
response = self._post({'auth-username': 'bouke',
'auth-password': 'secret',
'challenge_device': device.persistent_id})
self.assertContains(response, 'We sent you a text message, please '
'enter the tokens we sent.')
fake.return_value.send_sms.assert_called_with(
device=device, token='%06d' % totp(device.bin_key))

# Ask for phone challenge
device.method = 'call'
device.save()
response = self._post({'auth-username': 'bouke',
'auth-password': 'secret',
'challenge_device': device.persistent_id})
self.assertContains(response, 'We are calling your phone right now, '
'please enter the digits you hear.')
fake.return_value.make_call.assert_called_with(
device=device, token='%06d' % totp(device.bin_key))


class UserMixin(object):
def setUp(self):
super(UserMixin, self).setUp()
self.user = User.objects.create_user('bouke', None, 'secret')
assert self.client.login(username='bouke', password='secret')


class SetupTest(UserMixin, TestCase):
def test_form(self):
response = self.client.get(reverse('two_factor:setup'))
self.assertContains(response, 'Follow the steps in this wizard to '
Expand All @@ -68,31 +119,78 @@ def test_form(self):
def test_setup_generator(self):
response = self.client.post(
reverse('two_factor:setup'),
data={'setup_view-current_step': 'welcome'},
)
data={'setup_view-current_step': 'welcome'})
self.assertContains(response, 'Method:')

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

response = self.client.post(
reverse('two_factor:setup'),
data={'setup_view-current_step': 'generator'})
self.assertEqual(response.context_data['wizard']['form'].errors,
{'token': ['This field is required.']})

response = self.client.post(
reverse('two_factor:setup'),
data={'setup_view-current_step': 'generator',
'generator-token': '123456'},
)
self.assertContains(response, 'Please enter a valid token.')
'generator-token': '123456'})
self.assertEqual(response.context_data['wizard']['form'].errors,
{'token': ['Please enter a valid token.']})

key = response.context_data['keys'].get('generator')
bin_key = unhexlify(key.encode())
response = self.client.post(
reverse('two_factor:setup'),
data={'setup_view-current_step': 'generator',
'generator-token': totp(bin_key)},
)
'generator-token': totp(bin_key)})
self.assertRedirects(response, reverse('two_factor:setup_complete'))

self.assertEqual(1, len(self.user.totpdevice_set.all()))

def test_already_setup(self):
self.user.totpdevice_set.create(name='default')
response = self.client.get(reverse('two_factor:setup'))
self.assertRedirects(response, reverse('two_factor:setup_complete'))


class AdminPatchTest(TestCase):
def test(self):
response = self.client.get('/admin/')
self.assertRedirects(response, str(settings.LOGIN_URL))


class BackupTokensTest(UserMixin, TestCase):
def setUp(self):
super(BackupTokensTest, self).setUp()
self.device = self.user.totpdevice_set.create(name='default')
session = self.client.session
session[DEVICE_ID_SESSION_KEY] = self.device.persistent_id
session.save()

def test_empty(self):
response = self.client.get(reverse('two_factor:backup_tokens'))
self.assertContains(response, 'You don\'t have any backup codes yet.')

def test_generate(self):
url = reverse('two_factor:backup_tokens')

response = self.client.post(url)
self.assertRedirects(response, url)

response = self.client.get(url)
first_set = set([token.token for token in
response.context_data['device'].token_set.all()])
self.assertNotContains(response, 'You don\'t have any backup codes '
'yet.')
self.assertEqual(10, len(first_set))

# Generating the tokens should give a fresh set
self.client.post(url)
response = self.client.get(url)
second_set = set([token.token for token in
response.context_data['device'].token_set.all()])
self.assertNotEqual(first_set, second_set)
6 changes: 6 additions & 0 deletions tests/urls.py
Expand Up @@ -6,5 +6,11 @@

urlpatterns = patterns(
'',
url(
regex=r'^account/logout/$',
view='django.contrib.auth.views.logout',
name='logout',
),
url(r'', include('two_factor.urls', 'two_factor')),
url(r'^admin/', include(admin.site.urls)),
)
2 changes: 1 addition & 1 deletion tox.ini
Expand Up @@ -14,7 +14,7 @@ envlist =

[testenv]
commands = make test
deps =
deps = mock
whitelist_externals = make

[testenv:py26-django14]
Expand Down
9 changes: 7 additions & 2 deletions two_factor/admin.py
@@ -1,10 +1,15 @@
from django.conf import settings
from django.contrib import admin
from django.contrib.admin import sites
from django.shortcuts import redirect

from .models import PhoneDevice
from .utils import patch_admin_login


if getattr(settings, 'TWO_FACTOR_PATCH_ADMIN', True):
patch_admin_login()
def redirect_admin_login(self, request):
return redirect(str(settings.LOGIN_URL))
sites.AdminSite.login = redirect_admin_login


admin.site.register(PhoneDevice)
15 changes: 13 additions & 2 deletions two_factor/compat.py
Expand Up @@ -35,7 +35,7 @@ def import_by_path(dotted_path, error_prefix=''):


if django.VERSION[:2] >= (1, 6):
class Django16CompatInit(object):
class Django16Compat(object):
pass
else:
import django
Expand All @@ -46,7 +46,7 @@ class Django16CompatInit(object):

from django.contrib.formtools.wizard.storage.exceptions import NoFileStorageConfigured

class Django16CompatInit(object):
class Django16Compat(object):
@classmethod
def get_initkwargs(cls, form_list=None, initial_dict=None,
instance_dict=None, condition_dict=None, *args, **kwargs):
Expand Down Expand Up @@ -117,3 +117,14 @@ def get_initkwargs(cls, form_list=None, initial_dict=None,
# build the kwargs for the wizardview instances
kwargs['form_list'] = computed_form_list
return kwargs

def render_goto_step(self, goto_step, **kwargs):
"""
This method gets called when the current step has to be changed.
`goto_step` contains the requested step to go to.
"""
self.storage.current_step = goto_step
form = self.get_form(
data=self.storage.get_step_data(self.steps.current),
files=self.storage.get_step_files(self.steps.current))
return self.render(form)

0 comments on commit bfcbfaa

Please sign in to comment.