2019-02-26 14:40:58 +00:00
|
|
|
"""passbook core authentication views"""
|
2019-10-04 10:44:59 +00:00
|
|
|
from typing import Dict, Optional
|
2018-11-11 12:41:48 +00:00
|
|
|
|
|
|
|
from django.contrib import messages
|
2019-02-25 19:46:23 +00:00
|
|
|
from django.contrib.auth import login, logout
|
2018-11-23 08:44:30 +00:00
|
|
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
2019-02-26 14:40:58 +00:00
|
|
|
from django.forms.utils import ErrorList
|
2018-11-11 12:41:48 +00:00
|
|
|
from django.http import HttpRequest, HttpResponse
|
2019-02-25 19:46:23 +00:00
|
|
|
from django.shortcuts import get_object_or_404, redirect, reverse
|
2018-11-11 12:41:48 +00:00
|
|
|
from django.utils.translation import ugettext as _
|
2018-11-23 08:44:30 +00:00
|
|
|
from django.views import View
|
2018-11-11 12:41:48 +00:00
|
|
|
from django.views.generic import FormView
|
2019-10-01 08:24:10 +00:00
|
|
|
from structlog import get_logger
|
2018-11-11 12:41:48 +00:00
|
|
|
|
2018-12-10 12:51:38 +00:00
|
|
|
from passbook.core.forms.authentication import LoginForm, SignUpForm
|
2019-02-25 19:46:23 +00:00
|
|
|
from passbook.core.models import Invitation, Nonce, Source, User
|
2018-12-10 13:21:42 +00:00
|
|
|
from passbook.core.signals import invitation_used, user_signed_up
|
2019-10-07 14:33:48 +00:00
|
|
|
from passbook.factors.password.exceptions import PasswordPolicyInvalid
|
2020-05-08 14:10:27 +00:00
|
|
|
from passbook.flows.models import Flow, FlowDesignation
|
|
|
|
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlanner
|
|
|
|
from passbook.flows.views import SESSION_KEY_PLAN
|
2018-11-11 12:41:48 +00:00
|
|
|
from passbook.lib.config import CONFIG
|
2020-05-08 14:10:27 +00:00
|
|
|
from passbook.lib.utils.urls import redirect_with_qs
|
2018-11-11 12:41:48 +00:00
|
|
|
|
2019-10-04 08:08:53 +00:00
|
|
|
LOGGER = get_logger()
|
2018-11-11 12:41:48 +00:00
|
|
|
|
2019-02-26 13:07:47 +00:00
|
|
|
|
2018-11-11 12:41:48 +00:00
|
|
|
class LoginView(UserPassesTestMixin, FormView):
|
|
|
|
"""Allow users to sign in"""
|
|
|
|
|
2019-12-31 11:51:16 +00:00
|
|
|
template_name = "login/form.html"
|
2018-11-11 12:41:48 +00:00
|
|
|
form_class = LoginForm
|
2019-12-31 11:51:16 +00:00
|
|
|
success_url = "."
|
2018-11-11 12:41:48 +00:00
|
|
|
|
|
|
|
# Allow only not authenticated users to login
|
|
|
|
def test_func(self):
|
2018-12-09 20:07:38 +00:00
|
|
|
return self.request.user.is_authenticated is False
|
|
|
|
|
|
|
|
def handle_no_permission(self):
|
2019-12-31 11:51:16 +00:00
|
|
|
if "next" in self.request.GET:
|
|
|
|
return redirect(self.request.GET.get("next"))
|
|
|
|
return redirect(reverse("passbook_core:overview"))
|
2018-11-11 12:41:48 +00:00
|
|
|
|
|
|
|
def get_context_data(self, **kwargs):
|
2019-12-31 11:51:16 +00:00
|
|
|
kwargs["config"] = CONFIG.y("passbook")
|
|
|
|
kwargs["title"] = _("Log in to your account")
|
|
|
|
kwargs["primary_action"] = _("Log in")
|
|
|
|
kwargs["show_sign_up_notice"] = CONFIG.y("passbook.sign_up.enabled")
|
|
|
|
kwargs["sources"] = []
|
2020-02-23 19:16:25 +00:00
|
|
|
sources = (
|
|
|
|
Source.objects.filter(enabled=True).order_by("name").select_subclasses()
|
|
|
|
)
|
2019-10-13 14:47:05 +00:00
|
|
|
for source in sources:
|
2020-02-21 10:20:55 +00:00
|
|
|
ui_login_button = source.ui_login_button
|
2020-02-20 12:51:41 +00:00
|
|
|
if ui_login_button:
|
|
|
|
kwargs["sources"].append(ui_login_button)
|
2018-11-11 12:41:48 +00:00
|
|
|
return super().get_context_data(**kwargs)
|
|
|
|
|
2019-10-04 10:44:59 +00:00
|
|
|
def get_user(self, uid_value) -> Optional[User]:
|
2018-11-11 12:41:48 +00:00
|
|
|
"""Find user instance. Returns None if no user was found."""
|
2019-12-31 11:51:16 +00:00
|
|
|
for search_field in CONFIG.y("passbook.uid_fields"):
|
2019-02-25 18:43:33 +00:00
|
|
|
# Workaround for E-Mail -> email
|
2019-12-31 11:51:16 +00:00
|
|
|
if search_field == "e-mail":
|
|
|
|
search_field = "email"
|
2018-11-11 12:41:48 +00:00
|
|
|
users = User.objects.filter(**{search_field: uid_value})
|
|
|
|
if users.exists():
|
2019-10-04 10:44:59 +00:00
|
|
|
LOGGER.debug("Found user", user=users.first(), uid_field=search_field)
|
2018-11-11 12:41:48 +00:00
|
|
|
return users.first()
|
|
|
|
return None
|
|
|
|
|
|
|
|
def form_valid(self, form: LoginForm) -> HttpResponse:
|
|
|
|
"""Form data is valid"""
|
2019-12-31 11:51:16 +00:00
|
|
|
pre_user = self.get_user(form.cleaned_data.get("uid_field"))
|
2018-11-11 12:41:48 +00:00
|
|
|
if not pre_user:
|
|
|
|
# No user found
|
2018-11-23 08:44:30 +00:00
|
|
|
return self.invalid_login(self.request)
|
2020-05-08 14:10:27 +00:00
|
|
|
# We run the Flow planner here so we can pass the Pending user in the context
|
|
|
|
flow = get_object_or_404(Flow, designation=FlowDesignation.AUTHENTICATION)
|
|
|
|
planner = FlowPlanner(flow)
|
|
|
|
plan = planner.plan(self.request)
|
|
|
|
plan.context[PLAN_CONTEXT_PENDING_USER] = pre_user
|
|
|
|
self.request.session[SESSION_KEY_PLAN] = plan
|
|
|
|
return redirect_with_qs(
|
|
|
|
"passbook_flows:flow-executor", self.request.GET, flow_slug=flow.slug,
|
|
|
|
)
|
2018-11-11 12:41:48 +00:00
|
|
|
|
2019-12-31 11:51:16 +00:00
|
|
|
def invalid_login(
|
|
|
|
self, request: HttpRequest, disabled_user: User = None
|
|
|
|
) -> HttpResponse:
|
2018-11-11 12:41:48 +00:00
|
|
|
"""Handle login for disabled users/invalid login attempts"""
|
2019-12-31 11:45:29 +00:00
|
|
|
LOGGER.debug("invalid_login", user=disabled_user)
|
2019-12-31 11:51:16 +00:00
|
|
|
messages.error(request, _("Failed to authenticate."))
|
2018-11-23 08:44:30 +00:00
|
|
|
return self.render_to_response(self.get_context_data())
|
|
|
|
|
2019-02-26 13:07:47 +00:00
|
|
|
|
2018-11-23 08:44:30 +00:00
|
|
|
class LogoutView(LoginRequiredMixin, View):
|
|
|
|
"""Log current user out"""
|
|
|
|
|
|
|
|
def dispatch(self, request):
|
|
|
|
"""Log current user out"""
|
|
|
|
logout(request)
|
|
|
|
messages.success(request, _("You've successfully been logged out."))
|
2019-12-31 11:51:16 +00:00
|
|
|
return redirect(reverse("passbook_core:auth-login"))
|
2018-12-10 12:51:38 +00:00
|
|
|
|
|
|
|
|
|
|
|
class SignUpView(UserPassesTestMixin, FormView):
|
2018-12-10 13:21:42 +00:00
|
|
|
"""Sign up new user, optionally consume one-use invitation link."""
|
2018-12-10 12:51:38 +00:00
|
|
|
|
2019-12-31 11:51:16 +00:00
|
|
|
template_name = "login/form.html"
|
2018-12-10 12:51:38 +00:00
|
|
|
form_class = SignUpForm
|
2019-12-31 11:51:16 +00:00
|
|
|
success_url = "."
|
2019-10-08 12:30:17 +00:00
|
|
|
# Invitation instance, if invitation link was used
|
2018-12-10 13:21:42 +00:00
|
|
|
_invitation = None
|
2018-12-10 13:05:27 +00:00
|
|
|
# Instance of newly created user
|
|
|
|
_user = None
|
2018-12-10 12:51:38 +00:00
|
|
|
|
|
|
|
# Allow only not authenticated users to login
|
|
|
|
def test_func(self):
|
|
|
|
return self.request.user.is_authenticated is False
|
|
|
|
|
|
|
|
def handle_no_permission(self):
|
2019-12-31 11:51:16 +00:00
|
|
|
return redirect(reverse("passbook_core:overview"))
|
2018-12-10 12:51:38 +00:00
|
|
|
|
|
|
|
def dispatch(self, request, *args, **kwargs):
|
2018-12-10 13:21:42 +00:00
|
|
|
"""Check if sign-up is enabled or invitation link given"""
|
2018-12-10 12:51:38 +00:00
|
|
|
allowed = False
|
2019-12-31 11:51:16 +00:00
|
|
|
if "invitation" in request.GET:
|
|
|
|
invitations = Invitation.objects.filter(uuid=request.GET.get("invitation"))
|
2018-12-10 13:21:42 +00:00
|
|
|
allowed = invitations.exists()
|
2018-12-10 12:51:38 +00:00
|
|
|
if allowed:
|
2018-12-10 13:21:42 +00:00
|
|
|
self._invitation = invitations.first()
|
2019-12-31 11:51:16 +00:00
|
|
|
if CONFIG.y("passbook.sign_up.enabled"):
|
2018-12-10 12:51:38 +00:00
|
|
|
allowed = True
|
|
|
|
if not allowed:
|
2019-12-31 11:51:16 +00:00
|
|
|
messages.error(request, _("Sign-ups are currently disabled."))
|
|
|
|
return redirect(reverse("passbook_core:auth-login"))
|
2018-12-10 12:51:38 +00:00
|
|
|
return super().dispatch(request, *args, **kwargs)
|
|
|
|
|
2018-12-10 13:49:15 +00:00
|
|
|
def get_initial(self):
|
|
|
|
if self._invitation:
|
|
|
|
initial = {}
|
|
|
|
if self._invitation.fixed_username:
|
2019-12-31 11:51:16 +00:00
|
|
|
initial["username"] = self._invitation.fixed_username
|
2018-12-10 13:49:15 +00:00
|
|
|
if self._invitation.fixed_email:
|
2019-12-31 11:51:16 +00:00
|
|
|
initial["email"] = self._invitation.fixed_email
|
2018-12-10 13:49:15 +00:00
|
|
|
return initial
|
|
|
|
return super().get_initial()
|
|
|
|
|
2018-12-10 12:51:38 +00:00
|
|
|
def get_context_data(self, **kwargs):
|
2019-12-31 11:51:16 +00:00
|
|
|
kwargs["config"] = CONFIG.y("passbook")
|
|
|
|
kwargs["title"] = _("Sign Up")
|
|
|
|
kwargs["primary_action"] = _("Sign up")
|
2018-12-10 12:51:38 +00:00
|
|
|
return super().get_context_data(**kwargs)
|
|
|
|
|
|
|
|
def form_valid(self, form: SignUpForm) -> HttpResponse:
|
|
|
|
"""Create user"""
|
2019-02-26 14:40:58 +00:00
|
|
|
try:
|
|
|
|
self._user = SignUpView.create_user(form.cleaned_data, self.request)
|
|
|
|
except PasswordPolicyInvalid as exc:
|
|
|
|
# Manually inject error into form
|
|
|
|
# pylint: disable=protected-access
|
|
|
|
errors = form._errors.setdefault("password", ErrorList())
|
|
|
|
for error in exc.messages:
|
|
|
|
errors.append(error)
|
|
|
|
return self.form_invalid(form)
|
2018-12-10 13:21:42 +00:00
|
|
|
self.consume_invitation()
|
2018-12-10 12:51:38 +00:00
|
|
|
messages.success(self.request, _("Successfully signed up!"))
|
2020-02-18 20:35:58 +00:00
|
|
|
LOGGER.debug("Successfully signed up", email=form.cleaned_data.get("email"))
|
2019-12-31 11:51:16 +00:00
|
|
|
return redirect(reverse("passbook_core:auth-login"))
|
2018-12-10 12:51:38 +00:00
|
|
|
|
2018-12-10 13:21:42 +00:00
|
|
|
def consume_invitation(self):
|
|
|
|
"""Consume invitation if an invitation was used"""
|
|
|
|
if self._invitation:
|
|
|
|
invitation_used.send(
|
2018-12-10 13:05:27 +00:00
|
|
|
sender=self,
|
|
|
|
request=self.request,
|
2018-12-10 13:21:42 +00:00
|
|
|
invitation=self._invitation,
|
2019-12-31 11:51:16 +00:00
|
|
|
user=self._user,
|
|
|
|
)
|
2018-12-10 13:21:42 +00:00
|
|
|
self._invitation.delete()
|
2018-12-10 13:05:27 +00:00
|
|
|
|
2018-12-10 12:51:38 +00:00
|
|
|
@staticmethod
|
|
|
|
def create_user(data: Dict, request: HttpRequest = None) -> User:
|
|
|
|
"""Create user from data
|
|
|
|
|
|
|
|
Args:
|
2018-12-10 13:26:10 +00:00
|
|
|
data: Dictionary as returned by SignUpForm's cleaned_data
|
2018-12-10 12:51:38 +00:00
|
|
|
request: Optional current request.
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
The user created
|
|
|
|
|
|
|
|
Raises:
|
2019-02-26 14:40:58 +00:00
|
|
|
PasswordPolicyInvalid: if any policy are not fulfilled.
|
|
|
|
This also deletes the created user.
|
2018-12-10 12:51:38 +00:00
|
|
|
"""
|
|
|
|
# Create user
|
2019-02-26 14:40:58 +00:00
|
|
|
new_user = User.objects.create(
|
2019-12-31 11:51:16 +00:00
|
|
|
username=data.get("username"),
|
|
|
|
email=data.get("email"),
|
|
|
|
name=data.get("name"),
|
2018-12-10 12:51:38 +00:00
|
|
|
)
|
|
|
|
new_user.is_active = True
|
2019-02-26 14:40:58 +00:00
|
|
|
try:
|
2019-12-31 11:51:16 +00:00
|
|
|
new_user.set_password(data.get("password"))
|
2019-02-26 14:40:58 +00:00
|
|
|
new_user.save()
|
|
|
|
request.user = new_user
|
|
|
|
# Send signal for other auth sources
|
2019-12-31 11:51:16 +00:00
|
|
|
user_signed_up.send(sender=SignUpView, user=new_user, request=request)
|
2019-02-26 14:40:58 +00:00
|
|
|
return new_user
|
|
|
|
except PasswordPolicyInvalid as exc:
|
|
|
|
new_user.delete()
|
|
|
|
raise exc
|
2019-02-25 19:46:23 +00:00
|
|
|
|
2019-02-26 13:07:47 +00:00
|
|
|
|
2019-02-25 20:03:24 +00:00
|
|
|
class SignUpConfirmView(View):
|
|
|
|
"""Confirm registration from Nonce"""
|
|
|
|
|
|
|
|
def get(self, request, nonce):
|
|
|
|
"""Verify UUID and activate user"""
|
|
|
|
nonce = get_object_or_404(Nonce, uuid=nonce)
|
|
|
|
nonce.user.is_active = True
|
|
|
|
nonce.user.save()
|
|
|
|
# Workaround: hardcoded reference to ModelBackend, needs testing
|
2019-12-31 11:51:16 +00:00
|
|
|
nonce.user.backend = "django.contrib.auth.backends.ModelBackend"
|
2019-02-25 20:03:24 +00:00
|
|
|
login(request, nonce.user)
|
|
|
|
nonce.delete()
|
2019-12-31 11:51:16 +00:00
|
|
|
messages.success(request, _("Successfully confirmed registration."))
|
|
|
|
return redirect("passbook_core:overview")
|
2019-02-25 20:03:24 +00:00
|
|
|
|
|
|
|
|
2019-02-25 19:46:23 +00:00
|
|
|
class PasswordResetView(View):
|
|
|
|
"""Temporarily authenticate User and allow them to reset their password"""
|
|
|
|
|
|
|
|
def get(self, request, nonce):
|
|
|
|
"""Authenticate user with nonce and redirect to password change view"""
|
|
|
|
# 3. (Optional) Trap user in password change view
|
|
|
|
nonce = get_object_or_404(Nonce, uuid=nonce)
|
|
|
|
# Workaround: hardcoded reference to ModelBackend, needs testing
|
2019-12-31 11:51:16 +00:00
|
|
|
nonce.user.backend = "django.contrib.auth.backends.ModelBackend"
|
2019-02-25 19:46:23 +00:00
|
|
|
login(request, nonce.user)
|
|
|
|
nonce.delete()
|
2019-12-31 11:51:16 +00:00
|
|
|
messages.success(
|
2020-02-24 13:40:12 +00:00
|
|
|
request, _(("Temporarily authenticated, please change your password")),
|
2019-12-31 11:51:16 +00:00
|
|
|
)
|
|
|
|
return redirect("passbook_core:user-change-password")
|