totp: rename tfa to totp

This commit is contained in:
Jens Langhammer
2018-12-14 10:09:57 +01:00
parent 52d1920914
commit fbf58801ec
17 changed files with 79 additions and 78 deletions

View File

@ -0,0 +1,3 @@
"""passbook totp Header"""
__version__ = '0.0.1-alpha'
default_app_config = 'passbook.totp.apps.PassbookTOTPConfig'

11
passbook/totp/apps.py Normal file
View File

@ -0,0 +1,11 @@
"""passbook TOTP AppConfig"""
from django.apps.config import AppConfig
class PassbookTOTPConfig(AppConfig):
"""passbook TOTP AppConfig"""
name = 'passbook.totp'
label = 'passbook_totp'
mountpoint = 'user/totp/'

52
passbook/totp/forms.py Normal file
View File

@ -0,0 +1,52 @@
"""passbook TOTP Forms"""
from django import forms
from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
TOTP_CODE_VALIDATOR = RegexValidator(r'^[0-9a-z]{6,8}$',
_('Only alpha-numeric characters are allowed.'))
class PictureWidget(forms.widgets.Widget):
"""Widget to render value as img-tag"""
def render(self, name, value, attrs=None, renderer=None):
return mark_safe("<img src=\"%s\" />" % value) # nosec
class TOTPVerifyForm(forms.Form):
"""Simple Form to verify TOTP Code"""
order = ['code']
code = forms.CharField(label=_('Code'), validators=[TOTP_CODE_VALIDATOR],
widget=forms.TextInput(attrs={'autocomplete': 'off'}))
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# This is a little helper so the field is focused by default
self.fields['code'].widget.attrs.update({'autofocus': 'autofocus'})
class TOTPSetupInitForm(forms.Form):
"""Initial TOTP Setup form"""
title = _('Set up TOTP')
device = None
confirmed = False
qr_code = forms.CharField(widget=PictureWidget, disabled=True, required=False,
label=_('Scan this Code with your TOTP App.'))
code = forms.CharField(label=_('Code'), validators=[TOTP_CODE_VALIDATOR])
def clean_code(self):
"""Check code with new totp device"""
if self.device is not None:
if not self.device.verify_token(int(self.cleaned_data.get('code'))) \
and not self.confirmed:
raise forms.ValidationError(_("TOTP Code does not match"))
return self.cleaned_data.get('code')
class TOTPSetupStaticForm(forms.Form):
"""Static form to show generated static tokens"""
tokens = forms.MultipleChoiceField(disabled=True, required=False)

View File

@ -0,0 +1,32 @@
"""passbook TOTP Middleware to force users with TOTP set up to verify"""
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django_otp import user_has_device
def totp_force_verify(get_response):
"""Middleware to force TOTP Verification"""
def middleware(request):
"""Middleware to force TOTP Verification"""
# pylint: disable=too-many-boolean-expressions
if request.user.is_authenticated and \
user_has_device(request.user) and \
not request.user.is_verified() and \
request.path != reverse('passbook_totp:totp-verify') and \
request.path != reverse('account-logout') and \
not request.META.get('HTTP_AUTHORIZATION', '').startswith('Bearer'):
# User has TOTP set up but is not verified
# At this point the request is already forwarded to the target destination
# So we just add the current request's path as next parameter
args = '?%s' % urlencode({'next': request.get_full_path()})
return redirect(reverse('passbook_totp:totp-verify') + args)
response = get_response(request)
return response
return middleware

View File

@ -0,0 +1 @@
django-two-factor-auth

13
passbook/totp/settings.py Normal file
View File

@ -0,0 +1,13 @@
"""passbook TOTP Settings"""
OTP_LOGIN_URL = 'passbook_tfa:tfa-verify'
OTP_TOTP_ISSUER = 'passbook'
MIDDLEWARE = [
'django_otp.middleware.OTPMiddleware',
'passbook.tfa.middleware.tfa_force_verify',
]
INSTALLED_APPS = [
'django_otp',
'django_otp.plugins.otp_static',
'django_otp.plugins.otp_totp',
]

View File

@ -0,0 +1,54 @@
{% extends "user/base.html" %}
{% load utils %}
{% load i18n %}
{% load hostname %}
{% load setting %}
{% load fieldtype %}
{% block title %}
{% title "Overview" %}
{% endblock %}
{% block content %}
<h1><clr-icon shape="two-way-arrows" size="48"></clr-icon>{% trans "2-Factor Authentication" %}</h1>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans "Status" %}
</div>
<div class="card-footer">
<p>
{% blocktrans with state=state|yesno:"Enabled,Disabled" %}
Status: {{ state }}
{% endblocktrans %}
{% if state %}
<clr-icon shape="check" size="32" class="is-success"></clr-icon>
{% else %}
<clr-icon shape="times" size="32" class="is-error"></clr-icon>
{% endif %}
</p>
<p>
{% if not state %}
<a href="{% url 'passbook_tfa:tfa-enable' %}" class="btn btn-success btn-sm">{% trans "Enable TOTP" %}</a>
{% else %}
<a href="{% url 'passbook_tfa:tfa-disable' %}" class="btn btn-danger btn-sm">{% trans "Disable TOTP" %}</a>
{% endif %}
</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
{% trans "Your Backup tokens:" %}
</div>
<div class="card-block">
<pre>{% for token in static_tokens %}{{ token.token }}
{% endfor %}</pre>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "generic/wizard.html" %}
{% load utils %}
{% block title %}
{% title "Setup" %}
{% endblock %}
{% block form %}
<label for="">Keep these tokens somewhere safe. These are to be used if you loose your primary TOTP device.</label>
{% for field in wizard.form %}
{% if field.field.widget|fieldtype == 'SelectMultiple' %}
<ul class="list">
{% for token in field.field.choices %}
<li>{{ token.0 }}</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
{% endblock %}

View File

View File

@ -0,0 +1,25 @@
"""passbook TOTP Middleware Test"""
import os
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from django.urls import reverse
from passbook.core.views import overview
from passbook.totp.middleware import totp_force_verify
class TestMiddleware(TestCase):
"""passbook TOTP Middleware Test"""
def setUp(self):
os.environ['RECAPTCHA_TESTING'] = 'True'
self.factory = RequestFactory()
def test_totp_force_verify_anon(self):
"""Test Anonymous TFA Force"""
request = self.factory.get(reverse('passbook_core:overview'))
request.user = AnonymousUser()
response = totp_force_verify(overview.OverviewView.as_view())(request)
self.assertEqual(response.status_code, 302)

14
passbook/totp/urls.py Normal file
View File

@ -0,0 +1,14 @@
"""passbook TOTP Urls"""
from django.urls import path
from passbook.totp import views
urlpatterns = [
path('', views.index, name='totp-index'),
path('qr/', views.qr_code, name='totp-qr'),
path('verify/', views.verify, name='totp-verify'),
# path('enable/', views.TFASetupView.as_view(), name='totp-enable'),
path('disable/', views.disable, name='totp-disable'),
path('user_settings/', views.user_settings, name='totp-user_settings'),
]

22
passbook/totp/utils.py Normal file
View File

@ -0,0 +1,22 @@
"""passbook Mod TOTP Utils"""
from django.conf import settings
from django.utils.http import urlencode
def otpauth_url(accountname, secret, issuer=None, digits=6):
"""Create otpauth according to
https://github.com/google/google-authenticator/wiki/Key-Uri-Format"""
accountname = accountname
issuer = issuer if issuer else getattr(settings, 'OTP_TOTP_ISSUER')
# Ensure that the secret parameter is the FIRST parameter of the URI, this
# allows Microsoft Authenticator to work.
query = [
('secret', secret),
('digits', digits),
('issuer', issuer),
]
return 'otpauth://totp/%s:%s?%s' % (issuer, accountname, urlencode(query))

207
passbook/totp/views.py Normal file
View File

@ -0,0 +1,207 @@
"""passbook TOTP Views"""
# from base64 import b32encode
# from binascii import unhexlify
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.decorators.cache import never_cache
from django_otp import login, match_token, user_has_device
from django_otp.decorators import otp_required
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.plugins.otp_totp.models import TOTPDevice
from qrcode import make as qr_make
from qrcode.image.svg import SvgPathImage
from passbook.lib.decorators import reauth_required
# from passbook.core.models import Event
# from passbook.core.views.wizards import BaseWizardView
from passbook.totp.forms import TOTPVerifyForm
from passbook.totp.utils import otpauth_url
TFA_SESSION_KEY = 'passbook_2fa_key'
@login_required
@reauth_required
def index(request: HttpRequest) -> HttpResponse:
"""Show empty index page"""
return render(request, 'core/generic.html', {
'text': 'Test TOTP passed'
})
@login_required
def verify(request: HttpRequest) -> HttpResponse:
"""Verify TOTP Token"""
if not user_has_device(request.user):
messages.error(request, _("You don't have 2-Factor Authentication set up."))
if request.method == 'POST':
form = TOTPVerifyForm(request.POST)
if form.is_valid():
device = match_token(request.user, form.cleaned_data.get('code'))
if device:
login(request, device)
messages.success(request, _('Successfully validated TOTP Token.'))
# Check if there is a next GET parameter and redirect to that
if 'next' in request.GET:
return redirect(request.GET.get('next'))
# Otherwise just index
return redirect(reverse('common-index'))
messages.error(request, _('Invalid 2-Factor Token.'))
else:
form = TOTPVerifyForm()
return render(request, 'generic/form_login.html', {
'form': form,
'title': _("SSO - Two-factor verification"),
'primary_action': _("Verify"),
'extra_links': {
'account-logout': 'Logout',
}
})
@login_required
def user_settings(request: HttpRequest) -> HttpResponse:
"""View for user settings to control TOTP"""
static = get_object_or_404(StaticDevice, user=request.user, confirmed=True)
static_tokens = StaticToken.objects.filter(device=static).order_by('token')
finished_totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=True)
finished_static_devices = StaticDevice.objects.filter(user=request.user, confirmed=True)
state = finished_totp_devices.exists() and finished_static_devices.exists()
return render(request, 'totp/user_settings.html', {
'static_tokens': static_tokens,
'state': state,
})
@login_required
@reauth_required
@otp_required
def disable(request: HttpRequest) -> HttpResponse:
"""Disable TOTP for user"""
# Delete all the devices for user
static = get_object_or_404(StaticDevice, user=request.user, confirmed=True)
static_tokens = StaticToken.objects.filter(device=static).order_by('token')
totp = TOTPDevice.objects.filter(user=request.user, confirmed=True)
static.delete()
totp.delete()
for token in static_tokens:
token.delete()
messages.success(request, 'Successfully disabled TOTP')
# Create event with email notification
# Event.create(
# user=request.user,
# message=_('You disabled TOTP.'),
# current=True,
# request=request,
# send_notification=True)
return redirect(reverse('common-index'))
# # pylint: disable=too-many-ancestors
# @method_decorator([login_required, reauth_required], name="dispatch")
# class TFASetupView(BaseWizardView):
# """Wizard to create a Mail Account"""
# title = _('Set up TOTP')
# form_list = [TFASetupInitForm, TFASetupStaticForm]
# totp_device = None
# static_device = None
# confirmed = False
# def get_template_names(self):
# if self.steps.current == '1':
# return 'totp/wizard_setup_static.html'
# return self.template_name
# def handle_request(self, request: HttpRequest):
# # Check if user has TOTP setup already
# finished_totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=True)
# finished_static_devices = StaticDevice.objects.filter(user=request.user, confirmed=True)
# if finished_totp_devices.exists() or finished_static_devices.exists():
# messages.error(request, _('You already have TOTP enabled!'))
# return redirect(reverse('common-index'))
# # Check if there's an unconfirmed device left to set up
# totp_devices = TOTPDevice.objects.filter(user=request.user, confirmed=False)
# if not totp_devices.exists():
# # Create new TOTPDevice and save it, but not confirm it
# self.totp_device = TOTPDevice(user=request.user, confirmed=False)
# self.totp_device.save()
# else:
# self.totp_device = totp_devices.first()
# # Check if we have a static device already
# static_devices = StaticDevice.objects.filter(user=request.user, confirmed=False)
# if not static_devices.exists():
# # Create new static device and some codes
# self.static_device = StaticDevice(user=request.user, confirmed=False)
# self.static_device.save()
# # Create 9 tokens and save them
# # pylint: disable=unused-variable
# for counter in range(0, 9):
# token = StaticToken(device=self.static_device, token=StaticToken.random_token())
# token.save()
# else:
# self.static_device = static_devices.first()
# # Somehow convert the generated key to base32 for the QR code
# rawkey = unhexlify(self.totp_device.key.encode('ascii'))
# request.session[TFA_SESSION_KEY] = b32encode(rawkey).decode("utf-8")
# return True
# def get_form(self, step=None, data=None, files=None):
# form = super(TFASetupView, self).get_form(step, data, files)
# if step is None:
# step = self.steps.current
# if step == '0':
# form.confirmed = self.confirmed
# form.device = self.totp_device
# form.fields['qr_code'].initial = reverse('passbook_tfa:tfa-qr')
# elif step == '1':
# # This is a bit of a hack, but the 2fa token from step 1 has been checked here
# # And we need to save it, otherwise it's going to fail in render_done
# # and we're going to be redirected to step0
# self.confirmed = True
# tokens = [(x.token, x.token) for x in self.static_device.token_set.all()]
# form.fields['tokens'].choices = tokens
# return form
# def finish(self, *forms):
# # Save device as confirmed
# self.totp_device.confirmed = True
# self.totp_device.save()
# self.static_device.confirmed = True
# self.static_device.save()
# # Create event with email notification
# Event.create(
# user=self.request.user,
# message=_('You activated TOTP.'),
# current=True,
# request=self.request,
# send_notification=True)
# return redirect(reverse('passbook_tfa:tfa-index'))
@never_cache
@login_required
def qr_code(request: HttpRequest) -> HttpResponse:
"""View returns an SVG image with the OTP token information"""
# Get the data from the session
try:
key = request.session[TFA_SESSION_KEY]
except KeyError:
raise Http404
url = otpauth_url(accountname=request.user.username, secret=key)
# Make and return QR code
img = qr_make(url, image_factory=SvgPathImage)
resp = HttpResponse(content_type='image/svg+xml; charset=utf-8')
img.save(resp)
return resp