stages/consent: start implementing user consent

This commit is contained in:
Jens Langhammer 2020-07-20 13:19:58 +02:00
parent 37b2400cdb
commit 50612991fa
6 changed files with 183 additions and 5 deletions

View file

@ -11,7 +11,7 @@ class ConsentStageSerializer(ModelSerializer):
class Meta: class Meta:
model = ConsentStage model = ConsentStage
fields = ["pk", "name"] fields = ["pk", "name", "mode", "consent_expire_in"]
class ConsentStageViewSet(ModelViewSet): class ConsentStageViewSet(ModelViewSet):

View file

@ -14,7 +14,7 @@ class ConsentStageForm(forms.ModelForm):
class Meta: class Meta:
model = ConsentStage model = ConsentStage
fields = ["name"] fields = ["name", "mode", "consent_expire_in"]
widgets = { widgets = {
"name": forms.TextInput(), "name": forms.TextInput(),
} }

View file

@ -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")},
},
),
]

View file

@ -1,13 +1,39 @@
"""passbook consent stage""" """passbook consent stage"""
from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from passbook.core.models import Application, ExpiringModel, User
from passbook.flows.models import Stage 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): class ConsentStage(Stage):
"""Prompt the user for confirmation.""" """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" form = "passbook.stages.consent.forms.ConsentStageForm"
def __str__(self): def __str__(self):
@ -17,3 +43,20 @@ class ConsentStage(Stage):
verbose_name = _("Consent Stage") verbose_name = _("Consent Stage")
verbose_name_plural = _("Consent Stages") 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")

View file

@ -1,15 +1,20 @@
"""passbook consent stage""" """passbook consent stage"""
from typing import Any, Dict, List 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 django.views.generic import FormView
from passbook.flows.planner import PLAN_CONTEXT_APPLICATION
from passbook.flows.stage import StageView 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.forms import ConsentForm
from passbook.stages.consent.models import ConsentMode, ConsentStage, UserConsent
PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template" PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template"
class ConsentStage(FormView, StageView): class ConsentStageView(FormView, StageView):
"""Simple consent checker.""" """Simple consent checker."""
form_class = ConsentForm form_class = ConsentForm
@ -26,5 +31,40 @@ class ConsentStage(FormView, StageView):
return [template_name] return [template_name]
return super().get_template_names() 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() return self.executor.stage_ok()

View file

@ -6609,6 +6609,18 @@ definitions:
title: Name title: Name
type: string type: string
minLength: 1 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: DummyStage:
required: required:
- name - name