Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer
2024-08-24 15:43:33 +02:00
parent 98dc794597
commit 2149e81d8f
9 changed files with 155 additions and 25 deletions

View File

@ -27,6 +27,7 @@ class IdentificationStageSerializer(StageSerializer):
fields = StageSerializer.Meta.fields + [ fields = StageSerializer.Meta.fields + [
"user_fields", "user_fields",
"password_stage", "password_stage",
"captcha_stage",
"case_insensitive_matching", "case_insensitive_matching",
"show_matched_user", "show_matched_user",
"enrollment_flow", "enrollment_flow",

View File

@ -0,0 +1,26 @@
# Generated by Django 5.0.8 on 2024-08-24 12:58
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_captcha", "0003_captchastage_error_on_invalid_score_and_more"),
("authentik_stages_identification", "0014_identificationstage_pretend"),
]
operations = [
migrations.AddField(
model_name="identificationstage",
name="captcha_stage",
field=models.ForeignKey(
default=None,
help_text="When set, the captcha element is shown on the identification stage.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_stages_captcha.captchastage",
),
),
]

View File

@ -8,6 +8,7 @@ from rest_framework.serializers import BaseSerializer
from authentik.core.models import Source from authentik.core.models import Source
from authentik.flows.models import Flow, Stage from authentik.flows.models import Flow, Stage
from authentik.stages.captcha.models import CaptchaStage
from authentik.stages.password.models import PasswordStage from authentik.stages.password.models import PasswordStage
@ -42,6 +43,15 @@ class IdentificationStage(Stage):
), ),
), ),
) )
captcha_stage = models.ForeignKey(
CaptchaStage,
null=True,
default=None,
on_delete=models.SET_NULL,
help_text=_(
("When set, the captcha element is shown on the identification stage."),
),
)
case_insensitive_matching = models.BooleanField( case_insensitive_matching = models.BooleanField(
default=True, default=True,

View File

@ -30,6 +30,11 @@ from authentik.lib.utils.urls import reverse_with_qs
from authentik.root.middleware import ClientIPMiddleware from authentik.root.middleware import ClientIPMiddleware
from authentik.sources.oauth.types.apple import AppleLoginChallenge from authentik.sources.oauth.types.apple import AppleLoginChallenge
from authentik.sources.plex.models import PlexAuthenticationChallenge from authentik.sources.plex.models import PlexAuthenticationChallenge
from authentik.stages.captcha.stage import (
CaptchaChallenge,
CaptchaChallengeResponse,
CaptchaStageView,
)
from authentik.stages.identification.models import IdentificationStage from authentik.stages.identification.models import IdentificationStage
from authentik.stages.identification.signals import identification_failed from authentik.stages.identification.signals import identification_failed
from authentik.stages.password.stage import authenticate from authentik.stages.password.stage import authenticate
@ -64,6 +69,7 @@ class IdentificationChallenge(Challenge):
user_fields = ListField(child=CharField(), allow_empty=True, allow_null=True) user_fields = ListField(child=CharField(), allow_empty=True, allow_null=True)
password_fields = BooleanField() password_fields = BooleanField()
captcha_stage = CaptchaChallenge(required=False)
allow_show_password = BooleanField(default=False) allow_show_password = BooleanField(default=False)
application_pre = CharField(required=False) application_pre = CharField(required=False)
flow_designation = ChoiceField(FlowDesignation.choices) flow_designation = ChoiceField(FlowDesignation.choices)
@ -84,6 +90,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
uid_field = CharField() uid_field = CharField()
password = CharField(required=False, allow_blank=True, allow_null=True) password = CharField(required=False, allow_blank=True, allow_null=True)
component = CharField(default="ak-stage-identification") component = CharField(default="ak-stage-identification")
captcha = CaptchaChallengeResponse(required=False)
pre_user: User | None = None pre_user: User | None = None
@ -128,10 +135,7 @@ class IdentificationChallengeResponse(ChallengeResponse):
return attrs return attrs
raise ValidationError("Failed to authenticate.") raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user self.pre_user = pre_user
if not current_stage.password_stage: if current_stage.password_stage:
# No password stage select, don't validate the password
return attrs
password = attrs.get("password", None) password = attrs.get("password", None)
if not password: if not password:
self.stage.logger.warning("Password not set for ident+auth attempt") self.stage.logger.warning("Password not set for ident+auth attempt")
@ -152,6 +156,11 @@ class IdentificationChallengeResponse(ChallengeResponse):
self.pre_user = user self.pre_user = user
except PermissionDenied as exc: except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc raise ValidationError(str(exc)) from exc
print(attrs)
# if current_stage.captcha_stage:
# captcha = CaptchaStageView(self.stage.executor)
# captcha.stage = current_stage.captcha_stage
# captcha.challenge_valid(attrs.get("captcha"))
return attrs return attrs
@ -230,6 +239,12 @@ class IdentificationStageView(ChallengeStageView):
query=get_qs, query=get_qs,
kwargs={"flow_slug": current_stage.passwordless_flow.slug}, kwargs={"flow_slug": current_stage.passwordless_flow.slug},
) )
if current_stage.captcha_stage:
captcha = CaptchaStageView(self.executor)
captcha.stage = current_stage.captcha_stage
captcha_challenge = captcha.get_challenge()
captcha_challenge.is_valid()
challenge.initial_data["captcha_stage"] = captcha_challenge.data
# Check all enabled source, add them if they have a UI Login button. # Check all enabled source, add them if they have a UI Login button.
ui_sources = [] ui_sources = []

View File

@ -10091,6 +10091,11 @@
"title": "Password stage", "title": "Password stage",
"description": "When set, shows a password field, instead of showing the password field as separate step." "description": "When set, shows a password field, instead of showing the password field as separate step."
}, },
"captcha_stage": {
"type": "integer",
"title": "Captcha stage",
"description": "When set, the captcha element is shown on the identification stage."
},
"case_insensitive_matching": { "case_insensitive_matching": {
"type": "boolean", "type": "boolean",
"title": "Case insensitive matching", "title": "Case insensitive matching",

View File

@ -40459,6 +40459,8 @@ components:
nullable: true nullable: true
password_fields: password_fields:
type: boolean type: boolean
captcha_stage:
$ref: '#/components/schemas/CaptchaChallenge'
allow_show_password: allow_show_password:
type: boolean type: boolean
default: false default: false
@ -40500,6 +40502,8 @@ components:
password: password:
type: string type: string
nullable: true nullable: true
captcha:
$ref: '#/components/schemas/CaptchaChallengeResponseRequest'
required: required:
- uid_field - uid_field
IdentificationStage: IdentificationStage:
@ -40545,6 +40549,12 @@ components:
nullable: true nullable: true
description: When set, shows a password field, instead of showing the password description: When set, shows a password field, instead of showing the password
field as separate step. field as separate step.
captcha_stage:
type: string
format: uuid
nullable: true
description: When set, the captcha element is shown on the identification
stage.
case_insensitive_matching: case_insensitive_matching:
type: boolean type: boolean
description: When enabled, user fields are matched regardless of their casing. description: When enabled, user fields are matched regardless of their casing.
@ -40613,6 +40623,12 @@ components:
nullable: true nullable: true
description: When set, shows a password field, instead of showing the password description: When set, shows a password field, instead of showing the password
field as separate step. field as separate step.
captcha_stage:
type: string
format: uuid
nullable: true
description: When set, the captcha element is shown on the identification
stage.
case_insensitive_matching: case_insensitive_matching:
type: boolean type: boolean
description: When enabled, user fields are matched regardless of their casing. description: When enabled, user fields are matched regardless of their casing.
@ -45745,6 +45761,12 @@ components:
nullable: true nullable: true
description: When set, shows a password field, instead of showing the password description: When set, shows a password field, instead of showing the password
field as separate step. field as separate step.
captcha_stage:
type: string
format: uuid
nullable: true
description: When set, the captcha element is shown on the identification
stage.
case_insensitive_matching: case_insensitive_matching:
type: boolean type: boolean
description: When enabled, user fields are matched regardless of their casing. description: When enabled, user fields are matched regardless of their casing.

View File

@ -21,6 +21,7 @@ import {
SourcesApi, SourcesApi,
Stage, Stage,
StagesApi, StagesApi,
StagesCaptchaListRequest,
StagesPasswordListRequest, StagesPasswordListRequest,
UserFieldsEnum, UserFieldsEnum,
} from "@goauthentik/api"; } from "@goauthentik/api";
@ -160,6 +161,41 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
)} )}
</p> </p>
</ak-form-element-horizontal> </ak-form-element-horizontal>
<ak-form-element-horizontal label=${msg("Captcha stage")} name="captchaStage">
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Stage[]> => {
const args: StagesCaptchaListRequest = {
ordering: "name",
};
if (query !== undefined) {
args.search = query;
}
const stages = await new StagesApi(
DEFAULT_CONFIG,
).stagesCaptchaList(args);
return stages.results;
}}
.groupBy=${(items: Stage[]) => {
return groupBy(items, (stage) => stage.verboseNamePlural);
}}
.renderElement=${(stage: Stage): string => {
return stage.name;
}}
.value=${(stage: Stage | undefined): string | undefined => {
return stage?.pk;
}}
.selected=${(stage: Stage): boolean => {
return stage.pk === this.instance?.captchaStage;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"TODO.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="caseInsensitiveMatching"> <ak-form-element-horizontal name="caseInsensitiveMatching">
<label class="pf-c-switch"> <label class="pf-c-switch">
<input <input

View File

@ -7,7 +7,7 @@ import type { TurnstileObject } from "turnstile-types";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, html } from "lit"; import { CSSResult, PropertyValues, TemplateResult, html } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js"; import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -45,6 +45,9 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
@state() @state()
scriptElement?: HTMLScriptElement; scriptElement?: HTMLScriptElement;
@property({type: Boolean})
embedded = false;
constructor() { constructor() {
super(); super();
this.captchaContainer = document.createElement("div"); this.captchaContainer = document.createElement("div");
@ -161,6 +164,9 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
} }
render(): TemplateResult { render(): TemplateResult {
if (this.embedded) {
return this.renderBody();
}
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state loading> </ak-empty-state>`; return html`<ak-empty-state loading> </ak-empty-state>`;
} }

View File

@ -3,6 +3,7 @@ import "@goauthentik/elements/Divider";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/components/ak-flow-password-input.js"; import "@goauthentik/flow/components/ak-flow-password-input.js";
import "@goauthentik/flow/stages/captcha/CaptchaStage";
import { BaseStage } from "@goauthentik/flow/stages/base"; import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg, str } from "@lit/localize"; import { msg, str } from "@lit/localize";
@ -274,6 +275,9 @@ export class IdentificationStage extends BaseStage<
` `
: nothing} : nothing}
${this.renderNonFieldErrors()} ${this.renderNonFieldErrors()}
${this.challenge.captchaStage ? html`
<ak-stage-captcha .challenge=${this.challenge.captchaStage} embedded></ak-stage-captcha>
` : nothing}
<div class="pf-c-form__group pf-m-action"> <div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block"> <button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${this.challenge.primaryAction} ${this.challenge.primaryAction}
@ -284,6 +288,11 @@ export class IdentificationStage extends BaseStage<
: nothing}`; : nothing}`;
} }
submitForm(e: Event, defaults?: IdentificationChallengeResponseRequest | undefined): Promise<boolean> {
return super.submitForm(e, defaults);
}
render(): TemplateResult { render(): TemplateResult {
if (!this.challenge) { if (!this.challenge) {
return html`<ak-empty-state loading> </ak-empty-state>`; return html`<ak-empty-state loading> </ak-empty-state>`;