Skip to content

Commit

Permalink
Start of modularising branch
Browse files Browse the repository at this point in the history
This relates to django-two-factor-auth jazzband#233, jazzband#215 (and by extension jazzband#86 and
others)

In a broken state, I'll re visit another time (this was really just to try some
ideas)
  • Loading branch information
goetzk committed Nov 4, 2017
1 parent 583f34a commit f544ebc
Show file tree
Hide file tree
Showing 12 changed files with 333 additions and 285 deletions.
Empty file added two_factor/backends/__init__.py
Empty file.
52 changes: 52 additions & 0 deletions two_factor/backends/generator/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from django import forms
from django_otp.oath import totp
from django_otp.plugins.otp_totp.models import TOTPDevice

class TOTPDeviceForm(forms.Form):
token = forms.IntegerField(label=_("Token"), min_value=0, max_value=int('9' * totp_digits()))

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

def __init__(self, key, user, metadata=None, **kwargs):
super(TOTPDeviceForm, self).__init__(**kwargs)
self.key = key
self.tolerance = 1
self.t0 = 0
self.step = 30
self.drift = 0
self.digits = totp_digits()
self.user = user
self.metadata = metadata or {}

@property
def bin_key(self):
"""
The secret key as a binary string.
"""
return unhexlify(self.key.encode())

def clean_token(self):
token = self.cleaned_data.get('token')
validated = False
t0s = [self.t0]
key = self.bin_key
if 'valid_t0' in self.metadata:
t0s.append(int(time()) - self.metadata['valid_t0'])
for t0 in t0s:
for offset in range(-self.tolerance, self.tolerance):
if totp(key, self.step, t0, self.digits, self.drift + offset) == token:
self.drift = offset
self.metadata['valid_t0'] = int(time()) - t0
validated = True
if not validated:
raise forms.ValidationError(self.error_messages['invalid_token'])
return token

def save(self):
return TOTPDevice.objects.create(user=self.user, key=self.key,
tolerance=self.tolerance, t0=self.t0,
step=self.step, drift=self.drift,
digits=self.digits,
name='default')
8 changes: 8 additions & 0 deletions two_factor/backends/generator/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@

import logging
logger = logging.getLogger(__name__)

def get_available_methods():
return [('generator', _('Token generator'))]


31 changes: 31 additions & 0 deletions two_factor/backends/phone/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.forms import Form, ModelForm
from django import forms

from .models import (
PhoneDevice, get_available_phone_methods,
)
from .validators import validate_international_phonenumber

class PhoneNumberMethodForm(ModelForm):
number = forms.CharField(label=_("Phone Number"),
validators=[validate_international_phonenumber])
method = forms.ChoiceField(widget=forms.RadioSelect, label=_('Method'))

class Meta:
model = PhoneDevice
fields = 'number', 'method',

def __init__(self, **kwargs):
super(PhoneNumberMethodForm, self).__init__(**kwargs)
self.fields['method'].choices = get_available_phone_methods()


class PhoneNumberForm(ModelForm):
# Cannot use PhoneNumberField, as it produces a PhoneNumber object, which cannot be serialized.
number = forms.CharField(label=_("Phone Number"),
validators=[validate_international_phonenumber])

class Meta:
model = PhoneDevice
fields = 'number',

93 changes: 93 additions & 0 deletions two_factor/backends/phone/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from __future__ import absolute_import, division, unicode_literals

from binascii import unhexlify

from django.conf import settings
from django.db import models
from django.utils.translation import ugettext_lazy as _

from django_otp.oath import totp
from django_otp.models import Device
from django_otp.util import random_hex

from phonenumber_field.modelfields import PhoneNumberField

from .gateways import make_call, send_sms

import logging
logger = logging.getLogger(__name__)

PHONE_METHODS = (
('call', _('Phone Call')),
('sms', _('Text Message')),
)


def get_available_methods():
methods = []
if getattr(settings, 'TWO_FACTOR_CALL_GATEWAY', None):
methods.append(('call', _('Phone call')))
if getattr(settings, 'TWO_FACTOR_SMS_GATEWAY', None):
methods.append(('sms', _('Text message')))
return methods


class PhoneDevice(Device):
"""
Model with phone number and token seed linked to a user.
"""
class Meta:
app_label = 'two_factor'

number = PhoneNumberField()
key = models.CharField(max_length=40,
validators=[key_validator],
default=random_hex,
help_text="Hex-encoded secret key")
method = models.CharField(max_length=4, choices=PHONE_METHODS,
verbose_name=_('method'))

def __repr__(self):
return '<PhoneDevice(number={!r}, method={!r}>'.format(
self.number,
self.method,
)

def __eq__(self, other):
if not isinstance(other, PhoneDevice):
return False
return self.number == other.number \
and self.method == other.method \
and self.key == other.key

@property
def bin_key(self):
return unhexlify(self.key.encode())

def verify_token(self, token):
# local import to avoid circular import
from two_factor.utils import totp_digits

try:
token = int(token)
except ValueError:
return False

for drift in range(-5, 1):
if totp(self.bin_key, drift=drift, digits=totp_digits()) == token:
return True
return False

def generate_challenge(self):
# local import to avoid circular import
from two_factor.utils import totp_digits

"""
Sends the current TOTP token to `self.number` using `self.method`.
"""
no_digits = totp_digits()
token = str(totp(self.bin_key, digits=no_digits)).zfill(no_digits)
if self.method == 'call':
make_call(device=self, token=token)
else:
send_sms(device=self, token=token)
88 changes: 88 additions & 0 deletions two_factor/backends/phone/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
@class_view_decorator(never_cache)
@class_view_decorator(otp_required)
class PhoneSetupView(IdempotentSessionWizardView):
"""
View for configuring a phone number for receiving tokens.
A user can have multiple backup :class:`~two_factor.models.PhoneDevice`
for receiving OTP tokens. If the primary phone number is not available, as
the battery might have drained or the phone is lost, these backup phone
numbers can be used for verification.
"""
template_name = 'two_factor/core/phone_register.html'
success_url = settings.LOGIN_REDIRECT_URL
form_list = (
('setup', PhoneNumberMethodForm),
('validation', DeviceValidationForm),
)
key_name = 'key'

def get(self, request, *args, **kwargs):
"""
Start the setup wizard. Redirect if no phone methods available.
"""
if not get_available_phone_methods():
return redirect(self.success_url)
return super(PhoneSetupView, self).get(request, *args, **kwargs)

def done(self, form_list, **kwargs):
"""
Store the device and redirect to profile page.
"""
self.get_device(user=self.request.user, name='backup').save()
return redirect(self.success_url)

def render_next_step(self, form, **kwargs):
"""
In the validation step, ask the device to generate a challenge.
"""
next_step = self.steps.next
if next_step == 'validation':
self.get_device().generate_challenge()
return super(PhoneSetupView, self).render_next_step(form, **kwargs)

def get_form_kwargs(self, step=None):
"""
Provide the device to the DeviceValidationForm.
"""
if step == 'validation':
return {'device': self.get_device()}
return {}

def get_device(self, **kwargs):
"""
Uses the data from the setup step and generated key to recreate device.
"""
kwargs = kwargs or {}
kwargs.update(self.storage.validated_step_data.get('setup', {}))
return PhoneDevice(key=self.get_key(), **kwargs)

def get_key(self):
"""
The key is preserved between steps and stored as ascii in the session.
"""
if self.key_name not in self.storage.extra_data:
key = random_hex(20).decode('ascii')
self.storage.extra_data[self.key_name] = key
return self.storage.extra_data[self.key_name]

def get_context_data(self, form, **kwargs):
kwargs.setdefault('cancel_url', resolve_url(self.success_url))
return super(PhoneSetupView, self).get_context_data(form, **kwargs)


@class_view_decorator(never_cache)
@class_view_decorator(otp_required)
class PhoneDeleteView(DeleteView):
"""
View for removing a phone number used for verification.
"""
success_url = settings.LOGIN_REDIRECT_URL

def get_queryset(self):
return self.request.user.phonedevice_set.filter(name='backup')

def get_success_url(self):
return resolve_url(self.success_url)


4 changes: 4 additions & 0 deletions two_factor/backends/static/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

class BackupTokenForm(AuthenticationTokenForm):
otp_token = forms.CharField(label=_("Token"))

24 changes: 24 additions & 0 deletions two_factor/backends/yubikey/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from django import forms

from two_factor.forms import DeviceValidationForm

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

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()


# TODO: Decide if AuthenticationTokenForm should be re produced here (it has
# YubiKey specific handling built in)

21 changes: 21 additions & 0 deletions two_factor/backends/yubikey/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from __future__ import absolute_import, division, unicode_literals

from django.conf import settings
from django.utils.translation import ugettext_lazy as _

import logging
logger = logging.getLogger(__name__)

try:
import yubiotp
except ImportError:
yubiotp = None


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


Loading

0 comments on commit f544ebc

Please sign in to comment.