crypto: add certificate discovery to automatically import certificates from lets encrypt
Signed-off-by: Jens Langhammer <jens.langhammer@beryju.org> #1835
This commit is contained in:
parent
8db68410c6
commit
572f6d4ea0
|
@ -20,6 +20,7 @@ from authentik.api.decorators import permission_required
|
||||||
from authentik.core.api.used_by import UsedByMixin
|
from authentik.core.api.used_by import UsedByMixin
|
||||||
from authentik.core.api.utils import PassiveSerializer
|
from authentik.core.api.utils import PassiveSerializer
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
|
from authentik.crypto.managed import MANAGED_KEY
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
from authentik.events.models import Event, EventAction
|
from authentik.events.models import Event, EventAction
|
||||||
|
|
||||||
|
@ -141,7 +142,7 @@ class CertificateKeyPairFilter(FilterSet):
|
||||||
class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet):
|
||||||
"""CertificateKeyPair Viewset"""
|
"""CertificateKeyPair Viewset"""
|
||||||
|
|
||||||
queryset = CertificateKeyPair.objects.exclude(managed__isnull=False)
|
queryset = CertificateKeyPair.objects.exclude(managed=MANAGED_KEY)
|
||||||
serializer_class = CertificateKeyPairSerializer
|
serializer_class = CertificateKeyPairSerializer
|
||||||
filterset_class = CertificateKeyPairFilter
|
filterset_class = CertificateKeyPairFilter
|
||||||
ordering = ["name"]
|
ordering = ["name"]
|
||||||
|
|
|
@ -13,3 +13,4 @@ class AuthentikCryptoConfig(AppConfig):
|
||||||
|
|
||||||
def ready(self):
|
def ready(self):
|
||||||
import_module("authentik.crypto.managed")
|
import_module("authentik.crypto.managed")
|
||||||
|
import_module("authentik.crypto.tasks")
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""Crypto task Settings"""
|
||||||
|
from celery.schedules import crontab
|
||||||
|
|
||||||
|
CELERY_BEAT_SCHEDULE = {
|
||||||
|
"crypto_certificate_discovery": {
|
||||||
|
"task": "authentik.crypto.tasks.certificate_discovery",
|
||||||
|
"schedule": crontab(minute="*/5"),
|
||||||
|
"options": {"queue": "authentik_scheduled"},
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Crypto tasks"""
|
||||||
|
from glob import glob
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from structlog.stdlib import get_logger
|
||||||
|
|
||||||
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.events.monitored_tasks import PrefilledMonitoredTask, TaskResult, TaskResultStatus
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
|
from authentik.root.celery import CELERY_APP
|
||||||
|
|
||||||
|
LOGGER = get_logger()
|
||||||
|
|
||||||
|
MANAGED_DISCOVERED = "goauthentik.io/crypto/discovered/%s"
|
||||||
|
|
||||||
|
|
||||||
|
@CELERY_APP.task(bind=True, base=PrefilledMonitoredTask)
|
||||||
|
def certificate_discovery(self: PrefilledMonitoredTask):
|
||||||
|
"""Discover and update certificates form the filesystem"""
|
||||||
|
certs = {}
|
||||||
|
private_keys = {}
|
||||||
|
discovered = 0
|
||||||
|
for file in glob(CONFIG.y("cert_discovery_dir") + "/**", recursive=True):
|
||||||
|
path = Path(file)
|
||||||
|
if not path.exists():
|
||||||
|
continue
|
||||||
|
if path.is_dir():
|
||||||
|
continue
|
||||||
|
# Support certbot's directory structure
|
||||||
|
if path.name in ["fullchain.pem", "privkey.pem"]:
|
||||||
|
cert_name = path.parent.name
|
||||||
|
else:
|
||||||
|
cert_name = path.name.replace(path.suffix, "")
|
||||||
|
try:
|
||||||
|
with open(path, "r+", encoding="utf-8") as _file:
|
||||||
|
body = _file.read()
|
||||||
|
if "BEGIN RSA PRIVATE KEY" in body:
|
||||||
|
private_keys[cert_name] = body
|
||||||
|
else:
|
||||||
|
certs[cert_name] = body
|
||||||
|
except OSError as exc:
|
||||||
|
LOGGER.warning("Failed to open file", exc=exc, file=path)
|
||||||
|
discovered += 1
|
||||||
|
for name, cert_data in certs.items():
|
||||||
|
cert = CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % name).first()
|
||||||
|
if not cert:
|
||||||
|
cert = CertificateKeyPair(
|
||||||
|
name=name,
|
||||||
|
managed=MANAGED_DISCOVERED % name,
|
||||||
|
)
|
||||||
|
dirty = False
|
||||||
|
if cert.certificate_data != cert_data:
|
||||||
|
cert.certificate_data = cert_data
|
||||||
|
dirty = True
|
||||||
|
if name in private_keys:
|
||||||
|
if cert.key_data == private_keys[name]:
|
||||||
|
cert.key_data = private_keys[name]
|
||||||
|
dirty = True
|
||||||
|
if dirty:
|
||||||
|
cert.save()
|
||||||
|
self.set_status(
|
||||||
|
TaskResult(
|
||||||
|
TaskResultStatus.SUCCESSFUL,
|
||||||
|
messages=[_("Successfully imported %(count)d files." % {"count": discovered})],
|
||||||
|
)
|
||||||
|
)
|
|
@ -1,5 +1,7 @@
|
||||||
"""Crypto tests"""
|
"""Crypto tests"""
|
||||||
import datetime
|
import datetime
|
||||||
|
from os import makedirs, mkdir
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
@ -9,6 +11,8 @@ from authentik.core.tests.utils import create_test_admin_user, create_test_cert,
|
||||||
from authentik.crypto.api import CertificateKeyPairSerializer
|
from authentik.crypto.api import CertificateKeyPairSerializer
|
||||||
from authentik.crypto.builder import CertificateBuilder
|
from authentik.crypto.builder import CertificateBuilder
|
||||||
from authentik.crypto.models import CertificateKeyPair
|
from authentik.crypto.models import CertificateKeyPair
|
||||||
|
from authentik.crypto.tasks import MANAGED_DISCOVERED, certificate_discovery
|
||||||
|
from authentik.lib.config import CONFIG
|
||||||
from authentik.lib.generators import generate_key
|
from authentik.lib.generators import generate_key
|
||||||
from authentik.providers.oauth2.models import OAuth2Provider
|
from authentik.providers.oauth2.models import OAuth2Provider
|
||||||
|
|
||||||
|
@ -163,3 +167,32 @@ class TestCrypto(APITestCase):
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_discovery(self):
|
||||||
|
"""Test certificate discovery"""
|
||||||
|
builder = CertificateBuilder()
|
||||||
|
builder.common_name = "test-cert"
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
builder.save()
|
||||||
|
builder.build(
|
||||||
|
subject_alt_names=[],
|
||||||
|
validity_days=3,
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as temp_dir:
|
||||||
|
with open(f"{temp_dir}/foo.pem", "w+", encoding="utf-8") as _cert:
|
||||||
|
_cert.write(builder.certificate)
|
||||||
|
with open(f"{temp_dir}/foo.key", "w+", encoding="utf-8") as _key:
|
||||||
|
_key.write(builder.private_key)
|
||||||
|
makedirs(f"{temp_dir}/foo.bar", exist_ok=True)
|
||||||
|
with open(f"{temp_dir}/foo.bar/fullchain.pem", "w+", encoding="utf-8") as _cert:
|
||||||
|
_cert.write(builder.certificate)
|
||||||
|
with open(f"{temp_dir}/foo.bar/privkey.pem", "w+", encoding="utf-8") as _key:
|
||||||
|
_key.write(builder.private_key)
|
||||||
|
with CONFIG.patch("cert_discovery_dir", temp_dir):
|
||||||
|
certificate_discovery()
|
||||||
|
self.assertTrue(
|
||||||
|
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo").exists()
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
CertificateKeyPair.objects.filter(managed=MANAGED_DISCOVERED % "foo.bar").exists()
|
||||||
|
)
|
||||||
|
|
|
@ -83,3 +83,4 @@ default_user_change_email: true
|
||||||
default_user_change_username: true
|
default_user_change_username: true
|
||||||
|
|
||||||
gdpr_compliance: true
|
gdpr_compliance: true
|
||||||
|
cert_discovery_dir: /certs
|
||||||
|
|
|
@ -55,6 +55,7 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./backups:/backups
|
- ./backups:/backups
|
||||||
- ./media:/media
|
- ./media:/media
|
||||||
|
- ./certs:/certs
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./custom-templates:/templates
|
- ./custom-templates:/templates
|
||||||
- geoip:/geoip
|
- geoip:/geoip
|
||||||
|
|
|
@ -28,7 +28,7 @@ function check_if_root {
|
||||||
GROUP="authentik:${GROUP_NAME}"
|
GROUP="authentik:${GROUP_NAME}"
|
||||||
fi
|
fi
|
||||||
# Fix permissions of backups and media
|
# Fix permissions of backups and media
|
||||||
chown -R authentik:authentik /media /backups
|
chown -R authentik:authentik /media /backups /certs
|
||||||
chpst -u authentik:$GROUP env HOME=/authentik $1
|
chpst -u authentik:$GROUP env HOME=/authentik $1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -2705,6 +2705,14 @@ msgstr "MFA Devices"
|
||||||
msgid "Make sure to keep these tokens in a safe place."
|
msgid "Make sure to keep these tokens in a safe place."
|
||||||
msgstr "Make sure to keep these tokens in a safe place."
|
msgstr "Make sure to keep these tokens in a safe place."
|
||||||
|
|
||||||
|
#: src/pages/crypto/CertificateKeyPairListPage.ts
|
||||||
|
msgid "Managed by authentik"
|
||||||
|
msgstr "Managed by authentik"
|
||||||
|
|
||||||
|
#: src/pages/crypto/CertificateKeyPairListPage.ts
|
||||||
|
msgid "Managed by authentik (Discovered)"
|
||||||
|
msgstr "Managed by authentik (Discovered)"
|
||||||
|
|
||||||
#: src/pages/stages/user_write/UserWriteStageForm.ts
|
#: src/pages/stages/user_write/UserWriteStageForm.ts
|
||||||
msgid "Mark newly created users as inactive."
|
msgid "Mark newly created users as inactive."
|
||||||
msgstr "Mark newly created users as inactive."
|
msgstr "Mark newly created users as inactive."
|
||||||
|
|
|
@ -2686,6 +2686,14 @@ msgstr ""
|
||||||
msgid "Make sure to keep these tokens in a safe place."
|
msgid "Make sure to keep these tokens in a safe place."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/crypto/CertificateKeyPairListPage.ts
|
||||||
|
msgid "Managed by authentik"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/crypto/CertificateKeyPairListPage.ts
|
||||||
|
msgid "Managed by authentik (Discovered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/stages/user_write/UserWriteStageForm.ts
|
#: src/pages/stages/user_write/UserWriteStageForm.ts
|
||||||
msgid "Mark newly created users as inactive."
|
msgid "Mark newly created users as inactive."
|
||||||
msgstr "Marquer les utilisateurs nouvellements créés comme inactifs."
|
msgstr "Marquer les utilisateurs nouvellements créés comme inactifs."
|
||||||
|
|
|
@ -2697,6 +2697,14 @@ msgstr ""
|
||||||
msgid "Make sure to keep these tokens in a safe place."
|
msgid "Make sure to keep these tokens in a safe place."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/crypto/CertificateKeyPairListPage.ts
|
||||||
|
msgid "Managed by authentik"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: src/pages/crypto/CertificateKeyPairListPage.ts
|
||||||
|
msgid "Managed by authentik (Discovered)"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: src/pages/stages/user_write/UserWriteStageForm.ts
|
#: src/pages/stages/user_write/UserWriteStageForm.ts
|
||||||
msgid "Mark newly created users as inactive."
|
msgid "Mark newly created users as inactive."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
@ -91,8 +91,13 @@ export class CertificateKeyPairListPage extends TablePage<CertificateKeyPair> {
|
||||||
}
|
}
|
||||||
|
|
||||||
row(item: CertificateKeyPair): TemplateResult[] {
|
row(item: CertificateKeyPair): TemplateResult[] {
|
||||||
|
let managedSubText = t`Managed by authentik`;
|
||||||
|
if (item.managed && item.managed.startsWith("goauthentik.io/crypto/discovered")) {
|
||||||
|
managedSubText = t`Managed by authentik (Discovered)`;
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
html`${item.name}`,
|
html`<div>${item.name}</div>
|
||||||
|
${item.managed ? html`<small>${managedSubText}</small>` : html``}`,
|
||||||
html`<ak-label color=${item.privateKeyAvailable ? PFColor.Green : PFColor.Grey}>
|
html`<ak-label color=${item.privateKeyAvailable ? PFColor.Green : PFColor.Grey}>
|
||||||
${item.privateKeyAvailable ? t`Yes` : t`No`}
|
${item.privateKeyAvailable ? t`Yes` : t`No`}
|
||||||
</ak-label>`,
|
</ak-label>`,
|
||||||
|
|
|
@ -0,0 +1,57 @@
|
||||||
|
---
|
||||||
|
title: Certificates
|
||||||
|
---
|
||||||
|
|
||||||
|
Certificates in authentik are used for the following use cases:
|
||||||
|
|
||||||
|
- Signing and verifying SAML Requests and Responses
|
||||||
|
- Signing JSON Web Tokens for OAuth and OIDC
|
||||||
|
- Connecting to remote docker hosts using the Docker integration
|
||||||
|
- Verifying LDAP Servers' certificates
|
||||||
|
- Encrypting outposts's endpoints
|
||||||
|
|
||||||
|
## Default certificate
|
||||||
|
|
||||||
|
Every authentik install generates a self-signed certificate on the first start. The certificate is called *authentik Self-signed Certificate* and is valid for 1 year.
|
||||||
|
|
||||||
|
This certificate is generated to be used as a default for all OAuth2/OIDC providers, as these don't require the certificate to be configured on both sides (the signature of a JWT is validated using the [JWKS](https://auth0.com/docs/security/tokens/json-web-tokens/json-web-key-sets) URL).
|
||||||
|
|
||||||
|
This certificate can also be used for SAML Providers/Sources, just keep in mind that the certificate is only valid for a year. Some SAML applications require the certificate to be valid, so they might need to be rotated regularly.
|
||||||
|
|
||||||
|
For SAML use-cases, you can generate a Certificate thats valid for longer than 1 year, on your own risk.
|
||||||
|
|
||||||
|
## External certificates
|
||||||
|
|
||||||
|
To use externally managed certificates, for example generated with certbot or HashiCorp Vault, you can use the discovery feature.
|
||||||
|
|
||||||
|
The docker-compose installation maps a `certs` directory to `/certs`, you can simply use this as an output directory for certbot.
|
||||||
|
|
||||||
|
For Kubernetes, you can map custom secrets/volumes under `/certs`.
|
||||||
|
|
||||||
|
You can also bind mount single files into the folder, as long as they fall under this naming schema.
|
||||||
|
|
||||||
|
- Files in the root directory will be imported based on their filename.
|
||||||
|
|
||||||
|
`/foo.pem` Will be imported as the keypair `foo`. Based on its content its either imported as certificate or private key.
|
||||||
|
|
||||||
|
Currently, only RSA Keys are supported, so if the file contains `BEGIN RSA PRIVATE KEY` it will imported as private key.
|
||||||
|
|
||||||
|
Otherwise it will be imported as certificate.
|
||||||
|
|
||||||
|
- If the file is called `fullchain.pem` or `privkey.pem` (the output naming of certbot), they will get the name of the parent folder.
|
||||||
|
- Files can be in any arbitrary file structure, and can have extension.
|
||||||
|
|
||||||
|
```
|
||||||
|
certs/
|
||||||
|
├── baz
|
||||||
|
│ └── bar.baz
|
||||||
|
│ ├── fullchain.pem
|
||||||
|
│ └── privkey.key
|
||||||
|
├── foo.bar
|
||||||
|
│ ├── fullchain.pem
|
||||||
|
│ └── privkey.key
|
||||||
|
├── foo.key
|
||||||
|
└── foo.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
Files are checked every 5 minutes, and will trigger an Outpost refresh if the files differ.
|
|
@ -22,7 +22,12 @@ module.exports = {
|
||||||
type: "category",
|
type: "category",
|
||||||
label: "Core Concepts",
|
label: "Core Concepts",
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
items: ["core/terminology", "core/applications", "core/tenants"],
|
items: [
|
||||||
|
"core/terminology",
|
||||||
|
"core/applications",
|
||||||
|
"core/tenants",
|
||||||
|
"core/certificates",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "category",
|
type: "category",
|
||||||
|
|
Reference in New Issue