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:
parent
f996f9d4e3
commit
24da24b5d5
|
@ -2,6 +2,7 @@
|
||||||
from traceback import format_tb
|
from traceback import format_tb
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
from django.http import Http404, HttpRequest, HttpResponse, HttpResponseRedirect
|
||||||
from django.http.request import QueryDict
|
from django.http.request import QueryDict
|
||||||
|
@ -203,6 +204,8 @@ class FlowExecutorView(APIView):
|
||||||
stage_response = self.current_stage_view.get(request, *args, **kwargs)
|
stage_response = self.current_stage_view.get(request, *args, **kwargs)
|
||||||
return to_stage_response(request, stage_response)
|
return to_stage_response(request, stage_response)
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
if settings.DEBUG or settings.TEST:
|
||||||
|
raise exc
|
||||||
capture_exception(exc)
|
capture_exception(exc)
|
||||||
self._logger.warning(exc)
|
self._logger.warning(exc)
|
||||||
return to_stage_response(request, FlowErrorResponse(request, 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)
|
stage_response = self.current_stage_view.post(request, *args, **kwargs)
|
||||||
return to_stage_response(request, stage_response)
|
return to_stage_response(request, stage_response)
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
if settings.DEBUG or settings.TEST:
|
||||||
|
raise exc
|
||||||
capture_exception(exc)
|
capture_exception(exc)
|
||||||
self._logger.warning(exc)
|
self._logger.warning(exc)
|
||||||
return to_stage_response(request, FlowErrorResponse(request, exc))
|
return to_stage_response(request, FlowErrorResponse(request, exc))
|
||||||
|
|
|
@ -13,6 +13,7 @@ class IdentificationStageSerializer(StageSerializer):
|
||||||
model = IdentificationStage
|
model = IdentificationStage
|
||||||
fields = StageSerializer.Meta.fields + [
|
fields = StageSerializer.Meta.fields + [
|
||||||
"user_fields",
|
"user_fields",
|
||||||
|
"password_stage",
|
||||||
"case_insensitive_matching",
|
"case_insensitive_matching",
|
||||||
"show_matched_user",
|
"show_matched_user",
|
||||||
"enrollment_flow",
|
"enrollment_flow",
|
||||||
|
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
|
@ -9,6 +9,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.password.models import PasswordStage
|
||||||
|
|
||||||
|
|
||||||
class UserFields(models.TextChoices):
|
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(
|
case_insensitive_matching = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
help_text=_(
|
help_text=_(
|
||||||
|
|
|
@ -1,13 +1,14 @@
|
||||||
"""Identification stage logic"""
|
"""Identification stage logic"""
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from time import sleep
|
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.db.models import Q
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext as _
|
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 rest_framework.serializers import ValidationError
|
||||||
from structlog.stdlib import get_logger
|
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.flows.views import SESSION_KEY_APPLICATION_PRE
|
||||||
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
|
||||||
|
|
||||||
LOGGER = get_logger()
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
@ -30,6 +32,7 @@ class IdentificationChallenge(Challenge):
|
||||||
"""Identification challenges with all UI elements"""
|
"""Identification challenges with all UI elements"""
|
||||||
|
|
||||||
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()
|
||||||
application_pre = CharField(required=False)
|
application_pre = CharField(required=False)
|
||||||
|
|
||||||
enroll_url = CharField(required=False)
|
enroll_url = CharField(required=False)
|
||||||
|
@ -44,22 +47,43 @@ class IdentificationChallengeResponse(ChallengeResponse):
|
||||||
"""Identification challenge"""
|
"""Identification challenge"""
|
||||||
|
|
||||||
uid_field = CharField()
|
uid_field = CharField()
|
||||||
|
password = CharField(required=False, allow_blank=True, allow_null=True)
|
||||||
component = CharField(default="ak-stage-identification")
|
component = CharField(default="ak-stage-identification")
|
||||||
|
|
||||||
pre_user: Optional[User] = None
|
pre_user: Optional[User] = None
|
||||||
|
|
||||||
def validate_uid_field(self, value: str) -> str:
|
def validate(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Validate that user exists"""
|
"""Validate that user exists, and optionally their password"""
|
||||||
pre_user = self.stage.get_user(value)
|
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:
|
if not pre_user:
|
||||||
sleep(0.150)
|
sleep(0.150)
|
||||||
LOGGER.debug("invalid_login", identifier=value)
|
LOGGER.debug("invalid_login", identifier=uid_field)
|
||||||
identification_failed.send(
|
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.")
|
raise ValidationError("Failed to authenticate.")
|
||||||
self.pre_user = pre_user
|
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):
|
class IdentificationStageView(ChallengeStageView):
|
||||||
|
@ -92,6 +116,7 @@ class IdentificationStageView(ChallengeStageView):
|
||||||
"primary_action": _("Log in"),
|
"primary_action": _("Log in"),
|
||||||
"component": "ak-stage-identification",
|
"component": "ak-stage-identification",
|
||||||
"user_fields": current_stage.user_fields,
|
"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
|
# If the user has been redirected to us whilst trying to access an
|
||||||
|
|
|
@ -6,8 +6,10 @@ from django.utils.encoding import force_str
|
||||||
from authentik.core.models import User
|
from authentik.core.models import User
|
||||||
from authentik.flows.challenge import ChallengeTypes
|
from authentik.flows.challenge import ChallengeTypes
|
||||||
from authentik.flows.models import Flow, FlowDesignation, FlowStageBinding
|
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.sources.oauth.models import OAuthSource
|
||||||
from authentik.stages.identification.models import IdentificationStage, UserFields
|
from authentik.stages.identification.models import IdentificationStage, UserFields
|
||||||
|
from authentik.stages.password.models import PasswordStage
|
||||||
|
|
||||||
|
|
||||||
class TestIdentificationStage(TestCase):
|
class TestIdentificationStage(TestCase):
|
||||||
|
@ -15,7 +17,10 @@ class TestIdentificationStage(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
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()
|
self.client = Client()
|
||||||
|
|
||||||
# OAuthSource for the login view
|
# 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):
|
def test_invalid_with_username(self):
|
||||||
"""Test invalid with username (user exists but stage only allows email)"""
|
"""Test invalid with username (user exists but stage only allows email)"""
|
||||||
form_data = {"uid_field": self.user.username}
|
form_data = {"uid_field": self.user.username}
|
||||||
|
@ -113,6 +185,7 @@ class TestIdentificationStage(TestCase):
|
||||||
"type": ChallengeTypes.NATIVE.value,
|
"type": ChallengeTypes.NATIVE.value,
|
||||||
"component": "ak-stage-identification",
|
"component": "ak-stage-identification",
|
||||||
"user_fields": ["email"],
|
"user_fields": ["email"],
|
||||||
|
"password_fields": False,
|
||||||
"enroll_url": reverse(
|
"enroll_url": reverse(
|
||||||
"authentik_core:if-flow",
|
"authentik_core:if-flow",
|
||||||
kwargs={"flow_slug": "unique-enrollment-string"},
|
kwargs={"flow_slug": "unique-enrollment-string"},
|
||||||
|
@ -160,6 +233,7 @@ class TestIdentificationStage(TestCase):
|
||||||
"type": ChallengeTypes.NATIVE.value,
|
"type": ChallengeTypes.NATIVE.value,
|
||||||
"component": "ak-stage-identification",
|
"component": "ak-stage-identification",
|
||||||
"user_fields": ["email"],
|
"user_fields": ["email"],
|
||||||
|
"password_fields": False,
|
||||||
"recovery_url": reverse(
|
"recovery_url": reverse(
|
||||||
"authentik_core:if-flow",
|
"authentik_core:if-flow",
|
||||||
kwargs={"flow_slug": "unique-recovery-string"},
|
kwargs={"flow_slug": "unique-recovery-string"},
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
"""password tests"""
|
"""password tests"""
|
||||||
import string
|
|
||||||
from random import SystemRandom
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied
|
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.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
|
||||||
from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
|
from authentik.flows.tests.test_views import TO_STAGE_RESPONSE_MOCK
|
||||||
from authentik.flows.views import SESSION_KEY_PLAN
|
from authentik.flows.views import SESSION_KEY_PLAN
|
||||||
|
from authentik.providers.oauth2.generators import generate_client_secret
|
||||||
from authentik.stages.password.models import PasswordStage
|
from authentik.stages.password.models import PasswordStage
|
||||||
|
|
||||||
MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test"))
|
MOCK_BACKEND_AUTHENTICATE = MagicMock(side_effect=PermissionDenied("test"))
|
||||||
|
@ -25,10 +24,7 @@ class TestPasswordStage(TestCase):
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.password = "".join(
|
self.password = generate_client_secret()
|
||||||
SystemRandom().choice(string.ascii_uppercase + string.digits)
|
|
||||||
for _ in range(8)
|
|
||||||
)
|
|
||||||
self.user = User.objects.create_user(
|
self.user = User.objects.create_user(
|
||||||
username="unittest", email="test@beryju.org", password=self.password
|
username="unittest", email="test@beryju.org", password=self.password
|
||||||
)
|
)
|
||||||
|
|
24
schema.yml
24
schema.yml
|
@ -17679,6 +17679,8 @@ components:
|
||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
password_fields:
|
||||||
|
type: boolean
|
||||||
application_pre:
|
application_pre:
|
||||||
type: string
|
type: string
|
||||||
enroll_url:
|
enroll_url:
|
||||||
|
@ -17692,6 +17694,7 @@ components:
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/UILoginButton'
|
$ref: '#/components/schemas/UILoginButton'
|
||||||
required:
|
required:
|
||||||
|
- password_fields
|
||||||
- primary_action
|
- primary_action
|
||||||
- type
|
- type
|
||||||
- user_fields
|
- user_fields
|
||||||
|
@ -17704,6 +17707,9 @@ components:
|
||||||
default: ak-stage-identification
|
default: ak-stage-identification
|
||||||
uid_field:
|
uid_field:
|
||||||
type: string
|
type: string
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
required:
|
required:
|
||||||
- uid_field
|
- uid_field
|
||||||
IdentificationStage:
|
IdentificationStage:
|
||||||
|
@ -17736,6 +17742,12 @@ components:
|
||||||
$ref: '#/components/schemas/UserFieldsEnum'
|
$ref: '#/components/schemas/UserFieldsEnum'
|
||||||
description: Fields of the user object to match against. (Hold shift to
|
description: Fields of the user object to match against. (Hold shift to
|
||||||
select multiple options)
|
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:
|
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.
|
||||||
|
@ -17784,6 +17796,12 @@ components:
|
||||||
$ref: '#/components/schemas/UserFieldsEnum'
|
$ref: '#/components/schemas/UserFieldsEnum'
|
||||||
description: Fields of the user object to match against. (Hold shift to
|
description: Fields of the user object to match against. (Hold shift to
|
||||||
select multiple options)
|
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:
|
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.
|
||||||
|
@ -22307,6 +22325,12 @@ components:
|
||||||
$ref: '#/components/schemas/UserFieldsEnum'
|
$ref: '#/components/schemas/UserFieldsEnum'
|
||||||
description: Fields of the user object to match against. (Hold shift to
|
description: Fields of the user object to match against. (Hold shift to
|
||||||
select multiple options)
|
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:
|
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.
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
import { ChallengeChoices } from "authentik-api";
|
|
||||||
|
|
||||||
export interface Error {
|
|
||||||
code: string;
|
|
||||||
string: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorDict {
|
|
||||||
[key: string]: Error[];
|
|
||||||
}
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { customElement, LitElement, CSSResult, property, css } from "lit-element";
|
import { customElement, LitElement, CSSResult, property, css } from "lit-element";
|
||||||
import { TemplateResult, html } from "lit-html";
|
import { TemplateResult, html } from "lit-html";
|
||||||
import { Error } from "../../api/Flows";
|
|
||||||
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
import PFForm from "@patternfly/patternfly/components/Form/form.css";
|
||||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||||
|
import { ErrorDetail } from "authentik-api";
|
||||||
|
|
||||||
@customElement("ak-form-element")
|
@customElement("ak-form-element")
|
||||||
export class FormElement extends LitElement {
|
export class FormElement extends LitElement {
|
||||||
|
@ -25,7 +25,7 @@ export class FormElement extends LitElement {
|
||||||
required = false;
|
required = false;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
errors?: Error[];
|
errors?: ErrorDetail[];
|
||||||
|
|
||||||
updated(): void {
|
updated(): void {
|
||||||
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach(input => {
|
this.querySelectorAll<HTMLInputElement>("input[autofocus]").forEach(input => {
|
||||||
|
|
|
@ -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 {
|
export interface StageHost {
|
||||||
challenge?: unknown;
|
challenge?: unknown;
|
||||||
|
@ -22,4 +23,23 @@ export class BaseStage<Tin, Tout> extends LitElement {
|
||||||
this.host?.submit(object as unknown as Tout);
|
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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ import PFFormControl from "@patternfly/patternfly/components/FormControl/form-co
|
||||||
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||||
|
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||||
import AKGlobal from "../../../authentik.css";
|
import AKGlobal from "../../../authentik.css";
|
||||||
import "../../../elements/forms/FormElement";
|
import "../../../elements/forms/FormElement";
|
||||||
import "../../../elements/EmptyState";
|
import "../../../elements/EmptyState";
|
||||||
|
@ -25,7 +26,7 @@ export const PasswordManagerPrefill: {
|
||||||
export class IdentificationStage extends BaseStage<IdentificationChallenge, IdentificationChallengeResponseRequest> {
|
export class IdentificationStage extends BaseStage<IdentificationChallenge, IdentificationChallengeResponseRequest> {
|
||||||
|
|
||||||
static get styles(): CSSResult[] {
|
static get styles(): CSSResult[] {
|
||||||
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat(
|
return [PFBase, PFAlert, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal].concat(
|
||||||
css`
|
css`
|
||||||
/* login page's icons */
|
/* login page's icons */
|
||||||
.pf-c-login__main-footer-links-item button {
|
.pf-c-login__main-footer-links-item button {
|
||||||
|
@ -160,7 +161,7 @@ export class IdentificationStage extends BaseStage<IdentificationChallenge, Iden
|
||||||
label=${label}
|
label=${label}
|
||||||
?required="${true}"
|
?required="${true}"
|
||||||
class="pf-c-form__group"
|
class="pf-c-form__group"
|
||||||
.errors=${(this.challenge?.responseErrors || {})["uid_field"]}>
|
.errors=${(this.challenge.responseErrors || {})["uid_field"]}>
|
||||||
<!-- @ts-ignore -->
|
<!-- @ts-ignore -->
|
||||||
<input type=${type}
|
<input type=${type}
|
||||||
name="uidField"
|
name="uidField"
|
||||||
|
@ -170,6 +171,25 @@ export class IdentificationStage extends BaseStage<IdentificationChallenge, Iden
|
||||||
class="pf-c-form-control"
|
class="pf-c-form-control"
|
||||||
required>
|
required>
|
||||||
</ak-form-element>
|
</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">
|
<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}
|
||||||
|
|
|
@ -13,7 +13,6 @@ import { BaseStage } from "../base";
|
||||||
import "../../../elements/forms/FormElement";
|
import "../../../elements/forms/FormElement";
|
||||||
import "../../../elements/EmptyState";
|
import "../../../elements/EmptyState";
|
||||||
import "../../../elements/Divider";
|
import "../../../elements/Divider";
|
||||||
import { Error } from "../../../api/Flows";
|
|
||||||
import { PromptChallenge, PromptChallengeResponseRequest, StagePrompt } from "authentik-api";
|
import { PromptChallenge, PromptChallengeResponseRequest, StagePrompt } from "authentik-api";
|
||||||
|
|
||||||
|
|
||||||
|
@ -103,24 +102,6 @@ export class PromptStage extends BaseStage<PromptChallenge, PromptChallengeRespo
|
||||||
return "";
|
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 {
|
render(): TemplateResult {
|
||||||
if (!this.challenge) {
|
if (!this.challenge) {
|
||||||
return html`<ak-empty-state
|
return html`<ak-empty-state
|
||||||
|
|
|
@ -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/identification/IdentificationStageForm.ts
|
#: src/pages/stages/identification/IdentificationStageForm.ts
|
||||||
|
#: src/pages/stages/identification/IdentificationStageForm.ts
|
||||||
#: src/pages/stages/password/PasswordStageForm.ts
|
#: src/pages/stages/password/PasswordStageForm.ts
|
||||||
#: src/pages/stages/prompt/PromptStageForm.ts
|
#: src/pages/stages/prompt/PromptStageForm.ts
|
||||||
#: src/pages/stages/prompt/PromptStageForm.ts
|
#: src/pages/stages/prompt/PromptStageForm.ts
|
||||||
|
@ -2549,6 +2550,8 @@ msgstr "Pass policy?"
|
||||||
msgid "Passing"
|
msgid "Passing"
|
||||||
msgstr "Passing"
|
msgstr "Passing"
|
||||||
|
|
||||||
|
#: src/flows/stages/identification/IdentificationStage.ts
|
||||||
|
#: src/flows/stages/identification/IdentificationStage.ts
|
||||||
#: src/flows/stages/password/PasswordStage.ts
|
#: src/flows/stages/password/PasswordStage.ts
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Password"
|
msgstr "Password"
|
||||||
|
@ -2558,6 +2561,10 @@ msgstr "Password"
|
||||||
msgid "Password field"
|
msgid "Password field"
|
||||||
msgstr "Password field"
|
msgstr "Password field"
|
||||||
|
|
||||||
|
#: src/pages/stages/identification/IdentificationStageForm.ts
|
||||||
|
msgid "Password stage"
|
||||||
|
msgstr "Password stage"
|
||||||
|
|
||||||
#: src/pages/stages/prompt/PromptForm.ts
|
#: 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."
|
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."
|
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."
|
msgid "When enabled, user fields are matched regardless of their casing."
|
||||||
msgstr "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
|
#: 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."
|
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."
|
msgstr "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default."
|
||||||
|
|
|
@ -2031,6 +2031,7 @@ msgstr ""
|
||||||
#:
|
#:
|
||||||
#:
|
#:
|
||||||
#:
|
#:
|
||||||
|
#:
|
||||||
msgid "Loading..."
|
msgid "Loading..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -2541,6 +2542,8 @@ msgstr ""
|
||||||
msgid "Passing"
|
msgid "Passing"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#:
|
||||||
|
#:
|
||||||
#:
|
#:
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
@ -2550,6 +2553,10 @@ msgstr ""
|
||||||
msgid "Password field"
|
msgid "Password field"
|
||||||
msgstr ""
|
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."
|
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 ""
|
msgstr ""
|
||||||
|
@ -4342,6 +4349,10 @@ msgstr ""
|
||||||
msgid "When enabled, user fields are matched regardless of their casing."
|
msgid "When enabled, user fields are matched regardless of their casing."
|
||||||
msgstr ""
|
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."
|
msgid "When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
@ -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`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>
|
<p class="pf-c-form__helper-text">${t`Hold control/command to select multiple items.`}</p>
|
||||||
</ak-form-element-horizontal>
|
</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">
|
<ak-form-element-horizontal name="caseInsensitiveMatching">
|
||||||
<div class="pf-c-check">
|
<div class="pf-c-check">
|
||||||
<input type="checkbox" class="pf-c-check__input" ?checked=${first(this.instance?.caseInsensitiveMatching, true)}>
|
<input type="checkbox" class="pf-c-check__input" ?checked=${first(this.instance?.caseInsensitiveMatching, true)}>
|
||||||
|
|
Reference in New Issue