stages/identification: allow setting of a password stage to check password and identity in a single step

closes #970

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-06-05 14:02:15 +02:00
parent f996f9d4e3
commit 24da24b5d5
16 changed files with 260 additions and 49 deletions

View File

@ -2,6 +2,7 @@
from traceback import format_tb
from typing import Any, Optional
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
from django.http.request import QueryDict
@ -203,6 +204,8 @@ class FlowExecutorView(APIView):
stage_response = self.current_stage_view.get(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
if settings.DEBUG or settings.TEST:
raise exc
capture_exception(exc)
self._logger.warning(exc)
return to_stage_response(request, FlowErrorResponse(request, exc))
@ -242,6 +245,8 @@ class FlowExecutorView(APIView):
stage_response = self.current_stage_view.post(request, *args, **kwargs)
return to_stage_response(request, stage_response)
except Exception as exc: # pylint: disable=broad-except
if settings.DEBUG or settings.TEST:
raise exc
capture_exception(exc)
self._logger.warning(exc)
return to_stage_response(request, FlowErrorResponse(request, exc))

View File

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

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.3 on 2021-06-05 12:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_password", "0005_auto_20210402_2221"),
("authentik_stages_identification", "0009_identificationstage_sources"),
]
operations = [
migrations.AddField(
model_name="identificationstage",
name="password_stage",
field=models.ForeignKey(
default=None,
help_text="When set, shows a password field, instead of showing the password field as seaprate step.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_stages_password.passwordstage",
),
),
]

View File

@ -9,6 +9,7 @@ from rest_framework.serializers import BaseSerializer
from authentik.core.models import Source
from authentik.flows.models import Flow, Stage
from authentik.stages.password.models import PasswordStage
class UserFields(models.TextChoices):
@ -32,6 +33,16 @@ class IdentificationStage(Stage):
),
)
password_stage = models.ForeignKey(
PasswordStage,
null=True,
default=None,
on_delete=models.SET_NULL,
help_text=_(
"When set, shows a password field, instead of showing the "
"password field as seaprate step.",
),
)
case_insensitive_matching = models.BooleanField(
default=True,
help_text=_(

View File

@ -1,13 +1,14 @@
"""Identification stage logic"""
from dataclasses import asdict
from time import sleep
from typing import Optional
from typing import Any, Optional
from django.core.exceptions import PermissionDenied
from django.db.models import Q
from django.http import HttpResponse
from django.urls import reverse
from django.utils.translation import gettext as _
from rest_framework.fields import CharField, ListField
from rest_framework.fields import BooleanField, CharField, ListField
from rest_framework.serializers import ValidationError
from structlog.stdlib import get_logger
@ -22,6 +23,7 @@ from authentik.flows.stage import (
from authentik.flows.views import SESSION_KEY_APPLICATION_PRE
from authentik.stages.identification.models import IdentificationStage
from authentik.stages.identification.signals import identification_failed
from authentik.stages.password.stage import authenticate
LOGGER = get_logger()
@ -30,6 +32,7 @@ class IdentificationChallenge(Challenge):
"""Identification challenges with all UI elements"""
user_fields = ListField(child=CharField(), allow_empty=True, allow_null=True)
password_fields = BooleanField()
application_pre = CharField(required=False)
enroll_url = CharField(required=False)
@ -44,22 +47,43 @@ class IdentificationChallengeResponse(ChallengeResponse):
"""Identification challenge"""
uid_field = CharField()
password = CharField(required=False, allow_blank=True, allow_null=True)
component = CharField(default="ak-stage-identification")
pre_user: Optional[User] = None
def validate_uid_field(self, value: str) -> str:
"""Validate that user exists"""
pre_user = self.stage.get_user(value)
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
"""Validate that user exists, and optionally their password"""
uid_field = data["uid_field"]
current_stage: IdentificationStage = self.stage.executor.current_stage
pre_user = self.stage.get_user(uid_field)
if not pre_user:
sleep(0.150)
LOGGER.debug("invalid_login", identifier=value)
LOGGER.debug("invalid_login", identifier=uid_field)
identification_failed.send(
sender=self, request=self.stage.request, uid_field=value
sender=self, request=self.stage.request, uid_field=uid_field
)
raise ValidationError("Failed to authenticate.")
self.pre_user = pre_user
return value
if not current_stage.password_stage:
# No password stage select, don't validate the password
return data
password = data["password"]
try:
user = authenticate(
self.stage.request,
current_stage.password_stage.backends,
username=self.pre_user.username,
password=password,
)
if not user:
raise ValidationError("Failed to authenticate.")
self.pre_user = user
except PermissionDenied as exc:
raise ValidationError(str(exc)) from exc
return data
class IdentificationStageView(ChallengeStageView):
@ -92,6 +116,7 @@ class IdentificationStageView(ChallengeStageView):
"primary_action": _("Log in"),
"component": "ak-stage-identification",
"user_fields": current_stage.user_fields,
"password_fields": bool(current_stage.password_stage),
}
)
# If the user has been redirected to us whilst trying to access an

View File

@ -6,8 +6,10 @@ 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.providers.oauth2.generators import generate_client_secret
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.password.models import PasswordStage
class TestIdentificationStage(TestCase):
@ -15,7 +17,10 @@ class TestIdentificationStage(TestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create(username="unittest", email="test@beryju.org")
self.password = generate_client_secret()
self.user = User.objects.create_user(
username="unittest", email="test@beryju.org", password=self.password
)
self.client = Client()
# OAuthSource for the login view
@ -62,6 +67,73 @@ class TestIdentificationStage(TestCase):
},
)
def test_valid_with_password(self):
"""Test with valid email and password in single step"""
pw_stage = PasswordStage.objects.create(
name="password", backends=["django.contrib.auth.backends.ModelBackend"]
)
self.stage.password_stage = pw_stage
self.stage.save()
form_data = {"uid_field": self.user.email, "password": self.password}
url = reverse(
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
response = self.client.post(url, form_data)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"component": "xak-flow-redirect",
"to": reverse("authentik_core:root-redirect"),
"type": ChallengeTypes.REDIRECT.value,
},
)
def test_invalid_with_password(self):
"""Test with valid email and invalid password in single step"""
pw_stage = PasswordStage.objects.create(
name="password", backends=["django.contrib.auth.backends.ModelBackend"]
)
self.stage.password_stage = pw_stage
self.stage.save()
form_data = {
"uid_field": self.user.email,
"password": self.password + "test",
}
url = reverse(
"authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}
)
response = self.client.post(url, form_data)
self.assertEqual(response.status_code, 200)
self.assertJSONEqual(
force_str(response.content),
{
"background": self.flow.background.url,
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-identification",
"password_fields": True,
"primary_action": "Log in",
"response_errors": {
"non_field_errors": [
{"code": "invalid", "string": "Failed to " "authenticate."}
]
},
"sources": [
{
"challenge": {
"component": "xak-flow-redirect",
"to": "/source/oauth/login/test/",
"type": ChallengeTypes.REDIRECT.value,
},
"icon_url": "/static/authentik/sources/.svg",
"name": "test",
}
],
"title": "",
"user_fields": ["email"],
},
)
def test_invalid_with_username(self):
"""Test invalid with username (user exists but stage only allows email)"""
form_data = {"uid_field": self.user.username}
@ -113,6 +185,7 @@ class TestIdentificationStage(TestCase):
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-identification",
"user_fields": ["email"],
"password_fields": False,
"enroll_url": reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": "unique-enrollment-string"},
@ -160,6 +233,7 @@ class TestIdentificationStage(TestCase):
"type": ChallengeTypes.NATIVE.value,
"component": "ak-stage-identification",
"user_fields": ["email"],
"password_fields": False,
"recovery_url": reverse(
"authentik_core:if-flow",
kwargs={"flow_slug": "unique-recovery-string"},

View File

@ -1,6 +1,4 @@
"""password tests"""
import string
from random import SystemRandom
from unittest.mock import MagicMock, patch
from django.core.exceptions import PermissionDenied
@ -15,6 +13,7 @@ from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
from authentik.flows.views import SESSION_KEY_PLAN
from authentik.providers.oauth2.generators import generate_client_secret
from authentik.stages.password.models import PasswordStage
MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test"))
@ -25,10 +24,7 @@ class TestPasswordStage(TestCase):
def setUp(self):
super().setUp()
self.password = "".join(
SystemRandom().choice(string.ascii_uppercase + string.digits)
for _ in range(8)
)
self.password = generate_client_secret()
self.user = User.objects.create_user(
username="unittest", email="test@beryju.org", password=self.password
)

View File

@ -17679,6 +17679,8 @@ components:
items:
type: string
nullable: true
password_fields:
type: boolean
application_pre:
type: string
enroll_url:
@ -17692,6 +17694,7 @@ components:
items:
$ref: '#/components/schemas/UILoginButton'
required:
- password_fields
- primary_action
- type
- user_fields
@ -17704,6 +17707,9 @@ components:
default: ak-stage-identification
uid_field:
type: string
password:
type: string
nullable: true
required:
- uid_field
IdentificationStage:
@ -17736,6 +17742,12 @@ components:
$ref: '#/components/schemas/UserFieldsEnum'
description: Fields of the user object to match against. (Hold shift to
select multiple options)
password_stage:
type: string
format: uuid
nullable: true
description: When set, shows a password field, instead of showing the password
field as seaprate step.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.
@ -17784,6 +17796,12 @@ components:
$ref: '#/components/schemas/UserFieldsEnum'
description: Fields of the user object to match against. (Hold shift to
select multiple options)
password_stage:
type: string
format: uuid
nullable: true
description: When set, shows a password field, instead of showing the password
field as seaprate step.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.
@ -22307,6 +22325,12 @@ components:
$ref: '#/components/schemas/UserFieldsEnum'
description: Fields of the user object to match against. (Hold shift to
select multiple options)
password_stage:
type: string
format: uuid
nullable: true
description: When set, shows a password field, instead of showing the password
field as seaprate step.
case_insensitive_matching:
type: boolean
description: When enabled, user fields are matched regardless of their casing.

View File

@ -1,10 +0,0 @@
import { ChallengeChoices } from "authentik-api";
export interface Error {
code: string;
string: string;
}
export interface ErrorDict {
[key: string]: Error[];
}

View File

@ -1,8 +1,8 @@
import { customElement, LitElement, CSSResult, property, css } from "lit-element";
import { TemplateResult, html } from "lit-html";
import { Error } from "../../api/Flows";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import { ErrorDetail } from "authentik-api";
@customElement("ak-form-element")
export class FormElement extends LitElement {
@ -25,7 +25,7 @@ export class FormElement extends LitElement {
required = false;
@property({ attribute: false })
errors?: Error[];
errors?: ErrorDetail[];
updated(): void {
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach(input => {

View File

@ -1,4 +1,5 @@
import { LitElement, property } from "lit-element";
import { ErrorDetail } from "authentik-api";
import { html, LitElement, property, TemplateResult } from "lit-element";
export interface StageHost {
challenge?: unknown;
@ -22,4 +23,23 @@ export class BaseStage<Tin, Tout> extends LitElement {
this.host?.submit(object as unknown as Tout);
}
renderNonFieldErrors(errors: ErrorDetail[]): TemplateResult {
if (!errors) {
return html``;
}
return html`<div class="pf-c-form__alert">
${errors.map(err => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
</div>
<h4 class="pf-c-alert__title">
${err.string}
</h4>
</div>`;
})}
</div>`;
}
}

View File

@ -7,6 +7,7 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
import AKGlobal from "../../../authentik.css";
import "../../../elements/forms/FormElement";
import "../../../elements/EmptyState";
@ -25,7 +26,7 @@ export const PasswordManagerPrefill: {
export class IdentificationStage extends BaseStage<IdentificationChallenge, IdentificationChallengeResponseRequest> {
static get styles(): CSSResult[] {
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat(
return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat(
css`
/* login page's icons */
.pf-c-login__main-footer-links-item button {
@ -160,7 +161,7 @@ export class IdentificationStage extends BaseStage<IdentificationChallenge, Iden
label=${label}
?required="${true}"
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})["uid_field"]}>
.errors=${(this.challenge.responseErrors || {})["uid_field"]}>
<!-- @ts-ignore -->
<input type=${type}
name="uidField"
@ -170,6 +171,25 @@ export class IdentificationStage extends BaseStage<IdentificationChallenge, Iden
class="pf-c-form-control"
required>
</ak-form-element>
${this.challenge.passwordFields ? html`
<ak-form-element
label="${t`Password`}"
?required="${true}"
class="pf-c-form__group"
.errors=${(this.challenge.responseErrors || {})["password"]}>
<input type="password"
name="password"
placeholder="${t`Password`}"
autofocus=""
autocomplete="current-password"
class="pf-c-form-control"
required
value=${PasswordManagerPrefill.password || ""}>
</ak-form-element>
`: html``}
${"non_field_errors" in (this.challenge?.responseErrors || {}) ?
this.renderNonFieldErrors(this.challenge?.responseErrors?.non_field_errors || []) :
html``}
<div class="pf-c-form__group pf-m-action">
<button type="submit" class="pf-c-button pf-m-primary pf-m-block">
${this.challenge.primaryAction}

View File

@ -13,7 +13,6 @@ import { BaseStage } from "../base";
import "../../../elements/forms/FormElement";
import "../../../elements/EmptyState";
import "../../../elements/Divider";
import { Error } from "../../../api/Flows";
import { PromptChallenge, PromptChallengeResponseRequest, StagePrompt } from "authentik-api";
@ -103,24 +102,6 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
return "";
}
renderNonFieldErrors(errors: Error[]): TemplateResult {
if (!errors) {
return html``;
}
return html`<div class="pf-c-form__alert">
${errors.map(err => {
return html`<div class="pf-c-alert pf-m-inline pf-m-danger">
<div class="pf-c-alert__icon">
<i class="fas fa-exclamation-circle"></i>
</div>
<h4 class="pf-c-alert__title">
${err.string}
</h4>
</div>`;
})}
</div>`;
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state

View File

@ -2032,6 +2032,7 @@ msgstr "Loading"
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/stages/identification/IdentificationStageForm.ts
#: src/pages/stages/password/PasswordStageForm.ts
#: src/pages/stages/prompt/PromptStageForm.ts
#: src/pages/stages/prompt/PromptStageForm.ts
@ -2549,6 +2550,8 @@ msgstr "Pass policy?"
msgid "Passing"
msgstr "Passing"
#: src/flows/stages/identification/IdentificationStage.ts
#: src/flows/stages/identification/IdentificationStage.ts
#: src/flows/stages/password/PasswordStage.ts
msgid "Password"
msgstr "Password"
@ -2558,6 +2561,10 @@ msgstr "Password"
msgid "Password field"
msgstr "Password field"
#: src/pages/stages/identification/IdentificationStageForm.ts
msgid "Password stage"
msgstr "Password stage"
#: src/pages/stages/prompt/PromptForm.ts
msgid "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical."
msgstr "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical."
@ -4354,6 +4361,10 @@ msgstr "When enabled, the invitation will be deleted after usage."
msgid "When enabled, user fields are matched regardless of their casing."
msgstr "When enabled, user fields are matched regardless of their casing."
#: src/pages/stages/identification/IdentificationStageForm.ts
msgid "When selected, a password field is shown on the same page instead of a separate page. This prevents username enumeration attacks."
msgstr "When selected, a password field is shown on the same page instead of a separate page. This prevents username enumeration attacks."
#: src/pages/providers/saml/SAMLProviderForm.ts
msgid "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default."
msgstr "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default."

View File

@ -2031,6 +2031,7 @@ msgstr ""
#:
#:
#:
#:
msgid "Loading..."
msgstr ""
@ -2541,6 +2542,8 @@ msgstr ""
msgid "Passing"
msgstr ""
#:
#:
#:
msgid "Password"
msgstr ""
@ -2550,6 +2553,10 @@ msgstr ""
msgid "Password field"
msgstr ""
#:
msgid "Password stage"
msgstr ""
#:
msgid "Password: Masked input, password is validated against sources. Policies still have to be applied to this Stage. If two of these are used in the same stage, they are ensured to be identical."
msgstr ""
@ -4342,6 +4349,10 @@ msgstr ""
msgid "When enabled, user fields are matched regardless of their casing."
msgstr ""
#:
msgid "When selected, a password field is shown on the same page instead of a separate page. This prevents username enumeration attacks."
msgstr ""
#:
msgid "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default."
msgstr ""

View File

@ -76,6 +76,22 @@ export class IdentificationStageForm extends ModelForm<IdentificationStage, stri
<p class="pf-c-form__helper-text">${t`Fields a user can identify themselves with. If no fields are selected, the user will only be able to use sources.`}</p>
<p class="pf-c-form__helper-text">${t`Hold control/command to select multiple items.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${t`Password stage`}
name="passwordStage">
<select class="pf-c-form-control">
<option value="" ?selected=${this.instance?.passwordStage === undefined}>---------</option>
${until(new StagesApi(DEFAULT_CONFIG).stagesPasswordList({
ordering: "pk",
}).then(stages => {
return stages.results.map(stage => {
const selected = this.instance?.passwordStage === stage.pk;
return html`<option value=${ifDefined(stage.pk)} ?selected=${selected}>${stage.name}</option>`;
});
}), html`<option>${t`Loading...`}</option>`)}
</select>
<p class="pf-c-form__helper-text">${t`When selected, a password field is shown on the same page instead of a separate page. This prevents username enumeration attacks.`}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="caseInsensitiveMatching">
<div class="pf-c-check">
<input type="checkbox" class="pf-c-check__input" ?checked=${first(this.instance?.caseInsensitiveMatching, true)}>