From 3b41c662ed350ebcffdbe945233f618fcf211560 Mon Sep 17 00:00:00 2001 From: Jens Langhammer Date: Sun, 23 May 2021 22:26:50 +0200 Subject: [PATCH] stages/authenticator_validate: add Duo support Signed-off-by: Jens Langhammer --- authentik/stages/authenticator_duo/api.py | 2 + .../migrations/0001_initial.py | 87 ++++++++++--------- authentik/stages/authenticator_duo/models.py | 4 +- authentik/stages/authenticator_duo/stage.py | 10 ++- .../authenticator_validate/challenge.py | 38 ++++++-- .../stages/authenticator_validate/models.py | 1 + .../stages/authenticator_validate/stage.py | 35 ++++---- .../stages/authenticator_webauthn/stage.py | 7 +- schema.yml | 7 +- .../AuthenticatorValidateStage.ts | 20 ++++- .../AuthenticatorValidateStageDuo.ts | 81 +++++++++++++++++ web/src/pages/flows/FlowViewPage.ts | 2 +- .../AuthenticatorValidateStageForm.ts | 7 +- 13 files changed, 227 insertions(+), 74 deletions(-) create mode 100644 web/src/flows/stages/authenticator_validate/AuthenticatorValidateStageDuo.ts diff --git a/authentik/stages/authenticator_duo/api.py b/authentik/stages/authenticator_duo/api.py index 6b9c6b475..fe69a1ace 100644 --- a/authentik/stages/authenticator_duo/api.py +++ b/authentik/stages/authenticator_duo/api.py @@ -51,7 +51,9 @@ class AuthenticatorDuoStageViewSet(ModelViewSet): }, ) @action(methods=["POST"], detail=True, permission_classes=[]) + # pylint: disable=invalid-name,unused-argument def enrollment_status(self, request: Request, pk: str) -> Response: + """Check enrollment status of user details in current session""" stage: AuthenticatorDuoStage = self.get_object() client = stage.client user_id = self.request.session.get(SESSION_KEY_DUO_USER_ID) diff --git a/authentik/stages/authenticator_duo/migrations/0001_initial.py b/authentik/stages/authenticator_duo/migrations/0001_initial.py index eebee53ca..89fd69207 100644 --- a/authentik/stages/authenticator_duo/migrations/0001_initial.py +++ b/authentik/stages/authenticator_duo/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.3 on 2021-05-23 17:54 +# Generated by Django 3.2.3 on 2021-05-23 20:28 import django.db.models.deletion from django.conf import settings @@ -15,45 +15,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name="DuoDevice", - fields=[ - ( - "id", - models.AutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ( - "name", - models.CharField( - help_text="The human-readable name of this device.", - max_length=64, - ), - ), - ( - "confirmed", - models.BooleanField( - default=True, help_text="Is this device ready for use?" - ), - ), - ("duo_user_id", models.TextField()), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "verbose_name": "Duo Device", - "verbose_name_plural": "Duo Devices", - }, - ), migrations.CreateModel( name="AuthenticatorDuoStage", fields=[ @@ -88,4 +49,50 @@ class Migration(migrations.Migration): }, bases=("authentik_flows.stage", models.Model), ), + migrations.CreateModel( + name="DuoDevice", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "name", + models.CharField( + help_text="The human-readable name of this device.", + max_length=64, + ), + ), + ( + "confirmed", + models.BooleanField( + default=True, help_text="Is this device ready for use?" + ), + ), + ("duo_user_id", models.TextField()), + ( + "stage", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentik_stages_authenticator_duo.authenticatorduostage", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "Duo Device", + "verbose_name_plural": "Duo Devices", + }, + ), ] diff --git a/authentik/stages/authenticator_duo/models.py b/authentik/stages/authenticator_duo/models.py index d0f9bbcb4..25a938aaa 100644 --- a/authentik/stages/authenticator_duo/models.py +++ b/authentik/stages/authenticator_duo/models.py @@ -3,7 +3,6 @@ from typing import Optional, Type from django.contrib.auth import get_user_model from django.db import models -from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views import View from django_otp.models import Device @@ -38,6 +37,7 @@ class AuthenticatorDuoStage(ConfigurableStage, Stage): @property def client(self) -> Auth: + """Get an API Client to talk to duo""" client = Auth( self.client_id, self.client_secret, @@ -73,6 +73,8 @@ class DuoDevice(Device): user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + # Connect to the stage to when validating access we know the API Credentials + stage = models.ForeignKey(AuthenticatorDuoStage, on_delete=models.CASCADE) duo_user_id = models.TextField() def __str__(self): diff --git a/authentik/stages/authenticator_duo/stage.py b/authentik/stages/authenticator_duo/stage.py index a014e709c..cee9f8814 100644 --- a/authentik/stages/authenticator_duo/stage.py +++ b/authentik/stages/authenticator_duo/stage.py @@ -3,7 +3,12 @@ from django.http import HttpRequest, HttpResponse from rest_framework.fields import CharField from structlog.stdlib import get_logger -from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes, WithUserInfoChallenge +from authentik.flows.challenge import ( + Challenge, + ChallengeResponse, + ChallengeTypes, + WithUserInfoChallenge, +) from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice @@ -64,8 +69,7 @@ class AuthenticatorDuoStageView(ChallengeStageView): self.request.session.pop(SESSION_KEY_DUO_ACTIVATION_CODE) if not existing_device: DuoDevice.objects.create( - user=self.get_pending_user(), - duo_user_id=user_id, + user=self.get_pending_user(), duo_user_id=user_id, stage=stage ) else: return self.executor.stage_invalid( diff --git a/authentik/stages/authenticator_validate/challenge.py b/authentik/stages/authenticator_validate/challenge.py index 92771a06d..bb592133c 100644 --- a/authentik/stages/authenticator_validate/challenge.py +++ b/authentik/stages/authenticator_validate/challenge.py @@ -1,12 +1,13 @@ """Validation stage challenge checking""" from django.http import HttpRequest +from django.http.response import Http404 +from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ from django_otp import match_token from django_otp.models import Device -from django_otp.plugins.otp_static.models import StaticDevice -from django_otp.plugins.otp_totp.models import TOTPDevice from rest_framework.fields import CharField, JSONField from rest_framework.serializers import ValidationError +from structlog.stdlib import get_logger from webauthn import WebAuthnAssertionOptions, WebAuthnAssertionResponse, WebAuthnUser from webauthn.webauthn import ( AuthenticationRejectedException, @@ -16,9 +17,13 @@ from webauthn.webauthn import ( from authentik.core.api.utils import PassiveSerializer from authentik.core.models import User +from authentik.lib.utils.http import get_client_ip +from authentik.stages.authenticator_duo.models import AuthenticatorDuoStage, DuoDevice from authentik.stages.authenticator_webauthn.models import WebAuthnDevice from authentik.stages.authenticator_webauthn.utils import generate_challenge, get_origin +LOGGER = get_logger() + class DeviceChallenge(PassiveSerializer): """Single device challenge""" @@ -30,10 +35,10 @@ class DeviceChallenge(PassiveSerializer): def get_challenge_for_device(request: HttpRequest, device: Device) -> dict: """Generate challenge for a single device""" - if isinstance(device, (TOTPDevice, StaticDevice)): - # Code-based challenges have no hints - return {} - return get_webauthn_challenge(request, device) + if isinstance(device, WebAuthnDevice): + return get_webauthn_challenge(request, device) + # Code-based challenges have no hints + return {} def get_webauthn_challenge(request: HttpRequest, device: WebAuthnDevice) -> dict: @@ -111,3 +116,24 @@ def validate_challenge_webauthn(data: dict, request: HttpRequest, user: User) -> device.set_sign_count(sign_count) return data + + +def validate_challenge_duo(device_pk: int, request: HttpRequest, user: User) -> int: + """Duo authentication""" + device = get_object_or_404(DuoDevice, pk=device_pk) + if device.user != user: + LOGGER.warning("device mismatch") + raise Http404 + stage: AuthenticatorDuoStage = device.stage + response = stage.client.auth( + "auto", + user_id=device.duo_user_id, + ipaddr=get_client_ip(request), + type="authentik Login request", + display_username=user.username, + device="auto", + ) + # {'result': 'allow', 'status': 'allow', 'status_msg': 'Success. Logging you in...'} + if response["result"] == "deny": + raise ValidationError("Duo denied access") + return device_pk diff --git a/authentik/stages/authenticator_validate/models.py b/authentik/stages/authenticator_validate/models.py index 321d51128..a9276babd 100644 --- a/authentik/stages/authenticator_validate/models.py +++ b/authentik/stages/authenticator_validate/models.py @@ -17,6 +17,7 @@ class DeviceClasses(models.TextChoices): STATIC = "static" TOTP = "totp", _("TOTP") WEBAUTHN = "webauthn", _("WebAuthn") + DUO = "duo", _("Duo") def default_device_classes() -> list: diff --git a/authentik/stages/authenticator_validate/stage.py b/authentik/stages/authenticator_validate/stage.py index c900437e0..807503ce5 100644 --- a/authentik/stages/authenticator_validate/stage.py +++ b/authentik/stages/authenticator_validate/stage.py @@ -1,7 +1,7 @@ """Authenticator Validation""" from django.http import HttpRequest, HttpResponse from django_otp import devices_for_user -from rest_framework.fields import CharField, JSONField, ListField +from rest_framework.fields import CharField, IntegerField, JSONField, ListField from rest_framework.serializers import ValidationError from structlog.stdlib import get_logger @@ -17,6 +17,7 @@ from authentik.stages.authenticator_validate.challenge import ( DeviceChallenge, get_challenge_for_device, validate_challenge_code, + validate_challenge_duo, validate_challenge_webauthn, ) from authentik.stages.authenticator_validate.models import ( @@ -40,17 +41,18 @@ class AuthenticatorChallengeResponse(ChallengeResponse): code = CharField(required=False) webauthn = JSONField(required=False) + duo = IntegerField(required=False) - def validate_code(self, code: str) -> str: - """Validate code-based response, raise error if code isn't allowed""" + def _challenge_allowed(self, classes: list): device_challenges: list[dict] = self.stage.request.session.get( "device_challenges" ) - if not any( - x["device_class"] in (DeviceClasses.TOTP, DeviceClasses.STATIC) - for x in device_challenges - ): - raise ValidationError("Got code but no compatible device class allowed") + if not any(x["device_class"] in classes for x in device_challenges): + raise ValidationError("No compatible device class allowed") + + def validate_code(self, code: str) -> str: + """Validate code-based response, raise error if code isn't allowed""" + self._challenge_allowed([DeviceClasses.TOTP, DeviceClasses.STATIC]) return validate_challenge_code( code, self.stage.request, self.stage.get_pending_user() ) @@ -58,21 +60,22 @@ class AuthenticatorChallengeResponse(ChallengeResponse): def validate_webauthn(self, webauthn: dict) -> dict: """Validate webauthn response, raise error if webauthn wasn't allowed or response is invalid""" - device_challenges: list[dict] = self.stage.request.session.get( - "device_challenges" - ) - if not any( - x["device_class"] in (DeviceClasses.WEBAUTHN) for x in device_challenges - ): - raise ValidationError("Got webauthn but no compatible device class allowed") + self._challenge_allowed([DeviceClasses.WEBAUTHN]) return validate_challenge_webauthn( webauthn, self.stage.request, self.stage.get_pending_user() ) + def validate_duo(self, duo: int) -> int: + """Initiate Duo authentication""" + self._challenge_allowed([DeviceClasses.DUO]) + return validate_challenge_duo( + duo, self.stage.request, self.stage.get_pending_user() + ) + def validate(self, data: dict): # Checking if the given data is from a valid device class is done above # Here we only check if the any data was sent at all - if "code" not in data and "webauthn" not in data: + if "code" not in data and "webauthn" not in data and "duo" not in data: raise ValidationError("Empty response") return data diff --git a/authentik/stages/authenticator_webauthn/stage.py b/authentik/stages/authenticator_webauthn/stage.py index 0feb7713d..ce4a0aa94 100644 --- a/authentik/stages/authenticator_webauthn/stage.py +++ b/authentik/stages/authenticator_webauthn/stage.py @@ -13,7 +13,12 @@ from webauthn.webauthn import ( ) from authentik.core.models import User -from authentik.flows.challenge import Challenge, ChallengeResponse, ChallengeTypes, WithUserInfoChallenge +from authentik.flows.challenge import ( + Challenge, + ChallengeResponse, + ChallengeTypes, + WithUserInfoChallenge, +) from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER from authentik.flows.stage import ChallengeStageView from authentik.stages.authenticator_webauthn.models import WebAuthnDevice diff --git a/schema.yml b/schema.yml index a6658f48a..f27706fc6 100644 --- a/schema.yml +++ b/schema.yml @@ -10966,7 +10966,7 @@ paths: /api/v2beta/stages/authenticator/duo/{stage_uuid}/enrollment_status/: post: operationId: stages_authenticator_duo_enrollment_status_create - description: AuthenticatorDuoStage Viewset + description: Check enrollment status of user details in current session parameters: - in: path name: stage_uuid @@ -10983,8 +10983,10 @@ paths: responses: '204': description: Enrollment successful - '400': + '420': description: Enrollment pending/failed + '400': + $ref: '#/components/schemas/ValidationError' '403': $ref: '#/components/schemas/GenericError' /api/v2beta/stages/authenticator/static/: @@ -15743,6 +15745,7 @@ components: - static - totp - webauthn + - duo type: string DigestAlgorithmEnum: enum: diff --git a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts index 6fd3c4867..533305855 100644 --- a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts +++ b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStage.ts @@ -11,12 +11,14 @@ import AKGlobal from "../../../authentik.css"; import { BaseStage, StageHost } from "../base"; import "./AuthenticatorValidateStageWebAuthn"; import "./AuthenticatorValidateStageCode"; +import "./AuthenticatorValidateStageDuo"; import { PasswordManagerPrefill } from "../identification/IdentificationStage"; export enum DeviceClasses { STATIC = "static", TOTP = "totp", WEBAUTHN = "webauthn", + DUO = "duo", } export interface DeviceChallenge { @@ -30,8 +32,9 @@ export interface AuthenticatorValidateStageChallenge extends WithUserInfoChallen } export interface AuthenticatorValidateStageChallengeResponse { - code: string; - webauthn: string; + code?: string; + webauthn?: string; + duo?: number; } @customElement("ak-stage-authenticator-validate") @@ -77,6 +80,12 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost { renderDevicePickerSingle(deviceChallenge: DeviceChallenge): TemplateResult { switch (deviceChallenge.device_class) { + case DeviceClasses.DUO: + return html` +
+

${t`Duo push-notifications`}

+ ${t`Receive a push notification on your phone to prove your identity.`} +
`; case DeviceClasses.WEBAUTHN: return html`
@@ -147,6 +156,13 @@ export class AuthenticatorValidateStage extends BaseStage implements StageHost { .deviceChallenge=${this.selectedDeviceChallenge} .showBackButton=${(this.challenge?.device_challenges.length || []) > 1}> `; + case DeviceClasses.DUO: + return html` 1}> + `; } } diff --git a/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStageDuo.ts b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStageDuo.ts new file mode 100644 index 000000000..d3ab1a61c --- /dev/null +++ b/web/src/flows/stages/authenticator_validate/AuthenticatorValidateStageDuo.ts @@ -0,0 +1,81 @@ +import { t } from "@lingui/macro"; +import { CSSResult, customElement, html, property, TemplateResult } from "lit-element"; +import PFLogin from "@patternfly/patternfly/components/Login/login.css"; +import PFForm from "@patternfly/patternfly/components/Form/form.css"; +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +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 AKGlobal from "../../../authentik.css"; +import { BaseStage } from "../base"; +import { AuthenticatorValidateStage, AuthenticatorValidateStageChallenge, DeviceChallenge } from "./AuthenticatorValidateStage"; +import "../../../elements/forms/FormElement"; +import "../../../elements/EmptyState"; +import { PasswordManagerPrefill } from "../identification/IdentificationStage"; +import "../../FormStatic"; +import { FlowURLManager } from "../../../api/legacy"; + +@customElement("ak-stage-authenticator-validate-duo") +export class AuthenticatorValidateStageWebDuo extends BaseStage { + + @property({ attribute: false }) + challenge?: AuthenticatorValidateStageChallenge; + + @property({ attribute: false }) + deviceChallenge?: DeviceChallenge; + + @property({ type: Boolean }) + showBackButton = false; + + static get styles(): CSSResult[] { + return [PFBase, PFLogin, PFForm, PFFormControl, PFTitle, PFButton, AKGlobal]; + } + + firstUpdated(): void { + this.host?.submit({ + "duo": this.deviceChallenge?.device_uid + }); + } + + render(): TemplateResult { + if (!this.challenge) { + return html` + `; + } + return html` +
+ +
`; + } + +} diff --git a/web/src/pages/flows/FlowViewPage.ts b/web/src/pages/flows/FlowViewPage.ts index 704f342ca..4710f17b6 100644 --- a/web/src/pages/flows/FlowViewPage.ts +++ b/web/src/pages/flows/FlowViewPage.ts @@ -74,7 +74,7 @@ export class FlowViewPage extends LitElement { new FlowsApi(DEFAULT_CONFIG).flowsInstancesExecuteRetrieve({ slug: this.flow.slug }).then(link => { - const finalURL = `${link.link}?next=/%23${window.location.href}`; + const finalURL = `${link.link}?next=/%23${window.location.hash}`; window.open(finalURL, "_blank"); }); }}> diff --git a/web/src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts b/web/src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts index f21fb01be..f5a919789 100644 --- a/web/src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts +++ b/web/src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts @@ -91,9 +91,9 @@ export class AuthenticatorValidateStageForm extends ModelForm + name="deviceClasses">

${t`Device classes which can be used to authenticate.`}

${t`Hold control/command to select multiple items.`}