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/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"""
|
"""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"""
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
12
passbook/lib/boilerplate.py
Normal file
12
passbook/lib/boilerplate.py
Normal 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)
|
|
@ -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'),
|
||||||
|
|
|
@ -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
12
passbook/otp/apps.py
Normal 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
49
passbook/otp/factors.py
Normal 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
66
passbook/otp/forms.py
Normal 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(),
|
||||||
|
}
|
28
passbook/otp/migrations/0001_initial.py
Normal file
28
passbook/otp/migrations/0001_initial.py
Normal 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
24
passbook/otp/models.py
Normal 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')
|
2
passbook/otp/requirements.txt
Normal file
2
passbook/otp/requirements.txt
Normal file
|
@ -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'
|
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',
|
50
passbook/otp/templates/otp/user_settings.html
Normal file
50
passbook/otp/templates/otp/user_settings.html
Normal 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
12
passbook/otp/urls.py
Normal 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'),
|
||||||
|
]
|
|
@ -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
164
passbook/otp/views.py
Normal 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
|
|
@ -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/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
|
||||||
|
|
Reference in a new issue