diff --git a/passbook/core/auth/__init__.py b/passbook/core/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/passbook/core/auth/mfa.py b/passbook/core/auth/mfa.py new file mode 100644 index 000000000..b49e851d5 --- /dev/null +++ b/passbook/core/auth/mfa.py @@ -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 diff --git a/passbook/core/forms/authentication.py b/passbook/core/forms/authentication.py index 8b409928a..febe59e9e 100644 --- a/passbook/core/forms/authentication.py +++ b/passbook/core/forms/authentication.py @@ -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""" diff --git a/passbook/core/urls.py b/passbook/core/urls.py index e9c79cfbd..8d5f80213 100644 --- a/passbook/core/urls.py +++ b/passbook/core/urls.py @@ -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'), diff --git a/passbook/core/views/authentication.py b/passbook/core/views/authentication.py index 9f1362470..2c4332b33 100644 --- a/passbook/core/views/authentication.py +++ b/passbook/core/views/authentication.py @@ -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"""