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 3132474c5..f75e8d739 100644 --- a/passbook/stages/consent/models.py +++ b/passbook/stages/consent/models.py @@ -1,13 +1,39 @@ """passbook consent stage""" +from django.db import models from django.utils.translation import gettext_lazy as _ +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.""" - type = "passbook.stages.consent.stage.ConsentStage" + 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)." + ) + ), + ) + + type = "passbook.stages.consent.stage.ConsentStageView" form = "passbook.stages.consent.forms.ConsentStageForm" def __str__(self): @@ -17,3 +43,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 3a1fa14a2..58894c15a 100644 --- a/passbook/stages/consent/stage.py +++ b/passbook/stages/consent/stage.py @@ -1,15 +1,20 @@ """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" -class ConsentStage(FormView, StageView): +class ConsentStageView(FormView, StageView): """Simple consent checker.""" form_class = ConsentForm @@ -26,5 +31,40 @@ class ConsentStage(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/swagger.yaml b/swagger.yaml index 2539f357c..073d2459c 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