Compare commits

...

10 Commits

Author SHA1 Message Date
318e0cf9f8 release: 2024.2.0-rc2 2024-02-19 14:10:53 +01:00
bd0815d894 root: fix app settings load order (cherry-pick #8569) (#8571)
Co-authored-by: Jens L <jens@goauthentik.io>
fix app settings load order (#8569)
2024-02-19 11:18:39 +00:00
af35ecfe66 ci: main: use correct previous version (cherry-pick #8539) (#8572)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-02-19 11:03:01 +00:00
0c05cd64bb web/flows: improve authenticator styling (cherry-pick #8560) (#8570)
Co-authored-by: Jens L <jens@goauthentik.io>
2024-02-19 10:37:19 +00:00
cb80b76490 web: change "delete" verb to "remove" for one-to-many relationships (cherry-pick #8535) (#8537)
web: change "delete" verb to "remove" for one-to-many relationships (#8535)

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
2024-02-16 14:32:41 +01:00
061d4bc758 ci: fix release sentry step (cherry-pick #8540) (#8541)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
fix release sentry step (#8540)
2024-02-15 21:11:15 +00:00
8ff27f69e1 release: 2024.2.0-rc1 2024-02-15 19:19:40 +01:00
045cd98276 web: fix save & reset behavior on System ➲ Settings page. (cherry-pick #8528) (#8534)
web: fix save & reset behavior on System ➲ Settings page. (#8528)

Co-authored-by: Ken Sternberg <133134217+kensternberg-authentik@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-02-15 19:01:47 +01:00
b520843984 ci: fix release pipeline (cherry-pick #8530) (#8533)
Co-authored-by: Jens L <jens@goauthentik.io>
fix release pipeline (#8530)
2024-02-15 17:33:55 +00:00
92216e4ea8 ci: docker push: re-add timestamp image tag (cherry-pick #8529) (#8532)
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2024-02-15 17:12:30 +00:00
26 changed files with 382 additions and 360 deletions

View File

@ -1,16 +1,16 @@
[bumpversion] [bumpversion]
current_version = 2023.10.7 current_version = 2024.2.0-rc2
tag = True tag = True
commit = True commit = True
parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))? parse = (?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)(?:-(?P<rc_t>[a-zA-Z-]+)(?P<rc_n>[1-9]\\d*))?
serialize = serialize =
{major}.{minor}.{patch}-{rc_t}{rc_n} {major}.{minor}.{patch}-{rc_t}{rc_n}
{major}.{minor}.{patch} {major}.{minor}.{patch}
message = release: {new_version} message = release: {new_version}
tag_name = version/{new_version} tag_name = version/{new_version}
[bumpversion:part:rc_t] [bumpversion:part:rc_t]
values = values =
rc rc
final final
optional_value = final optional_value = final

View File

@ -68,18 +68,21 @@ runs:
for name in image_names: for name in image_names:
image_tags += [ image_tags += [
f"{name}:{version}", f"{name}:{version}",
f"{name}:{version_family}",
] ]
if not prerelease: if not prerelease:
image_tags += [f"{name}:latest"] image_tags += [
f"{name}:latest",
f"{name}:{version_family}",
]
else: else:
suffix = "" suffix = ""
if image_arch and image_arch != "amd64": if image_arch and image_arch != "amd64":
suffix = f"-{image_arch}" suffix = f"-{image_arch}"
for name in image_names: for name in image_names:
image_tags += [ image_tags += [
f"{name}:gh-{sha}{suffix}", f"{name}:gh-{sha}{suffix}", # Used for ArgoCD and PR comments
f"{name}:gh-{safe_branch_name}{suffix}", f"{name}:gh-{safe_branch_name}{suffix}", # For convenience
f"{name}:gh-{safe_branch_name}-{int(time())}-{sha[:7]}{suffix}", # Use by FluxCD
] ]
image_main_tag = image_tags[0] image_main_tag = image_tags[0]

View File

@ -70,7 +70,7 @@ jobs:
cp authentik/lib/default.yml local.env.yml cp authentik/lib/default.yml local.env.yml
cp -R .github .. cp -R .github ..
cp -R scripts .. cp -R scripts ..
git checkout version/$(python -c "from authentik import __version__; print(__version__)") git checkout $(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
rm -rf .github/ scripts/ rm -rf .github/ scripts/
mv ../.github ../scripts . mv ../.github ../scripts .
- name: Setup authentik env (stable) - name: Setup authentik env (stable)

View File

@ -172,8 +172,8 @@ jobs:
image-name: ghcr.io/goauthentik/server image-name: ghcr.io/goauthentik/server
- name: Get static files from docker image - name: Get static files from docker image
run: | run: |
docker pull ghcr.io/goauthentik/server:${{ steps.ev.outputs.imageMainTag }} docker pull ${{ steps.ev.outputs.imageMainTag }}
container=$(docker container create ghcr.io/goauthentik/server:${{ steps.ev.outputs.imageMainTag }}) container=$(docker container create ${{ steps.ev.outputs.imageMainTag }})
docker cp ${container}:web/ . docker cp ${container}:web/ .
- name: Create a Sentry.io release - name: Create a Sentry.io release
uses: getsentry/action-release@v1 uses: getsentry/action-release@v1

View File

@ -3,7 +3,7 @@
from os import environ from os import environ
from typing import Optional from typing import Optional
__version__ = "2023.10.7" __version__ = "2024.2.0"
ENV_GIT_HASH_KEY = "GIT_BUILD_HASH" ENV_GIT_HASH_KEY = "GIT_BUILD_HASH"

View File

@ -481,13 +481,6 @@ def _update_settings(app_path: str):
pass pass
# Load subapps's settings
for _app in set(SHARED_APPS + TENANT_APPS):
if not _app.startswith("authentik"):
continue
_update_settings(f"{_app}.settings")
_update_settings("data.user_settings")
if DEBUG: if DEBUG:
CELERY["task_always_eager"] = True CELERY["task_always_eager"] = True
os.environ[ENV_GIT_HASH_KEY] = "dev" os.environ[ENV_GIT_HASH_KEY] = "dev"
@ -512,5 +505,13 @@ except ImportError:
# being imported for @prefill_task # being imported for @prefill_task
TENANT_APPS.append("authentik.events") TENANT_APPS.append("authentik.events")
# Load subapps's settings
for _app in set(SHARED_APPS + TENANT_APPS):
if not _app.startswith("authentik"):
continue
_update_settings(f"{_app}.settings")
_update_settings("data.user_settings")
SHARED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS)) SHARED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))
INSTALLED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS)) INSTALLED_APPS = list(OrderedDict.fromkeys(SHARED_APPS + TENANT_APPS))

View File

@ -32,7 +32,7 @@ services:
volumes: volumes:
- redis:/data - redis:/data
server: server:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.7} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.0}
restart: unless-stopped restart: unless-stopped
command: server command: server
environment: environment:
@ -53,7 +53,7 @@ services:
- postgresql - postgresql
- redis - redis
worker: worker:
image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2023.10.7} image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2024.2.0}
restart: unless-stopped restart: unless-stopped
command: worker command: worker
environment: environment:

View File

@ -29,4 +29,4 @@ func UserAgent() string {
return fmt.Sprintf("authentik@%s", FullVersion()) return fmt.Sprintf("authentik@%s", FullVersion())
} }
const VERSION = "2023.10.7" const VERSION = "2024.2.0"

View File

@ -86,6 +86,7 @@ elif [[ "$1" == "bash" ]]; then
/bin/bash /bin/bash
elif [[ "$1" == "test-all" ]]; then elif [[ "$1" == "test-all" ]]; then
prepare_debug prepare_debug
chmod 777 /root
check_if_root "python -m manage test authentik" check_if_root "python -m manage test authentik"
elif [[ "$1" == "healthcheck" ]]; then elif [[ "$1" == "healthcheck" ]]; then
run_authentik healthcheck $(cat $MODE_FILE) run_authentik healthcheck $(cat $MODE_FILE)

View File

@ -113,7 +113,7 @@ filterwarnings = [
[tool.poetry] [tool.poetry]
name = "authentik" name = "authentik"
version = "2023.10.7" version = "2024.2.0"
description = "" description = ""
authors = ["authentik Team <hello@goauthentik.io>"] authors = ["authentik Team <hello@goauthentik.io>"]

View File

@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: authentik title: authentik
version: 2023.10.7 version: 2024.2.0
description: Making authentication simple. description: Making authentication simple.
contact: contact:
email: hello@goauthentik.io email: hello@goauthentik.io

View File

@ -6,3 +6,4 @@ dist
coverage coverage
src/locale-codes.ts src/locale-codes.ts
storybook-static/ storybook-static/
src/locales/**

View File

@ -22,25 +22,36 @@ import { AdminApi, Settings, SettingsRequest } from "@goauthentik/api";
@customElement("ak-admin-settings-form") @customElement("ak-admin-settings-form")
export class AdminSettingsForm extends Form<SettingsRequest> { export class AdminSettingsForm extends Form<SettingsRequest> {
@property({ attribute: false }) //
set settings(value: Settings) { // Custom property accessors in Lit 2 require a manual call to requestUpdate(). See:
// https://lit.dev/docs/v2/components/properties/#accessors-custom
//
set settings(value: Settings | undefined) {
this._settings = value; this._settings = value;
this.requestUpdate();
}
@property({ type: Object })
get settings() {
return this._settings;
} }
private _settings?: Settings; private _settings?: Settings;
static get styles(): CSSResult[] {
return super.styles.concat(PFList);
}
getSuccessMessage(): string { getSuccessMessage(): string {
return msg("Successfully updated settings."); return msg("Successfully updated settings.");
} }
async send(data: SettingsRequest): Promise<Settings> { async send(data: SettingsRequest): Promise<Settings> {
return new AdminApi(DEFAULT_CONFIG).adminSettingsUpdate({ const result = await new AdminApi(DEFAULT_CONFIG).adminSettingsUpdate({
settingsRequest: data, settingsRequest: data,
}); });
} this.dispatchEvent(new CustomEvent("ak-admin-setting-changed"));
return result;
static get styles(): CSSResult[] {
return super.styles.concat(PFList);
} }
renderForm(): TemplateResult { renderForm(): TemplateResult {

View File

@ -14,8 +14,8 @@ import "@goauthentik/elements/buttons/SpinnerButton";
import "@goauthentik/elements/forms/ModalForm"; import "@goauthentik/elements/forms/ModalForm";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
import PFBanner from "@patternfly/patternfly/components/Banner/banner.css"; import PFBanner from "@patternfly/patternfly/components/Banner/banner.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css"; import PFButton from "@patternfly/patternfly/components/Button/button.css";
@ -32,7 +32,7 @@ import { AdminApi, Settings } from "@goauthentik/api";
@customElement("ak-admin-settings") @customElement("ak-admin-settings")
export class AdminSettingsPage extends AKElement { export class AdminSettingsPage extends AKElement {
static get styles(): CSSResult[] { static get styles() {
return [ return [
PFBase, PFBase,
PFButton, PFButton,
@ -46,41 +46,46 @@ export class AdminSettingsPage extends AKElement {
PFBanner, PFBanner,
]; ];
} }
@property({ attribute: false })
@query("ak-admin-settings-form#form")
form?: AdminSettingsForm;
@state()
settings?: Settings; settings?: Settings;
loadSettings(): void { constructor() {
new AdminApi(DEFAULT_CONFIG).adminSettingsRetrieve().then((settings) => { super();
AdminSettingsPage.fetchSettings().then((settings) => {
this.settings = settings; this.settings = settings;
}); });
this.save = this.save.bind(this);
this.reset = this.reset.bind(this);
this.addEventListener("ak-admin-setting-changed", this.handleUpdate.bind(this));
} }
firstUpdated(): void { static async fetchSettings() {
this.loadSettings(); return await new AdminApi(DEFAULT_CONFIG).adminSettingsRetrieve();
} }
async save(): Promise<void> { async handleUpdate() {
const form = this.shadowRoot?.querySelector<AdminSettingsForm>("ak-admin-settings-form"); this.settings = await AdminSettingsPage.fetchSettings();
if (!form) { }
async save() {
if (!this.form) {
return; return;
} }
await form.submit(new Event("submit")); await this.form.submit(new Event("submit"));
this.resetForm(); this.settings = await AdminSettingsPage.fetchSettings();
} }
resetForm(): void { async reset() {
const form = this.shadowRoot?.querySelector<AdminSettingsForm>("ak-admin-settings-form"); this.form?.resetForm();
if (!form) {
return;
}
this.loadSettings();
form.settings = this.settings!;
form.resetForm();
} }
render(): TemplateResult { render() {
if (!this.settings) { if (!this.settings) {
return html``; return nothing;
} }
return html` return html`
<ak-page-header icon="fa fa-cog" header="" description=""> <ak-page-header icon="fa fa-cog" header="" description="">
@ -93,18 +98,10 @@ export class AdminSettingsPage extends AKElement {
</ak-admin-settings-form> </ak-admin-settings-form>
</div> </div>
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
<ak-spinner-button <ak-spinner-button .callAction=${this.save} class="pf-m-primary"
.callAction=${async () => {
await this.save();
}}
class="pf-m-primary"
>${msg("Save")}</ak-spinner-button >${msg("Save")}</ak-spinner-button
> >
<ak-spinner-button <ak-spinner-button .callAction=${this.reset} class="pf-m-secondary"
.callAction=${() => {
this.resetForm();
}}
class="pf-m-secondary"
>${msg("Cancel")}</ak-spinner-button >${msg("Cancel")}</ak-spinner-button
> >
</div> </div>

View File

@ -125,6 +125,7 @@ export class RelatedGroupList extends Table<Group> {
actionSubtext=${msg( actionSubtext=${msg(
str`Are you sure you want to remove user ${this.targetUser?.username} from the following groups?`, str`Are you sure you want to remove user ${this.targetUser?.username} from the following groups?`,
)} )}
buttonLabel=${msg("Remove")}
.objects=${this.selectedElements} .objects=${this.selectedElements}
.delete=${(item: Group) => { .delete=${(item: Group) => {
if (!this.targetUser) return; if (!this.targetUser) return;

View File

@ -3,7 +3,7 @@ export const SUCCESS_CLASS = "pf-m-success";
export const ERROR_CLASS = "pf-m-danger"; export const ERROR_CLASS = "pf-m-danger";
export const PROGRESS_CLASS = "pf-m-in-progress"; export const PROGRESS_CLASS = "pf-m-in-progress";
export const CURRENT_CLASS = "pf-m-current"; export const CURRENT_CLASS = "pf-m-current";
export const VERSION = "2023.10.7"; export const VERSION = "2024.2.0";
export const TITLE_DEFAULT = "authentik"; export const TITLE_DEFAULT = "authentik";
export const ROUTE_SEPARATOR = ";"; export const ROUTE_SEPARATOR = ";";

View File

@ -1,7 +1,7 @@
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { PFSize } from "@goauthentik/elements/Spinner"; import { PFSize } from "@goauthentik/elements/Spinner";
import { CSSResult, TemplateResult, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css"; import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
@ -23,7 +23,17 @@ export class EmptyState extends AKElement {
header = ""; header = "";
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFEmptyState, PFTitle]; return [
PFBase,
PFEmptyState,
PFTitle,
css`
i.pf-c-empty-state__icon {
height: var(--pf-global--icon--FontSize--2xl);
line-height: var(--pf-global--icon--FontSize--2xl);
}
`,
];
} }
render(): TemplateResult { render(): TemplateResult {

View File

@ -131,6 +131,9 @@ export class DeleteBulkForm<T> extends ModalButton {
@property() @property()
actionSubtext?: string; actionSubtext?: string;
@property()
buttonLabel = msg("Delete");
@property({ attribute: false }) @property({ attribute: false })
metadata: (item: T) => BulkDeleteMetadata = (item: T) => { metadata: (item: T) => BulkDeleteMetadata = (item: T) => {
const rec = item as Record<string, unknown>; const rec = item as Record<string, unknown>;
@ -222,7 +225,7 @@ export class DeleteBulkForm<T> extends ModalButton {
}} }}
class="pf-m-danger" class="pf-m-danger"
> >
${msg("Delete")} </ak-spinner-button ${this.buttonLabel} </ak-spinner-button
>&nbsp; >&nbsp;
<ak-spinner-button <ak-spinner-button
.callAction=${async () => { .callAction=${async () => {

View File

@ -15,7 +15,7 @@ import "@goauthentik/flow/sources/apple/AppleLoginInit";
import "@goauthentik/flow/sources/plex/PlexLoginInit"; import "@goauthentik/flow/sources/plex/PlexLoginInit";
import "@goauthentik/flow/stages/FlowErrorStage"; import "@goauthentik/flow/stages/FlowErrorStage";
import "@goauthentik/flow/stages/RedirectStage"; import "@goauthentik/flow/stages/RedirectStage";
import { StageHost } from "@goauthentik/flow/stages/base"; import { StageHost, SubmitOptions } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit"; import { CSSResult, TemplateResult, css, html, nothing } from "lit";
@ -189,12 +189,17 @@ export class FlowExecutor extends Interface implements StageHost {
return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic; return globalAK()?.brand.uiTheme || UiThemeEnum.Automatic;
} }
async submit(payload?: FlowChallengeResponseRequest): Promise<boolean> { async submit(
payload?: FlowChallengeResponseRequest,
options?: SubmitOptions,
): Promise<boolean> {
if (!payload) return Promise.reject(); if (!payload) return Promise.reject();
if (!this.challenge) return Promise.reject(); if (!this.challenge) return Promise.reject();
// @ts-ignore // @ts-expect-error
payload.component = this.challenge.component; payload.component = this.challenge.component;
this.loading = true; if (!options?.invisible) {
this.loading = true;
}
try { try {
const challenge = await new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({ const challenge = await new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({
flowSlug: this.flowSlug, flowSlug: this.flowSlug,

View File

@ -40,6 +40,7 @@ export class AuthenticatorStaticStage extends BaseStage<
columns: 2; columns: 2;
-webkit-columns: 2; -webkit-columns: 2;
-moz-columns: 2; -moz-columns: 2;
column-width: 1em;
margin-left: var(--pf-global--spacer--xs); margin-left: var(--pf-global--spacer--xs);
} }
ul li { ul li {

View File

@ -2,13 +2,12 @@ import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageCode"; import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageCode";
import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageDuo"; import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageDuo";
import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn"; import "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStageWebAuthn";
import { BaseStage, StageHost } from "@goauthentik/flow/stages/base"; import { BaseStage, StageHost, SubmitOptions } from "@goauthentik/flow/stages/base";
import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage"; import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, state } from "lit/decorators.js"; import { customElement, state } from "lit/decorators.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";
import PFForm from "@patternfly/patternfly/components/Form/form.css"; import PFForm from "@patternfly/patternfly/components/Form/form.css";
@ -59,7 +58,7 @@ export class AuthenticatorValidateStage
// We don't use this.submit here, as we don't want to advance the flow. // We don't use this.submit here, as we don't want to advance the flow.
// We just want to notify the backend which challenge has been selected. // We just want to notify the backend which challenge has been selected.
new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({ new FlowsApi(DEFAULT_CONFIG).flowsExecutorSolve({
flowSlug: this.host.flowSlug || "", flowSlug: this.host?.flowSlug || "",
query: window.location.search.substring(1), query: window.location.search.substring(1),
flowChallengeResponseRequest: { flowChallengeResponseRequest: {
// @ts-ignore // @ts-ignore
@ -73,8 +72,11 @@ export class AuthenticatorValidateStage
return this._selectedDeviceChallenge; return this._selectedDeviceChallenge;
} }
submit(payload: AuthenticatorValidationChallengeResponseRequest): Promise<boolean> { submit(
return this.host?.submit(payload) || Promise.resolve(); payload: AuthenticatorValidationChallengeResponseRequest,
options?: SubmitOptions,
): Promise<boolean> {
return this.host?.submit(payload, options) || Promise.resolve();
} }
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
@ -253,23 +255,7 @@ export class AuthenticatorValidateStage
? this.renderDeviceChallenge() ? this.renderDeviceChallenge()
: html`<div class="pf-c-login__main-body"> : html`<div class="pf-c-login__main-body">
<form class="pf-c-form"> <form class="pf-c-form">
<ak-form-static ${this.renderUserInfo()}
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
<input
name="username"
autocomplete="username"
type="hidden"
value="${this.challenge.pendingUser}"
/>
${this.selectedDeviceChallenge ${this.selectedDeviceChallenge
? "" ? ""
: html`<p>${msg("Select an authentication method.")}</p>`} : html`<p>${msg("Select an authentication method.")}</p>`}

View File

@ -1,59 +1,34 @@
import { BaseDeviceStage } from "@goauthentik/app/flow/stages/authenticator_validate/base";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage"; import { PasswordManagerPrefill } from "@goauthentik/flow/stages/identification/IdentificationStage";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit"; import { CSSResult, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { import {
AuthenticatorValidationChallenge, AuthenticatorValidationChallenge,
AuthenticatorValidationChallengeResponseRequest, AuthenticatorValidationChallengeResponseRequest,
DeviceChallenge,
DeviceClassesEnum, DeviceClassesEnum,
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-stage-authenticator-validate-code") @customElement("ak-stage-authenticator-validate-code")
export class AuthenticatorValidateStageWebCode extends BaseStage< export class AuthenticatorValidateStageWebCode extends BaseDeviceStage<
AuthenticatorValidationChallenge, AuthenticatorValidationChallenge,
AuthenticatorValidationChallengeResponseRequest AuthenticatorValidationChallengeResponseRequest
> { > {
@property({ attribute: false })
deviceChallenge?: DeviceChallenge;
@property({ type: Boolean })
showBackButton = false;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [ return super.styles.concat(css`
PFBase, .icon-description {
PFLogin, display: flex;
PFForm, }
PFFormControl, .icon-description i {
PFTitle, font-size: 2em;
PFButton, padding: 0.25em;
css` padding-right: 0.5em;
.icon-description { }
display: flex; `);
}
.icon-description i {
font-size: 2em;
padding: 0.25em;
padding-right: 0.5em;
}
`,
];
} }
render(): TemplateResult { render(): TemplateResult {
@ -62,92 +37,62 @@ export class AuthenticatorValidateStageWebCode extends BaseStage<
</ak-empty-state>`; </ak-empty-state>`;
} }
return html`<div class="pf-c-login__main-body"> return html`<div class="pf-c-login__main-body">
<form <form
class="pf-c-form" class="pf-c-form"
@submit=${(e: Event) => { @submit=${(e: Event) => {
this.submitForm(e); this.submitForm(e);
}} }}
>
${this.renderUserInfo()}
<div class="icon-description">
<i
class="fa ${this.deviceChallenge?.deviceClass == DeviceClassesEnum.Sms
? "fa-key"
: "fa-mobile-alt"}"
aria-hidden="true"
></i>
${this.deviceChallenge?.deviceClass == DeviceClassesEnum.Sms
? html`<p>${msg("A code has been sent to you via SMS.")}</p>`
: html`<p>
${msg(
"Open your two-factor authenticator app to view your authentication code.",
)}
</p>`}
</div>
<ak-form-element
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
? msg("Static token")
: msg("Authentication code")}"
?required="${true}"
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})["code"]}
> >
<ak-form-static <!-- @ts-ignore -->
class="pf-c-form__group" <input
userAvatar="${this.challenge.pendingUserAvatar}" type="text"
user=${this.challenge.pendingUser} name="code"
> inputmode="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
<div slot="link"> ? "text"
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}" : "numeric"}"
>${msg("Not you?")}</a pattern="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
> ? "[0-9a-zA-Z]*"
</div> : "[0-9]*"}"
</ak-form-static> placeholder="${msg("Please enter your code")}"
<div class="icon-description"> autofocus=""
<i autocomplete="one-time-code"
class="fa ${this.deviceChallenge?.deviceClass == DeviceClassesEnum.Sms class="pf-c-form-control"
? "fa-key" value="${PasswordManagerPrefill.totp || ""}"
: "fa-mobile-alt"}" required
aria-hidden="true" />
></i> </ak-form-element>
${this.deviceChallenge?.deviceClass == DeviceClassesEnum.Sms
? html`<p>${msg("A code has been sent to you via SMS.")}</p>`
: html`<p>
${msg(
"Open your two-factor authenticator app to view your authentication code.",
)}
</p>`}
</div>
<ak-form-element
label="${this.deviceChallenge?.deviceClass === DeviceClassesEnum.Static
? msg("Static token")
: msg("Authentication code")}"
?required="${true}"
class="pf-c-form__group"
.errors=${(this.challenge?.responseErrors || {})["code"]}
>
<!-- @ts-ignore -->
<input
type="text"
name="code"
inputmode="${this.deviceChallenge?.deviceClass ===
DeviceClassesEnum.Static
? "text"
: "numeric"}"
pattern="${this.deviceChallenge?.deviceClass ===
DeviceClassesEnum.Static
? "[0-9a-zA-Z]*"
: "[0-9]*"}"
placeholder="${msg("Please enter your code")}"
autofocus=""
autocomplete="one-time-code"
class="pf-c-form-control"
value="${PasswordManagerPrefill.totp || ""}"
required
/>
</ak-form-element>
<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">
${msg("Continue")} ${msg("Continue")}
</button> </button>
</div> ${this.renderReturnToDevicePicker()}
</form> </div>
</div> </form>
<footer class="pf-c-login__main-footer"> </div>`;
<ul class="pf-c-login__main-footer-links">
${this.showBackButton
? html`<li class="pf-c-login__main-footer-links-item">
<button
class="pf-c-button pf-m-secondary pf-m-block"
@click=${() => {
if (!this.host) return;
(
this.host as AuthenticatorValidateStage
).selectedDeviceChallenge = undefined;
}}
>
${msg("Return to device picker")}
</button>
</li>`
: html``}
</ul>
</footer>`;
} }
} }

View File

@ -1,20 +1,10 @@
import { BaseDeviceStage } from "@goauthentik/app/flow/stages/authenticator_validate/base";
import "@goauthentik/elements/EmptyState"; import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement"; import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { import {
AuthenticatorValidationChallenge, AuthenticatorValidationChallenge,
@ -23,7 +13,7 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-stage-authenticator-validate-duo") @customElement("ak-stage-authenticator-validate-duo")
export class AuthenticatorValidateStageWebDuo extends BaseStage< export class AuthenticatorValidateStageWebDuo extends BaseDeviceStage<
AuthenticatorValidationChallenge, AuthenticatorValidationChallenge,
AuthenticatorValidationChallengeResponseRequest AuthenticatorValidationChallengeResponseRequest
> { > {
@ -33,14 +23,24 @@ export class AuthenticatorValidateStageWebDuo extends BaseStage<
@property({ type: Boolean }) @property({ type: Boolean })
showBackButton = false; showBackButton = false;
static get styles(): CSSResult[] { @state()
return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton]; authenticating = false;
}
firstUpdated(): void { firstUpdated(): void {
this.host?.submit({ this.authenticating = true;
duo: this.deviceChallenge?.deviceUid, this.host
}); ?.submit(
{
duo: this.deviceChallenge?.deviceUid,
},
{ invisible: true },
)
.then(() => {
this.authenticating = false;
})
.catch(() => {
this.authenticating = false;
});
} }
render(): TemplateResult { render(): TemplateResult {
@ -49,56 +49,25 @@ export class AuthenticatorValidateStageWebDuo extends BaseStage<
</ak-empty-state>`; </ak-empty-state>`;
} }
const errors = this.challenge.responseErrors?.duo || []; const errors = this.challenge.responseErrors?.duo || [];
const errorMessage = errors.map((err) => err.string);
return html`<div class="pf-c-login__main-body"> return html`<div class="pf-c-login__main-body">
<form <form
class="pf-c-form" class="pf-c-form"
@submit=${(e: Event) => { @submit=${(e: Event) => {
this.submitForm(e); this.submitForm(e);
}} }}
>
${this.renderUserInfo()}
<ak-empty-state
?loading="${this.authenticating}"
header=${this.authenticating
? msg("Sending Duo push notification...")
: errorMessage.join(", ") || msg("Failed to authenticate")}
icon="fas fa-times"
> >
<ak-form-static </ak-empty-state>
class="pf-c-form__group" <div class="pf-c-form__group pf-m-action">${this.renderReturnToDevicePicker()}</div>
userAvatar="${this.challenge.pendingUserAvatar}" </form>
user=${this.challenge.pendingUser} </div>`;
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
${errors.length > 0
? errors.map((err) => {
if (err.code === "denied") {
return html` <ak-stage-access-denied-icon
errorMessage=${err.string}
>
</ak-stage-access-denied-icon>`;
}
return html`<p>${err.string}</p>`;
})
: html`${msg("Sending Duo push notification")}`}
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
${this.showBackButton
? html`<li class="pf-c-login__main-footer-links-item">
<button
class="pf-c-button pf-m-secondary pf-m-block"
@click=${() => {
if (!this.host) return;
(
this.host as AuthenticatorValidateStage
).selectedDeviceChallenge = undefined;
}}
>
${msg("Return to device picker")}
</button>
</li>`
: html``}
</ul>
</footer>`;
} }
} }

View File

@ -1,23 +1,14 @@
import { BaseDeviceStage } from "@goauthentik/app/flow/stages/authenticator_validate/base";
import { import {
checkWebAuthnSupport, checkWebAuthnSupport,
transformAssertionForServer, transformAssertionForServer,
transformCredentialRequestOptions, transformCredentialRequestOptions,
} from "@goauthentik/common/helpers/webauthn"; } from "@goauthentik/common/helpers/webauthn";
import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage"; import "@goauthentik/elements/EmptyState";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg, str } from "@lit/localize"; import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, html } from "lit"; import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property, state } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { import {
AuthenticatorValidationChallenge, AuthenticatorValidationChallenge,
@ -26,7 +17,7 @@ import {
} from "@goauthentik/api"; } from "@goauthentik/api";
@customElement("ak-stage-authenticator-validate-webauthn") @customElement("ak-stage-authenticator-validate-webauthn")
export class AuthenticatorValidateStageWebAuthn extends BaseStage< export class AuthenticatorValidateStageWebAuthn extends BaseDeviceStage<
AuthenticatorValidationChallenge, AuthenticatorValidationChallenge,
AuthenticatorValidationChallengeResponseRequest AuthenticatorValidationChallengeResponseRequest
> { > {
@ -34,25 +25,15 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage<
deviceChallenge?: DeviceChallenge; deviceChallenge?: DeviceChallenge;
@property() @property()
authenticateMessage?: string; errorMessage?: string;
@property({ type: Boolean }) @property({ type: Boolean })
showBackButton = false; showBackButton = false;
transformedCredentialRequestOptions?: PublicKeyCredentialRequestOptions; @state()
authenticating = false;
static get styles(): CSSResult[] { transformedCredentialRequestOptions?: PublicKeyCredentialRequestOptions;
return [
PFBase,
PFLogin,
PFEmptyState,
PFBullseye,
PFForm,
PFFormControl,
PFTitle,
PFButton,
];
}
async authenticate(): Promise<void> { async authenticate(): Promise<void> {
// request the authenticator to create an assertion signature using the // request the authenticator to create an assertion signature using the
@ -64,10 +45,10 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage<
publicKey: this.transformedCredentialRequestOptions, publicKey: this.transformedCredentialRequestOptions,
}); });
if (!assertion) { if (!assertion) {
throw new Error(msg("Assertions is empty")); throw new Error("Assertions is empty");
} }
} catch (err) { } catch (err) {
throw new Error(msg(str`Error when creating credential: ${err}`)); throw new Error(`Error when creating credential: ${err}`);
} }
// we now have an authentication assertion! encode the byte arrays contained // we now have an authentication assertion! encode the byte arrays contained
@ -78,11 +59,16 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage<
// post the assertion to the server for verification. // post the assertion to the server for verification.
try { try {
await this.host?.submit({ await this.host?.submit(
webauthn: transformedAssertionForServer, {
}); webauthn: transformedAssertionForServer,
},
{
invisible: true,
},
);
} catch (err) { } catch (err) {
throw new Error(msg(str`Error when validating assertion on server: ${err}`)); throw new Error(`Error when validating assertion on server: ${err}`);
} }
} }
@ -97,58 +83,46 @@ export class AuthenticatorValidateStageWebAuthn extends BaseStage<
} }
async authenticateWrapper(): Promise<void> { async authenticateWrapper(): Promise<void> {
if (this.host.loading) { if (this.authenticating) {
return; return;
} }
this.host.loading = true; this.authenticating = true;
this.authenticate() this.authenticate()
.catch((e) => { .catch((e: Error) => {
console.error(e); console.warn(`authentik/flows/authenticator_validate/webauthn: ${e.toString()}`);
this.authenticateMessage = e.toString(); this.errorMessage = msg("Authentication failed.");
}) })
.finally(() => { .finally(() => {
this.host.loading = false; this.authenticating = false;
}); });
} }
render(): TemplateResult { render(): TemplateResult {
return html`<div class="pf-c-login__main-body"> return html`<div class="pf-c-login__main-body">
${this.authenticateMessage <form class="pf-c-form">
? html`<div class="pf-c-form__group pf-m-action"> ${this.renderUserInfo()}
<p class="pf-m-block">${this.authenticateMessage}</p> <ak-empty-state
<button ?loading="${this.authenticating}"
header=${this.authenticating
? msg("Authenticating...")
: this.errorMessage || msg("Failed to authenticate")}
icon="fa-times"
>
</ak-empty-state>
<div class="pf-c-form__group pf-m-action">
${this.errorMessage
? html` <button
class="pf-c-button pf-m-primary pf-m-block" class="pf-c-button pf-m-primary pf-m-block"
@click=${() => { @click=${() => {
this.authenticateWrapper(); this.authenticateWrapper();
}} }}
> >
${msg("Retry authentication")} ${msg("Retry authentication")}
</button> </button>`
</div>` : nothing}
: html`<div class="pf-c-form__group pf-m-action"> ${this.renderReturnToDevicePicker()}
<p class="pf-m-block">&nbsp;</p> </div>
<p class="pf-m-block">&nbsp;</p> </form>
<p class="pf-m-block">&nbsp;</p> </div>`;
</div> `}
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links">
${this.showBackButton
? html`<li class="pf-c-login__main-footer-links-item">
<button
class="pf-c-button pf-m-secondary pf-m-block"
@click=${() => {
if (!this.host) return;
(
this.host as AuthenticatorValidateStage
).selectedDeviceChallenge = undefined;
}}
>
${msg("Return to device picker")}
</button>
</li>`
: html``}
</ul>
</footer>`;
} }
} }

View File

@ -0,0 +1,69 @@
import {
BaseStage,
FlowInfoChallenge,
PendingUserChallenge,
} from "@goauthentik/app/flow/stages/base";
import { AuthenticatorValidateStage } from "@goauthentik/flow/stages/authenticator_validate/AuthenticatorValidateStage";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
import { property } from "lit/decorators.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFLogin from "@patternfly/patternfly/components/Login/login.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { DeviceChallenge } from "@goauthentik/api";
export class BaseDeviceStage<
Tin extends FlowInfoChallenge & PendingUserChallenge,
Tout,
> extends BaseStage<Tin, Tout> {
@property({ attribute: false })
deviceChallenge?: DeviceChallenge;
@property({ type: Boolean })
showBackButton = false;
static get styles(): CSSResult[] {
return [
PFBase,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
PFButton,
css`
.pf-c-form__group.pf-m-action {
display: flex;
gap: 16px;
margin-top: 0;
margin-bottom: calc(var(--pf-c-form__group--m-action--MarginTop) / 2);
flex-direction: column;
}
`,
];
}
submit(payload: Tin): Promise<boolean> {
return this.host?.submit(payload) || Promise.resolve();
}
renderReturnToDevicePicker(): TemplateResult {
if (!this.showBackButton) {
return html``;
}
return html`<button
class="pf-c-button pf-m-secondary pf-m-block"
@click=${() => {
if (!this.host) return;
(this.host as AuthenticatorValidateStage).selectedDeviceChallenge = undefined;
}}
>
${msg("Return to device picker")}
</button>`;
}
}

View File

@ -1,16 +1,22 @@
import { AKElement } from "@goauthentik/elements/Base"; import { AKElement } from "@goauthentik/elements/Base";
import { KeyUnknown } from "@goauthentik/elements/forms/Form"; import { KeyUnknown } from "@goauthentik/elements/forms/Form";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit"; import { TemplateResult, html } from "lit";
import { property } from "lit/decorators.js"; import { property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { CurrentBrand, ErrorDetail } from "@goauthentik/api"; import { ContextualFlowInfo, CurrentBrand, ErrorDetail } from "@goauthentik/api";
export interface SubmitOptions {
invisible: boolean;
}
export interface StageHost { export interface StageHost {
challenge?: unknown; challenge?: unknown;
flowSlug?: string; flowSlug?: string;
loading: boolean; loading: boolean;
submit(payload: unknown): Promise<boolean>; submit(payload: unknown, options?: SubmitOptions): Promise<boolean>;
readonly brand?: CurrentBrand; readonly brand?: CurrentBrand;
} }
@ -26,7 +32,21 @@ export function readFileAsync(file: Blob) {
}); });
} }
export class BaseStage<Tin, Tout> extends AKElement { // Challenge which contains flow info
export interface FlowInfoChallenge {
flowInfo?: ContextualFlowInfo;
}
// Challenge which has a pending user
export interface PendingUserChallenge {
pendingUser?: string;
pendingUserAvatar?: string;
}
export class BaseStage<
Tin extends FlowInfoChallenge & PendingUserChallenge,
Tout,
> extends AKElement {
host!: StageHost; host!: StageHost;
@property({ attribute: false }) @property({ attribute: false })
@ -68,6 +88,31 @@ export class BaseStage<Tin, Tout> extends AKElement {
</div>`; </div>`;
} }
renderUserInfo(): TemplateResult {
if (!this.challenge.pendingUser || !this.challenge.pendingUserAvatar) {
return html``;
}
return html`
<ak-form-static
class="pf-c-form__group"
userAvatar="${this.challenge.pendingUserAvatar}"
user=${this.challenge.pendingUser}
>
<div slot="link">
<a href="${ifDefined(this.challenge.flowInfo?.cancelUrl)}"
>${msg("Not you?")}</a
>
</div>
</ak-form-static>
<input
name="username"
autocomplete="username"
type="hidden"
value="${this.challenge.pendingUser}"
/>
`;
}
cleanup(): void { cleanup(): void {
// Method that can be overridden by stages // Method that can be overridden by stages
return; return;