diff --git a/authentik/flows/challenge.py b/authentik/flows/challenge.py index d2ed89ac2..9f45da474 100644 --- a/authentik/flows/challenge.py +++ b/authentik/flows/challenge.py @@ -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""" diff --git a/authentik/providers/oauth2/templates/providers/oauth2/consent.html b/authentik/providers/oauth2/templates/providers/oauth2/consent.html deleted file mode 100644 index 6dea2e852..000000000 --- a/authentik/providers/oauth2/templates/providers/oauth2/consent.html +++ /dev/null @@ -1,20 +0,0 @@ -{% extends 'login/form_with_user.html' %} - -{% load i18n %} - -{% block beneath_form %} -
-

- {% blocktrans with name=context.application.name %} - You're about to sign into {{ name }}. - {% endblocktrans %} -

-

{% trans "Application requires following permissions" %}

- - {{ hidden_inputs }} -
-{% endblock %} diff --git a/authentik/providers/oauth2/views/authorize.py b/authentik/providers/oauth2/views/authorize.py index aacafb8f4..83dbff71a 100644 --- a/authentik/providers/oauth2/views/authorize.py +++ b/authentik/providers/oauth2/views/authorize.py @@ -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 diff --git a/authentik/providers/oauth2/views/userinfo.py b/authentik/providers/oauth2/views/userinfo.py index 22dabbf5f..1c9160dcf 100644 --- a/authentik/providers/oauth2/views/userinfo.py +++ b/authentik/providers/oauth2/views/userinfo.py @@ -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]: diff --git a/authentik/providers/saml/templates/providers/saml/consent.html b/authentik/providers/saml/templates/providers/saml/consent.html deleted file mode 100644 index 0618a3c94..000000000 --- a/authentik/providers/saml/templates/providers/saml/consent.html +++ /dev/null @@ -1,14 +0,0 @@ -{% extends 'login/form_with_user.html' %} - -{% load i18n %} - -{% block beneath_form %} -
-

- {% blocktrans with name=context.application.name %} - You're about to sign into {{ name }}. - {% endblocktrans %} -

- {{ hidden_inputs }} -
-{% endblock %} diff --git a/authentik/providers/saml/views/sso.py b/authentik/providers/saml/views/sso.py index 7eea5d9ae..ae869ca47 100644 --- a/authentik/providers/saml/views/sso.py +++ b/authentik/providers/saml/views/sso.py @@ -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)) diff --git a/authentik/stages/consent/forms.py b/authentik/stages/consent/forms.py index 508ff1a7c..f61e483fd 100644 --- a/authentik/stages/consent/forms.py +++ b/authentik/stages/consent/forms.py @@ -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""" diff --git a/authentik/stages/consent/stage.py b/authentik/stages/consent/stage.py index 2ac1ab849..9513ffaa0 100644 --- a/authentik/stages/consent/stage.py +++ b/authentik/stages/consent/stage.py @@ -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() diff --git a/authentik/stages/consent/templates/stages/consent/fallback.html b/authentik/stages/consent/templates/stages/consent/fallback.html deleted file mode 100644 index cdb16e689..000000000 --- a/authentik/stages/consent/templates/stages/consent/fallback.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'login/form_with_user.html' %} - -{% load i18n %} - -{% block beneath_form %} -
- {{ hidden_inputs }} -
-{% endblock %} diff --git a/authentik/stages/identification/tests.py b/authentik/stages/identification/tests.py index 66f85f489..8916d3b24 100644 --- a/authentik/stages/identification/tests.py +++ b/authentik/stages/identification/tests.py @@ -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/", diff --git a/authentik/stages/password/stage.py b/authentik/stages/password/stage.py index e69d3e6b4..3a8557f29 100644 --- a/authentik/stages/password/stage.py +++ b/authentik/stages/password/stage.py @@ -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) diff --git a/tests/e2e/test_provider_oauth2_github.py b/tests/e2e/test_provider_oauth2_github.py index aae6c11c2..f442c54c6 100644 --- a/tests/e2e/test_provider_oauth2_github.py +++ b/tests/e2e/test_provider_oauth2_github.py @@ -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, diff --git a/web/src/api/Flows.ts b/web/src/api/Flows.ts index 2afed6395..226fed3a3 100644 --- a/web/src/api/Flows.ts +++ b/web/src/api/Flows.ts @@ -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; diff --git a/web/src/elements/stages/consent/ConsentStage.ts b/web/src/elements/stages/consent/ConsentStage.ts new file mode 100644 index 000000000..ed77081ce --- /dev/null +++ b/web/src/elements/stages/consent/ConsentStage.ts @@ -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``; + } + return html`
+

+ ${this.challenge.title} +

+
+
+
{ this.submit(e); }}> +
+
+
+ ${gettext( + ${this.challenge.pending_user} +
+ +
+
+ +
+

+ ${this.challenge.header_text} +

+

${gettext("Application requires following permissions")}

+
    + ${(this.challenge.permissions || []).map((permission) => { + return html`
  • ${permission.name}
  • `; + })} +
+
+ +
+ +
+
+
+ `; + } + +} diff --git a/web/src/elements/stages/password/PasswordStage.ts b/web/src/elements/stages/password/PasswordStage.ts index e0e218d64..49efe8b77 100644 --- a/web/src/elements/stages/password/PasswordStage.ts +++ b/web/src/elements/stages/password/PasswordStage.ts @@ -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") diff --git a/web/src/pages/generic/FlowExecutor.ts b/web/src/pages/generic/FlowExecutor.ts index d7cb9b093..6dab191b0 100644 --- a/web/src/pages/generic/FlowExecutor.ts +++ b/web/src/pages/generic/FlowExecutor.ts @@ -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``; case "ak-stage-password": return html``; + case "ak-stage-consent": + return html``; default: break; }