tenants: add web certificate field, make authentik's core certificate configurable based on keypair
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org>
This commit is contained in:
parent
9e2492be5c
commit
34b11524f1
|
@ -44,7 +44,7 @@ class CertificateBuilder:
|
|||
"""Build self-signed certificate"""
|
||||
one_day = datetime.timedelta(1, 0, 0)
|
||||
self.__private_key = rsa.generate_private_key(
|
||||
public_exponent=65537, key_size=2048, backend=default_backend()
|
||||
public_exponent=65537, key_size=4096, backend=default_backend()
|
||||
)
|
||||
self.__public_key = self.__private_key.public_key()
|
||||
alt_names: list[x509.GeneralName] = [x509.DNSName(x) for x in subject_alt_names or []]
|
||||
|
|
|
@ -45,6 +45,7 @@ from authentik.lib.utils.errors import exception_to_string
|
|||
from authentik.managed.models import ManagedModel
|
||||
from authentik.outposts.controllers.k8s.utils import get_namespace
|
||||
from authentik.outposts.docker_tls import DockerInlineTLS
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
OUR_VERSION = parse(__version__)
|
||||
OUTPOST_HELLO_INTERVAL = 10
|
||||
|
@ -385,7 +386,8 @@ class Outpost(ManagedModel):
|
|||
user.user_permissions.add(permission.first())
|
||||
LOGGER.debug(
|
||||
"Updated service account's permissions",
|
||||
perms=UserObjectPermission.objects.filter(user=user),
|
||||
obj_perms=UserObjectPermission.objects.filter(user=user),
|
||||
perms=user.user_permissions.all(),
|
||||
)
|
||||
|
||||
@property
|
||||
|
@ -449,6 +451,10 @@ class Outpost(ManagedModel):
|
|||
objects.extend(provider.get_required_objects())
|
||||
else:
|
||||
objects.append(provider)
|
||||
if self.managed:
|
||||
for tenant in Tenant.objects.filter(web_certificate__isnull=False):
|
||||
objects.append(tenant)
|
||||
objects.append(tenant.web_certificate)
|
||||
return objects
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
|
|
@ -10,6 +10,7 @@ from authentik.crypto.models import CertificateKeyPair
|
|||
from authentik.lib.utils.reflection import class_to_path
|
||||
from authentik.outposts.models import Outpost, OutpostServiceConnection
|
||||
from authentik.outposts.tasks import CACHE_KEY_OUTPOST_DOWN, outpost_controller, outpost_post_save
|
||||
from authentik.tenants.models import Tenant
|
||||
|
||||
LOGGER = get_logger()
|
||||
UPDATE_TRIGGERING_MODELS = (
|
||||
|
@ -17,6 +18,7 @@ UPDATE_TRIGGERING_MODELS = (
|
|||
OutpostServiceConnection,
|
||||
Provider,
|
||||
CertificateKeyPair,
|
||||
Tenant,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -39,6 +39,7 @@ class TenantSerializer(ModelSerializer):
|
|||
"flow_recovery",
|
||||
"flow_unenrollment",
|
||||
"event_retention",
|
||||
"web_certificate",
|
||||
]
|
||||
|
||||
|
||||
|
@ -69,6 +70,7 @@ class TenantViewSet(UsedByMixin, ModelViewSet):
|
|||
search_fields = [
|
||||
"domain",
|
||||
"branding_title",
|
||||
"web_certificate__name",
|
||||
]
|
||||
filterset_fields = "__all__"
|
||||
ordering = ["domain"]
|
||||
|
|
|
@ -0,0 +1,146 @@
|
|||
# Generated by Django 4.0 on 2021-12-22 09:42
|
||||
|
||||
import uuid
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.apps.registry import Apps
|
||||
from django.db import migrations, models
|
||||
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
||||
|
||||
import authentik.lib.utils.time
|
||||
|
||||
|
||||
def create_default_tenant(apps: Apps, schema_editor: BaseDatabaseSchemaEditor):
|
||||
Flow = apps.get_model("authentik_flows", "Flow")
|
||||
Tenant = apps.get_model("authentik_tenants", "Tenant")
|
||||
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
default_authentication = (
|
||||
Flow.objects.using(db_alias).filter(slug="default-authentication-flow").first()
|
||||
)
|
||||
default_invalidation = (
|
||||
Flow.objects.using(db_alias).filter(slug="default-invalidation-flow").first()
|
||||
)
|
||||
|
||||
tenant, _ = Tenant.objects.using(db_alias).update_or_create(
|
||||
domain="authentik-default",
|
||||
default=True,
|
||||
defaults={
|
||||
"flow_authentication": default_authentication,
|
||||
"flow_invalidation": default_invalidation,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("authentik_tenants", "0001_initial"),
|
||||
("authentik_tenants", "0002_default"),
|
||||
("authentik_tenants", "0003_tenant_branding_favicon"),
|
||||
("authentik_tenants", "0004_tenant_event_retention"),
|
||||
("authentik_tenants", "0005_tenant_web_certificate"),
|
||||
]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("authentik_flows", "0018_oob_flows"),
|
||||
("authentik_flows", "0008_default_flows"),
|
||||
("authentik_crypto", "0003_certificatekeypair_managed"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="Tenant",
|
||||
fields=[
|
||||
(
|
||||
"tenant_uuid",
|
||||
models.UUIDField(
|
||||
default=uuid.uuid4, editable=False, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"domain",
|
||||
models.TextField(
|
||||
help_text="Domain that activates this tenant. Can be a superset, i.e. `a.b` for `aa.b` and `ba.b`"
|
||||
),
|
||||
),
|
||||
("default", models.BooleanField(default=False)),
|
||||
("branding_title", models.TextField(default="authentik")),
|
||||
(
|
||||
"branding_logo",
|
||||
models.TextField(default="/static/dist/assets/icons/icon_left_brand.svg"),
|
||||
),
|
||||
(
|
||||
"flow_authentication",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_authentication",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
(
|
||||
"flow_invalidation",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_invalidation",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
(
|
||||
"flow_recovery",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_recovery",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
(
|
||||
"flow_unenrollment",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="tenant_unenrollment",
|
||||
to="authentik_flows.flow",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Tenant",
|
||||
"verbose_name_plural": "Tenants",
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=create_default_tenant,
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="branding_favicon",
|
||||
field=models.TextField(default="/static/dist/assets/icons/icon.png"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="event_retention",
|
||||
field=models.TextField(
|
||||
default="days=365",
|
||||
help_text="Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).",
|
||||
validators=[authentik.lib.utils.time.timedelta_string_validator],
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="web_certificate",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="Web Certificate used by the authentik Core webserver.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 4.0 on 2021-12-22 09:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_crypto", "0003_certificatekeypair_managed"),
|
||||
("authentik_tenants", "0004_tenant_event_retention"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tenant",
|
||||
name="web_certificate",
|
||||
field=models.ForeignKey(
|
||||
default=None,
|
||||
help_text="Web Certificate used by the authentik Core webserver.",
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_DEFAULT,
|
||||
to="authentik_crypto.certificatekeypair",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -4,6 +4,7 @@ from uuid import uuid4
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from authentik.crypto.models import CertificateKeyPair
|
||||
from authentik.flows.models import Flow
|
||||
from authentik.lib.utils.time import timedelta_string_validator
|
||||
|
||||
|
@ -51,6 +52,14 @@ class Tenant(models.Model):
|
|||
),
|
||||
)
|
||||
|
||||
web_certificate = models.ForeignKey(
|
||||
CertificateKeyPair,
|
||||
null=True,
|
||||
default=None,
|
||||
on_delete=models.SET_DEFAULT,
|
||||
help_text=_(("Web Certificate used by the authentik Core webserver.")),
|
||||
)
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.default:
|
||||
return "Default tenant"
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
"goauthentik.io/internal/outpost/ak"
|
||||
"goauthentik.io/internal/outpost/proxyv2"
|
||||
"goauthentik.io/internal/web"
|
||||
"goauthentik.io/internal/web/tenant_tls"
|
||||
)
|
||||
|
||||
var running = true
|
||||
|
@ -110,6 +111,12 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) {
|
|||
}
|
||||
continue
|
||||
}
|
||||
// Init tenant_tls here too since it requires an API Client,
|
||||
// so we just re-use the same one as the outpost uses
|
||||
tw := tenant_tls.NewWatcher(ac.Client)
|
||||
go tw.Start()
|
||||
ws.TenantTLS = tw
|
||||
|
||||
srv := proxyv2.NewProxyServer(ac, 0)
|
||||
ws.ProxyServer = srv
|
||||
ac.Server = srv
|
||||
|
|
|
@ -6,7 +6,6 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type tracingTransport struct {
|
||||
|
@ -26,9 +25,5 @@ func (tt *tracingTransport) RoundTrip(r *http.Request) (*http.Response, error) {
|
|||
span.SetTag("method", r.Method)
|
||||
defer span.Finish()
|
||||
res, err := tt.inner.RoundTrip(r.WithContext(span.Context()))
|
||||
log.WithFields(log.Fields{
|
||||
"url": r.URL.String(),
|
||||
"method": r.Method,
|
||||
}).Trace("http request")
|
||||
return res, err
|
||||
}
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
package tenant_tls
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"goauthentik.io/api"
|
||||
"goauthentik.io/internal/crypto"
|
||||
"goauthentik.io/internal/outpost/ak"
|
||||
)
|
||||
|
||||
type Watcher struct {
|
||||
client *api.APIClient
|
||||
log *log.Entry
|
||||
cs *ak.CryptoStore
|
||||
fallback *tls.Certificate
|
||||
tenants []api.Tenant
|
||||
}
|
||||
|
||||
func NewWatcher(client *api.APIClient) *Watcher {
|
||||
cs := ak.NewCryptoStore(client.CryptoApi)
|
||||
l := log.WithField("logger", "authentik.router.tenant_tls")
|
||||
cert, err := crypto.GenerateSelfSignedCert()
|
||||
if err != nil {
|
||||
l.WithError(err).Error("failed to generate default cert")
|
||||
}
|
||||
|
||||
return &Watcher{
|
||||
client: client,
|
||||
log: l,
|
||||
cs: cs,
|
||||
fallback: &cert,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) Start() {
|
||||
ticker := time.NewTicker(time.Minute * 3)
|
||||
w.log.Info("Starting Tenant TLS Checker")
|
||||
for ; true; <-ticker.C {
|
||||
w.Check()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watcher) Check() {
|
||||
tenants, _, err := w.client.CoreApi.CoreTenantsListExecute(api.ApiCoreTenantsListRequest{})
|
||||
if err != nil {
|
||||
w.log.WithError(err).Warning("failed to get tenants")
|
||||
return
|
||||
}
|
||||
for _, t := range tenants.Results {
|
||||
if t.WebCertificate.IsSet() {
|
||||
err := w.cs.AddKeypair(*t.WebCertificate.Get())
|
||||
if err != nil {
|
||||
w.log.WithError(err).Warning("failed to add certificate")
|
||||
}
|
||||
}
|
||||
}
|
||||
w.tenants = tenants.Results
|
||||
}
|
||||
|
||||
func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
var bestSelection *api.Tenant
|
||||
for _, t := range w.tenants {
|
||||
if !t.WebCertificate.IsSet() {
|
||||
continue
|
||||
}
|
||||
if *t.Default {
|
||||
bestSelection = &t
|
||||
}
|
||||
if strings.HasSuffix(ch.ServerName, t.Domain) {
|
||||
bestSelection = &t
|
||||
}
|
||||
}
|
||||
if bestSelection == nil {
|
||||
return w.fallback, nil
|
||||
}
|
||||
cert := w.cs.Get(*bestSelection.WebCertificate.Get())
|
||||
return cert, nil
|
||||
}
|
|
@ -22,6 +22,9 @@ func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Certif
|
|||
return appCert, nil
|
||||
}
|
||||
}
|
||||
if ws.TenantTLS != nil {
|
||||
return ws.TenantTLS.GetCertificate(ch)
|
||||
}
|
||||
ws.log.Trace("using default, self-signed certificate")
|
||||
return &cert, nil
|
||||
}
|
||||
|
|
|
@ -15,17 +15,17 @@ import (
|
|||
"goauthentik.io/internal/gounicorn"
|
||||
"goauthentik.io/internal/outpost/proxyv2"
|
||||
"goauthentik.io/internal/utils/web"
|
||||
"goauthentik.io/internal/web/tenant_tls"
|
||||
)
|
||||
|
||||
type WebServer struct {
|
||||
Bind string
|
||||
BindTLS bool
|
||||
|
||||
LegacyProxy bool
|
||||
|
||||
stop chan struct{} // channel for waiting shutdown
|
||||
|
||||
ProxyServer *proxyv2.ProxyServer
|
||||
TenantTLS *tenant_tls.Watcher
|
||||
|
||||
m *mux.Router
|
||||
lh *mux.Router
|
||||
|
@ -43,8 +43,6 @@ func NewWebServer(g *gounicorn.GoUnicorn) *WebServer {
|
|||
logginRouter.Use(web.NewLoggingHandler(l, nil))
|
||||
|
||||
ws := &WebServer{
|
||||
LegacyProxy: true,
|
||||
|
||||
m: mainHandler,
|
||||
lh: logginRouter,
|
||||
log: l,
|
||||
|
|
20
schema.yml
20
schema.yml
|
@ -2371,6 +2371,11 @@ paths:
|
|||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: web_certificate
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
tags:
|
||||
- core
|
||||
security:
|
||||
|
@ -28211,6 +28216,11 @@ components:
|
|||
type: string
|
||||
minLength: 1
|
||||
description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).'
|
||||
web_certificate:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: Web Certificate used by the authentik Core webserver.
|
||||
PatchedTokenRequest:
|
||||
type: object
|
||||
description: Token Serializer
|
||||
|
@ -30575,6 +30585,11 @@ components:
|
|||
event_retention:
|
||||
type: string
|
||||
description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).'
|
||||
web_certificate:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: Web Certificate used by the authentik Core webserver.
|
||||
required:
|
||||
- domain
|
||||
- tenant_uuid
|
||||
|
@ -30618,6 +30633,11 @@ components:
|
|||
type: string
|
||||
minLength: 1
|
||||
description: 'Events will be deleted after this duration.(Format: weeks=3;days=2;hours=3,seconds=2).'
|
||||
web_certificate:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
description: Web Certificate used by the authentik Core webserver.
|
||||
required:
|
||||
- domain
|
||||
Token:
|
||||
|
|
|
@ -2709,6 +2709,7 @@ msgstr "Loading"
|
|||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tokens/TokenForm.ts
|
||||
#: src/pages/users/UserForm.ts
|
||||
#: src/pages/users/UserResetEmailForm.ts
|
||||
|
@ -5809,6 +5810,10 @@ msgstr "Warning: You're about to delete the user you're logged in as ({0}). Proc
|
|||
msgid "Warning: authentik Domain is not configured, authentication will not work."
|
||||
msgstr "Warning: authentik Domain is not configured, authentication will not work."
|
||||
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
msgid "Web Certificate"
|
||||
msgstr "Web Certificate"
|
||||
|
||||
#: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts
|
||||
msgid "WebAuthn Authenticators"
|
||||
msgstr "WebAuthn Authenticators"
|
||||
|
|
|
@ -2688,6 +2688,7 @@ msgstr "Chargement en cours"
|
|||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tokens/TokenForm.ts
|
||||
#: src/pages/users/UserForm.ts
|
||||
#: src/pages/users/UserResetEmailForm.ts
|
||||
|
@ -5747,6 +5748,10 @@ msgstr ""
|
|||
msgid "Warning: authentik Domain is not configured, authentication will not work."
|
||||
msgstr "Avertissement : le domaine d'authentik n'est pas configuré, l'authentification ne fonctionnera pas."
|
||||
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
msgid "Web Certificate"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts
|
||||
msgid "WebAuthn Authenticators"
|
||||
msgstr "Authentificateurs WebAuthn"
|
||||
|
|
|
@ -2699,6 +2699,7 @@ msgstr ""
|
|||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
#: src/pages/tokens/TokenForm.ts
|
||||
#: src/pages/users/UserForm.ts
|
||||
#: src/pages/users/UserResetEmailForm.ts
|
||||
|
@ -5789,6 +5790,10 @@ msgstr ""
|
|||
msgid "Warning: authentik Domain is not configured, authentication will not work."
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/tenants/TenantForm.ts
|
||||
msgid "Web Certificate"
|
||||
msgstr ""
|
||||
|
||||
#: src/pages/stages/authenticator_validate/AuthenticatorValidateStageForm.ts
|
||||
msgid "WebAuthn Authenticators"
|
||||
msgstr ""
|
||||
|
|
|
@ -2,9 +2,16 @@ import { t } from "@lingui/macro";
|
|||
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
|
||||
import { CoreApi, FlowsApi, FlowsInstancesListDesignationEnum, Tenant } from "@goauthentik/api";
|
||||
import {
|
||||
CoreApi,
|
||||
CryptoApi,
|
||||
FlowsApi,
|
||||
FlowsInstancesListDesignationEnum,
|
||||
Tenant,
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { DEFAULT_CONFIG } from "../../api/Config";
|
||||
import "../../elements/forms/FormGroup";
|
||||
|
@ -297,6 +304,35 @@ export class TenantForm extends ModelForm<Tenant, string> {
|
|||
${t`Format: "weeks=3;days=2;hours=3,seconds=2".`}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${t`Web Certificate`} name="webCertificate">
|
||||
<select class="pf-c-form-control">
|
||||
<option
|
||||
value=""
|
||||
?selected=${this.instance?.webCertificate === undefined}
|
||||
>
|
||||
---------
|
||||
</option>
|
||||
${until(
|
||||
new CryptoApi(DEFAULT_CONFIG)
|
||||
.cryptoCertificatekeypairsList({
|
||||
ordering: "name",
|
||||
hasKey: true,
|
||||
})
|
||||
.then((keys) => {
|
||||
return keys.results.map((key) => {
|
||||
return html`<option
|
||||
value=${ifDefined(key.pk)}
|
||||
?selected=${this.instance?.webCertificate ===
|
||||
key.pk}
|
||||
>
|
||||
${key.name}
|
||||
</option>`;
|
||||
});
|
||||
}),
|
||||
html`<option>${t`Loading...`}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</ak-form-element-horizontal>
|
||||
</div>
|
||||
</ak-form-group>
|
||||
</form>`;
|
||||
|
|
Reference in New Issue