From a2a35e49a91f4209ccba2808dd1ccb1a7977255e Mon Sep 17 00:00:00 2001 From: Jens L Date: Tue, 6 Apr 2021 20:25:22 +0200 Subject: [PATCH] improved out-of-box experience (#704) --- .../core/migrations/0003_default_user.py | 9 +- authentik/events/migrations/0014_expiry.py | 7 +- .../flows/migrations/0009_source_flows.py | 5 +- authentik/flows/migrations/0011_flow_title.py | 2 +- authentik/flows/migrations/0018_oob_flows.py | 139 ++++++++++++++++++ .../0002_passwordstage_change_flow.py | 4 +- authentik/stages/prompt/stage.py | 2 +- authentik/stages/user_write/stage.py | 4 + authentik/stages/user_write/tests.py | 33 +++++ helm/templates/NOTES.txt | 10 +- web/src/elements/messages/MessageContainer.ts | 3 +- web/src/flows/FlowExecutor.ts | 22 ++- web/src/flows/stages/prompt/PromptStage.ts | 37 ++++- web/src/locales/en.po | 48 +++--- web/src/locales/pseudo-LOCALE.po | 44 +++--- web/src/pages/flows/StageBindingForm.ts | 22 +-- website/docs/installation/docker-compose.md | 2 +- website/docs/installation/kubernetes.md | 2 +- 18 files changed, 321 insertions(+), 74 deletions(-) create mode 100644 authentik/flows/migrations/0018_oob_flows.py diff --git a/authentik/core/migrations/0003_default_user.py b/authentik/core/migrations/0003_default_user.py index ffa3eee82..a955ac60c 100644 --- a/authentik/core/migrations/0003_default_user.py +++ b/authentik/core/migrations/0003_default_user.py @@ -1,6 +1,8 @@ # Generated by Django 3.0.6 on 2020-05-23 16:40 +from os import environ from django.apps.registry import Apps +from django.conf import settings from django.db import migrations, models from django.db.backends.base.schema import BaseDatabaseSchemaEditor @@ -14,7 +16,12 @@ def create_default_user(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): akadmin, _ = User.objects.using(db_alias).get_or_create( username="akadmin", email="root@localhost", name="authentik Default Admin" ) - akadmin.set_password("akadmin", signal=False) # noqa # nosec + if "TF_BUILD" in environ or "AK_ADMIN_PASS" in environ or settings.TEST: + akadmin.set_password( + environ.get("AK_ADMIN_PASS", "akadmin"), signal=False + ) # noqa # nosec + else: + akadmin.set_unusable_password() akadmin.save() diff --git a/authentik/events/migrations/0014_expiry.py b/authentik/events/migrations/0014_expiry.py index 032aeb404..ef3939165 100644 --- a/authentik/events/migrations/0014_expiry.py +++ b/authentik/events/migrations/0014_expiry.py @@ -57,11 +57,12 @@ def progress_bar( def update_expires(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): db_alias = schema_editor.connection.alias - - print("\nAdding expiry to events, this might take a couple of minutes...") - Event = apps.get_model("authentik_events", "event") all_events = Event.objects.using(db_alias).all() + if all_events.count() < 1: + return + + print("\nAdding expiry to events, this might take a couple of minutes...") for event in progress_bar(all_events): event.expires = event.created + timedelta(days=365) event.save() diff --git a/authentik/flows/migrations/0009_source_flows.py b/authentik/flows/migrations/0009_source_flows.py index d441ceeb9..d262250f4 100644 --- a/authentik/flows/migrations/0009_source_flows.py +++ b/authentik/flows/migrations/0009_source_flows.py @@ -45,7 +45,7 @@ def create_default_source_enrollment_flow( slug="default-source-enrollment", designation=FlowDesignation.ENROLLMENT, defaults={ - "name": "Welcome to authentik!", + "name": "Welcome to authentik! Please select a username.", }, ) PolicyBinding.objects.using(db_alias).update_or_create( @@ -54,7 +54,7 @@ def create_default_source_enrollment_flow( # PromptStage to ask user for their username prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( - name="Welcome to authentik! Please select a username.", + name="default-source-enrollment-prompt", ) prompt, _ = Prompt.objects.using(db_alias).update_or_create( field_key="username", @@ -63,6 +63,7 @@ def create_default_source_enrollment_flow( "type": FieldTypes.TEXT, "required": True, "placeholder": "Username", + "order": 100, }, ) prompt_stage.fields.add(prompt) diff --git a/authentik/flows/migrations/0011_flow_title.py b/authentik/flows/migrations/0011_flow_title.py index 06f89d86d..2baea0992 100644 --- a/authentik/flows/migrations/0011_flow_title.py +++ b/authentik/flows/migrations/0011_flow_title.py @@ -8,7 +8,7 @@ def add_title_for_defaults(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): slug_title_map = { "default-authentication-flow": "Welcome to authentik!", "default-invalidation-flow": "Default Invalidation Flow", - "default-source-enrollment": "Welcome to authentik!", + "default-source-enrollment": "Welcome to authentik! Please select a username.", "default-source-authentication": "Welcome to authentik!", "default-provider-authorization-implicit-consent": "Default Provider Authorization Flow (implicit consent)", "default-provider-authorization-explicit-consent": "Default Provider Authorization Flow (explicit consent)", diff --git a/authentik/flows/migrations/0018_oob_flows.py b/authentik/flows/migrations/0018_oob_flows.py new file mode 100644 index 000000000..e39872f10 --- /dev/null +++ b/authentik/flows/migrations/0018_oob_flows.py @@ -0,0 +1,139 @@ +# Generated by Django 3.1.7 on 2021-04-06 13:25 + +from django.apps.registry import Apps +from django.contrib.auth.hashers import is_password_usable +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + +from authentik.flows.models import FlowDesignation + +PW_USABLE_POLICY_EXPRESSION = """# This policy ensures that the setup flow can only be +# executed when the admin user doesn't have a password set +akadmin = ak_user_by(username="akadmin") +return not akadmin.has_usable_password()""" +PREFILL_POLICY_EXPRESSION = """# This policy sets the user for the currently running flow +# by injecting "pending_user" +akadmin = ak_user_by(username="akadmin") +context["pending_user"] = akadmin +# We're also setting the backend for the user, so we can +# directly login without having to identify again +context["user_backend"] = "django.contrib.auth.backends.ModelBackend" +return True""" + + +def create_default_oob_flow(apps: Apps, schema_editor: BaseDatabaseSchemaEditor): + from authentik.stages.prompt.models import FieldTypes + + User = apps.get_model("authentik_core", "User") + + PolicyBinding = apps.get_model("authentik_policies", "PolicyBinding") + Flow = apps.get_model("authentik_flows", "Flow") + FlowStageBinding = apps.get_model("authentik_flows", "FlowStageBinding") + + UserLoginStage = apps.get_model("authentik_stages_user_login", "UserLoginStage") + UserWriteStage = apps.get_model("authentik_stages_user_write", "UserWriteStage") + PromptStage = apps.get_model("authentik_stages_prompt", "PromptStage") + Prompt = apps.get_model("authentik_stages_prompt", "Prompt") + + ExpressionPolicy = apps.get_model( + "authentik_policies_expression", "ExpressionPolicy" + ) + + db_alias = schema_editor.connection.alias + + # Only create the flow if the akadmin user exists, + # and has an un-usable password + akadmins = User.objects.filter(username="akadmin") + if not akadmins.exists(): + return + akadmin = akadmins.first() + if is_password_usable(akadmin.password): + return + + # Create a policy that sets the flow's user + prefill_policy, _ = ExpressionPolicy.objects.using(db_alias).update_or_create( + name="default-oob-prefill-user", + defaults={"expression": PREFILL_POLICY_EXPRESSION}, + ) + password_usable_policy, _ = ExpressionPolicy.objects.using( + db_alias + ).update_or_create( + name="default-oob-password-usable", + defaults={"expression": PW_USABLE_POLICY_EXPRESSION}, + ) + + prompt_header, _ = Prompt.objects.using(db_alias).update_or_create( + field_key="oob-header-text", + defaults={ + "label": "oob-header-text", + "type": FieldTypes.STATIC, + "placeholder": "Welcome to authentik! Please set a password for the default admin user, akadmin.", + "order": 100, + }, + ) + password_first = Prompt.objects.using(db_alias).get(field_key="password") + password_second = Prompt.objects.using(db_alias).get(field_key="password_repeat") + + prompt_stage, _ = PromptStage.objects.using(db_alias).update_or_create( + name="default-oob-password", + ) + prompt_stage.fields.set([prompt_header, password_first, password_second]) + prompt_stage.save() + + user_write, _ = UserWriteStage.objects.using(db_alias).update_or_create( + name="default-password-change-write" + ) + login_stage, _ = UserLoginStage.objects.using(db_alias).update_or_create( + name="default-authentication-login" + ) + + flow, _ = Flow.objects.using(db_alias).update_or_create( + slug="initial-setup", + designation=FlowDesignation.STAGE_CONFIGURATION, + defaults={ + "name": "default-oob-setup", + "title": "Welcome to authentik!", + }, + ) + PolicyBinding.objects.using(db_alias).update_or_create( + policy=password_usable_policy, target=flow, defaults={"order": 0} + ) + + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=prompt_stage, + defaults={ + "order": 10, + }, + ) + user_write_binding, _ = FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=user_write, + defaults={"order": 20, "evaluate_on_plan": False, "re_evaluate_policies": True}, + ) + PolicyBinding.objects.using(db_alias).update_or_create( + policy=prefill_policy, target=user_write_binding, defaults={"order": 0} + ) + FlowStageBinding.objects.using(db_alias).update_or_create( + target=flow, + stage=login_stage, + defaults={ + "order": 100, + }, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_flows", "0017_auto_20210329_1334"), + ("authentik_stages_user_write", "__latest__"), + ("authentik_stages_user_login", "__latest__"), + ("authentik_stages_password", "0002_passwordstage_change_flow"), + ("authentik_policies", "0001_initial"), + ("authentik_policies_expression", "0001_initial"), + ] + + operations = [ + migrations.RunPython(create_default_oob_flow), + ] diff --git a/authentik/stages/password/migrations/0002_passwordstage_change_flow.py b/authentik/stages/password/migrations/0002_passwordstage_change_flow.py index 025aa1d3b..63d288506 100644 --- a/authentik/stages/password/migrations/0002_passwordstage_change_flow.py +++ b/authentik/stages/password/migrations/0002_passwordstage_change_flow.py @@ -36,7 +36,7 @@ def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchema "type": FieldTypes.PASSWORD, "required": True, "placeholder": "Password", - "order": 0, + "order": 300, }, ) password_rep_prompt, _ = Prompt.objects.using(db_alias).update_or_create( @@ -46,7 +46,7 @@ def create_default_password_change(apps: Apps, schema_editor: BaseDatabaseSchema "type": FieldTypes.PASSWORD, "required": True, "placeholder": "Password (repeat)", - "order": 1, + "order": 301, }, ) diff --git a/authentik/stages/prompt/stage.py b/authentik/stages/prompt/stage.py index 15cc9e772..8b76e614e 100644 --- a/authentik/stages/prompt/stage.py +++ b/authentik/stages/prompt/stage.py @@ -30,7 +30,7 @@ class PromptSerializer(PassiveSerializer): """Serializer for a single Prompt field""" field_key = CharField() - label = CharField() + label = CharField(allow_blank=True) type = CharField() required = BooleanField() placeholder = CharField() diff --git a/authentik/stages/user_write/stage.py b/authentik/stages/user_write/stage.py index 33151d674..39bdb4fb2 100644 --- a/authentik/stages/user_write/stage.py +++ b/authentik/stages/user_write/stage.py @@ -69,6 +69,10 @@ class UserWriteStageView(StageView): LOGGER.debug("discarding key", key=key) continue user.attributes[key.replace("attribute_", "", 1)] = value + # Extra check to prevent flows from saving a user with a blank username + if user.username == "": + LOGGER.warning("Aborting write to empty username", user=user) + return self.executor.stage_invalid() user.save() user_write.send( sender=self, request=request, user=user, data=data, created=user_created diff --git a/authentik/stages/user_write/tests.py b/authentik/stages/user_write/tests.py index 00cce0ac8..d9f3de1f4 100644 --- a/authentik/stages/user_write/tests.py +++ b/authentik/stages/user_write/tests.py @@ -134,3 +134,36 @@ class TestUserWriteStage(TestCase): "type": ChallengeTypes.NATIVE.value, }, ) + + @patch( + "authentik.flows.views.to_stage_response", + TO_STAGE_RESPONSE_MOCK, + ) + def test_with_blank_username(self): + """Test with blank username results in error""" + plan = FlowPlan( + flow_pk=self.flow.pk.hex, stages=[self.stage], markers=[StageMarker()] + ) + session = self.client.session + plan.context[PLAN_CONTEXT_PROMPT] = { + "username": "", + "attribute_some-custom-attribute": "test", + "some_ignored_attribute": "bar", + } + session[SESSION_KEY_PLAN] = plan + session.save() + + response = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}) + ) + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual( + force_str(response.content), + { + "component": "ak-stage-access-denied", + "error_message": None, + "title": "", + "type": ChallengeTypes.NATIVE.value, + }, + ) diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt index 5f47f8c69..60cb63432 100644 --- a/helm/templates/NOTES.txt +++ b/helm/templates/NOTES.txt @@ -1,5 +1,11 @@ -1. Access authentik using the following URL: +Access authentik using the following URL: +{{- if .Release.IsUpgrade -}} {{- range .Values.ingress.hosts }} http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }} {{- end }} -2. Login to authentik using the user "akadmin" and the password "akadmin". +{{- else -}} +{{- range .Values.ingress.hosts }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ . }}{{ $.Values.ingress.path }}/if/flow/initial-setup/ +{{- end }} +To configure your authentik instance, and set a password for the akadmin user. +{{- end }} diff --git a/web/src/elements/messages/MessageContainer.ts b/web/src/elements/messages/MessageContainer.ts index 5e9845b9c..d0e1121d4 100644 --- a/web/src/elements/messages/MessageContainer.ts +++ b/web/src/elements/messages/MessageContainer.ts @@ -78,8 +78,7 @@ export class MessageContainer extends LitElement { this.addMessage(data); this.requestUpdate(); }); - this.messageSocket.addEventListener("error", (e) => { - console.warn(`authentik/messages: error ${e}`); + this.messageSocket.addEventListener("error", () => { this.retryDelay = this.retryDelay * 2; }); } diff --git a/web/src/flows/FlowExecutor.ts b/web/src/flows/FlowExecutor.ts index dbad89642..ddb622375 100644 --- a/web/src/flows/FlowExecutor.ts +++ b/web/src/flows/FlowExecutor.ts @@ -6,6 +6,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFTitle from "@patternfly/patternfly/components/Title/title.css"; import PFBackgroundImage from "@patternfly/patternfly/components/BackgroundImage/background-image.css"; import PFList from "@patternfly/patternfly/components/List/list.css"; +import PFButton from "@patternfly/patternfly/components/Button/button.css"; import AKGlobal from "../authentik.css"; import { unsafeHTML } from "lit-html/directives/unsafe-html"; @@ -58,7 +59,7 @@ export class FlowExecutor extends LitElement implements StageHost { config?: Config; static get styles(): CSSResult[] { - return [PFBase, PFLogin, PFTitle, PFList, PFBackgroundImage, AKGlobal].concat(css` + return [PFBase, PFLogin, PFButton, PFTitle, PFList, PFBackgroundImage, AKGlobal].concat(css` .ak-loading { display: flex; height: 100%; @@ -115,8 +116,8 @@ export class FlowExecutor extends LitElement implements StageHost { }).then((data) => { this.challenge = data; this.postUpdate(); - }).catch((e) => { - this.errorMessage(e); + }).catch((e: Response) => { + this.errorMessage(e.statusText); }).finally(() => { this.loading = false; }); @@ -139,9 +140,9 @@ export class FlowExecutor extends LitElement implements StageHost { this.setBackground(this.challenge.background); } this.postUpdate(); - }).catch((e) => { + }).catch((e: Response) => { // Catch JSON or Update errors - this.errorMessage(e); + this.errorMessage(e.statusText); }).finally(() => { this.loading = false; }); @@ -158,7 +159,16 @@ export class FlowExecutor extends LitElement implements StageHost {

${t`Something went wrong! Please try again later.`}

${error}
-
` + + ` }; } diff --git a/web/src/flows/stages/prompt/PromptStage.ts b/web/src/flows/stages/prompt/PromptStage.ts index 6c7b699dd..07853f6e5 100644 --- a/web/src/flows/stages/prompt/PromptStage.ts +++ b/web/src/flows/stages/prompt/PromptStage.ts @@ -7,11 +7,13 @@ 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 { BaseStage } from "../base"; import "../../../elements/forms/FormElement"; import "../../../elements/EmptyState"; -import { Challenge } from "../../../api/Flows"; +import "../../../elements/Divider"; +import { Challenge, Error } from "../../../api/Flows"; export interface Prompt { field_key: string; @@ -33,7 +35,7 @@ export class PromptStage extends BaseStage { challenge?: PromptChallenge; static get styles(): CSSResult[] { - return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal]; + return [PFBase, PFLogin, PFAlert, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal]; } renderPromptInner(prompt: Prompt): string { @@ -101,7 +103,7 @@ export class PromptStage extends BaseStage { class="pf-c-form-control" ?required=${prompt.required}>`; case "separator": - return "
"; + return `${prompt.placeholder}`; case "hidden": return ``; case "static": - return `

${prompt.placeholder} -

`; + return `

${prompt.placeholder}

`; } return ""; } + renderNonFieldErrors(errors: Error[]): TemplateResult { + if (!errors) { + return html``; + } + return html`
+ ${errors.map(err => { + return html`
+
+ +
+

+ ${err.string} +

+
`; + })} +
`; + } + render(): TemplateResult { if (!this.challenge) { return html`
{this.submitForm(e);}}> ${this.challenge.fields.map((prompt) => { + // Special types that aren't rendered in a wrapper + if (prompt.type === "static" || prompt.type === "hidden" || prompt.type === "separator") { + return unsafeHTML(this.renderPromptInner(prompt)); + } return html``; })} + ${"non_field_errors" in (this.challenge?.response_errors || {}) ? + this.renderNonFieldErrors(this.challenge?.response_errors?.non_field_errors || []): + html``}