core: implement new mfa authentication
This commit is contained in:
parent
32a73cbbf3
commit
83ed1d857b
|
@ -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
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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"""
|
||||
|
|
Reference in New Issue