stages/password: add failed_attempts_before_cancel to cancel a flow after x failed entries

This commit is contained in:
Jens Langhammer 2020-09-19 02:18:43 +02:00
parent d30abc64d0
commit 83408b6ae0
7 changed files with 80 additions and 2 deletions

View File

@ -340,7 +340,6 @@ class BaseGrantModel(models.Model):
abstract = True
# pylint: disable=too-many-instance-attributes
class AuthorizationCode(ExpiringModel, BaseGrantModel):
"""OAuth2 Authorization Code"""

View File

@ -15,6 +15,8 @@ class PasswordStageSerializer(ModelSerializer):
"pk",
"name",
"backends",
"change_flow",
"failed_attempts_before_cancel",
]

View File

@ -48,7 +48,7 @@ class PasswordStageForm(forms.ModelForm):
class Meta:
model = PasswordStage
fields = ["name", "backends", "change_flow"]
fields = ["name", "backends", "change_flow", "failed_attempts_before_cancel"]
widgets = {
"name": forms.TextInput(),
"backends": FilteredSelectMultiple(

View File

@ -0,0 +1,21 @@
# Generated by Django 3.1.1 on 2020-09-18 23:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("passbook_stages_password", "0002_passwordstage_change_flow"),
]
operations = [
migrations.AddField(
model_name="passwordstage",
name="failed_attempts_before_cancel",
field=models.IntegerField(
default=5,
help_text="How many attempts a user has before the flow is canceled. To lock the user out, use a reputation policy and a user_write stage.",
),
),
]

View File

@ -22,6 +22,15 @@ class PasswordStage(Stage):
models.TextField(),
help_text=_("Selection of backends to test the password against."),
)
failed_attempts_before_cancel = models.IntegerField(
default=5,
help_text=_(
(
"How many attempts a user has before the flow is canceled. "
"To lock the user out, use a reputation policy and a user_write stage."
)
),
)
change_flow = models.ForeignKey(
Flow,

View File

@ -17,9 +17,11 @@ from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
from passbook.flows.stage import StageView
from passbook.lib.utils.reflection import path_to_class
from passbook.stages.password.forms import PasswordForm
from passbook.stages.password.models import PasswordStage
LOGGER = get_logger()
PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend"
SESSION_INVALID_TRIES = "user_invalid_tries"
def authenticate(
@ -71,6 +73,20 @@ class PasswordStageView(FormView, StageView):
kwargs["recovery_flow"] = recovery_flow.first()
return kwargs
def form_invalid(self, form: PasswordForm) -> HttpResponse:
if SESSION_INVALID_TRIES not in self.request.session:
self.request.session[SESSION_INVALID_TRIES] = 0
self.request.session[SESSION_INVALID_TRIES] += 1
current_stage: PasswordStage = self.executor.current_stage
if (
self.request.session[SESSION_INVALID_TRIES]
> current_stage.failed_attempts_before_cancel
):
LOGGER.debug("User has exceeded maximum tries")
del self.request.session[SESSION_INVALID_TRIES]
return self.executor.stage_invalid()
return super().form_invalid(form)
def form_valid(self, form: PasswordForm) -> HttpResponse:
"""Authenticate against django's authentication backend"""
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:

View File

@ -131,6 +131,37 @@ class TestPasswordStage(TestCase):
)
self.assertEqual(response.status_code, 200)
def test_invalid_password_lockout(self):
"""Test with a valid pending user and invalid password (trigger logout counter)"""
plan = FlowPlan(
flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()]
)
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
for _ in range(self.stage.failed_attempts_before_cancel):
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)
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)
# To ensure the plan has been cancelled, check SESSION_KEY_PLAN
self.assertNotIn(SESSION_KEY_PLAN, self.client.session)
@patch(
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
)