diff --git a/passbook/flows/planner.py b/passbook/flows/planner.py index 9e92e889a..546417bd3 100644 --- a/passbook/flows/planner.py +++ b/passbook/flows/planner.py @@ -52,7 +52,8 @@ class FlowPlan: stage = self.stages[0] marker = self.markers[0] - LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker) + if marker.__class__ is not StageMarker: + LOGGER.debug("f(plan_inst): stage has marker", stage=stage, marker=marker) marked_stage = marker.process(self, stage) if not marked_stage: LOGGER.debug("f(plan_inst): marker returned none, next stage", stage=stage) diff --git a/passbook/lib/utils/time.py b/passbook/lib/utils/time.py new file mode 100644 index 000000000..4ba41ddb5 --- /dev/null +++ b/passbook/lib/utils/time.py @@ -0,0 +1,38 @@ +"""Time utilities""" +import datetime + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +ALLOWED_KEYS = ( + "days", + "seconds", + "microseconds", + "milliseconds", + "minutes", + "hours", + "weeks", +) + + +def timedelta_string_validator(value: str): + """Validator for Django that checks if value can be parsed with `timedelta_from_string`""" + try: + timedelta_from_string(value) + except ValueError as exc: + raise ValidationError( + _("%(value)s is not in the correct format of 'hours=3;minutes=1'."), + params={"value": value}, + ) from exc + + +def timedelta_from_string(expr: str) -> datetime.timedelta: + """Convert a string with the format of 'hours=1;minute=3;seconds=5' to a + `datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5""" + kwargs = {} + for duration_pair in expr.split(";"): + key, value = duration_pair.split("=") + if key.lower() not in ALLOWED_KEYS: + continue + kwargs[key.lower()] = float(value) + return datetime.timedelta(**kwargs) diff --git a/passbook/providers/saml/migrations/0001_initial.py b/passbook/providers/saml/migrations/0001_initial.py index 22ca56c21..c50faae78 100644 --- a/passbook/providers/saml/migrations/0001_initial.py +++ b/passbook/providers/saml/migrations/0001_initial.py @@ -3,7 +3,7 @@ import django.db.models.deletion from django.db import migrations, models -import passbook.providers.saml.utils.time +import passbook.lib.utils.time class Migration(migrations.Migration): @@ -66,9 +66,7 @@ class Migration(migrations.Migration): models.TextField( default="minutes=-5", help_text="Assertion valid not before current time + this value (Format: hours=-1;minutes=-2;seconds=-3).", - validators=[ - passbook.providers.saml.utils.time.timedelta_string_validator - ], + validators=[passbook.lib.utils.time.timedelta_string_validator], ), ), ( @@ -76,9 +74,7 @@ class Migration(migrations.Migration): models.TextField( default="minutes=5", help_text="Assertion not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", - validators=[ - passbook.providers.saml.utils.time.timedelta_string_validator - ], + validators=[passbook.lib.utils.time.timedelta_string_validator], ), ), ( @@ -86,9 +82,7 @@ class Migration(migrations.Migration): models.TextField( default="minutes=86400", help_text="Session not valid on or after current time + this value (Format: hours=1;minutes=2;seconds=3).", - validators=[ - passbook.providers.saml.utils.time.timedelta_string_validator - ], + validators=[passbook.lib.utils.time.timedelta_string_validator], ), ), ( diff --git a/passbook/providers/saml/models.py b/passbook/providers/saml/models.py index b98c67fae..bae2fa59d 100644 --- a/passbook/providers/saml/models.py +++ b/passbook/providers/saml/models.py @@ -11,7 +11,7 @@ from structlog import get_logger from passbook.core.models import PropertyMapping, Provider from passbook.crypto.models import CertificateKeyPair from passbook.lib.utils.template import render_to_string -from passbook.providers.saml.utils.time import timedelta_string_validator +from passbook.lib.utils.time import timedelta_string_validator LOGGER = get_logger() diff --git a/passbook/providers/saml/processors/assertion.py b/passbook/providers/saml/processors/assertion.py index a36aa7d68..014817a88 100644 --- a/passbook/providers/saml/processors/assertion.py +++ b/passbook/providers/saml/processors/assertion.py @@ -9,10 +9,11 @@ from signxml import XMLSigner, XMLVerifier from structlog import get_logger from passbook.core.exceptions import PropertyMappingExpressionException +from passbook.lib.utils.time import timedelta_from_string from passbook.providers.saml.models import SAMLPropertyMapping, SAMLProvider from passbook.providers.saml.processors.request_parser import AuthNRequest from passbook.providers.saml.utils import get_random_id -from passbook.providers.saml.utils.time import get_time_string, timedelta_from_string +from passbook.providers.saml.utils.time import get_time_string from passbook.sources.saml.exceptions import UnsupportedNameIDFormat from passbook.sources.saml.processors.constants import ( NS_MAP, diff --git a/passbook/providers/saml/tests/test_utils_time.py b/passbook/providers/saml/tests/test_utils_time.py index 7a35ed8f9..a596481df 100644 --- a/passbook/providers/saml/tests/test_utils_time.py +++ b/passbook/providers/saml/tests/test_utils_time.py @@ -4,10 +4,7 @@ from datetime import timedelta from django.core.exceptions import ValidationError from django.test import TestCase -from passbook.providers.saml.utils.time import ( - timedelta_from_string, - timedelta_string_validator, -) +from passbook.lib.utils.time import timedelta_from_string, timedelta_string_validator class TestTimeUtils(TestCase): diff --git a/passbook/providers/saml/utils/time.py b/passbook/providers/saml/utils/time.py index 2fe490ba9..c807315a2 100644 --- a/passbook/providers/saml/utils/time.py +++ b/passbook/providers/saml/utils/time.py @@ -2,42 +2,6 @@ import datetime from typing import Optional -from django.core.exceptions import ValidationError -from django.utils.translation import gettext_lazy as _ - -ALLOWED_KEYS = ( - "days", - "seconds", - "microseconds", - "milliseconds", - "minutes", - "hours", - "weeks", -) - - -def timedelta_string_validator(value: str): - """Validator for Django that checks if value can be parsed with `timedelta_from_string`""" - try: - timedelta_from_string(value) - except ValueError as exc: - raise ValidationError( - _("%(value)s is not in the correct format of 'hours=3;minutes=1'."), - params={"value": value}, - ) from exc - - -def timedelta_from_string(expr: str) -> datetime.timedelta: - """Convert a string with the format of 'hours=1;minute=3;seconds=5' to a - `datetime.timedelta` Object with hours = 1, minutes = 3, seconds = 5""" - kwargs = {} - for duration_pair in expr.split(";"): - key, value = duration_pair.split("=") - if key.lower() not in ALLOWED_KEYS: - continue - kwargs[key.lower()] = float(value) - return datetime.timedelta(**kwargs) - def get_time_string(delta: Optional[datetime.timedelta] = None) -> str: """Get Data formatted in SAML format""" diff --git a/passbook/sources/saml/migrations/0003_auto_20200624_1957.py b/passbook/sources/saml/migrations/0003_auto_20200624_1957.py index 8164b5d6a..9f9f0fc90 100644 --- a/passbook/sources/saml/migrations/0003_auto_20200624_1957.py +++ b/passbook/sources/saml/migrations/0003_auto_20200624_1957.py @@ -3,7 +3,7 @@ import django.db.models.deletion from django.db import migrations, models -import passbook.providers.saml.utils.time +import passbook.lib.utils.time class Migration(migrations.Migration): @@ -27,9 +27,7 @@ class Migration(migrations.Migration): field=models.TextField( default="days=1", help_text="Time offset when temporary users should be deleted. This only applies if your IDP uses the NameID Format 'transient', and the user doesn't log out manually. (Format: hours=1;minutes=2;seconds=3).", - validators=[ - passbook.providers.saml.utils.time.timedelta_string_validator - ], + validators=[passbook.lib.utils.time.timedelta_string_validator], verbose_name="Delete temporary users after", ), ), diff --git a/passbook/sources/saml/models.py b/passbook/sources/saml/models.py index 9a76a83d8..a5fa74128 100644 --- a/passbook/sources/saml/models.py +++ b/passbook/sources/saml/models.py @@ -11,7 +11,7 @@ from django.utils.translation import gettext_lazy as _ from passbook.core.models import Source from passbook.core.types import UILoginButton from passbook.crypto.models import CertificateKeyPair -from passbook.providers.saml.utils.time import timedelta_string_validator +from passbook.lib.utils.time import timedelta_string_validator from passbook.sources.saml.processors.constants import ( SAML_NAME_ID_FORMAT_EMAIL, SAML_NAME_ID_FORMAT_PERSISTENT, diff --git a/passbook/sources/saml/tasks.py b/passbook/sources/saml/tasks.py index caad70dab..dde27b87d 100644 --- a/passbook/sources/saml/tasks.py +++ b/passbook/sources/saml/tasks.py @@ -3,7 +3,7 @@ from django.utils.timezone import now from structlog import get_logger from passbook.core.models import User -from passbook.providers.saml.utils.time import timedelta_from_string +from passbook.lib.utils.time import timedelta_from_string from passbook.root.celery import CELERY_APP from passbook.sources.saml.models import SAMLSource diff --git a/passbook/stages/consent/api.py b/passbook/stages/consent/api.py index 845f840e6..f0623dd78 100644 --- a/passbook/stages/consent/api.py +++ b/passbook/stages/consent/api.py @@ -11,7 +11,7 @@ class ConsentStageSerializer(ModelSerializer): class Meta: model = ConsentStage - fields = ["pk", "name"] + fields = ["pk", "name", "mode", "consent_expire_in"] class ConsentStageViewSet(ModelViewSet): diff --git a/passbook/stages/consent/forms.py b/passbook/stages/consent/forms.py index 1b7b34d2c..d716490ae 100644 --- a/passbook/stages/consent/forms.py +++ b/passbook/stages/consent/forms.py @@ -14,7 +14,7 @@ class ConsentStageForm(forms.ModelForm): class Meta: model = ConsentStage - fields = ["name"] + fields = ["name", "mode", "consent_expire_in"] widgets = { "name": forms.TextInput(), } diff --git a/passbook/stages/consent/migrations/0002_auto_20200720_0941.py b/passbook/stages/consent/migrations/0002_auto_20200720_0941.py new file mode 100644 index 000000000..4849be788 --- /dev/null +++ b/passbook/stages/consent/migrations/0002_auto_20200720_0941.py @@ -0,0 +1,83 @@ +# Generated by Django 3.0.8 on 2020-07-20 09:41 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import passbook.core.models +import passbook.lib.utils.time + + +class Migration(migrations.Migration): + + dependencies = [ + ("passbook_core", "0006_auto_20200709_1608"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("passbook_stages_consent", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="consentstage", + name="consent_expire_in", + field=models.TextField( + default="weeks=4", + help_text="Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3).", + validators=[passbook.lib.utils.time.timedelta_string_validator], + verbose_name="Consent expires in", + ), + ), + migrations.AddField( + model_name="consentstage", + name="mode", + field=models.TextField( + choices=[ + ("always_require", "Always Require"), + ("permanent", "Permanent"), + ("expiring", "Expiring"), + ], + default="always_require", + ), + ), + migrations.CreateModel( + name="UserConsent", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "expires", + models.DateTimeField( + default=passbook.core.models.default_token_duration + ), + ), + ("expiring", models.BooleanField(default=True)), + ( + "application", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="passbook_core.Application", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pb_consent", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "User Consent", + "verbose_name_plural": "User Consents", + "unique_together": {("user", "application")}, + }, + ), + ] diff --git a/passbook/stages/consent/models.py b/passbook/stages/consent/models.py index 5c3679391..b0b17e2a0 100644 --- a/passbook/stages/consent/models.py +++ b/passbook/stages/consent/models.py @@ -1,16 +1,42 @@ """passbook consent stage""" from typing import Type +from django.db import models from django.forms import ModelForm from django.utils.translation import gettext_lazy as _ from django.views import View +from passbook.core.models import Application, ExpiringModel, User from passbook.flows.models import Stage +from passbook.lib.utils.time import timedelta_string_validator + + +class ConsentMode(models.TextChoices): + """Modes a Consent Stage can operate in""" + + ALWAYS_REQUIRE = "always_require" + PERMANENT = "permanent" + EXPIRING = "expiring" class ConsentStage(Stage): """Prompt the user for confirmation.""" + mode = models.TextField( + choices=ConsentMode.choices, default=ConsentMode.ALWAYS_REQUIRE + ) + consent_expire_in = models.TextField( + validators=[timedelta_string_validator], + default="weeks=4", + verbose_name="Consent expires in", + help_text=_( + ( + "Offset after which consent expires. " + "(Format: hours=1;minutes=2;seconds=3)." + ) + ), + ) + def type(self) -> Type[View]: from passbook.stages.consent.stage import ConsentStageView @@ -28,3 +54,20 @@ class ConsentStage(Stage): verbose_name = _("Consent Stage") verbose_name_plural = _("Consent Stages") + + +class UserConsent(ExpiringModel): + """Consent given by a user for an application""" + + # TODO: Remove related_name when oidc provider is v2 + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="pb_consent") + application = models.ForeignKey(Application, on_delete=models.CASCADE) + + def __str__(self): + return f"User Consent {self.application} by {self.user}" + + class Meta: + + unique_together = (("user", "application"),) + verbose_name = _("User Consent") + verbose_name_plural = _("User Consents") diff --git a/passbook/stages/consent/stage.py b/passbook/stages/consent/stage.py index 9ad58fde2..58894c15a 100644 --- a/passbook/stages/consent/stage.py +++ b/passbook/stages/consent/stage.py @@ -1,10 +1,15 @@ """passbook consent stage""" from typing import Any, Dict, List +from django.http import HttpRequest, HttpResponse +from django.utils.timezone import now from django.views.generic import FormView +from passbook.flows.planner import PLAN_CONTEXT_APPLICATION from passbook.flows.stage import StageView +from passbook.lib.utils.time import timedelta_from_string from passbook.stages.consent.forms import ConsentForm +from passbook.stages.consent.models import ConsentMode, ConsentStage, UserConsent PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template" @@ -26,5 +31,40 @@ class ConsentStageView(FormView, StageView): return [template_name] return super().get_template_names() - def form_valid(self, form): + def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + current_stage: ConsentStage = self.executor.current_stage + # For always require, we always show the form + if current_stage.mode == ConsentMode.ALWAYS_REQUIRE: + return super().get(request, *args, **kwargs) + # at this point we need to check consent from database + if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: + # No application in this plan, hence we can't check DB and require user consent + return super().get(request, *args, **kwargs) + + application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + # TODO: Check for user in plan? + if UserConsent.filter_not_expired( + user=self.request.user, application=application + ).exists(): + return self.executor.stage_ok() + + # No consent found, show form + return super().get(request, *args, **kwargs) + + def form_valid(self, form: ConsentForm) -> HttpResponse: + current_stage: ConsentStage = self.executor.current_stage + if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: + return self.executor.stage_ok() + application = self.executor.plan.context[PLAN_CONTEXT_APPLICATION] + # Since we only get here when no consent exists, we can create it without update + if current_stage.mode == ConsentMode.PERMANENT: + UserConsent.objects.create( + user=self.request.user, application=application, expiring=False + ) + if current_stage.mode == ConsentMode.EXPIRING: + UserConsent.objects.create( + user=self.request.user, + application=application, + expires=now() + timedelta_from_string(current_stage.consent_expire_in), + ) return self.executor.stage_ok() diff --git a/passbook/stages/consent/tests.py b/passbook/stages/consent/tests.py index 2af565dc9..536b41c6b 100644 --- a/passbook/stages/consent/tests.py +++ b/passbook/stages/consent/tests.py @@ -1,14 +1,17 @@ """consent tests""" +from time import sleep + from django.shortcuts import reverse from django.test import Client, TestCase from django.utils.encoding import force_text -from passbook.core.models import User +from passbook.core.models import Application, User +from passbook.core.tasks import clean_expired_models from passbook.flows.markers import StageMarker from passbook.flows.models import Flow, FlowDesignation, FlowStageBinding -from passbook.flows.planner import FlowPlan +from passbook.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlan from passbook.flows.views import SESSION_KEY_PLAN -from passbook.stages.consent.models import ConsentStage +from passbook.stages.consent.models import ConsentMode, ConsentStage, UserConsent class TestConsentStage(TestCase): @@ -19,28 +22,29 @@ class TestConsentStage(TestCase): self.user = User.objects.create_user( username="unittest", email="test@beryju.org" ) + self.application = Application.objects.create( + name="test-application", slug="test-application", + ) self.client = Client() - self.flow = Flow.objects.create( + def test_always_required(self): + """Test always required consent""" + flow = Flow.objects.create( name="test-consent", slug="test-consent", designation=FlowDesignation.AUTHENTICATION, ) - self.stage = ConsentStage.objects.create(name="consent",) - FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2) - - def test_valid(self): - """Test valid consent""" - plan = FlowPlan( - flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + stage = ConsentStage.objects.create( + name="consent", mode=ConsentMode.ALWAYS_REQUIRE ) + FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + plan = FlowPlan(flow_pk=flow.pk.hex, stages=[stage], markers=[StageMarker()]) 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} - ), + reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), {}, ) self.assertEqual(response.status_code, 200) @@ -48,3 +52,83 @@ class TestConsentStage(TestCase): force_text(response.content), {"type": "redirect", "to": reverse("passbook_core:overview")}, ) + self.assertFalse(UserConsent.objects.filter(user=self.user).exists()) + + def test_permanent(self): + """Test permanent consent from user""" + self.client.force_login(self.user) + flow = Flow.objects.create( + name="test-consent", + slug="test-consent", + designation=FlowDesignation.AUTHENTICATION, + ) + stage = ConsentStage.objects.create(name="consent", mode=ConsentMode.PERMANENT) + FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + plan = FlowPlan( + flow_pk=flow.pk.hex, + stages=[stage], + markers=[StageMarker()], + context={PLAN_CONTEXT_APPLICATION: self.application}, + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + response = self.client.post( + reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_text(response.content), + {"type": "redirect", "to": reverse("passbook_core:overview")}, + ) + self.assertTrue( + UserConsent.objects.filter( + user=self.user, application=self.application + ).exists() + ) + + def test_expire(self): + """Test expiring consent from user""" + self.client.force_login(self.user) + flow = Flow.objects.create( + name="test-consent", + slug="test-consent", + designation=FlowDesignation.AUTHENTICATION, + ) + stage = ConsentStage.objects.create( + name="consent", mode=ConsentMode.EXPIRING, consent_expire_in="seconds=1" + ) + FlowStageBinding.objects.create(target=flow, stage=stage, order=2) + + plan = FlowPlan( + flow_pk=flow.pk.hex, + stages=[stage], + markers=[StageMarker()], + context={PLAN_CONTEXT_APPLICATION: self.application}, + ) + session = self.client.session + session[SESSION_KEY_PLAN] = plan + session.save() + response = self.client.post( + reverse("passbook_flows:flow-executor", kwargs={"flow_slug": flow.slug}), + {}, + ) + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_text(response.content), + {"type": "redirect", "to": reverse("passbook_core:overview")}, + ) + self.assertTrue( + UserConsent.objects.filter( + user=self.user, application=self.application + ).exists() + ) + sleep(1) + clean_expired_models() + self.assertFalse( + UserConsent.objects.filter( + user=self.user, application=self.application + ).exists() + ) diff --git a/swagger.yaml b/swagger.yaml index 2539f357c..6637f4edd 100755 --- a/swagger.yaml +++ b/swagger.yaml @@ -6609,6 +6609,18 @@ definitions: title: Name type: string minLength: 1 + mode: + title: Mode + type: string + enum: + - always_require + - permanent + - expiring + consent_expire_in: + title: Consent expires in + description: 'Offset after which consent expires. (Format: hours=1;minutes=2;seconds=3).' + type: string + minLength: 1 DummyStage: required: - name