stages/consent: migrate to SPA
This commit is contained in:
parent
a8681ac88f
commit
b9f409d6d9
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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 %}
|
|
@ -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
|
||||
|
|
|
@ -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]:
|
||||
|
|
|
@ -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 %}
|
|
@ -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))
|
||||
|
|
|
@ -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"""
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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 %}
|
|
@ -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/",
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
77
web/src/elements/stages/consent/ConsentStage.ts
Normal file
77
web/src/elements/stages/consent/ConsentStage.ts
Normal 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>`;
|
||||
}
|
||||
|
||||
}
|
|
@ -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")
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
Reference in a new issue