stages/consent: migrate to SPA

This commit is contained in:
Jens Langhammer 2021-02-21 13:15:45 +01:00
parent a8681ac88f
commit b9f409d6d9
16 changed files with 197 additions and 96 deletions

View File

@ -65,6 +65,26 @@ class ShellChallenge(Challenge):
body = CharField() body = CharField()
class WithUserInfoChallenge(Challenge):
"""Challenge base which shows some user info"""
pending_user = CharField()
pending_user_avatar = CharField()
class PermissionSerializer(Serializer):
"""Permission used for consent"""
name = CharField()
id = CharField()
def create(self, validated_data: dict) -> Model:
return Model()
def update(self, instance: Model, validated_data: dict) -> Model:
return Model()
class ChallengeResponse(Serializer): class ChallengeResponse(Serializer):
"""Base class for all challenge responses""" """Base class for all challenge responses"""

View File

@ -1,20 +0,0 @@
{% extends 'login/form_with_user.html' %}
{% load i18n %}
{% block beneath_form %}
<div class="pf-c-form__group">
<p>
{% blocktrans with name=context.application.name %}
You're about to sign into <strong id="application-name">{{ name }}</strong>.
{% endblocktrans %}
</p>
<p>{% trans "Application requires following permissions" %}</p>
<ul class="pf-c-list" id="scopes">
{% for scope_name, description in context.scope_descriptions.items %}
<li id="scope-{{ scope_name }}">{{ description }}</li>
{% endfor %}
</ul>
{{ hidden_inputs }}
</div>
{% endblock %}

View File

@ -9,6 +9,7 @@ from django.http import HttpRequest, HttpResponse
from django.http.response import Http404 from django.http.response import Http404
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import Application from authentik.core.models import Application
@ -48,14 +49,14 @@ from authentik.providers.oauth2.models import (
from authentik.providers.oauth2.views.userinfo import UserInfoView from authentik.providers.oauth2.views.userinfo import UserInfoView
from authentik.stages.consent.models import ConsentMode, ConsentStage from authentik.stages.consent.models import ConsentMode, ConsentStage
from authentik.stages.consent.stage import ( from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_TEMPLATE, PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
ConsentStageView, ConsentStageView,
) )
LOGGER = get_logger() LOGGER = get_logger()
PLAN_CONTEXT_PARAMS = "params" PLAN_CONTEXT_PARAMS = "params"
PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login" SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login"
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET, PROMPT_LOGIN} ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET, PROMPT_LOGIN}
@ -432,6 +433,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
planner = FlowPlanner(self.provider.authorization_flow) planner = FlowPlanner(self.provider.authorization_flow)
# planner.use_cache = False # planner.use_cache = False
planner.allow_empty_flows = True planner.allow_empty_flows = True
scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope)
plan: FlowPlan = planner.plan( plan: FlowPlan = planner.plan(
self.request, self.request,
{ {
@ -439,11 +441,12 @@ class AuthorizationFlowInitView(PolicyAccessView):
PLAN_CONTEXT_APPLICATION: self.application, PLAN_CONTEXT_APPLICATION: self.application,
# OAuth2 related params # OAuth2 related params
PLAN_CONTEXT_PARAMS: self.params, PLAN_CONTEXT_PARAMS: self.params,
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
self.params.scope
),
# Consent related params # Consent related params
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/oauth2/consent.html", PLAN_CONTEXT_CONSENT_HEADER: _(
"You're about to sign into %(application)s."
)
% {"application": self.application.name},
PLAN_CONTEXT_CONSENT_PERMISSIONS: scope_descriptions,
}, },
) )
# OpenID clients can specify a `prompt` parameter, and if its set to consent we # OpenID clients can specify a `prompt` parameter, and if its set to consent we

View File

@ -22,14 +22,16 @@ class UserInfoView(View):
"""Create a dictionary with all the requested claims about the End-User. """Create a dictionary with all the requested claims about the End-User.
See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse""" See: http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse"""
def get_scope_descriptions(self, scopes: list[str]) -> dict[str, str]: def get_scope_descriptions(self, scopes: list[str]) -> list[dict[str, str]]:
"""Get a list of all Scopes's descriptions""" """Get a list of all Scopes's descriptions"""
scope_descriptions = {} scope_descriptions = []
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by( for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by(
"scope_name" "scope_name"
): ):
if scope.description != "": if scope.description != "":
scope_descriptions[scope.scope_name] = scope.description scope_descriptions.append(
{"id": scope.scope_name, "name": scope.description}
)
# GitHub Compatibility Scopes are handeled differently, since they required custom paths # GitHub Compatibility Scopes are handeled differently, since they required custom paths
# Hence they don't exist as Scope objects # Hence they don't exist as Scope objects
github_scope_map = { github_scope_map = {
@ -44,7 +46,9 @@ class UserInfoView(View):
} }
for scope in scopes: for scope in scopes:
if scope in github_scope_map: if scope in github_scope_map:
scope_descriptions[scope] = github_scope_map[scope] scope_descriptions.append(
{"id": scope, "name": github_scope_map[scope]}
)
return scope_descriptions return scope_descriptions
def get_claims(self, token: RefreshToken) -> dict[str, Any]: def get_claims(self, token: RefreshToken) -> dict[str, Any]:

View File

@ -1,14 +0,0 @@
{% extends 'login/form_with_user.html' %}
{% load i18n %}
{% block beneath_form %}
<div class="pf-c-form__group">
<p>
{% blocktrans with name=context.application.name %}
You're about to sign into <strong id="application-name">{{ name }}</strong>.
{% endblocktrans %}
</p>
{{ hidden_inputs }}
</div>
{% endblock %}

View File

@ -4,6 +4,7 @@ from typing import Optional
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -31,7 +32,10 @@ from authentik.providers.saml.views.flows import (
SESSION_KEY_AUTH_N_REQUEST, SESSION_KEY_AUTH_N_REQUEST,
SAMLFlowFinalView, SAMLFlowFinalView,
) )
from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_TEMPLATE from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
)
LOGGER = get_logger() LOGGER = get_logger()
@ -68,7 +72,11 @@ class SAMLSSOView(PolicyAccessView):
{ {
PLAN_CONTEXT_SSO: True, PLAN_CONTEXT_SSO: True,
PLAN_CONTEXT_APPLICATION: self.application, PLAN_CONTEXT_APPLICATION: self.application,
PLAN_CONTEXT_CONSENT_TEMPLATE: "providers/saml/consent.html", PLAN_CONTEXT_CONSENT_HEADER: _(
"You're about to sign into %(application)s."
)
% {"application": self.application.name},
PLAN_CONTEXT_CONSENT_PERMISSIONS: [],
}, },
) )
plan.append(in_memory_stage(SAMLFlowFinalView)) plan.append(in_memory_stage(SAMLFlowFinalView))

View File

@ -4,10 +4,6 @@ from django import forms
from authentik.stages.consent.models import ConsentStage from authentik.stages.consent.models import ConsentStage
class ConsentForm(forms.Form):
"""authentik consent stage form"""
class ConsentStageForm(forms.ModelForm): class ConsentStageForm(forms.ModelForm):
"""Form to edit ConsentStage Instance""" """Form to edit ConsentStage Instance"""

View File

@ -1,40 +1,69 @@
"""authentik consent stage""" """authentik consent stage"""
from typing import Any
from django.http import HttpRequest, HttpResponse from django.http import HttpRequest, HttpResponse
from django.utils.timezone import now from django.utils.timezone import now
from django.views.generic import FormView from rest_framework.fields import CharField
from authentik.core.models import User
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
ChallengeTypes,
PermissionSerializer,
WithUserInfoChallenge,
)
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import StageView from authentik.flows.stage import ChallengeStageView
from authentik.lib.templatetags.authentik_utils import avatar
from authentik.lib.utils.time import timedelta_from_string from authentik.lib.utils.time import timedelta_from_string
from authentik.stages.consent.forms import ConsentForm
from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent from authentik.stages.consent.models import ConsentMode, ConsentStage, UserConsent
PLAN_CONTEXT_CONSENT_TEMPLATE = "consent_template" PLAN_CONTEXT_CONSENT_HEADER = "consent_header"
PLAN_CONTEXT_CONSENT_PERMISSIONS = "consent_permissions"
class ConsentStageView(FormView, StageView): class ConsentChallenge(WithUserInfoChallenge):
"""Challenge info for consent screens"""
header_text = CharField()
permissions = PermissionSerializer(many=True)
class ConsentChallengeResponse(ChallengeResponse):
"""Consent challenge response, any valid response request is valid"""
class ConsentStageView(ChallengeStageView):
"""Simple consent checker.""" """Simple consent checker."""
form_class = ConsentForm response_class = ConsentChallengeResponse
def get_context_data(self, **kwargs: dict[str, Any]) -> dict[str, Any]: def get_challenge(self) -> Challenge:
kwargs = super().get_context_data(**kwargs) challenge = ConsentChallenge(
kwargs["current_stage"] = self.executor.current_stage data={
kwargs["context"] = self.executor.plan.context "type": ChallengeTypes.native,
return kwargs "component": "ak-stage-consent",
}
def get_template_names(self) -> list[str]: )
# PLAN_CONTEXT_CONSENT_TEMPLATE has to be set by a template that calls this stage if PLAN_CONTEXT_CONSENT_HEADER in self.executor.plan.context:
if PLAN_CONTEXT_CONSENT_TEMPLATE in self.executor.plan.context: challenge.initial_data["header_text"] = self.executor.plan.context[
template_name = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TEMPLATE] PLAN_CONTEXT_CONSENT_HEADER
return [template_name] ]
return ["stages/consent/fallback.html"] if PLAN_CONTEXT_CONSENT_PERMISSIONS in self.executor.plan.context:
challenge.initial_data["permissions"] = self.executor.plan.context[
PLAN_CONTEXT_CONSENT_PERMISSIONS
]
# If there's a pending user, update the `username` field
# this field is only used by password managers.
# If there's no user set, an error is raised later.
if PLAN_CONTEXT_PENDING_USER in self.executor.plan.context:
pending_user: User = self.executor.plan.context[PLAN_CONTEXT_PENDING_USER]
challenge.initial_data["pending_user"] = pending_user.username
challenge.initial_data["pending_user_avatar"] = avatar(pending_user)
return challenge
def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
current_stage: ConsentStage = self.executor.current_stage current_stage: ConsentStage = self.executor.current_stage
# For always require, we always show the form # For always require, we always return the challenge
if current_stage.mode == ConsentMode.ALWAYS_REQUIRE: if current_stage.mode == ConsentMode.ALWAYS_REQUIRE:
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
# at this point we need to check consent from database # at this point we need to check consent from database
@ -51,10 +80,10 @@ class ConsentStageView(FormView, StageView):
if UserConsent.filter_not_expired(user=user, application=application).exists(): if UserConsent.filter_not_expired(user=user, application=application).exists():
return self.executor.stage_ok() return self.executor.stage_ok()
# No consent found, show form # No consent found, return consent
return super().get(request, *args, **kwargs) return super().get(request, *args, **kwargs)
def form_valid(self, form: ConsentForm) -> HttpResponse: def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
current_stage: ConsentStage = self.executor.current_stage current_stage: ConsentStage = self.executor.current_stage
if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context: if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context:
return self.executor.stage_ok() return self.executor.stage_ok()

View File

@ -1,9 +0,0 @@
{% extends 'login/form_with_user.html' %}
{% load i18n %}
{% block beneath_form %}
<div class="pf-c-form__group">
{{ hidden_inputs }}
</div>
{% endblock %}

View File

@ -4,6 +4,7 @@ from django.urls import reverse
from django.utils.encoding import force_str from django.utils.encoding import force_str
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.sources.oauth.models import OAuthSource from authentik.sources.oauth.models import OAuthSource
from authentik.stages.identification.models import IdentificationStage, UserFields from authentik.stages.identification.models import IdentificationStage, UserFields
@ -102,7 +103,7 @@ class TestIdentificationStage(TestCase):
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{ {
"type": "native", "type": ChallengeTypes.native,
"component": "ak-stage-identification", "component": "ak-stage-identification",
"input_type": "email", "input_type": "email",
"enroll_url": "/flows/unique-enrollment-string/", "enroll_url": "/flows/unique-enrollment-string/",
@ -141,7 +142,7 @@ class TestIdentificationStage(TestCase):
self.assertJSONEqual( self.assertJSONEqual(
force_str(response.content), force_str(response.content),
{ {
"type": "native", "type": ChallengeTypes.native,
"component": "ak-stage-identification", "component": "ak-stage-identification",
"input_type": "email", "input_type": "email",
"recovery_url": "/flows/unique-recovery-string/", "recovery_url": "/flows/unique-recovery-string/",

View File

@ -10,11 +10,15 @@ from django.urls import reverse
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework.exceptions import ErrorDetail from rest_framework.exceptions import ErrorDetail
from rest_framework.fields import CharField from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
from authentik.core.models import User from authentik.core.models import User
from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
ChallengeTypes,
WithUserInfoChallenge,
)
from authentik.flows.models import Flow, FlowDesignation from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView from authentik.flows.stage import ChallengeStageView
@ -55,11 +59,9 @@ def authenticate(
) )
class PasswordChallenge(Challenge): class PasswordChallenge(WithUserInfoChallenge):
"""Password challenge UI fields""" """Password challenge UI fields"""
pending_user = CharField()
pending_user_avatar = CharField()
recovery_url = CharField(required=False) recovery_url = CharField(required=False)

View File

@ -152,7 +152,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
) )
self.assertEqual( self.assertEqual(
"GitHub Compatibility: Access you Email addresses", "GitHub Compatibility: Access you Email addresses",
self.driver.find_element(By.ID, "scope-user:email").text, self.driver.find_element(By.ID, "permission-user:email").text,
) )
self.driver.find_element( self.driver.find_element(
By.CSS_SELECTOR, By.CSS_SELECTOR,

View File

@ -23,6 +23,10 @@ export interface Challenge {
title?: string; title?: string;
response_errors?: ErrorDict; response_errors?: ErrorDict;
} }
export interface WithUserInfoChallenge extends Challenge {
pending_user: string;
pending_user_avatar: string;
}
export interface ShellChallenge extends Challenge { export interface ShellChallenge extends Challenge {
body: string; body: string;

View File

@ -0,0 +1,77 @@
import { gettext } from "django";
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
import { WithUserInfoChallenge } from "../../../api/Flows";
import { COMMON_STYLES } from "../../../common/styles";
import { BaseStage } from "../base";
export interface Permission {
name: string;
id: string;
}
export interface ConsentChallenge extends WithUserInfoChallenge {
header_text: string;
permissions?: Permission[];
}
@customElement("ak-stage-consent")
export class ConsentStage extends BaseStage {
@property({ attribute: false })
challenge?: ConsentChallenge;
static get styles(): CSSResult[] {
return COMMON_STYLES;
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-loading-state></ak-loading-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">
${this.challenge.title}
</h1>
</header>
<div class="pf-c-login__main-body">
<form class="pf-c-form" @submit=${(e: Event) => { this.submit(e); }}>
<div class="pf-c-form__group">
<div class="form-control-static">
<div class="left">
<img class="pf-c-avatar" src="${this.challenge.pending_user_avatar}" alt="${gettext("User's avatar")}">
${this.challenge.pending_user}
</div>
<div class="right">
<a href="/-/cancel/">${gettext("Not you?")}</a>
</div>
</div>
</div>
<div class="pf-c-form__group">
<p>
${this.challenge.header_text}
</p>
<p>${gettext("Application requires following permissions")}</p>
<ul class="pf-c-list" id="permmissions">
${(this.challenge.permissions || []).map((permission) => {
return html`<li id="permission-${permission.id}">${permission.name}</li>`;
})}
</ul>
</div>
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${gettext("Continue")}
</button>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
</ul>
</footer>`;
}
}

View File

@ -1,15 +1,11 @@
import { gettext } from "django"; import { gettext } from "django";
import { CSSResult, customElement, html, property, TemplateResult } from "lit-element"; import { CSSResult, customElement, html, property, TemplateResult } from "lit-element";
import { Challenge } from "../../../api/Flows"; import { WithUserInfoChallenge } from "../../../api/Flows";
import { COMMON_STYLES } from "../../../common/styles"; import { COMMON_STYLES } from "../../../common/styles";
import { BaseStage } from "../base"; import { BaseStage } from "../base";
export interface PasswordChallenge extends Challenge { export interface PasswordChallenge extends WithUserInfoChallenge {
pending_user: string;
pending_user_avatar: string;
recovery_url?: string; recovery_url?: string;
} }
@customElement("ak-stage-password") @customElement("ak-stage-password")

View File

@ -4,10 +4,12 @@ import { unsafeHTML } from "lit-html/directives/unsafe-html";
import { getCookie } from "../../utils"; import { getCookie } from "../../utils";
import "../../elements/stages/identification/IdentificationStage"; import "../../elements/stages/identification/IdentificationStage";
import "../../elements/stages/password/PasswordStage"; import "../../elements/stages/password/PasswordStage";
import "../../elements/stages/consent/ConsentStage";
import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows"; import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows";
import { DefaultClient } from "../../api/Client"; import { DefaultClient } from "../../api/Client";
import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage"; import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage";
import { PasswordChallenge } from "../../elements/stages/password/PasswordStage"; import { PasswordChallenge } from "../../elements/stages/password/PasswordStage";
import { ConsentChallenge } from "../../elements/stages/consent/ConsentStage";
@customElement("ak-flow-executor") @customElement("ak-flow-executor")
export class FlowExecutor extends LitElement { export class FlowExecutor extends LitElement {
@ -108,6 +110,8 @@ export class FlowExecutor extends LitElement {
return html`<ak-stage-identification .host=${this} .challenge=${this.challenge as IdentificationChallenge}></ak-stage-identification>`; return html`<ak-stage-identification .host=${this} .challenge=${this.challenge as IdentificationChallenge}></ak-stage-identification>`;
case "ak-stage-password": case "ak-stage-password":
return html`<ak-stage-password .host=${this} .challenge=${this.challenge as PasswordChallenge}></ak-stage-password>`; return html`<ak-stage-password .host=${this} .challenge=${this.challenge as PasswordChallenge}></ak-stage-password>`;
case "ak-stage-consent":
return html`<ak-stage-consent .host=${this} .challenge=${this.challenge as ConsentChallenge}></ak-stage-consent>`;
default: default:
break; break;
} }