stages/password: add failed_attempts_before_cancel to cancel a flow after x failed entries
This commit is contained in:
parent
d30abc64d0
commit
83408b6ae0
|
@ -340,7 +340,6 @@ class BaseGrantModel(models.Model):
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-instance-attributes
|
|
||||||
class AuthorizationCode(ExpiringModel, BaseGrantModel):
|
class AuthorizationCode(ExpiringModel, BaseGrantModel):
|
||||||
"""OAuth2 Authorization Code"""
|
"""OAuth2 Authorization Code"""
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@ class PasswordStageSerializer(ModelSerializer):
|
||||||
"pk",
|
"pk",
|
||||||
"name",
|
"name",
|
||||||
"backends",
|
"backends",
|
||||||
|
"change_flow",
|
||||||
|
"failed_attempts_before_cancel",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ class PasswordStageForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
||||||
model = PasswordStage
|
model = PasswordStage
|
||||||
fields = ["name", "backends", "change_flow"]
|
fields = ["name", "backends", "change_flow", "failed_attempts_before_cancel"]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"name": forms.TextInput(),
|
||||||
"backends": FilteredSelectMultiple(
|
"backends": FilteredSelectMultiple(
|
||||||
|
|
|
@ -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.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -22,6 +22,15 @@ class PasswordStage(Stage):
|
||||||
models.TextField(),
|
models.TextField(),
|
||||||
help_text=_("Selection of backends to test the password against."),
|
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(
|
change_flow = models.ForeignKey(
|
||||||
Flow,
|
Flow,
|
||||||
|
|
|
@ -17,9 +17,11 @@ from passbook.flows.planner import PLAN_CONTEXT_PENDING_USER
|
||||||
from passbook.flows.stage import StageView
|
from passbook.flows.stage import StageView
|
||||||
from passbook.lib.utils.reflection import path_to_class
|
from passbook.lib.utils.reflection import path_to_class
|
||||||
from passbook.stages.password.forms import PasswordForm
|
from passbook.stages.password.forms import PasswordForm
|
||||||
|
from passbook.stages.password.models import PasswordStage
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend"
|
PLAN_CONTEXT_AUTHENTICATION_BACKEND = "user_backend"
|
||||||
|
SESSION_INVALID_TRIES = "user_invalid_tries"
|
||||||
|
|
||||||
|
|
||||||
def authenticate(
|
def authenticate(
|
||||||
|
@ -71,6 +73,20 @@ class PasswordStageView(FormView, StageView):
|
||||||
kwargs["recovery_flow"] = recovery_flow.first()
|
kwargs["recovery_flow"] = recovery_flow.first()
|
||||||
return kwargs
|
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:
|
def form_valid(self, form: PasswordForm) -> HttpResponse:
|
||||||
"""Authenticate against django's authentication backend"""
|
"""Authenticate against django's authentication backend"""
|
||||||
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
|
if PLAN_CONTEXT_PENDING_USER not in self.executor.plan.context:
|
||||||
|
|
|
@ -131,6 +131,37 @@ class TestPasswordStage(TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, 200)
|
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(
|
@patch(
|
||||||
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
|
"passbook.flows.views.to_stage_response", TO_STAGE_RESPONSE_MOCK,
|
||||||
)
|
)
|
||||||
|
|
Reference in a new issue