providers/saml: migrate import to API, add API tests

Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
Jens Langhammer 2021-04-01 19:28:12 +02:00
parent 4e3701ca8d
commit 5eb9b95ab5
13 changed files with 305 additions and 198 deletions

View file

@ -2,7 +2,6 @@
from django.urls import path from django.urls import path
from authentik.admin.views import policies, providers, sources, stages from authentik.admin.views import policies, providers, sources, stages
from authentik.providers.saml.views.metadata import MetadataImportView
urlpatterns = [ urlpatterns = [
# Sources # Sources
@ -25,11 +24,6 @@ urlpatterns = [
providers.ProviderCreateView.as_view(), providers.ProviderCreateView.as_view(),
name="provider-create", name="provider-create",
), ),
path(
"providers/create/saml/from-metadata/",
MetadataImportView.as_view(),
name="provider-saml-from-metadata",
),
path( path(
"providers/<int:pk>/update/", "providers/<int:pk>/update/",
providers.ProviderUpdateView.as_view(), providers.ProviderUpdateView.as_view(),

View file

@ -1,17 +1,33 @@
"""SAMLProvider API Views""" """SAMLProvider API Views"""
from xml.etree.ElementTree import ParseError # nosec
from defusedxml.ElementTree import fromstring
from django.http.response import HttpResponse
from django.utils.translation import gettext_lazy as _
from drf_yasg.utils import swagger_auto_schema from drf_yasg.utils import swagger_auto_schema
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import ReadOnlyField from rest_framework.fields import CharField, FileField, ReadOnlyField
from rest_framework.parsers import MultiPartParser
from rest_framework.relations import SlugRelatedField
from rest_framework.request import Request from rest_framework.request import Request
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.serializers import Serializer from rest_framework.serializers import ValidationError
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from structlog.stdlib import get_logger
from authentik.api.decorators import permission_required
from authentik.core.api.propertymappings import PropertyMappingSerializer from authentik.core.api.propertymappings import PropertyMappingSerializer
from authentik.core.api.providers import ProviderSerializer from authentik.core.api.providers import ProviderSerializer
from authentik.core.api.utils import PassiveSerializer
from authentik.core.models import Provider from authentik.core.models import Provider
from authentik.flows.models import Flow, FlowDesignation
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
from authentik.providers.saml.views.metadata import DescriptorDownloadView from authentik.providers.saml.processors.metadata import MetadataProcessor
from authentik.providers.saml.processors.metadata_parser import (
ServiceProviderMetadataParser,
)
LOGGER = get_logger()
class SAMLProviderSerializer(ProviderSerializer): class SAMLProviderSerializer(ProviderSerializer):
@ -33,19 +49,26 @@ class SAMLProviderSerializer(ProviderSerializer):
"signature_algorithm", "signature_algorithm",
"signing_kp", "signing_kp",
"verification_kp", "verification_kp",
"sp_binding",
] ]
class SAMLMetadataSerializer(Serializer): class SAMLMetadataSerializer(PassiveSerializer):
"""SAML Provider Metadata serializer""" """SAML Provider Metadata serializer"""
metadata = ReadOnlyField() metadata = ReadOnlyField()
def create(self, request: Request) -> Response:
raise NotImplementedError
def update(self, request: Request) -> Response: class SAMLProviderImportSerializer(PassiveSerializer):
raise NotImplementedError """Import saml provider from XML Metadata"""
name = CharField(required=True)
# Using SlugField because https://github.com/OpenAPITools/openapi-generator/issues/3278
authorization_flow = SlugRelatedField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION),
slug_field="slug",
)
file = FileField()
class SAMLProviderViewSet(ModelViewSet): class SAMLProviderViewSet(ModelViewSet):
@ -61,11 +84,53 @@ class SAMLProviderViewSet(ModelViewSet):
"""Return metadata as XML string""" """Return metadata as XML string"""
provider = self.get_object() provider = self.get_object()
try: try:
metadata = DescriptorDownloadView.get_metadata(request, provider) metadata = MetadataProcessor(provider, request).build_entity_descriptor()
if "download" in request._request.GET:
response = HttpResponse(metadata, content_type="application/xml")
response[
"Content-Disposition"
] = f'attachment; filename="{provider.name}_authentik_meta.xml"'
return response
return Response({"metadata": metadata}) return Response({"metadata": metadata})
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
return Response({"metadata": ""}) return Response({"metadata": ""})
@permission_required(
None,
[
"authentik_providers_saml.add_samlprovider",
"authentik_crypto.add_certificatekeypair",
],
)
@swagger_auto_schema(
request_body=SAMLProviderImportSerializer(),
responses={204: "Successfully imported provider", 400: "Bad request"},
)
@action(detail=False, methods=["POST"], parser_classes=(MultiPartParser,))
def import_metadata(self, request: Request) -> Response:
"""Create provider from SAML Metadata"""
data = SAMLProviderImportSerializer(data=request.data)
if not data.is_valid():
raise ValidationError(data.errors)
file = data.validated_data["file"]
# Validate syntax first
try:
fromstring(file.read())
except ParseError:
raise ValidationError(_("Invalid XML Syntax"))
file.seek(0)
try:
metadata = ServiceProviderMetadataParser().parse(file.read().decode())
metadata.to_provider(
data.validated_data["name"], data.validated_data["authorization_flow"]
)
except ValueError as exc: # pragma: no cover
LOGGER.warning(str(exc))
return ValidationError(
_("Failed to import Metadata: %(message)s" % {"message": str(exc)}),
)
return Response(status=204)
class SAMLPropertyMappingSerializer(PropertyMappingSerializer): class SAMLPropertyMappingSerializer(PropertyMappingSerializer):
"""SAMLPropertyMapping Serializer""" """SAMLPropertyMapping Serializer"""

View file

@ -1,78 +0,0 @@
"""authentik SAML IDP Forms"""
from xml.etree.ElementTree import ParseError # nosec
from defusedxml.ElementTree import fromstring
from django import forms
from django.core.exceptions import ValidationError
from django.core.validators import FileExtensionValidator
from django.utils.translation import gettext_lazy as _
from authentik.crypto.models import CertificateKeyPair
from authentik.flows.models import Flow, FlowDesignation
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
class SAMLProviderForm(forms.ModelForm):
"""SAML Provider form"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["authorization_flow"].queryset = Flow.objects.filter(
designation=FlowDesignation.AUTHORIZATION
)
self.fields["property_mappings"].queryset = SAMLPropertyMapping.objects.all()
self.fields["signing_kp"].queryset = CertificateKeyPair.objects.exclude(
key_data__iexact=""
)
class Meta:
model = SAMLProvider
fields = [
"name",
"authorization_flow",
"acs_url",
"issuer",
"sp_binding",
"audience",
"signing_kp",
"verification_kp",
"property_mappings",
"name_id_mapping",
"assertion_valid_not_before",
"assertion_valid_not_on_or_after",
"session_valid_not_on_or_after",
"digest_algorithm",
"signature_algorithm",
]
widgets = {
"name": forms.TextInput(),
"audience": forms.TextInput(),
"issuer": forms.TextInput(),
"assertion_valid_not_before": forms.TextInput(),
"assertion_valid_not_on_or_after": forms.TextInput(),
"session_valid_not_on_or_after": forms.TextInput(),
}
class SAMLProviderImportForm(forms.Form):
"""Create a SAML Provider from SP Metadata."""
provider_name = forms.CharField()
authorization_flow = forms.ModelChoiceField(
queryset=Flow.objects.filter(designation=FlowDesignation.AUTHORIZATION)
)
metadata = forms.FileField(
validators=[FileExtensionValidator(allowed_extensions=["xml"])]
)
def clean_metadata(self):
"""Check if the flow is valid XML"""
metadata = self.cleaned_data["metadata"].read()
try:
fromstring(metadata)
except ParseError:
raise ValidationError(_("Invalid XML Syntax"))
self.cleaned_data["metadata"].seek(0)
return self.cleaned_data["metadata"]

View file

@ -3,7 +3,6 @@ from typing import Optional, Type
from urllib.parse import urlparse from urllib.parse import urlparse
from django.db import models from django.db import models
from django.forms import ModelForm
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework.serializers import Serializer from rest_framework.serializers import Serializer
from structlog.stdlib import get_logger from structlog.stdlib import get_logger
@ -171,10 +170,8 @@ class SAMLProvider(Provider):
return SAMLProviderSerializer return SAMLProviderSerializer
@property @property
def form(self) -> Type[ModelForm]: def component(self) -> str:
from authentik.providers.saml.forms import SAMLProviderForm return "ak-provider-saml-form"
return SAMLProviderForm
def __str__(self): def __str__(self):
return f"SAML Provider {self.name}" return f"SAML Provider {self.name}"

View file

@ -0,0 +1,115 @@
"""SAML Provider API Tests"""
from tempfile import TemporaryFile
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Application, User
from authentik.flows.models import Flow, FlowDesignation
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.tests.test_metadata import METADATA_SIMPLE
class TestSAMLProviderAPI(APITestCase):
"""SAML Provider API Tests"""
def setUp(self) -> None:
super().setUp()
self.user = User.objects.get(username="akadmin")
self.client.force_login(self.user)
def test_metadata(self):
"""Test metadata export (normal)"""
provider = SAMLProvider.objects.create(
name="test",
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
)
Application.objects.create(name="test", provider=provider, slug="test")
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}),
)
self.assertEqual(200, response.status_code)
def test_metadata_download(self):
"""Test metadata export (download)"""
provider = SAMLProvider.objects.create(
name="test",
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
)
Application.objects.create(name="test", provider=provider, slug="test")
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk})
+ "?download",
)
self.assertEqual(200, response.status_code)
self.assertIn("Content-Disposition", response)
def test_metadata_invalid(self):
"""Test metadata export (invalid)"""
# Provider without application
provider = SAMLProvider.objects.create(
name="test",
authorization_flow=Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
),
)
response = self.client.get(
reverse("authentik_api:samlprovider-metadata", kwargs={"pk": provider.pk}),
)
self.assertEqual(200, response.status_code)
def test_import_success(self):
"""Test metadata import (success case)"""
with TemporaryFile() as metadata:
metadata.write(METADATA_SIMPLE.encode())
metadata.seek(0)
response = self.client.post(
reverse("authentik_api:samlprovider-import-metadata"),
{
"file": metadata,
"name": "test",
"authorization_flow": Flow.objects.filter(
designation=FlowDesignation.AUTHORIZATION
)
.first()
.pk,
},
format="multipart",
)
self.assertEqual(204, response.status_code)
# We don't test the actual object being created here, that has its own tests
def test_import_failed(self):
"""Test metadata import (invalid xml)"""
with TemporaryFile() as metadata:
metadata.write(b"invalid")
metadata.seek(0)
response = self.client.post(
reverse("authentik_api:samlprovider-import-metadata"),
{
"file": metadata,
"name": "test",
"authorization_flow": Flow.objects.filter(
designation=FlowDesignation.AUTHORIZATION
)
.first()
.pk,
},
format="multipart",
)
self.assertEqual(400, response.status_code)
def test_import_invalid(self):
"""Test metadata import (invalid input)"""
response = self.client.post(
reverse("authentik_api:samlprovider-import-metadata"),
{
"name": "test",
},
format="multipart",
)
self.assertEqual(400, response.status_code)

View file

@ -1,7 +1,7 @@
"""authentik SAML IDP URLs""" """authentik SAML IDP URLs"""
from django.urls import path from django.urls import path
from authentik.providers.saml.views import metadata, sso from authentik.providers.saml.views import sso
urlpatterns = [ urlpatterns = [
# SSO Bindings # SSO Bindings
@ -21,9 +21,4 @@ urlpatterns = [
sso.SAMLSSOBindingInitView.as_view(), sso.SAMLSSOBindingInitView.as_view(),
name="sso-init", name="sso-init",
), ),
path(
"<slug:application_slug>/metadata/",
metadata.DescriptorDownloadView.as_view(),
name="metadata",
),
] ]

View file

@ -1,81 +0,0 @@
"""authentik SAML IDP Views"""
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpRequest, HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.generic.edit import FormView
from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider
from authentik.lib.views import bad_request_message
from authentik.providers.saml.forms import SAMLProviderImportForm
from authentik.providers.saml.models import SAMLProvider
from authentik.providers.saml.processors.metadata import MetadataProcessor
from authentik.providers.saml.processors.metadata_parser import (
ServiceProviderMetadataParser,
)
LOGGER = get_logger()
class DescriptorDownloadView(View):
"""Replies with the XML Metadata IDSSODescriptor."""
@staticmethod
def get_metadata(request: HttpRequest, provider: SAMLProvider) -> str:
"""Return rendered XML Metadata"""
return MetadataProcessor(provider, request).build_entity_descriptor()
def get(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""Replies with the XML Metadata IDSSODescriptor."""
application = get_object_or_404(Application, slug=application_slug)
provider: SAMLProvider = get_object_or_404(
SAMLProvider, pk=application.provider_id
)
try:
metadata = DescriptorDownloadView.get_metadata(request, provider)
except Provider.application.RelatedObjectDoesNotExist: # pylint: disable=no-member
return bad_request_message(
request, "Provider is not assigned to an application."
)
else:
response = HttpResponse(metadata, content_type="application/xml")
response[
"Content-Disposition"
] = f'attachment; filename="{provider.name}_authentik_meta.xml"'
return response
class MetadataImportView(LoginRequiredMixin, FormView):
"""Import Metadata from XML, and create provider"""
form_class = SAMLProviderImportForm
template_name = "providers/saml/import.html"
success_url = "/"
def dispatch(self, request, *args, **kwargs):
if not request.user.is_superuser:
return self.handle_no_permission()
return super().dispatch(request, *args, **kwargs)
def form_valid(self, form: SAMLProviderImportForm) -> HttpResponse:
try:
metadata = ServiceProviderMetadataParser().parse(
form.cleaned_data["metadata"].read().decode()
)
metadata.to_provider(
form.cleaned_data["provider_name"],
form.cleaned_data["authorization_flow"],
)
messages.success(self.request, _("Successfully created Provider"))
except ValueError as exc:
LOGGER.warning(str(exc))
messages.error(
self.request,
_("Failed to import Metadata: %(message)s" % {"message": str(exc)}),
)
return super().form_invalid(form)
return super().form_valid(form)

View file

@ -9227,6 +9227,42 @@ paths:
tags: tags:
- providers - providers
parameters: [] parameters: []
/providers/saml/import_metadata/:
post:
operationId: providers_saml_import_metadata
description: Create provider from SAML Metadata
parameters:
- name: name
in: formData
required: true
type: string
minLength: 1
- name: authorization_flow
in: formData
required: true
type: string
format: slug
pattern: ^[-a-zA-Z0-9_]+$
- name: file
in: formData
required: true
type: file
responses:
'204':
description: Successfully imported provider
'400':
description: Invalid input.
schema:
$ref: '#/definitions/ValidationError'
'403':
description: Authentication credentials were invalid, absent or insufficient.
schema:
$ref: '#/definitions/GenericError'
consumes:
- multipart/form-data
tags:
- providers
parameters: []
/providers/saml/{id}/: /providers/saml/{id}/:
get: get:
operationId: providers_saml_read operationId: providers_saml_read

View file

@ -12,6 +12,7 @@ const resources = [
{ src: "node_modules/@patternfly/patternfly/patternfly-base.css", dest: "dist/" }, { src: "node_modules/@patternfly/patternfly/patternfly-base.css", dest: "dist/" },
{ src: "node_modules/@patternfly/patternfly/patternfly.min.css", dest: "dist/" }, { src: "node_modules/@patternfly/patternfly/patternfly.min.css", dest: "dist/" },
{ src: "node_modules/@patternfly/patternfly/patternfly.min.css.map", dest: "dist/" },
{ src: "src/authentik.css", dest: "dist/" }, { src: "src/authentik.css", dest: "dist/" },
{ src: "node_modules/@patternfly/patternfly/assets/*", dest: "dist/assets/" }, { src: "node_modules/@patternfly/patternfly/assets/*", dest: "dist/assets/" },

View file

@ -22,9 +22,6 @@ export class AppURLManager {
static sourceOAuth(slug: string, action: string): string { static sourceOAuth(slug: string, action: string): string {
return `/source/oauth/${action}/${slug}/`; return `/source/oauth/${action}/${slug}/`;
} }
static providerSAML(rest: string): string {
return `/application/saml/${rest}`;
}
} }

View file

@ -11,6 +11,7 @@ import "../../elements/forms/ProxyForm";
import "./oauth2/OAuth2ProviderForm"; import "./oauth2/OAuth2ProviderForm";
import "./proxy/ProxyProviderForm"; import "./proxy/ProxyProviderForm";
import "./saml/SAMLProviderForm"; import "./saml/SAMLProviderForm";
import "./saml/SAMLProviderImportForm";
import { TableColumn } from "../../elements/table/Table"; import { TableColumn } from "../../elements/table/Table";
import { until } from "lit-html/directives/until"; import { until } from "lit-html/directives/until";
import { PAGE_SIZE } from "../../constants"; import { PAGE_SIZE } from "../../constants";

View file

@ -0,0 +1,64 @@
import { FlowDesignationEnum, FlowsApi, ProvidersApi, SAMLProvider } from "authentik-api";
import { gettext } from "django";
import { customElement } from "lit-element";
import { html, TemplateResult } from "lit-html";
import { ifDefined } from "lit-html/directives/if-defined";
import { until } from "lit-html/directives/until";
import { DEFAULT_CONFIG } from "../../../api/Config";
import { Form } from "../../../elements/forms/Form";
import "../../../elements/forms/HorizontalFormElement";
@customElement("ak-provider-saml-import-form")
export class SAMLProviderImportForm extends Form<SAMLProvider> {
getSuccessMessage(): string {
return gettext("Successfully imported provider.");
}
// eslint-disable-next-line
send = (data: SAMLProvider): Promise<void> => {
const file = this.getFormFile();
if (!file) {
throw new Error("No form data");
}
return new ProvidersApi(DEFAULT_CONFIG).providersSamlImportMetadata({
file: file,
name: data.name,
authorizationFlow: data.authorizationFlow,
});
};
renderForm(): TemplateResult {
return html`<form class="pf-c-form pf-m-horizontal">
<ak-form-element-horizontal
label=${gettext("Name")}
?required=${true}
name="name">
<input type="text" class="pf-c-form-control" required>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Authorization flow")}
?required=${true}
name="authorizationFlow">
<select class="pf-c-form-control">
${until(new FlowsApi(DEFAULT_CONFIG).flowsInstancesList({
ordering: "pk",
designation: FlowDesignationEnum.Authorization,
}).then(flows => {
return flows.results.map(flow => {
return html`<option value=${ifDefined(flow.pk)}>${flow.name}</option>`;
});
}))}
</select>
<p class="pf-c-form__helper-text">${gettext("Flow used when authorizing this provider.")}</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${gettext("Metadata")}
name="flow">
<input type="file" value="" class="pf-c-form-control">
</ak-form-element-horizontal>
</form>`;
}
}

View file

@ -12,6 +12,7 @@ import PFFlex from "@patternfly/patternfly/utilities/Flex/flex.css";
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import AKGlobal from "../../../authentik.css"; import AKGlobal from "../../../authentik.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css"; import PFBase from "@patternfly/patternfly/patternfly-base.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import "../../../elements/buttons/ModalButton"; import "../../../elements/buttons/ModalButton";
import "../../../elements/buttons/SpinnerButton"; import "../../../elements/buttons/SpinnerButton";
@ -23,7 +24,6 @@ import "./SAMLProviderForm";
import { Page } from "../../../elements/Page"; import { Page } from "../../../elements/Page";
import { ProvidersApi, SAMLProvider } from "authentik-api"; import { ProvidersApi, SAMLProvider } from "authentik-api";
import { DEFAULT_CONFIG } from "../../../api/Config"; import { DEFAULT_CONFIG } from "../../../api/Config";
import { AppURLManager } from "../../../api/legacy";
import { EVENT_REFRESH } from "../../../constants"; import { EVENT_REFRESH } from "../../../constants";
import { ifDefined } from "lit-html/directives/if-defined"; import { ifDefined } from "lit-html/directives/if-defined";
@ -55,7 +55,7 @@ export class SAMLProviderViewPage extends Page {
provider?: SAMLProvider; provider?: SAMLProvider;
static get styles(): CSSResult[] { static get styles(): CSSResult[] {
return [PFBase, PFPage, PFFlex, PFDisplay, PFGallery, PFContent, PFCard, PFDescriptionList, PFSizing, AKGlobal]; return [PFBase, PFPage, PFButton, PFFlex, PFDisplay, PFGallery, PFContent, PFCard, PFDescriptionList, PFSizing, AKGlobal];
} }
constructor() { constructor() {
@ -153,27 +153,28 @@ export class SAMLProviderViewPage extends Page {
</div> </div>
</div> </div>
</section> </section>
${this.provider.assignedApplicationName ? html`
<section slot="page-3" data-tab-title="${gettext("Metadata")}" class="pf-c-page__main-section pf-m-no-padding-mobile"> <section slot="page-3" data-tab-title="${gettext("Metadata")}" class="pf-c-page__main-section pf-m-no-padding-mobile">
<div class="pf-u-display-flex pf-u-justify-content-center"> <div class="pf-u-display-flex pf-u-justify-content-center">
<div class="pf-u-w-75"> <div class="pf-u-w-75">
<div class="pf-c-card"> <div class="pf-c-card">
<div class="pf-c-card__body"> <div class="pf-c-card__body">
${until( ${until(new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({
new ProvidersApi(DEFAULT_CONFIG).providersSamlMetadata({ id: this.provider.pk || 0,
id: this.provider.pk || 0, }).then(m => {
}).then(m => { return html`<ak-codemirror mode="xml" ?readOnly=${true} value="${ifDefined(m.metadata)}"></ak-codemirror>`;
return html`<ak-codemirror mode="xml" ?readOnly=${true} value="${ifDefined(m.metadata)}"></ak-codemirror>`; }))}
})
)}
</div> </div>
<div class="pf-c-card__footer"> <div class="pf-c-card__footer">
<a class="pf-c-button pf-m-primary" target="_blank" href="${AppURLManager.providerSAML(`${this.provider.assignedApplicationSlug}/metadata/`)}"> <a class="pf-c-button pf-m-primary" target="_blank"
href="/api/v2beta/providers/saml/${this.provider.pk}/metadata/?download">
${gettext("Download")} ${gettext("Download")}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
` : html``}
</section> </section>
</ak-tabs>`; </ak-tabs>`;
} }