core: implement new mfa authentication

This commit is contained in:
Jens Langhammer 2018-12-13 18:02:08 +01:00
parent 32a73cbbf3
commit 83ed1d857b
5 changed files with 151 additions and 41 deletions

View File

138
passbook/core/auth/mfa.py Normal file
View File

@ -0,0 +1,138 @@
"""passbook multi-factor authentication engine"""
from logging import getLogger
from django.contrib.auth import authenticate, login
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, reverse
from django.utils.translation import gettext as _
from django.views.generic import FormView, TemplateView, View
from passbook.core.forms.authentication import AuthenticationBackendFactorForm
from passbook.core.models import User
from passbook.lib.config import CONFIG
LOGGER = getLogger(__name__)
class AuthenticationFactor(TemplateView):
"""Abstract Authentication factor, inherits TemplateView but can be combined with FormView"""
form = None
required = True
authenticator = None
request = None
template_name = 'login/form.html'
def __init__(self, authenticator):
self.authenticator = authenticator
def get_context_data(self, **kwargs):
kwargs['config'] = CONFIG.get('passbook')
kwargs['is_login'] = True
kwargs['title'] = _('Log in to your account')
kwargs['primary_action'] = _('Log in')
return super().get_context_data(**kwargs)
class AuthenticationBackendFactor(FormView, AuthenticationFactor):
"""Authentication factor which authenticates against django's AuthBackend"""
form_class = AuthenticationBackendFactorForm
def form_valid(self, form):
"""Authenticate against django's authentication backend"""
uid_fields = CONFIG.y('passbook.uid_fields')
kwargs = {
'password': form.cleaned_data.get('password'),
}
for uid_field in uid_fields:
kwargs[uid_field] = getattr(self.authenticator.pending_user, uid_field)
user = authenticate(self.request, **kwargs)
if user:
# User instance returned from authenticate() has .backend property set
self.authenticator.pending_user = user
return self.authenticator.user_ok()
return self.authenticator.user_invalid()
class DummyFactor(AuthenticationFactor):
"""Dummy factor for testing with multiple factors"""
def post(self, request):
"""Just redirect to next factor"""
return self.authenticator.user_ok()
class MultiFactorAuthenticator(View):
"""Wizard-like Multi-factor authenticator"""
SESSION_FACTOR = 'passbook_factor'
SESSION_PENDING_FACTORS = 'passbook_pending_factors'
SESSION_PENDING_USER = 'passbook_pending_user'
pending_user = None
pending_factors = []
factors = [
AuthenticationBackendFactor,
DummyFactor
]
_current_factor = None
def dispatch(self, request, *args, **kwargs):
# Extract pending user from session (only remember uid)
if MultiFactorAuthenticator.SESSION_PENDING_USER in request.session:
self.pending_user = get_object_or_404(
User, id=self.request.session[MultiFactorAuthenticator.SESSION_PENDING_USER])
else:
raise Http404
# Read and instantiate factor from session
factor = None
if MultiFactorAuthenticator.SESSION_FACTOR in request.session:
factor = next(x for x in self.factors if x.__name__ ==
request.session[MultiFactorAuthenticator.SESSION_FACTOR])
else:
factor = self.factors[0]
# Write pending factors to session
if MultiFactorAuthenticator.SESSION_PENDING_FACTORS in request.session:
self.pending_factors = request.session[MultiFactorAuthenticator.SESSION_PENDING_FACTORS]
else:
self.pending_factors = MultiFactorAuthenticator.factors.copy()
self._current_factor = factor(self)
self._current_factor.request = request
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
"""pass get request to current factor"""
return self._current_factor.get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""pass post request to current factor"""
return self._current_factor.post(request, *args, **kwargs)
def user_ok(self):
"""Redirect to next Factor"""
LOGGER.debug("Factor %s passed", self._current_factor.__name__)
next_factor = None
if self.pending_factors:
next_factor = self.pending_factors.pop()
self.request.session[MultiFactorAuthenticator.SESSION_PENDING_FACTORS] = \
self.pending_factors
LOGGER.debug("Next Factor is %s", next_factor)
if next_factor:
self.request.session[MultiFactorAuthenticator.SESSION_FACTOR] = next_factor.__name__
LOGGER.debug("Rendering next factor")
return self.dispatch(self.request)
# User passed all factors
LOGGER.debug("User passed all factors, logging in")
return self.user_passed()
def user_passed(self):
"""User Successfully passed all factors"""
# user = authenticate(request=self.request, )
login(self.request, self.pending_user)
LOGGER.debug("Logged in user %s", self.pending_user)
return redirect(reverse('passbook_core:overview'))
def user_invalid(self):
"""Show error message, user could not be authenticated"""
LOGGER.debug("User invalid")
# TODO: Redirect to error view

View File

@ -16,7 +16,6 @@ class LoginForm(forms.Form):
title = _('Log in to your account')
uid_field = forms.CharField(widget=forms.TextInput(attrs={'placeholder': _('UID')}))
password = forms.CharField(widget=forms.PasswordInput(attrs={'placeholder': _('Password')}))
remember_me = forms.BooleanField(required=False)
def clean_uid_field(self):
@ -25,6 +24,11 @@ class LoginForm(forms.Form):
validate_email(self.cleaned_data.get('uid_field'))
return self.cleaned_data.get('uid_field')
class AuthenticationBackendFactorForm(forms.Form):
"""Password authentication form"""
password = forms.CharField(widget=forms.PasswordInput(attrs={'placeholder': _('Password')}))
class SignUpForm(forms.Form):
"""SignUp Form"""

View File

@ -6,6 +6,7 @@ from django.contrib import admin
from django.urls import include, path
from django.views.generic import RedirectView
from passbook.core.auth import mfa
from passbook.core.views import authentication, overview, user
from passbook.lib.utils.reflection import get_apps
@ -18,6 +19,7 @@ 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/mfa/', mfa.MultiFactorAuthenticator.as_view(), name='mfa'),
# User views
path('user/', user.UserSettingsView.as_view(), name='user-settings'),
path('user/delete/', user.UserDeleteView.as_view(), name='user-delete'),

View File

@ -3,7 +3,7 @@ from logging import getLogger
from typing import Dict
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth import logout
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.http import HttpRequest, HttpResponse
from django.shortcuts import redirect, reverse
@ -11,6 +11,7 @@ from django.utils.translation import ugettext as _
from django.views import View
from django.views.generic import FormView
from passbook.core.auth.mfa import MultiFactorAuthenticator
from passbook.core.forms.authentication import LoginForm, SignUpForm
from passbook.core.models import Invitation, User
from passbook.core.signals import invitation_used, user_signed_up
@ -30,11 +31,6 @@ class LoginView(UserPassesTestMixin, FormView):
return self.request.user.is_authenticated is False
def handle_no_permission(self):
return self.logged_in_redirect()
def logged_in_redirect(self):
"""User failed check so user is authenticated already.
Either redirect to ?next param or home."""
if 'next' in self.request.GET:
return redirect(self.request.GET.get('next'))
return redirect(reverse('passbook_core:overview'))
@ -62,40 +58,10 @@ class LoginView(UserPassesTestMixin, FormView):
if not pre_user:
# No user found
return self.invalid_login(self.request)
user = authenticate(
email=pre_user.email,
username=pre_user.username,
password=form.cleaned_data.get('password'),
request=self.request)
if user:
# User authenticated successfully
return self.login(self.request, user, form.cleaned_data)
# User was found but couldn't authenticate
return self.invalid_login(self.request, disabled_user=pre_user)
def login(self, request: HttpRequest, user: User, cleaned_data: Dict) -> HttpResponse:
"""Handle actual login
Actually logs user in, sets session expiry and redirects to ?next parameter
Args:
request: The current request
user: The user to be logged in.
Returns:
Either redirect to ?next or if not present to overview
"""
if user is None:
raise ValueError("User cannot be None")
login(request, user)
if cleaned_data.get('remember') is True:
request.session.set_expiry(CONFIG.y('passbook.session.remember_age'))
else:
request.session.set_expiry(0) # Expires when browser is closed
messages.success(request, _("Successfully logged in!"))
LOGGER.debug("Successfully logged in %s", user.username)
return self.logged_in_redirect()
if MultiFactorAuthenticator.SESSION_FACTOR in self.request.session:
del self.request.session[MultiFactorAuthenticator.SESSION_FACTOR]
self.request.session[MultiFactorAuthenticator.SESSION_PENDING_USER] = pre_user.pk
return redirect(reverse('passbook_core:mfa'))
def invalid_login(self, request: HttpRequest, disabled_user: User = None) -> HttpResponse:
"""Handle login for disabled users/invalid login attempts"""