stages/password: add unittests

This commit is contained in:
Jens Langhammer 2020-05-10 02:00:38 +02:00
parent c140c39d07
commit c0b05a62f4
2 changed files with 137 additions and 30 deletions

View File

@ -1,5 +1,4 @@
"""passbook password stage""" """passbook password stage"""
from inspect import Signature
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from django.contrib.auth import _clean_credentials from django.contrib.auth import _clean_credentials
@ -23,32 +22,21 @@ PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend"
def authenticate( def authenticate(
request: HttpRequest, backends: List[BaseBackend], **credentials: Dict[str, Any] request: HttpRequest, backends: List[str], **credentials: Dict[str, Any]
) -> Optional[User]: ) -> Optional[User]:
"""If the given credentials are valid, return a User object. """If the given credentials are valid, return a User object.
Customized version of django's authenticate, which accepts a list of backends""" Customized version of django's authenticate, which accepts a list of backends"""
for backend_path in backends: for backend_path in backends:
backend = path_to_class(backend_path)() backend: BaseBackend = path_to_class(backend_path)()
try:
signature = Signature.from_callable(backend.authenticate)
signature.bind(request, **credentials)
except TypeError:
LOGGER.warning("Backend doesn't accept our arguments", backend=backend)
# This backend doesn't accept these credentials as arguments. Try the next one.
continue
LOGGER.debug("Attempting authentication...", backend=backend) LOGGER.debug("Attempting authentication...", backend=backend)
try: user = backend.authenticate(request, **credentials)
user = backend.authenticate(request, **credentials)
except PermissionDenied:
LOGGER.debug("Backend threw PermissionDenied", backend=backend)
# This backend says to stop in our tracks - this user should not be allowed in at all.
break
if user is None: if user is None:
LOGGER.debug("Backend returned nothing, continuing") LOGGER.debug("Backend returned nothing, continuing")
continue continue
# Annotate the user object with the path of the backend. # Annotate the user object with the path of the backend.
user.backend = backend_path user.backend = backend_path
LOGGER.debug("Successful authentication", user=user, backend=backend)
return user return user
# The credentials supplied are invalid to all backends, fire signal # The credentials supplied are invalid to all backends, fire signal
@ -78,22 +66,23 @@ class PasswordStage(FormView, AuthenticationStage):
user = authenticate( user = authenticate(
self.request, self.executor.current_stage.backends, **auth_kwargs self.request, self.executor.current_stage.backends, **auth_kwargs
) )
if user:
# User instance returned from authenticate() has .backend property set
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
self.executor.plan.context[
PLAN_CONTEXT_AUTHENTICATION_BACKEND
] = user.backend
return self.executor.stage_ok()
# No user was found -> invalid credentials
LOGGER.debug("Invalid credentials")
# Manually inject error into form
# pylint: disable=protected-access
errors = form._errors.setdefault("password", ErrorList())
errors.append(_("Invalid password"))
return self.form_invalid(form)
except PermissionDenied: except PermissionDenied:
del auth_kwargs["password"] del auth_kwargs["password"]
# User was found, but permission was denied (i.e. user is not active) # User was found, but permission was denied (i.e. user is not active)
LOGGER.debug("Denied access", **auth_kwargs) LOGGER.debug("Denied access", **auth_kwargs)
return self.executor.stage_invalid() return self.executor.stage_invalid()
else:
if not user:
# No user was found -> invalid credentials
LOGGER.debug("Invalid credentials")
# Manually inject error into form
# pylint: disable=protected-access
errors = form._errors.setdefault("password", ErrorList())
errors.append(_("Invalid password"))
return self.form_invalid(form)
# User instance returned from authenticate() has .backend property set
self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user
self.executor.plan.context[
PLAN_CONTEXT_AUTHENTICATION_BACKEND
] = user.backend
return self.executor.stage_ok()

View File

@ -0,0 +1,118 @@
"""password tests"""
import string
from random import SystemRandom
from unittest.mock import MagicMock, patch
from django.core.exceptions import PermissionDenied
from django.shortcuts import reverse
from django.test import Client, TestCase
from passbook.core.models import User
from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding
from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from passbook.flows.views import SESSION_KEY_PLAN
from passbook.stages.password.models import PasswordStage
MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test"))
class TestPasswordStage(TestCase):
"""Password tests"""
def setUp(self):
super().setUp()
self.password = "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(8)
)
self.user = User.objects.create_user(
username="unittest", email="test@beryju.org", password=self.password
)
self.client = Client()
self.flow = Flow.objects.create(
name="test-password",
slug="test-password",
designation=FlowDesignation.AUTHENTICATION,
)
self.stage = PasswordStage.objects.create(
name="password", backends=["django.contrib.auth.backends.ModelBackend"]
)
FlowStageBinding.objects.create(flow=self.flow, stage=self.stage, order=2)
def test_without_user(self):
"""Test without user"""
plan = FlowPlan(stages=[self.stage])
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Still have to send the password so the form is valid
{"password": self.password},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("passbook_flows:denied"))
def test_valid_password(self):
"""Test with a valid pending user and valid password"""
plan = FlowPlan(stages=[self.stage])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Form data
{"password": self.password},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("passbook_core:overview"))
def test_invalid_password(self):
"""Test with a valid pending user and invalid password"""
plan = FlowPlan(stages=[self.stage])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Form data
{"password": self.password + "test"},
)
self.assertEqual(response.status_code, 200)
@patch(
"django.contrib.auth.backends.ModelBackend.authenticate",
MOCK_BACKEND_AUTHENTICATE,
)
def test_permission_denied(self):
"""Test with a valid pending user and valid password.
Backend is patched to return PermissionError"""
# from django.contrib.auth.backends import ModelBackend
# ModelBackend().authenticate()
plan = FlowPlan(stages=[self.stage])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse(
"passbook_flows:flow-executor", kwargs={"flow_slug": self.flow.slug}
),
# Form data
{"password": self.password + "test"},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("passbook_flows:denied"))