totp => otp, integrate with factors, new setup form

This commit is contained in:
Jens Langhammer 2019-02-25 12:29:40 +01:00
parent 9c2cfd7db4
commit bb81bb5a8d
30 changed files with 455 additions and 429 deletions

View File

@ -9,7 +9,7 @@ tag_name = version/{new_version}
[bumpversion:part:release] [bumpversion:part:release]
optional_value = stable optional_value = stable
values = values =
alpha alpha
beta beta
stable stable
@ -40,5 +40,5 @@ values =
[bumpversion:file:passbook/oauth_provider/__init__.py] [bumpversion:file:passbook/oauth_provider/__init__.py]
[bumpversion:file:passbook/totp/__init__.py] [bumpversion:file:passbook/otp/__init__.py]

View File

@ -1,13 +1,9 @@
"""passbook multi-factor authentication engine""" """passbook multi-factor authentication engine"""
from logging import getLogger
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django.views.generic import TemplateView from django.views.generic import TemplateView
from passbook.lib.config import CONFIG from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__)
class AuthenticationFactor(TemplateView): class AuthenticationFactor(TemplateView):
"""Abstract Authentication factor, inherits TemplateView but can be combined with FormView""" """Abstract Authentication factor, inherits TemplateView but can be combined with FormView"""

View File

@ -24,7 +24,9 @@ class AuthenticationView(UserPassesTestMixin, View):
pending_user = None pending_user = None
pending_factors = [] pending_factors = []
_current_factor = None _current_factor_class = None
current_factor = None
# Allow only not authenticated users to login # Allow only not authenticated users to login
def test_func(self): def test_func(self):
@ -37,6 +39,7 @@ class AuthenticationView(UserPassesTestMixin, View):
return redirect(reverse('passbook_core:overview')) return redirect(reverse('passbook_core:overview'))
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
print(request.session.keys())
# Extract pending user from session (only remember uid) # Extract pending user from session (only remember uid)
if AuthenticationView.SESSION_PENDING_USER in request.session: if AuthenticationView.SESSION_PENDING_USER in request.session:
self.pending_user = get_object_or_404( self.pending_user = get_object_or_404(
@ -50,43 +53,47 @@ class AuthenticationView(UserPassesTestMixin, View):
else: else:
# Get an initial list of factors which are currently enabled # Get an initial list of factors which are currently enabled
# and apply to the current user. We check policies here and block the request # and apply to the current user. We check policies here and block the request
_all_factors = Factor.objects.filter(enabled=True) _all_factors = Factor.objects.filter(enabled=True).order_by('order').select_subclasses()
self.pending_factors = [] self.pending_factors = []
for factor in _all_factors: for factor in _all_factors:
if factor.passes(self.pending_user): if factor.passes(self.pending_user):
self.pending_factors.append(factor.type) self.pending_factors.append((factor.uuid.hex, factor.type))
# Read and instantiate factor from session # Read and instantiate factor from session
factor_class = None factor_uuid, factor_class = None, None
if AuthenticationView.SESSION_FACTOR not in request.session: if AuthenticationView.SESSION_FACTOR not in request.session:
# Case when no factors apply to user, return error denied # Case when no factors apply to user, return error denied
if not self.pending_factors: if not self.pending_factors:
return self.user_invalid() return self.user_invalid()
factor_class = self.pending_factors[0] factor_uuid, factor_class = self.pending_factors[0]
else: else:
factor_class = request.session[AuthenticationView.SESSION_FACTOR] factor_uuid, factor_class = request.session[AuthenticationView.SESSION_FACTOR]
# Lookup current factor object
self.current_factor = Factor.objects.filter(uuid=factor_uuid).select_subclasses().first()
# Instantiate Next Factor and pass request # Instantiate Next Factor and pass request
factor = path_to_class(factor_class) factor = path_to_class(factor_class)
self._current_factor = factor(self) self._current_factor_class = factor(self)
self._current_factor.pending_user = self.pending_user self._current_factor_class.pending_user = self.pending_user
self._current_factor.request = request self._current_factor_class.request = request
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
"""pass get request to current factor""" """pass get request to current factor"""
LOGGER.debug("Passing GET to %s", class_to_path(self._current_factor.__class__)) LOGGER.debug("Passing GET to %s", class_to_path(self._current_factor_class.__class__))
return self._current_factor.get(request, *args, **kwargs) return self._current_factor_class.get(request, *args, **kwargs)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
"""pass post request to current factor""" """pass post request to current factor"""
LOGGER.debug("Passing POST to %s", class_to_path(self._current_factor.__class__)) LOGGER.debug("Passing POST to %s", class_to_path(self._current_factor_class.__class__))
return self._current_factor.post(request, *args, **kwargs) return self._current_factor_class.post(request, *args, **kwargs)
def user_ok(self): def user_ok(self):
"""Redirect to next Factor""" """Redirect to next Factor"""
LOGGER.debug("Factor %s passed", class_to_path(self._current_factor.__class__)) LOGGER.debug("Factor %s passed", class_to_path(self._current_factor_class.__class__))
# Remove passed factor from pending factors # Remove passed factor from pending factors
if class_to_path(self._current_factor.__class__) in self.pending_factors: current_factor_tuple = (self.current_factor.uuid.hex,
self.pending_factors.remove(class_to_path(self._current_factor.__class__)) class_to_path(self._current_factor_class.__class__))
if current_factor_tuple in self.pending_factors:
self.pending_factors.remove(current_factor_tuple)
next_factor = None next_factor = None
if self.pending_factors: if self.pending_factors:
next_factor = self.pending_factors.pop() next_factor = self.pending_factors.pop()
@ -120,11 +127,12 @@ class AuthenticationView(UserPassesTestMixin, View):
def _cleanup(self): def _cleanup(self):
"""Remove temporary data from session""" """Remove temporary data from session"""
session_keys = ['SESSION_FACTOR', 'SESSION_PENDING_FACTORS', session_keys = [self.SESSION_FACTOR, self.SESSION_PENDING_FACTORS,
'SESSION_PENDING_USER', 'SESSION_USER_BACKEND', ] self.SESSION_PENDING_USER, self.SESSION_USER_BACKEND, ]
for key in session_keys: for key in session_keys:
if key in self.request.session: if key in self.request.session:
del self.request.session[key] del self.request.session[key]
print(self.request.session.keys())
LOGGER.debug("Cleaned up sessions") LOGGER.debug("Cleaned up sessions")
class FactorPermissionDeniedView(PermissionDeniedView): class FactorPermissionDeniedView(PermissionDeniedView):

View File

@ -71,7 +71,7 @@ INSTALLED_APPS = [
'passbook.oauth_client.apps.PassbookOAuthClientConfig', 'passbook.oauth_client.apps.PassbookOAuthClientConfig',
'passbook.oauth_provider.apps.PassbookOAuthProviderConfig', 'passbook.oauth_provider.apps.PassbookOAuthProviderConfig',
'passbook.saml_idp.apps.PassbookSAMLIDPConfig', 'passbook.saml_idp.apps.PassbookSAMLIDPConfig',
'passbook.totp.apps.PassbookTOTPConfig', 'passbook.otp.apps.PassbookOTPConfig',
'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig', 'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig',
] ]

View File

@ -19,9 +19,9 @@ core_urls = [
path('auth/login/', authentication.LoginView.as_view(), name='auth-login'), path('auth/login/', authentication.LoginView.as_view(), name='auth-login'),
path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'), path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'),
path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'), path('auth/sign_up/', authentication.SignUpView.as_view(), name='auth-sign-up'),
path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'),
path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'), path('auth/process/', view.AuthenticationView.as_view(), name='auth-process'),
path('auth/process/<slug:factor>/', view.AuthenticationView.as_view(), name='auth-process'), path('auth/process/<slug:factor>/', view.AuthenticationView.as_view(), name='auth-process'),
path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'),
# User views # User views
path('user/', user.UserSettingsView.as_view(), name='user-settings'), path('user/', user.UserSettingsView.as_view(), name='user-settings'),
path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'), path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'),

View File

@ -0,0 +1,12 @@
"""passbook django boilerplate code"""
from django.utils.decorators import method_decorator
from django.views.decorators.cache import never_cache
class NeverCacheMixin():
"""Use never_cache as mixin for CBV"""
@method_decorator(never_cache)
def dispatch(self, *args, **kwargs):
"""Use never_cache as mixin for CBV"""
return super().dispatch(*args, **kwargs)

View File

@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
from passbook.admin.forms.source import SOURCE_FORM_FIELDS from passbook.admin.forms.source import SOURCE_FORM_FIELDS
from passbook.oauth_client.models import OAuthSource from passbook.oauth_client.models import OAuthSource
from passbook.oauth_client.source_types.manager import MANAGER
class OAuthSourceForm(forms.ModelForm): class OAuthSourceForm(forms.ModelForm):
@ -27,6 +28,7 @@ class OAuthSourceForm(forms.ModelForm):
'name': forms.TextInput(), 'name': forms.TextInput(),
'consumer_key': forms.TextInput(), 'consumer_key': forms.TextInput(),
'consumer_secret': forms.TextInput(), 'consumer_secret': forms.TextInput(),
'provider_type': forms.Select(choices=MANAGER.get_name_tuple()),
} }
labels = { labels = {
'request_token_url': _('Request Token URL'), 'request_token_url': _('Request Token URL'),

View File

@ -1,2 +1,2 @@
"""passbook totp Header""" """passbook otp Header"""
__version__ = '0.0.6-alpha' __version__ = '0.0.6-alpha'

12
passbook/otp/apps.py Normal file
View File

@ -0,0 +1,12 @@
"""passbook OTP AppConfig"""
from django.apps.config import AppConfig
class PassbookOTPConfig(AppConfig):
"""passbook OTP AppConfig"""
name = 'passbook.otp'
label = 'passbook_otp'
verbose_name = 'passbook OTP'
mountpoint = 'user/otp/'

49
passbook/otp/factors.py Normal file
View File

@ -0,0 +1,49 @@
"""OTP Factor logic"""
from logging import getLogger
from django.contrib import messages
from django.utils.translation import gettext as _
from django.views.generic import FormView
from django_otp import match_token, user_has_device
from passbook.core.auth.factor import AuthenticationFactor
from passbook.otp.forms import OTPVerifyForm
from passbook.otp.views import OTP_SETTING_UP_KEY, EnableView
LOGGER = getLogger(__name__)
class OTPFactor(FormView, AuthenticationFactor):
"""OTP Factor View"""
template_name = 'login/form_with_user.html'
form_class = OTPVerifyForm
def get(self, request, *args, **kwargs):
"""Check if User has OTP enabled and if OTP is enforced"""
if not user_has_device(self.pending_user):
LOGGER.debug("User doesn't have OTP Setup.")
if self.authenticator.current_factor.enforced:
# Redirect to setup view
LOGGER.debug("OTP is enforced, redirecting to setup")
request.user = self.pending_user
LOGGER.debug("Passing GET to EnableView")
return EnableView().dispatch(request)
LOGGER.debug("OTP is not enforced, skipping form")
return self.authenticator.user_ok()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""Check if setup is in progress and redirect to EnableView"""
if OTP_SETTING_UP_KEY in request.session:
LOGGER.debug("Passing POST to EnableView")
request.user = self.pending_user
return EnableView().dispatch(request)
return super().post(self, request, *args, **kwargs)
def form_valid(self, form: OTPVerifyForm):
"""Verify OTP Token"""
device = match_token(self.pending_user, form.cleaned_data.get('code'))
if device:
return self.authenticator.user_ok()
messages.error(self.request, _('Invalid OTP.'))
return self.form_invalid(form)

66
passbook/otp/forms.py Normal file
View File

@ -0,0 +1,66 @@
"""passbook OTP 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 _
from passbook.core.forms.factors import GENERAL_FIELDS
from passbook.otp.models import OTPFactor
OTP_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 OTPVerifyForm(forms.Form):
"""Simple Form to verify OTP Code"""
order = ['code']
code = forms.CharField(label=_('Code'), validators=[OTP_CODE_VALIDATOR],
widget=forms.TextInput(attrs={
'autocomplete': 'off',
'placeholder': 'Code'
}))
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 OTPSetupForm(forms.Form):
"""OTP Setup form"""
title = _('Set up OTP')
device = None
qr_code = forms.CharField(widget=PictureWidget, disabled=True, required=False,
label=_('Scan this Code with your OTP App.'))
code = forms.CharField(label=_('Code'), validators=[OTP_CODE_VALIDATOR],
widget=forms.TextInput(attrs={'placeholder': _('One-Time Password')}))
tokens = forms.MultipleChoiceField(disabled=True, required=False)
def clean_code(self):
"""Check code with new otp device"""
if self.device is not None:
if not self.device.verify_token(int(self.cleaned_data.get('code'))):
raise forms.ValidationError(_("OTP Code does not match"))
return self.cleaned_data.get('code')
class OTPFactorForm(forms.ModelForm):
"""Form to edit OTPFactor instances"""
class Meta:
model = OTPFactor
fields = GENERAL_FIELDS + ['enforced']
widgets = {
'name': forms.TextInput(),
'order': forms.NumberInput(),
}

View File

@ -0,0 +1,28 @@
# Generated by Django 2.1.7 on 2019-02-25 09:42
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('passbook_core', '0010_auto_20190224_1016'),
]
operations = [
migrations.CreateModel(
name='OTPFactor',
fields=[
('factor_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='passbook_core.Factor')),
('enforced', models.BooleanField(default=False, help_text='Enforce enabled OTP for Users this factor applies to.')),
],
options={
'verbose_name': 'OTP Factor',
'verbose_name_plural': 'OTP Factors',
},
bases=('passbook_core.factor',),
),
]

24
passbook/otp/models.py Normal file
View File

@ -0,0 +1,24 @@
"""OTP Factor"""
from django.db import models
from django.utils.translation import gettext as _
from passbook.core.models import Factor
class OTPFactor(Factor):
"""OTP Factor"""
enforced = models.BooleanField(default=False, help_text=('Enforce enabled OTP for Users '
'this factor applies to.'))
type = 'passbook.otp.factors.OTPFactor'
form = 'passbook.otp.forms.OTPFactorForm'
def __str__(self):
return "OTP Factor %s" % self.slug
class Meta:
verbose_name = _('OTP Factor')
verbose_name_plural = _('OTP Factors')

View File

@ -0,0 +1,2 @@
django_otp
qrcode

View File

@ -1,10 +1,8 @@
"""passbook TOTP Settings""" """passbook OTP Settings"""
OTP_LOGIN_URL = 'passbook_totp:totp-verify'
OTP_TOTP_ISSUER = 'passbook' OTP_TOTP_ISSUER = 'passbook'
MIDDLEWARE = [ MIDDLEWARE = [
'django_otp.middleware.OTPMiddleware', 'django_otp.middleware.OTPMiddleware',
'passbook.totp.middleware.totp_force_verify',
] ]
INSTALLED_APPS = [ INSTALLED_APPS = [
'django_otp', 'django_otp',

View File

@ -0,0 +1,50 @@
{% extends "user/base.html" %}
{% load utils %}
{% load i18n %}
{% block title %}
{% title "OTP" %}
{% endblock %}
{% block page %}
<h1>
<clr-icon shape="two-way-arrows" size="48"></clr-icon>{% trans "One-Time Passwords" %}
</h1>
<div class="row">
<div class="col-md-6">
<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_otp:otp-enable' %}"
class="btn btn-success btn-sm">{% trans "Enable OTP" %}</a>
{% else %}
<a href="{% url 'passbook_otp:otp-disable' %}"
class="btn btn-danger btn-sm">{% trans "Disable OTP" %}</a>
{% endif %}
</p>
</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 }}
{% empty %}{% trans 'N/A' %}{% endfor %}</pre>
</div>
</div>
</div>
</div>
{% endblock %}

12
passbook/otp/urls.py Normal file
View File

@ -0,0 +1,12 @@
"""passbook OTP Urls"""
from django.urls import path
from passbook.otp import views
urlpatterns = [
path('', views.UserSettingsView.as_view(), name='otp-user-settings'),
path('qr/', views.QRView.as_view(), name='otp-qr'),
path('enable/', views.EnableView.as_view(), name='otp-enable'),
path('disable/', views.DisableView.as_view(), name='otp-disable'),
]

View File

@ -1,4 +1,4 @@
"""passbook Mod TOTP Utils""" """passbook OTP Utils"""
from django.conf import settings from django.conf import settings
from django.utils.http import urlencode from django.utils.http import urlencode

164
passbook/otp/views.py Normal file
View File

@ -0,0 +1,164 @@
"""passbook OTP Views"""
from base64 import b32encode
from binascii import unhexlify
from logging import getLogger
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpRequest, HttpResponse
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views import View
from django.views.generic import FormView, TemplateView
from django_otp.plugins.otp_static.models import StaticDevice, StaticToken
from django_otp.plugins.otp_totp.models import TOTPDevice
from qrcode import make
from qrcode.image.svg import SvgPathImage
from passbook.lib.boilerplate import NeverCacheMixin
from passbook.lib.config import CONFIG
from passbook.otp.forms import OTPSetupForm
from passbook.otp.utils import otpauth_url
OTP_SESSION_KEY = 'passbook_otp_key'
OTP_SETTING_UP_KEY = 'passbook_otp_setup'
LOGGER = getLogger(__name__)
class UserSettingsView(LoginRequiredMixin, TemplateView):
"""View for user settings to control OTP"""
template_name = 'otp/user_settings.html'
# TODO: Check if OTP Factor exists and applies to user
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
static = StaticDevice.objects.filter(user=self.request.user, confirmed=True)
if static.exists():
kwargs['static_tokens'] = StaticToken.objects.filter(device=static.first()) \
.order_by('token')
totp_devices = TOTPDevice.objects.filter(user=self.request.user, confirmed=True)
kwargs['state'] = totp_devices.exists() and static.exists()
return kwargs
class DisableView(LoginRequiredMixin, TemplateView):
"""Disable TOTP for user"""
# TODO: Use Django DeleteView with custom delete?
# def
# # 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('passbook_core:overview'))
class EnableView(LoginRequiredMixin, FormView):
"""View to set up OTP"""
title = _('Set up OTP')
form_class = OTPSetupForm
template_name = 'login/form.html'
totp_device = None
static_device = None
# TODO: Check if OTP Factor exists and applies to user
def get_context_data(self, **kwargs):
kwargs['config'] = CONFIG.get('passbook')
kwargs['is_login'] = True
kwargs['title'] = _('Configue OTP')
kwargs['primary_action'] = _('Setup')
return super().get_context_data(**kwargs)
def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
# 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() and finished_static_devices.exists():
messages.error(request, _('You already have TOTP enabled!'))
del request.session[OTP_SETTING_UP_KEY]
return redirect('passbook_otp:otp-user-settings')
request.session[OTP_SETTING_UP_KEY] = True
# 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[OTP_SESSION_KEY] = b32encode(rawkey).decode("utf-8")
return super().dispatch(request, *args, **kwargs)
def get_form(self, form_class=None):
form = super().get_form(form_class=form_class)
form.device = self.totp_device
form.fields['qr_code'].initial = reverse('passbook_otp:otp-qr')
tokens = [(x.token, x.token) for x in self.static_device.token_set.all()]
form.fields['tokens'].choices = tokens
return form
def form_valid(self, form):
# Save device as confirmed
LOGGER.debug("Saved OTP Devices")
self.totp_device.confirmed = True
self.totp_device.save()
self.static_device.confirmed = True
self.static_device.save()
del self.request.session[OTP_SETTING_UP_KEY]
# Create event with email notification
# TODO: Create Audit Log entry
# Event.create(
# user=self.request.user,
# message=_('You activated TOTP.'),
# current=True,
# request=self.request,
# send_notification=True)
return redirect('passbook_otp:otp-user-settings')
class QRView(NeverCacheMixin, View):
"""View returns an SVG image with the OTP token information"""
def get(self, request: HttpRequest) -> HttpResponse:
"""View returns an SVG image with the OTP token information"""
# Get the data from the session
try:
key = request.session[OTP_SESSION_KEY]
except KeyError:
raise Http404
url = otpauth_url(accountname=request.user.username, secret=key)
# Make and return QR code
img = make(url, image_factory=SvgPathImage)
resp = HttpResponse(content_type='image/svg+xml; charset=utf-8')
img.save(resp)
return resp

View File

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

View File

@ -1,52 +0,0 @@
"""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

@ -1,32 +0,0 @@
"""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('passbook_core:auth-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

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

View File

@ -1,54 +0,0 @@
{% 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

@ -1,25 +0,0 @@
"""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)

View File

@ -1,14 +0,0 @@
"""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'),
]

View File

@ -1,207 +0,0 @@
"""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('passbook_core:overview'))
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': {
'passbook_core:auth-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('passbook_core:overview'))
# # 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('passbook_core:overview'))
# # 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

View File

@ -2,7 +2,7 @@
-r passbook/oauth_client/requirements.txt -r passbook/oauth_client/requirements.txt
-r passbook/ldap/requirements.txt -r passbook/ldap/requirements.txt
-r passbook/saml_idp/requirements.txt -r passbook/saml_idp/requirements.txt
-r passbook/totp/requirements.txt -r passbook/otp/requirements.txt
-r passbook/oauth_provider/requirements.txt -r passbook/oauth_provider/requirements.txt
-r passbook/audit/requirements.txt -r passbook/audit/requirements.txt
-r passbook/captcha_factor/requirements.txt -r passbook/captcha_factor/requirements.txt