diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py index 4eb02dddc..fc62285fa 100644 --- a/authentik/crypto/api.py +++ b/authentik/crypto/api.py @@ -20,6 +20,7 @@ from authentik.api.decorators import permission_required from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import PassiveSerializer from authentik.crypto.builder import CertificateBuilder +from authentik.crypto.managed import MANAGED_KEY from authentik.crypto.models import CertificateKeyPair from authentik.events.models import Event, EventAction @@ -141,7 +142,7 @@ class CertificateKeyPairFilter(FilterSet): class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): """CertificateKeyPair Viewset""" - queryset = CertificateKeyPair.objects.exclude(managed__isnull=False) + queryset = CertificateKeyPair.objects.exclude(managed=MANAGED_KEY) serializer_class = CertificateKeyPairSerializer filterset_class = CertificateKeyPairFilter ordering = ["name"] diff --git a/authentik/crypto/apps.py b/authentik/crypto/apps.py index 8f84f0839..17da6d2cc 100644 --- a/authentik/crypto/apps.py +++ b/authentik/crypto/apps.py @@ -13,3 +13,4 @@ class AuthentikCryptoConfig(AppConfig): def ready(self): import_module("authentik.crypto.managed") + import_module("authentik.crypto.tasks") diff --git a/authentik/crypto/settings.py b/authentik/crypto/settings.py new file mode 100644 index 000000000..598576d48 --- /dev/null +++ b/authentik/crypto/settings.py @@ -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"}, + }, +} diff --git a/authentik/crypto/tasks.py b/authentik/crypto/tasks.py new file mode 100644 index 000000000..06d6b02ee --- /dev/null +++ b/authentik/crypto/tasks.py @@ -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})], + ) + ) diff --git a/authentik/crypto/tests.py b/authentik/crypto/tests.py index f621b0f0d..59a1d1094 100644 --- a/authentik/crypto/tests.py +++ b/authentik/crypto/tests.py @@ -1,5 +1,7 @@ """Crypto tests""" import datetime +from os import makedirs, mkdir +from tempfile import TemporaryDirectory from django.urls import reverse 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.builder import CertificateBuilder 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.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() + ) diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml index 08b462746..28549d7dc 100644 --- a/authentik/lib/default.yml +++ b/authentik/lib/default.yml @@ -83,3 +83,4 @@ default_user_change_email: true default_user_change_username: true gdpr_compliance: true +cert_discovery_dir: /certs diff --git a/docker-compose.yml b/docker-compose.yml index 495249111..23110b672 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,7 @@ services: volumes: - ./backups:/backups - ./media:/media + - ./certs:/certs - /var/run/docker.sock:/var/run/docker.sock - ./custom-templates:/templates - geoip:/geoip diff --git a/lifecycle/ak b/lifecycle/ak index d1d3f5025..accd86db8 100755 --- a/lifecycle/ak +++ b/lifecycle/ak @@ -28,7 +28,7 @@ function check_if_root { GROUP="authentik:${GROUP_NAME}" fi # 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 } diff --git a/web/src/locales/en.po b/web/src/locales/en.po index 8a93dad05..ad6dff11a 100644 --- a/web/src/locales/en.po +++ b/web/src/locales/en.po @@ -2705,6 +2705,14 @@ msgstr "MFA Devices" msgid "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 msgid "Mark newly created users as inactive." msgstr "Mark newly created users as inactive." diff --git a/web/src/locales/fr_FR.po b/web/src/locales/fr_FR.po index 8ef47c80a..da51ee1c8 100644 --- a/web/src/locales/fr_FR.po +++ b/web/src/locales/fr_FR.po @@ -2686,6 +2686,14 @@ msgstr "" msgid "Make sure to keep these tokens in a safe place." 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 msgid "Mark newly created users as inactive." msgstr "Marquer les utilisateurs nouvellements créés comme inactifs." diff --git a/web/src/locales/pseudo-LOCALE.po b/web/src/locales/pseudo-LOCALE.po index d3d7b7917..0c42260db 100644 --- a/web/src/locales/pseudo-LOCALE.po +++ b/web/src/locales/pseudo-LOCALE.po @@ -2697,6 +2697,14 @@ msgstr "" msgid "Make sure to keep these tokens in a safe place." 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 msgid "Mark newly created users as inactive." msgstr "" diff --git a/web/src/pages/crypto/CertificateKeyPairListPage.ts b/web/src/pages/crypto/CertificateKeyPairListPage.ts index 844626a4c..c773d30dc 100644 --- a/web/src/pages/crypto/CertificateKeyPairListPage.ts +++ b/web/src/pages/crypto/CertificateKeyPairListPage.ts @@ -91,8 +91,13 @@ export class CertificateKeyPairListPage extends TablePage { } 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 [ - html`${item.name}`, + html`
${item.name}
+ ${item.managed ? html`${managedSubText}` : html``}`, html` ${item.privateKeyAvailable ? t`Yes` : t`No`} `, diff --git a/website/docs/core/certificates.md b/website/docs/core/certificates.md new file mode 100644 index 000000000..41b828d6b --- /dev/null +++ b/website/docs/core/certificates.md @@ -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. diff --git a/website/sidebars.js b/website/sidebars.js index 1fe80b9d7..7d6b9fd0d 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -22,7 +22,12 @@ module.exports = { type: "category", label: "Core Concepts", collapsed: false, - items: ["core/terminology", "core/applications", "core/tenants"], + items: [ + "core/terminology", + "core/applications", + "core/tenants", + "core/certificates", + ], }, { type: "category",