initial scaffold

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
This commit is contained in:
Jens Langhammer 2023-07-24 20:58:04 +02:00
parent b7532740ef
commit 6e9c3affc8
No known key found for this signature in database
15 changed files with 1765 additions and 0 deletions

View File

@ -84,6 +84,7 @@ INSTALLED_APPS = [
"authentik.sources.saml",
"authentik.stages.authenticator",
"authentik.stages.authenticator_duo",
"authentik.stages.authenticator_mobile",
"authentik.stages.authenticator_sms",
"authentik.stages.authenticator_static",
"authentik.stages.authenticator_totp",

View File

@ -0,0 +1,92 @@
"""AuthenticatorDuoStage API Views"""
from django.http import Http404
from django_filters.rest_framework.backends import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer
from guardian.shortcuts import get_objects_for_user
from rest_framework import mixins
from rest_framework.decorators import action
from rest_framework.fields import CharField, ChoiceField, IntegerField
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.permissions import IsAdminUser
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import ModelSerializer
from rest_framework.viewsets import GenericViewSet, ModelViewSet
from authentik.api.authorization import OwnerFilter, OwnerPermissions
from authentik.api.decorators import permission_required
from authentik.core.api.used_by import UsedByMixin
from authentik.flows.api.stages import StageSerializer
from authentik.stages.authenticator_mobile.models import AuthenticatorMobileStage, MobileDevice
class AuthenticatorMobileStageSerializer(StageSerializer):
"""AuthenticatorMobileStage Serializer"""
class Meta:
model = AuthenticatorMobileStage
fields = StageSerializer.Meta.fields + [
"configure_flow",
"friendly_name",
]
# extra_kwargs = {
# "client_secret": {"write_only": True},
# "admin_secret_key": {"write_only": True},
# }
class AuthenticatorMobileStageViewSet(UsedByMixin, ModelViewSet):
"""AuthenticatorMobileStage Viewset"""
queryset = AuthenticatorMobileStage.objects.all()
serializer_class = AuthenticatorMobileStageSerializer
filterset_fields = [
"name",
"configure_flow",
]
search_fields = ["name"]
ordering = ["name"]
@action(methods=["GET"], detail=True)
def enrollment_callback(self, request: Request, pk: str) -> Response:
pass
class MobileDeviceSerializer(ModelSerializer):
"""Serializer for Mobile authenticator devices"""
class Meta:
model = MobileDevice
fields = ["pk", "name"]
depth = 2
class MobileDeviceViewSet(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
UsedByMixin,
mixins.ListModelMixin,
GenericViewSet,
):
"""Viewset for Mobile authenticator devices"""
queryset = MobileDevice.objects.all()
serializer_class = MobileDeviceSerializer
search_fields = ["name"]
filterset_fields = ["name"]
ordering = ["name"]
permission_classes = [OwnerPermissions]
filter_backends = [OwnerFilter, DjangoFilterBackend, OrderingFilter, SearchFilter]
class AdminMobileDeviceViewSet(ModelViewSet):
"""Viewset for Mobile authenticator devices (for admins)"""
permission_classes = [IsAdminUser]
queryset = MobileDevice.objects.all()
serializer_class = MobileDeviceSerializer
search_fields = ["name"]
filterset_fields = ["name"]
ordering = ["name"]

View File

@ -0,0 +1,12 @@
"""authentik mobile app config"""
from authentik.blueprints.apps import ManagedAppConfig
class AuthentikStageAuthenticatorMobileConfig(ManagedAppConfig):
"""authentik mobile config"""
name = "authentik.stages.authenticator_mobile"
label = "authentik_stages_authenticator_mobile"
verbose_name = "authentik Stages.Authenticator.Mobile"
default = True

View File

@ -0,0 +1,88 @@
# Generated by Django 4.1.10 on 2023-07-24 18:48
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_flows", "0025_alter_flowstagebinding_evaluate_on_plan_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="AuthenticatorMobileStage",
fields=[
(
"stage_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_flows.stage",
),
),
("friendly_name", models.TextField(null=True)),
(
"configure_flow",
models.ForeignKey(
blank=True,
help_text="Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="authentik_flows.flow",
),
),
],
options={
"verbose_name": "Mobile Authenticator Setup Stage",
"verbose_name_plural": "Mobile Authenticator Setup Stages",
},
bases=("authentik_flows.stage", models.Model),
),
migrations.CreateModel(
name="MobileDevice",
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?"),
),
("device_id", models.TextField(unique=True)),
(
"stage",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="authentik_stages_authenticator_mobile.authenticatormobilestage",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
),
),
],
options={
"verbose_name": "Mobile Device",
"verbose_name_plural": "Mobile Devices",
},
),
]

View File

@ -0,0 +1,72 @@
"""Mobile authenticator stage"""
from typing import Optional
from django.contrib.auth import get_user_model
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.views import View
from django_otp.models import Device
from rest_framework.serializers import BaseSerializer, Serializer
from authentik.core.types import UserSettingSerializer
from authentik.flows.models import ConfigurableStage, FriendlyNamedStage, Stage
from authentik.lib.models import SerializerModel
class AuthenticatorMobileStage(ConfigurableStage, FriendlyNamedStage, Stage):
"""Setup Duo authenticator devices"""
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.stages.authenticator_mobile.api import AuthenticatorMobileStageSerializer
return AuthenticatorMobileStageSerializer
@property
def type(self) -> type[View]:
from authentik.stages.authenticator_mobile.stage import AuthenticatorMobileStageView
return AuthenticatorMobileStageView
@property
def component(self) -> str:
return "ak-stage-authenticator-mobile-form"
def ui_user_settings(self) -> Optional[UserSettingSerializer]:
return UserSettingSerializer(
data={
"title": self.friendly_name or str(self._meta.verbose_name),
"component": "ak-user-settings-authenticator-mobile",
}
)
def __str__(self) -> str:
return f"Mobile Authenticator Setup Stage {self.name}"
class Meta:
verbose_name = _("Mobile Authenticator Setup Stage")
verbose_name_plural = _("Mobile Authenticator Setup Stages")
class MobileDevice(SerializerModel, Device):
"""Mobile authenticator for a single user"""
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(AuthenticatorMobileStage, on_delete=models.CASCADE)
device_id = models.TextField(unique=True)
@property
def serializer(self) -> Serializer:
from authentik.stages.authenticator_mobile.api import MobileDeviceSerializer
return MobileDeviceSerializer
def __str__(self):
return str(self.name) or str(self.user)
class Meta:
verbose_name = _("Mobile Device")
verbose_name_plural = _("Mobile Devices")

View File

@ -0,0 +1,49 @@
"""Mobile stage"""
from django.http import HttpResponse
from django.utils.timezone import now
from rest_framework.fields import CharField
from authentik.events.models import Event, EventAction
from authentik.flows.challenge import (
Challenge,
ChallengeResponse,
ChallengeTypes,
WithUserInfoChallenge,
)
from authentik.flows.stage import ChallengeStageView
from authentik.stages.authenticator_mobile.models import AuthenticatorMobileStage
SESSION_KEY_MOBILE_ENROLL = "authentik/stages/authenticator_mobile/enroll"
class AuthenticatorMobileChallenge(WithUserInfoChallenge):
"""Mobile Challenge"""
authentik_url = CharField(required=True)
stage_uuid = CharField(required=True)
component = CharField(default="ak-stage-authenticator-mobile")
class AuthenticatorMobileChallengeResponse(ChallengeResponse):
"""Pseudo class for mobile response"""
component = CharField(default="ak-stage-authenticator-mobile")
class AuthenticatorMobileStageView(ChallengeStageView):
"""Mobile stage"""
response_class = AuthenticatorMobileChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
stage: AuthenticatorMobileStage = self.executor.current_stage
return AuthenticatorMobileChallenge(
data={
"type": ChallengeTypes.NATIVE.value,
"authentik_url": self.request.get_host(),
"stage_uuid": str(stage.stage_uuid),
}
)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
return self.executor.stage_ok()

View File

@ -0,0 +1,16 @@
"""API URLs"""
from authentik.stages.authenticator_mobile.api import (
AdminMobileDeviceViewSet,
AuthenticatorMobileStageViewSet,
MobileDeviceViewSet,
)
api_urlpatterns = [
("authenticators/mobile", MobileDeviceViewSet),
(
"authenticators/admin/mobile",
AdminMobileDeviceViewSet,
"admin-mobiledevice",
),
("stages/authenticator/mobile", AuthenticatorMobileStageViewSet),
]

View File

@ -1595,6 +1595,78 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_stages_authenticator_mobile.authenticatormobilestage"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_authenticator_mobile.authenticatormobilestage"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_authenticator_mobile.authenticatormobilestage"
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_stages_authenticator_mobile.mobiledevice"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"attrs": {
"$ref": "#/$defs/model_authentik_stages_authenticator_mobile.mobiledevice"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_stages_authenticator_mobile.mobiledevice"
}
}
},
{
"type": "object",
"required": [
@ -3455,6 +3527,7 @@
"authentik.sources.saml",
"authentik.stages.authenticator",
"authentik.stages.authenticator_duo",
"authentik.stages.authenticator_mobile",
"authentik.stages.authenticator_sms",
"authentik.stages.authenticator_static",
"authentik.stages.authenticator_totp",
@ -3530,6 +3603,8 @@
"authentik_sources_saml.usersamlsourceconnection",
"authentik_stages_authenticator_duo.authenticatorduostage",
"authentik_stages_authenticator_duo.duodevice",
"authentik_stages_authenticator_mobile.authenticatormobilestage",
"authentik_stages_authenticator_mobile.mobiledevice",
"authentik_stages_authenticator_sms.authenticatorsmsstage",
"authentik_stages_authenticator_sms.smsdevice",
"authentik_stages_authenticator_static.authenticatorstaticstage",
@ -5843,6 +5918,125 @@
},
"required": []
},
"model_authentik_stages_authenticator_mobile.authenticatormobilestage": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"flow_set": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"slug": {
"type": "string",
"maxLength": 50,
"minLength": 1,
"pattern": "^[-a-zA-Z0-9_]+$",
"title": "Slug",
"description": "Visible in the URL."
},
"title": {
"type": "string",
"minLength": 1,
"title": "Title",
"description": "Shown as the Title in Flow pages."
},
"designation": {
"type": "string",
"enum": [
"authentication",
"authorization",
"invalidation",
"enrollment",
"unenrollment",
"recovery",
"stage_configuration"
],
"title": "Designation",
"description": "Decides what this Flow is used for. For example, the Authentication flow is redirect to when an un-authenticated user visits authentik."
},
"policy_engine_mode": {
"type": "string",
"enum": [
"all",
"any"
],
"title": "Policy engine mode"
},
"compatibility_mode": {
"type": "boolean",
"title": "Compatibility mode",
"description": "Enable compatibility mode, increases compatibility with password managers on mobile devices."
},
"layout": {
"type": "string",
"enum": [
"stacked",
"content_left",
"content_right",
"sidebar_left",
"sidebar_right"
],
"title": "Layout"
},
"denied_action": {
"type": "string",
"enum": [
"message_continue",
"message",
"continue"
],
"title": "Denied action",
"description": "Configure what should happen when a flow denies access to a user."
}
},
"required": [
"name",
"slug",
"title",
"designation"
]
},
"title": "Flow set"
},
"configure_flow": {
"type": "integer",
"title": "Configure flow",
"description": "Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage."
},
"friendly_name": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Friendly name"
}
},
"required": []
},
"model_authentik_stages_authenticator_mobile.mobiledevice": {
"type": "object",
"properties": {
"name": {
"type": "string",
"maxLength": 64,
"minLength": 1,
"title": "Name",
"description": "The human-readable name of this device."
}
},
"required": []
},
"model_authentik_stages_authenticator_sms.authenticatorsmsstage": {
"type": "object",
"properties": {

1023
schema.yml

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import "@goauthentik/admin/stages/StageWizard";
import "@goauthentik/admin/stages/authenticator_duo/AuthenticatorDuoStageForm";
import "@goauthentik/admin/stages/authenticator_duo/DuoDeviceImportForm";
import "@goauthentik/admin/stages/authenticator_mobile/AuthenticatorMobileStageForm";
import "@goauthentik/admin/stages/authenticator_sms/AuthenticatorSMSStageForm";
import "@goauthentik/admin/stages/authenticator_static/AuthenticatorStaticStageForm";
import "@goauthentik/admin/stages/authenticator_totp/AuthenticatorTOTPStageForm";

View File

@ -0,0 +1,130 @@
import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { ModelForm } from "@goauthentik/elements/forms/ModelForm";
import "@goauthentik/elements/forms/SearchSelect";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import {
AuthenticatorMobileStage,
AuthenticatorMobileStageRequest,
Flow,
FlowsApi,
FlowsInstancesListDesignationEnum,
FlowsInstancesListRequest,
StagesApi,
} from "@goauthentik/api";
@customElement("ak-stage-authenticator-mobile-form")
export class AuthenticatorMobileStageForm extends ModelForm<AuthenticatorMobileStage, string> {
loadInstance(pk: string): Promise<AuthenticatorMobileStage> {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorMobileRetrieve({
stageUuid: pk,
});
}
getSuccessMessage(): string {
if (this.instance) {
return msg("Successfully updated stage.");
} else {
return msg("Successfully created stage.");
}
}
async send(data: AuthenticatorMobileStage): Promise<AuthenticatorMobileStage> {
if (this.instance) {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorMobilePartialUpdate({
stageUuid: this.instance.pk || "",
patchedAuthenticatorMobileStageRequest: data,
});
} else {
return new StagesApi(DEFAULT_CONFIG).stagesAuthenticatorMobileCreate({
authenticatorMobileStageRequest: data as unknown as AuthenticatorMobileStageRequest,
});
}
}
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<div class="form-help-text">
${msg(
"Stage used to configure a mobile-based authenticator. This stage should be used for configuration flows.",
)}
</div>
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${first(this.instance?.name, "")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Authenticator type name")}
?required=${false}
name="friendlyName"
>
<input
type="text"
value="${first(this.instance?.friendlyName, "")}"
class="pf-c-form-control"
/>
<p class="pf-c-form__helper-text">
${msg(
"Display name of this authenticator, used by users when they enroll an authenticator.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-group .expanded=${true}>
<span slot="header"> ${msg("Stage-specific settings")} </span>
<div slot="body" class="pf-c-form">
<ak-form-element-horizontal
label=${msg("Configuration flow")}
name="configureFlow"
>
<ak-search-select
.fetchObjects=${async (query?: string): Promise<Flow[]> => {
const args: FlowsInstancesListRequest = {
ordering: "slug",
designation:
FlowsInstancesListDesignationEnum.StageConfiguration,
};
if (query !== undefined) {
args.search = query;
}
const flows = await new FlowsApi(DEFAULT_CONFIG).flowsInstancesList(
args,
);
return flows.results;
}}
.renderElement=${(flow: Flow): string => {
return RenderFlowOption(flow);
}}
.renderDescription=${(flow: Flow): TemplateResult => {
return html`${flow.name}`;
}}
.value=${(flow: Flow | undefined): string | undefined => {
return flow?.pk;
}}
.selected=${(flow: Flow): boolean => {
return this.instance?.configureFlow === flow.pk;
}}
?blankable=${true}
>
</ak-search-select>
<p class="pf-c-form__helper-text">
${msg(
"Flow used by an authenticated user to configure this Stage. If empty, user will not be able to configure this stage.",
)}
</p>
</ak-form-element-horizontal>
</div>
</ak-form-group>
</form>`;
}
}

View File

@ -337,6 +337,12 @@ export class FlowExecutor extends Interface implements StageHost {
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-duo>`;
case "ak-stage-authenticator-mobile":
await import("@goauthentik/flow/stages/authenticator_mobile/AuthenticatorMobileStage");
return html`<ak-stage-authenticator-mobile
.host=${this as StageHost}
.challenge=${this.challenge}
></ak-stage-authenticator-mobile>`;
case "ak-stage-authenticator-static":
await import(
"@goauthentik/flow/stages/authenticator_static/AuthenticatorStaticStage"

View File

@ -0,0 +1,81 @@
import "@goauthentik/elements/EmptyState";
import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/FormStatic";
import { BaseStage } from "@goauthentik/flow/stages/base";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html } from "lit";
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 {
AuthenticatorMobileChallenge,
AuthenticatorMobileChallengeResponseRequest,
} from "@goauthentik/api";
@customElement("ak-stage-authenticator-mobile")
export class AuthenticatorMobileStage extends BaseStage<
AuthenticatorMobileChallenge,
AuthenticatorMobileChallengeResponseRequest
> {
static get styles(): CSSResult[] {
return [
PFBase,
PFLogin,
PFForm,
PFFormControl,
PFTitle,
PFButton,
css`
.qr-container {
display: flex;
flex-direction: column;
place-items: center;
}
`,
];
}
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state ?loading="${true}" header=${msg("Loading")}>
</ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>
</header>
<div class="pf-c-login__main-body">
<form
class="pf-c-form"
@submit=${(e: Event) => {
this.submitForm(e);
}}
>
<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>
<div class="qr-container">
<qr-code data="${JSON.stringify(this.challenge)}"></qr-code>
</div>
</form>
</div>
<footer class="pf-c-login__main-footer">
<ul class="pf-c-login__main-footer-links"></ul>
</footer>`;
}
}