totp => otp, integrate with factors, new setup form
This commit is contained in:
parent
9c2cfd7db4
commit
bb81bb5a8d
|
@ -40,5 +40,5 @@ values =
|
|||
|
||||
[bumpversion:file:passbook/oauth_provider/__init__.py]
|
||||
|
||||
[bumpversion:file:passbook/totp/__init__.py]
|
||||
[bumpversion:file:passbook/otp/__init__.py]
|
||||
|
||||
|
|
|
@ -1,13 +1,9 @@
|
|||
"""passbook multi-factor authentication engine"""
|
||||
from logging import getLogger
|
||||
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from passbook.lib.config import CONFIG
|
||||
|
||||
LOGGER = getLogger(__name__)
|
||||
|
||||
|
||||
class AuthenticationFactor(TemplateView):
|
||||
"""Abstract Authentication factor, inherits TemplateView but can be combined with FormView"""
|
||||
|
|
|
@ -24,7 +24,9 @@ class AuthenticationView(UserPassesTestMixin, View):
|
|||
pending_user = None
|
||||
pending_factors = []
|
||||
|
||||
_current_factor = None
|
||||
_current_factor_class = None
|
||||
|
||||
current_factor = None
|
||||
|
||||
# Allow only not authenticated users to login
|
||||
def test_func(self):
|
||||
|
@ -37,6 +39,7 @@ class AuthenticationView(UserPassesTestMixin, View):
|
|||
return redirect(reverse('passbook_core:overview'))
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
print(request.session.keys())
|
||||
# Extract pending user from session (only remember uid)
|
||||
if AuthenticationView.SESSION_PENDING_USER in request.session:
|
||||
self.pending_user = get_object_or_404(
|
||||
|
@ -50,43 +53,47 @@ class AuthenticationView(UserPassesTestMixin, View):
|
|||
else:
|
||||
# Get an initial list of factors which are currently enabled
|
||||
# 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 = []
|
||||
for factor in _all_factors:
|
||||
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
|
||||
factor_class = None
|
||||
factor_uuid, factor_class = None, None
|
||||
if AuthenticationView.SESSION_FACTOR not in request.session:
|
||||
# Case when no factors apply to user, return error denied
|
||||
if not self.pending_factors:
|
||||
return self.user_invalid()
|
||||
factor_class = self.pending_factors[0]
|
||||
factor_uuid, factor_class = self.pending_factors[0]
|
||||
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
|
||||
factor = path_to_class(factor_class)
|
||||
self._current_factor = factor(self)
|
||||
self._current_factor.pending_user = self.pending_user
|
||||
self._current_factor.request = request
|
||||
self._current_factor_class = factor(self)
|
||||
self._current_factor_class.pending_user = self.pending_user
|
||||
self._current_factor_class.request = request
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
"""pass get request to current factor"""
|
||||
LOGGER.debug("Passing GET to %s", class_to_path(self._current_factor.__class__))
|
||||
return self._current_factor.get(request, *args, **kwargs)
|
||||
LOGGER.debug("Passing GET to %s", class_to_path(self._current_factor_class.__class__))
|
||||
return self._current_factor_class.get(request, *args, **kwargs)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
"""pass post request to current factor"""
|
||||
LOGGER.debug("Passing POST to %s", class_to_path(self._current_factor.__class__))
|
||||
return self._current_factor.post(request, *args, **kwargs)
|
||||
LOGGER.debug("Passing POST to %s", class_to_path(self._current_factor_class.__class__))
|
||||
return self._current_factor_class.post(request, *args, **kwargs)
|
||||
|
||||
def user_ok(self):
|
||||
"""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
|
||||
if class_to_path(self._current_factor.__class__) in self.pending_factors:
|
||||
self.pending_factors.remove(class_to_path(self._current_factor.__class__))
|
||||
current_factor_tuple = (self.current_factor.uuid.hex,
|
||||
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
|
||||
if self.pending_factors:
|
||||
next_factor = self.pending_factors.pop()
|
||||
|
@ -120,11 +127,12 @@ class AuthenticationView(UserPassesTestMixin, View):
|
|||
|
||||
def _cleanup(self):
|
||||
"""Remove temporary data from session"""
|
||||
session_keys = ['SESSION_FACTOR', 'SESSION_PENDING_FACTORS',
|
||||
'SESSION_PENDING_USER', 'SESSION_USER_BACKEND', ]
|
||||
session_keys = [self.SESSION_FACTOR, self.SESSION_PENDING_FACTORS,
|
||||
self.SESSION_PENDING_USER, self.SESSION_USER_BACKEND, ]
|
||||
for key in session_keys:
|
||||
if key in self.request.session:
|
||||
del self.request.session[key]
|
||||
print(self.request.session.keys())
|
||||
LOGGER.debug("Cleaned up sessions")
|
||||
|
||||
class FactorPermissionDeniedView(PermissionDeniedView):
|
||||
|
|
|
@ -71,7 +71,7 @@ INSTALLED_APPS = [
|
|||
'passbook.oauth_client.apps.PassbookOAuthClientConfig',
|
||||
'passbook.oauth_provider.apps.PassbookOAuthProviderConfig',
|
||||
'passbook.saml_idp.apps.PassbookSAMLIDPConfig',
|
||||
'passbook.totp.apps.PassbookTOTPConfig',
|
||||
'passbook.otp.apps.PassbookOTPConfig',
|
||||
'passbook.captcha_factor.apps.PassbookCaptchaFactorConfig',
|
||||
]
|
||||
|
||||
|
|
|
@ -19,9 +19,9 @@ core_urls = [
|
|||
path('auth/login/', authentication.LoginView.as_view(), name='auth-login'),
|
||||
path('auth/logout/', authentication.LogoutView.as_view(), name='auth-logout'),
|
||||
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/<slug:factor>/', view.AuthenticationView.as_view(), name='auth-process'),
|
||||
path('auth/process/denied/', view.FactorPermissionDeniedView.as_view(), name='auth-denied'),
|
||||
# User views
|
||||
path('user/', user.UserSettingsView.as_view(), name='user-settings'),
|
||||
path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'),
|
||||
|
|
|
@ -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)
|
|
@ -5,6 +5,7 @@ from django.utils.translation import gettext as _
|
|||
|
||||
from passbook.admin.forms.source import SOURCE_FORM_FIELDS
|
||||
from passbook.oauth_client.models import OAuthSource
|
||||
from passbook.oauth_client.source_types.manager import MANAGER
|
||||
|
||||
|
||||
class OAuthSourceForm(forms.ModelForm):
|
||||
|
@ -27,6 +28,7 @@ class OAuthSourceForm(forms.ModelForm):
|
|||
'name': forms.TextInput(),
|
||||
'consumer_key': forms.TextInput(),
|
||||
'consumer_secret': forms.TextInput(),
|
||||
'provider_type': forms.Select(choices=MANAGER.get_name_tuple()),
|
||||
}
|
||||
labels = {
|
||||
'request_token_url': _('Request Token URL'),
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
"""passbook totp Header"""
|
||||
"""passbook otp Header"""
|
||||
__version__ = '0.0.6-alpha'
|
|
@ -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/'
|
|
@ -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)
|
|
@ -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(),
|
||||
}
|
|
@ -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',),
|
||||
),
|
||||
]
|
|
@ -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')
|
|
@ -0,0 +1,2 @@
|
|||
django_otp
|
||||
qrcode
|
|
@ -1,10 +1,8 @@
|
|||
"""passbook TOTP Settings"""
|
||||
"""passbook OTP Settings"""
|
||||
|
||||
OTP_LOGIN_URL = 'passbook_totp:totp-verify'
|
||||
OTP_TOTP_ISSUER = 'passbook'
|
||||
MIDDLEWARE = [
|
||||
'django_otp.middleware.OTPMiddleware',
|
||||
'passbook.totp.middleware.totp_force_verify',
|
||||
]
|
||||
INSTALLED_APPS = [
|
||||
'django_otp',
|
|
@ -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 %}
|
|
@ -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'),
|
||||
]
|
|
@ -1,4 +1,4 @@
|
|||
"""passbook Mod TOTP Utils"""
|
||||
"""passbook OTP Utils"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.http import urlencode
|
|
@ -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
|
|
@ -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/'
|
|
@ -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)
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
django-two-factor-auth
|
|
@ -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 %}
|
|
@ -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)
|
|
@ -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'),
|
||||
]
|
|
@ -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
|
|
@ -2,7 +2,7 @@
|
|||
-r passbook/oauth_client/requirements.txt
|
||||
-r passbook/ldap/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/audit/requirements.txt
|
||||
-r passbook/captcha_factor/requirements.txt
|
||||
|
|
Reference in New Issue