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()
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):
"""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.shortcuts import get_object_or_404, redirect
from django.utils import timezone
from django.utils.translation import gettext as _
from structlog.stdlib import get_logger
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.stages.consent.models import ConsentMode, ConsentStage
from authentik.stages.consent.stage import (
PLAN_CONTEXT_CONSENT_TEMPLATE,
PLAN_CONTEXT_CONSENT_HEADER,
PLAN_CONTEXT_CONSENT_PERMISSIONS,
ConsentStageView,
)
LOGGER = get_logger()
PLAN_CONTEXT_PARAMS = "params"
PLAN_CONTEXT_SCOPE_DESCRIPTIONS = "scope_descriptions"
SESSION_NEEDS_LOGIN = "authentik_oauth2_needs_login"
ALLOWED_PROMPT_PARAMS = {PROMPT_NONE, PROMPT_CONSNET, PROMPT_LOGIN}
@ -432,6 +433,7 @@ class AuthorizationFlowInitView(PolicyAccessView):
planner = FlowPlanner(self.provider.authorization_flow)
# planner.use_cache = False
planner.allow_empty_flows = True
scope_descriptions = UserInfoView().get_scope_descriptions(self.params.scope)
plan: FlowPlan = planner.plan(
self.request,
{
@ -439,11 +441,12 @@ class AuthorizationFlowInitView(PolicyAccessView):
PLAN_CONTEXT_APPLICATION: self.application,
# OAuth2 related params
PLAN_CONTEXT_PARAMS: self.params,
PLAN_CONTEXT_SCOPE_DESCRIPTIONS: UserInfoView().get_scope_descriptions(
self.params.scope
),
# 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

View File

@ -22,14 +22,16 @@ class UserInfoView(View):
"""Create a dictionary with all the requested claims about the End-User.
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"""
scope_descriptions = {}
scope_descriptions = []
for scope in ScopeMapping.objects.filter(scope_name__in=scopes).order_by(
"scope_name"
):
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
# Hence they don't exist as Scope objects
github_scope_map = {
@ -44,7 +46,9 @@ class UserInfoView(View):
}
for scope in scopes:
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
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.shortcuts import get_object_or_404
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.csrf import csrf_exempt
from structlog.stdlib import get_logger
@ -31,7 +32,10 @@ from authentik.providers.saml.views.flows import (
SESSION_KEY_AUTH_N_REQUEST,
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()
@ -68,7 +72,11 @@ class SAMLSSOView(PolicyAccessView):
{
PLAN_CONTEXT_SSO: True,
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))

View File

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

View File

@ -1,40 +1,69 @@
"""authentik consent stage"""
from typing import Any
from django.http import HttpRequest, HttpResponse
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.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.stages.consent.forms import ConsentForm
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."""
form_class = ConsentForm
response_class = ConsentChallengeResponse
def get_context_data(self, **kwargs: dict[str, Any]) -> dict[str, Any]:
kwargs = super().get_context_data(**kwargs)
kwargs["current_stage"] = self.executor.current_stage
kwargs["context"] = self.executor.plan.context
return kwargs
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_TEMPLATE in self.executor.plan.context:
template_name = self.executor.plan.context[PLAN_CONTEXT_CONSENT_TEMPLATE]
return [template_name]
return ["stages/consent/fallback.html"]
def get_challenge(self) -> Challenge:
challenge = ConsentChallenge(
data={
"type": ChallengeTypes.native,
"component": "ak-stage-consent",
}
)
if PLAN_CONTEXT_CONSENT_HEADER in self.executor.plan.context:
challenge.initial_data["header_text"] = self.executor.plan.context[
PLAN_CONTEXT_CONSENT_HEADER
]
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:
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:
return super().get(request, *args, **kwargs)
# 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():
return self.executor.stage_ok()
# No consent found, show form
# No consent found, return consent
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
if PLAN_CONTEXT_APPLICATION not in self.executor.plan.context:
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 authentik.core.models import User
from authentik.flows.challenge import ChallengeTypes
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.identification.models import IdentificationStage, UserFields
@ -102,7 +103,7 @@ class TestIdentificationStage(TestCase):
self.assertJSONEqual(
force_str(response.content),
{
"type": "native",
"type": ChallengeTypes.native,
"component": "ak-stage-identification",
"input_type": "email",
"enroll_url": "/flows/unique-enrollment-string/",
@ -141,7 +142,7 @@ class TestIdentificationStage(TestCase):
self.assertJSONEqual(
force_str(response.content),
{
"type": "native",
"type": ChallengeTypes.native,
"component": "ak-stage-identification",
"input_type": "email",
"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 rest_framework.exceptions import ErrorDetail
from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
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.planner import PLAN_CONTEXT_PENDING_USER
from authentik.flows.stage import ChallengeStageView
@ -55,11 +59,9 @@ def authenticate(
)
class PasswordChallenge(Challenge):
class PasswordChallenge(WithUserInfoChallenge):
"""Password challenge UI fields"""
pending_user = CharField()
pending_user_avatar = CharField()
recovery_url = CharField(required=False)

View File

@ -152,7 +152,7 @@ class TestProviderOAuth2Github(SeleniumTestCase):
)
self.assertEqual(
"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(
By.CSS_SELECTOR,

View File

@ -23,6 +23,10 @@ export interface Challenge {
title?: string;
response_errors?: ErrorDict;
}
export interface WithUserInfoChallenge extends Challenge {
pending_user: string;
pending_user_avatar: string;
}
export interface ShellChallenge extends Challenge {
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 { 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 { BaseStage } from "../base";
export interface PasswordChallenge extends Challenge {
pending_user: string;
pending_user_avatar: string;
export interface PasswordChallenge extends WithUserInfoChallenge {
recovery_url?: string;
}
@customElement("ak-stage-password")

View File

@ -4,10 +4,12 @@ import { unsafeHTML } from "lit-html/directives/unsafe-html";
import { getCookie } from "../../utils";
import "../../elements/stages/identification/IdentificationStage";
import "../../elements/stages/password/PasswordStage";
import "../../elements/stages/consent/ConsentStage";
import { ShellChallenge, Challenge, ChallengeTypes, Flow, RedirectChallenge } from "../../api/Flows";
import { DefaultClient } from "../../api/Client";
import { IdentificationChallenge } from "../../elements/stages/identification/IdentificationStage";
import { PasswordChallenge } from "../../elements/stages/password/PasswordStage";
import { ConsentChallenge } from "../../elements/stages/consent/ConsentStage";
@customElement("ak-flow-executor")
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>`;
case "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:
break;
}