totp: rename tfa to totp
This commit is contained in:
3
passbook/totp/__init__.py
Normal file
3
passbook/totp/__init__.py
Normal 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
11
passbook/totp/apps.py
Normal 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
52
passbook/totp/forms.py
Normal 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)
|
||||
32
passbook/totp/middleware.py
Normal file
32
passbook/totp/middleware.py
Normal 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
|
||||
1
passbook/totp/requirements.txt
Normal file
1
passbook/totp/requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
django-two-factor-auth
|
||||
13
passbook/totp/settings.py
Normal file
13
passbook/totp/settings.py
Normal 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',
|
||||
]
|
||||
54
passbook/totp/templates/totp/user_settings.html
Normal file
54
passbook/totp/templates/totp/user_settings.html
Normal 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 %}
|
||||
20
passbook/totp/templates/totp/wizard_setup_static.html
Normal file
20
passbook/totp/templates/totp/wizard_setup_static.html
Normal 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 %}
|
||||
0
passbook/totp/tests/__init__.py
Normal file
0
passbook/totp/tests/__init__.py
Normal file
25
passbook/totp/tests/test_middleware.py
Normal file
25
passbook/totp/tests/test_middleware.py
Normal 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
14
passbook/totp/urls.py
Normal 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
22
passbook/totp/utils.py
Normal 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
207
passbook/totp/views.py
Normal 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
|
||||
Reference in New Issue
Block a user