diff --git a/Makefile b/Makefile index dca1c20ad0..85be43dd05 100644 --- a/Makefile +++ b/Makefile @@ -169,8 +169,7 @@ gen-client-go: gen-clean-go ## Build and install the authentik API for Golang ifeq ($(wildcard ${PWD}/${GEN_API_GO}/.*),) git clone --depth 1 https://github.com/goauthentik/client-go.git ${PWD}/${GEN_API_GO} else - cd ${PWD}/${GEN_API_GO} - git pull + cd ${PWD}/${GEN_API_GO} && git pull endif cp ${PWD}/schema.yml ${PWD}/${GEN_API_GO} make -C ${PWD}/${GEN_API_GO} build diff --git a/authentik/api/authentication.py b/authentik/api/authentication.py index ab4b67d731..c9dfeac885 100644 --- a/authentik/api/authentication.py +++ b/authentik/api/authentication.py @@ -1,9 +1,12 @@ """API Authentication""" from hmac import compare_digest +from pathlib import Path +from tempfile import gettempdir from typing import Any from django.conf import settings +from django.contrib.auth.models import AnonymousUser from drf_spectacular.extensions import OpenApiAuthenticationExtension from rest_framework.authentication import BaseAuthentication, get_authorization_header from rest_framework.exceptions import AuthenticationFailed @@ -11,11 +14,17 @@ from rest_framework.request import Request from structlog.stdlib import get_logger from authentik.core.middleware import CTX_AUTH_VIA -from authentik.core.models import Token, TokenIntents, User +from authentik.core.models import Token, TokenIntents, User, UserTypes from authentik.outposts.models import Outpost from authentik.providers.oauth2.constants import SCOPE_AUTHENTIK_API LOGGER = get_logger() +_tmp = Path(gettempdir()) +try: + with open(_tmp / "authentik-core-ipc.key") as _f: + ipc_key = _f.read() +except OSError: + ipc_key = None def validate_auth(header: bytes) -> str | None: @@ -73,6 +82,11 @@ def auth_user_lookup(raw_header: bytes) -> User | None: if user: CTX_AUTH_VIA.set("secret_key") return user + # then try to auth via secret key (for embedded outpost/etc) + user = token_ipc(auth_credentials) + if user: + CTX_AUTH_VIA.set("ipc") + return user raise AuthenticationFailed("Token invalid/expired") @@ -90,6 +104,43 @@ def token_secret_key(value: str) -> User | None: return outpost.user +class IPCUser(AnonymousUser): + """'Virtual' user for IPC communication between authentik core and the authentik router""" + + username = "authentik:system" + is_active = True + is_superuser = True + + @property + def type(self): + return UserTypes.INTERNAL_SERVICE_ACCOUNT + + def has_perm(self, perm, obj=None): + return True + + def has_perms(self, perm_list, obj=None): + return True + + def has_module_perms(self, module): + return True + + @property + def is_anonymous(self): + return False + + @property + def is_authenticated(self): + return True + + +def token_ipc(value: str) -> User | None: + """Check if the token is the secret key + and return the service account for the managed outpost""" + if not ipc_key or not compare_digest(value, ipc_key): + return None + return IPCUser() + + class TokenAuthentication(BaseAuthentication): """Token-based authentication using HTTP Bearer authentication""" diff --git a/authentik/brands/api.py b/authentik/brands/api.py index bcfd0fc6db..b634e8c11e 100644 --- a/authentik/brands/api.py +++ b/authentik/brands/api.py @@ -59,6 +59,7 @@ class BrandSerializer(ModelSerializer): "flow_device_code", "default_application", "web_certificate", + "client_certificates", "attributes", ] extra_kwargs = { @@ -120,6 +121,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet): "domain", "branding_title", "web_certificate__name", + "client_certificates__name", ] filterset_fields = [ "brand_uuid", @@ -136,6 +138,7 @@ class BrandViewSet(UsedByMixin, ModelViewSet): "flow_user_settings", "flow_device_code", "web_certificate", + "client_certificates", ] ordering = ["domain"] diff --git a/authentik/brands/migrations/0010_brand_client_certificates_and_more.py b/authentik/brands/migrations/0010_brand_client_certificates_and_more.py new file mode 100644 index 0000000000..b7c48ac020 --- /dev/null +++ b/authentik/brands/migrations/0010_brand_client_certificates_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 5.1.9 on 2025-05-19 15:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentik_brands", "0009_brand_branding_default_flow_background"), + ("authentik_crypto", "0004_alter_certificatekeypair_name"), + ] + + operations = [ + migrations.AddField( + model_name="brand", + name="client_certificates", + field=models.ManyToManyField( + blank=True, + default=None, + help_text="Certificates used for client authentication.", + to="authentik_crypto.certificatekeypair", + ), + ), + migrations.AlterField( + model_name="brand", + 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, + related_name="+", + to="authentik_crypto.certificatekeypair", + ), + ), + ] diff --git a/authentik/brands/models.py b/authentik/brands/models.py index cc8cc43787..afc56d489a 100644 --- a/authentik/brands/models.py +++ b/authentik/brands/models.py @@ -73,6 +73,13 @@ class Brand(SerializerModel): default=None, on_delete=models.SET_DEFAULT, help_text=_("Web Certificate used by the authentik Core webserver."), + related_name="+", + ) + client_certificates = models.ManyToManyField( + CertificateKeyPair, + default=None, + blank=True, + help_text=_("Certificates used for client authentication."), ) attributes = models.JSONField(default=dict, blank=True) diff --git a/authentik/crypto/api.py b/authentik/crypto/api.py index c06fa0cb00..70dd92e519 100644 --- a/authentik/crypto/api.py +++ b/authentik/crypto/api.py @@ -30,6 +30,7 @@ from structlog.stdlib import get_logger from authentik.core.api.used_by import UsedByMixin from authentik.core.api.utils import ModelSerializer, PassiveSerializer +from authentik.core.models import UserTypes from authentik.crypto.apps import MANAGED_KEY from authentik.crypto.builder import CertificateBuilder, PrivateKeyAlg from authentik.crypto.models import CertificateKeyPair @@ -272,11 +273,12 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): def view_certificate(self, request: Request, pk: str) -> Response: """Return certificate-key pairs certificate and log access""" certificate: CertificateKeyPair = self.get_object() - Event.new( # noqa # nosec - EventAction.SECRET_VIEW, - secret=certificate, - type="certificate", - ).from_http(request) + if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: + Event.new( # noqa # nosec + EventAction.SECRET_VIEW, + secret=certificate, + type="certificate", + ).from_http(request) if "download" in request.query_params: # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html response = HttpResponse( @@ -302,11 +304,12 @@ class CertificateKeyPairViewSet(UsedByMixin, ModelViewSet): def view_private_key(self, request: Request, pk: str) -> Response: """Return certificate-key pairs private key and log access""" certificate: CertificateKeyPair = self.get_object() - Event.new( # noqa # nosec - EventAction.SECRET_VIEW, - secret=certificate, - type="private_key", - ).from_http(request) + if request.user.type != UserTypes.INTERNAL_SERVICE_ACCOUNT: + Event.new( # noqa # nosec + EventAction.SECRET_VIEW, + secret=certificate, + type="private_key", + ).from_http(request) if "download" in request.query_params: # Mime type from https://pki-tutorial.readthedocs.io/en/latest/mime.html response = HttpResponse(certificate.key_data, content_type="application/x-pem-file") diff --git a/authentik/enterprise/settings.py b/authentik/enterprise/settings.py index edc607d35d..676b6dc7c4 100644 --- a/authentik/enterprise/settings.py +++ b/authentik/enterprise/settings.py @@ -19,6 +19,7 @@ TENANT_APPS = [ "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.ssf", "authentik.enterprise.stages.authenticator_endpoint_gdtc", + "authentik.enterprise.stages.mtls", "authentik.enterprise.stages.source", ] diff --git a/authentik/enterprise/stages/mtls/__init__.py b/authentik/enterprise/stages/mtls/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/mtls/api.py b/authentik/enterprise/stages/mtls/api.py new file mode 100644 index 0000000000..1b7a471deb --- /dev/null +++ b/authentik/enterprise/stages/mtls/api.py @@ -0,0 +1,31 @@ +"""Mutual TLS Stage API Views""" + +from rest_framework.viewsets import ModelViewSet + +from authentik.core.api.used_by import UsedByMixin +from authentik.enterprise.api import EnterpriseRequiredMixin +from authentik.enterprise.stages.mtls.models import MutualTLSStage +from authentik.flows.api.stages import StageSerializer + + +class MutualTLSStageSerializer(EnterpriseRequiredMixin, StageSerializer): + """MutualTLSStage Serializer""" + + class Meta: + model = MutualTLSStage + fields = StageSerializer.Meta.fields + [ + "mode", + "certificate_authorities", + "cert_attribute", + "user_attribute", + ] + + +class MutualTLSStageViewSet(UsedByMixin, ModelViewSet): + """MutualTLSStage Viewset""" + + queryset = MutualTLSStage.objects.all() + serializer_class = MutualTLSStageSerializer + filterset_fields = "__all__" + ordering = ["name"] + search_fields = ["name"] diff --git a/authentik/enterprise/stages/mtls/apps.py b/authentik/enterprise/stages/mtls/apps.py new file mode 100644 index 0000000000..0d75e6e593 --- /dev/null +++ b/authentik/enterprise/stages/mtls/apps.py @@ -0,0 +1,12 @@ +"""authentik stage app config""" + +from authentik.enterprise.apps import EnterpriseConfig + + +class AuthentikEnterpriseStageMTLSConfig(EnterpriseConfig): + """authentik MTLS stage config""" + + name = "authentik.enterprise.stages.mtls" + label = "authentik_stages_mtls" + verbose_name = "authentik Enterprise.Stages.MTLS" + default = True diff --git a/authentik/enterprise/stages/mtls/migrations/0001_initial.py b/authentik/enterprise/stages/mtls/migrations/0001_initial.py new file mode 100644 index 0000000000..09d56deb4d --- /dev/null +++ b/authentik/enterprise/stages/mtls/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.1.9 on 2025-05-19 18:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("authentik_crypto", "0004_alter_certificatekeypair_name"), + ("authentik_flows", "0027_auto_20231028_1424"), + ] + + operations = [ + migrations.CreateModel( + name="MutualTLSStage", + 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", + ), + ), + ( + "mode", + models.TextField(choices=[("optional", "Optional"), ("required", "Required")]), + ), + ( + "cert_attribute", + models.TextField( + choices=[ + ("subject", "Subject"), + ("common_name", "Common Name"), + ("email", "Email"), + ] + ), + ), + ( + "user_attribute", + models.TextField(choices=[("username", "Username"), ("email", "Email")]), + ), + ( + "certificate_authorities", + models.ManyToManyField( + blank=True, + default=None, + help_text="Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`.", + to="authentik_crypto.certificatekeypair", + ), + ), + ], + options={ + "verbose_name": "Mutual TLS Stage", + "verbose_name_plural": "Mutual TLS Stages", + "permissions": [ + ("pass_outpost_certificate", "Permissions to pass Certificates for outposts.") + ], + }, + bases=("authentik_flows.stage",), + ), + ] diff --git a/authentik/enterprise/stages/mtls/migrations/__init__.py b/authentik/enterprise/stages/mtls/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/mtls/models.py b/authentik/enterprise/stages/mtls/models.py new file mode 100644 index 0000000000..e37f50224d --- /dev/null +++ b/authentik/enterprise/stages/mtls/models.py @@ -0,0 +1,71 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from rest_framework.serializers import Serializer + +from authentik.crypto.models import CertificateKeyPair +from authentik.flows.models import Stage +from authentik.flows.stage import StageView + + +class TLSMode(models.TextChoices): + """Modes the TLS Stage can operate in""" + + OPTIONAL = "optional" + REQUIRED = "required" + + +class CertAttributes(models.TextChoices): + """Certificate attribute used for user matching""" + + SUBJECT = "subject" + COMMON_NAME = "common_name" + EMAIL = "email" + + +class UserAttributes(models.TextChoices): + """User attribute for user matching""" + + USERNAME = "username" + EMAIL = "email" + + +class MutualTLSStage(Stage): + """Authenticate/enroll users using a client-certificate.""" + + mode = models.TextField(choices=TLSMode.choices) + + certificate_authorities = models.ManyToManyField( + CertificateKeyPair, + default=None, + blank=True, + help_text=_( + "Configure certificate authorities to validate the certificate against. " + "This option has a higher priority than the `client_certificate` option on `Brand`." + ), + ) + + cert_attribute = models.TextField(choices=CertAttributes.choices) + user_attribute = models.TextField(choices=UserAttributes.choices) + + @property + def view(self) -> type[StageView]: + from authentik.enterprise.stages.mtls.stage import MTLSStageView + + return MTLSStageView + + @property + def serializer(self) -> type[Serializer]: + from authentik.enterprise.stages.mtls.api import MutualTLSStageSerializer + + return MutualTLSStageSerializer + + @property + def component(self) -> str: + return "ak-stage-mtls-form" + + class Meta: + verbose_name = _("Mutual TLS Stage") + verbose_name_plural = _("Mutual TLS Stages") + permissions = [ + ("pass_outpost_certificate", _("Permissions to pass Certificates for outposts.")), + ] diff --git a/authentik/enterprise/stages/mtls/stage.py b/authentik/enterprise/stages/mtls/stage.py new file mode 100644 index 0000000000..650b4ea3ca --- /dev/null +++ b/authentik/enterprise/stages/mtls/stage.py @@ -0,0 +1,215 @@ +from binascii import hexlify +from urllib.parse import unquote_plus + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import hashes +from cryptography.x509 import Certificate, NameOID, ObjectIdentifier, load_pem_x509_certificate +from django.utils.translation import gettext_lazy as _ + +from authentik.brands.models import Brand +from authentik.core.models import User +from authentik.crypto.models import CertificateKeyPair +from authentik.enterprise.stages.mtls.models import ( + CertAttributes, + MutualTLSStage, + TLSMode, + UserAttributes, +) +from authentik.flows.challenge import AccessDeniedChallenge +from authentik.flows.models import FlowDesignation +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.stage import ChallengeStageView +from authentik.root.middleware import ClientIPMiddleware +from authentik.stages.password.stage import PLAN_CONTEXT_METHOD, PLAN_CONTEXT_METHOD_ARGS +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + +# All of these headers must only be accepted from "trusted" reverse proxies +# See internal/web/proxy.go:39 +HEADER_PROXY_FORWARDED = "X-Forwarded-Client-Cert" +HEADER_NGINX_FORWARDED = "SSL-Client-Cert" +HEADER_TRAEFIK_FORWARDED = "X-Forwarded-TLS-Client-Cert" +HEADER_OUTPOST_FORWARDED = "X-Authentik-Outpost-Certificate" + + +PLAN_CONTEXT_CERTIFICATE = "certificate" + + +class MTLSStageView(ChallengeStageView): + + def __parse_single_cert(self, raw: str | None) -> list[Certificate]: + """Helper to parse a single certificate""" + if not raw: + return [] + try: + cert = load_pem_x509_certificate(unquote_plus(raw).encode()) + return [cert] + except ValueError as exc: + self.logger.info("Failed to parse certificate", exc=exc) + return [] + + def _parse_cert_xfcc(self) -> list[Certificate]: + """Parse certificates in the format given to us in + the format of the authentik router/envoy""" + xfcc_raw = self.request.headers.get(HEADER_PROXY_FORWARDED) + if not xfcc_raw: + return [] + certs = [] + for r_cert in xfcc_raw.split(","): + el = r_cert.split(";") + raw_cert = {k.split("=")[0]: k.split("=")[1] for k in el} + if "Cert" not in raw_cert: + continue + certs.extend(self.__parse_single_cert(raw_cert["Cert"])) + return certs + + def _parse_cert_nginx(self) -> list[Certificate]: + """Parse certificates in the format nginx-ingress gives to us""" + sslcc_raw = self.request.headers.get(HEADER_NGINX_FORWARDED) + return self.__parse_single_cert(sslcc_raw) + + def _parse_cert_traefik(self) -> list[Certificate]: + """Parse certificates in the format traefik gives to us""" + ftcc_raw = self.request.headers.get(HEADER_TRAEFIK_FORWARDED) + return self.__parse_single_cert(ftcc_raw) + + def _parse_cert_outpost(self) -> list[Certificate]: + """Parse certificates in the format outposts give to us. Also authenticates + the outpost to ensure it has the permission to do so""" + user = ClientIPMiddleware.get_outpost_user(self.request) + if not user: + return [] + if not user.has_perm( + "pass_outpost_certificate", self.executor.current_stage + ) and not user.has_perm("authentik_stages_mtls.pass_outpost_certificate"): + return [] + outpost_raw = self.request.headers.get(HEADER_OUTPOST_FORWARDED) + return self.__parse_single_cert(outpost_raw) + + def get_authorities(self) -> list[CertificateKeyPair] | None: + # We can't access `certificate_authorities` on `self.executor.current_stage`, as that would + # load the certificate into the directly referenced foreign key, which we have to pickle + # as part of the flow plan, and cryptography certs can't be pickled + stage: MutualTLSStage = ( + MutualTLSStage.objects.filter(pk=self.executor.current_stage.pk) + .prefetch_related("certificate_authorities") + .first() + ) + if stage.certificate_authorities.exists(): + return stage.certificate_authorities.order_by("name") + brand: Brand = self.request.brand + if brand.client_certificates.exists(): + return brand.client_certificates.order_by("name") + return None + + def validate_cert(self, authorities: list[CertificateKeyPair], certs: list[Certificate]): + for _cert in certs: + for ca in authorities: + try: + _cert.verify_directly_issued_by(ca.certificate) + return _cert + except (InvalidSignature, TypeError, ValueError) as exc: + self.logger.warning( + "Discarding cert not issued by authority", cert=_cert, authority=ca, exc=exc + ) + continue + return None + + def check_if_user(self, cert: Certificate): + stage: MutualTLSStage = self.executor.current_stage + cert_attr = None + user_attr = None + match stage.cert_attribute: + case CertAttributes.SUBJECT: + cert_attr = cert.subject.rfc4514_string() + case CertAttributes.COMMON_NAME: + cert_attr = self.get_cert_attribute(cert, NameOID.COMMON_NAME) + case CertAttributes.EMAIL: + cert_attr = self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS) + match stage.user_attribute: + case UserAttributes.USERNAME: + user_attr = "username" + case UserAttributes.EMAIL: + user_attr = "email" + if not user_attr or not cert_attr: + return None + return User.objects.filter(**{user_attr: cert_attr}).first() + + def _cert_to_dict(self, cert: Certificate) -> dict: + """Represent a certificate in a dictionary, as certificate objects cannot be pickled""" + return { + "serial_number": str(cert.serial_number), + "subject": cert.subject.rfc4514_string(), + "issuer": cert.issuer.rfc4514_string(), + "fingerprint_sha256": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"), + "fingerprint_sha1": hexlify(cert.fingerprint(hashes.SHA256()), ":").decode("utf-8"), + } + + def auth_user(self, user: User, cert: Certificate): + self.executor.plan.context[PLAN_CONTEXT_PENDING_USER] = user + self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD, "mtls") + self.executor.plan.context.setdefault(PLAN_CONTEXT_METHOD_ARGS, {}) + self.executor.plan.context[PLAN_CONTEXT_METHOD_ARGS].update( + {"certificate": self._cert_to_dict(cert)} + ) + + def enroll_prepare_user(self, cert: Certificate): + self.executor.plan.context.setdefault(PLAN_CONTEXT_PROMPT, {}) + self.executor.plan.context[PLAN_CONTEXT_PROMPT].update( + { + "email": self.get_cert_attribute(cert, NameOID.EMAIL_ADDRESS), + "name": self.get_cert_attribute(cert, NameOID.COMMON_NAME), + } + ) + self.executor.plan.context[PLAN_CONTEXT_CERTIFICATE] = self._cert_to_dict(cert) + + def get_cert_attribute(self, cert: Certificate, oid: ObjectIdentifier) -> str | None: + attr = cert.subject.get_attributes_for_oid(oid) + if len(attr) < 1: + return None + return str(attr[0].value) + + def dispatch(self, request, *args, **kwargs): + stage: MutualTLSStage = self.executor.current_stage + certs = [ + *self._parse_cert_xfcc(), + *self._parse_cert_nginx(), + *self._parse_cert_traefik(), + *self._parse_cert_outpost(), + ] + authorities = self.get_authorities() + if not authorities: + self.logger.warning("No Certificate authority found") + if stage.mode == TLSMode.OPTIONAL: + return self.executor.stage_ok() + if stage.mode == TLSMode.REQUIRED: + return super().dispatch(request, *args, **kwargs) + cert = self.validate_cert(authorities, certs) + if not cert and stage.mode == TLSMode.REQUIRED: + self.logger.warning("Client certificate required but no certificates given") + return super().dispatch( + request, + *args, + error_message=_("Certificate required but no certificate was given."), + **kwargs, + ) + if not cert and stage.mode == TLSMode.OPTIONAL: + self.logger.info("No certificate given, continuing") + return self.executor.stage_ok() + existing_user = self.check_if_user(cert) + if self.executor.flow.designation == FlowDesignation.ENROLLMENT: + self.enroll_prepare_user(cert) + elif existing_user: + self.auth_user(existing_user, cert) + else: + return super().dispatch( + request, *args, error_message=_("No user found for certificate."), **kwargs + ) + return self.executor.stage_ok() + + def get_challenge(self, *args, error_message: str | None = None, **kwargs): + return AccessDeniedChallenge( + data={ + "component": "ak-stage-access-denied", + "error_message": str(error_message or "Unknown error"), + } + ) diff --git a/authentik/enterprise/stages/mtls/tests/__init__.py b/authentik/enterprise/stages/mtls/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/authentik/enterprise/stages/mtls/tests/fixtures/ca.pem b/authentik/enterprise/stages/mtls/tests/fixtures/ca.pem new file mode 100644 index 0000000000..68d2c41b97 --- /dev/null +++ b/authentik/enterprise/stages/mtls/tests/fixtures/ca.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFXDCCA0SgAwIBAgIUBmV7zREyC1SPr72/75/L9zpwV18wDQYJKoZIhvcNAQEL +BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl +bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMDUwWhcNMzUw +MzA3MTgzMDUwWjBGMRowGAYDVQQDDBFhdXRoZW50aWsgVGVzdCBDQTESMBAGA1UE +CgwJYXV0aGVudGlrMRQwEgYDVQQLDAtTZWxmLXNpZ25lZDCCAiIwDQYJKoZIhvcN +AQEBBQADggIPADCCAgoCggIBAMc0NxZj7j1mPu0aRToo8oMPdC3T99xgxnqdr18x +LV4pWyi/YLghgZHqNQY2xNP6JIlSeUZD6KFUYT2sPL4Av/zSg5zO8bl+/lf7ckje +O1/Bt5A8xtL0CpmpMDGiI6ibdDElaywM6AohisbxrV29pygSKGq2wugF/urqGtE+ +5z4y5Kt6qMdKkd0iXT+WagbQTIUlykFKgB0+qqTLzDl01lVDa/DoLl8Hqp45mVx2 +pqrGsSa3TCErLIv9hUlZklF7A8UV4ZB4JL20UKcP8dKzQClviNie17tpsUpOuy3A +SQ6+guWTHTLJNCSdLn1xIqc5q+f5wd2dIDf8zXCTHj+Xp0bJE3Vgaq5R31K9+b+1 +2dDWz1KcNJaLEnw2+b0O8M64wTMLxhqOv7QfLUr6Pmg1ZymghjLcZ6bnU9e31Vza +hlPKhxjqYQUC4Kq+oaYF6qdUeJy+dsYf0iDv5tTC+eReZDWIjxTPrNpwA773ZwT7 +WVmL7ULGpuP2g9rNvFBcZiN+i6d7CUoN+jd/iRdo79lrI0dfXiyy4bYgW/2HeZfF +HaOsc1xsoqnJdWbWkX/ooyaCjAfm07kS3HiOzz4q3QW4wgGrwV8lEraLPxYYeOQu +YcGMOM8NfnVkjc8gmyXUxedCje5Vz/Tu5fKrQEInnCmXxVsWbwr/LzEjMKAM/ivY +0TXxAgMBAAGjQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0G +A1UdDgQWBBTa+Ns6QzqlNvnTGszkouQQtZnVJDANBgkqhkiG9w0BAQsFAAOCAgEA +NpJEDMXjuEIzSzafkxSshvjnt5sMYmzmvjNoRlkxgN2YcWvPoxbalGAYzcpyggT2 +6xZY8R4tvB1oNTCArqwf860kkofUoJCr88D/pU3Cv4JhjCWs4pmXTsvSqlBSlJbo ++jPBZwbn6it/6jcit6Be3rW2PtHe8tASd9Lf8/2r1ZvupXwPzcR84R4Z10ve2lqV +xxcWlMmBh51CaYI0b1/WTe9Ua+wgkCVkxbf9zNcDQXjxw2ICWK+nR/4ld4nmqVm2 +C7nhvXwU8FAHl7ZgR2Z3PLrwPuhd+kd6NXQqNkS9A+n+1vSRLbRjmV8pwIPpdPEq +nslUAGJJBHDUBArxC3gOJSB+WtmaCfzDu2gepMf9Ng1H2ZhwSF/FH3v3fsJqZkzz +NBstT9KuNGQRYiCmAPJaoVAc9BoLa+BFML1govtWtpdmbFk8PZEcuUsP7iAZqFF1 +uuldPyZ8huGpQSR6Oq2bILRHowfGY0npTZAyxg0Vs8UMy1HTwNOp9OuRtArMZmsJ +jFIx1QzRf9S1i6bYpOzOudoXj4ARkS1KmVExGjJFcIT0xlFSSERie2fEKSeEYOyG +G+PA2qRt/F51FGOMm1ZscjPXqk2kt3C4BFbz6Vvxsq7D3lmhvFLn4jVA8+OidsM0 +YUrVMtWET/RkjEIbADbgRXxNUNo+jtQZDU9C1IiAdfk= +-----END CERTIFICATE----- diff --git a/authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem b/authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem new file mode 100644 index 0000000000..85bb9da1d9 --- /dev/null +++ b/authentik/enterprise/stages/mtls/tests/fixtures/cert_client.pem @@ -0,0 +1,30 @@ +-----BEGIN CERTIFICATE----- +MIIFOzCCAyOgAwIBAgIUbnIMy+Ewi5RvK7OBDxWMCk7wi08wDQYJKoZIhvcNAQEL +BQAwRjEaMBgGA1UEAwwRYXV0aGVudGlrIFRlc3QgQ0ExEjAQBgNVBAoMCWF1dGhl +bnRpazEUMBIGA1UECwwLU2VsZi1zaWduZWQwHhcNMjUwNDI3MTgzMTE3WhcNMjYw +NDIzMTgzMTE3WjARMQ8wDQYDVQQDDAZjbGllbnQwggIiMA0GCSqGSIb3DQEBAQUA +A4ICDwAwggIKAoICAQCdV+GEa7+7ito1i/z637OZW+0azv1kuF2aDwSzv+FJd+4L +6hCroRbVYTUFS3I3YwanOOZfau64xH0+pFM5Js8aREG68eqKBayx8vT27hyAOFhd +giEVmSQJfla4ogvPie1rJ0HVOL7CiR72HDPQvz+9k1iDX3xQ/4sdAb3XurN13e+M +Gtavhjiyqxmoo/H4WRd8BhD/BZQFWtaxWODDY8aKk5R7omw6Xf7aRv1BlHdE4Ucy +Wozvpsj2Kz0l61rRUhiMlE0D9dpijgaRYFB+M7R2casH3CdhGQbBHTRiqBkZa6iq +SDkTiTwNJQQJov8yPTsR+9P8OOuV6QN+DGm/FXJJFaPnsHw/HDy7EAbA1PcdbSyK +XvJ8nVjdNhCEGbLGVSwAQLO+78hChVIN5YH+QSrP84YBSxKZYArnf4z2e9drqAN3 +KmC26TkaUzkXnndnxOXBEIOSmyCdD4Dutg1XPE/bs8rA6rVGIR3pKXbCr29Z8hZn +Cn9jbxwDwTX865ljR1Oc3dnIeCWa9AS/uHaSMdGlbGbDrt4Bj/nyyfu8xc034K/0 +uPh3hF3FLWNAomRVZCvtuh/v7IEIQEgUbvQMWBhZJ8hu3HdtV8V9TIAryVKzEzGy +Q72UHuQyK0njRDTmA/T+jn7P8GWOuf9eNdzd0gH0gcEuhCZFxPPRvUAeDuC7DQID +AQABo1YwVDAdBgNVHSUEFjAUBggrBgEFBQcDAgYIKwYBBQUHAwEwFAYDVR0RAQH/ +BAowCIIGY2xpZW50MB0GA1UdDgQWBBQ5KZwTD8+4CqLnbM/cBSXg8XeLXTANBgkq +hkiG9w0BAQsFAAOCAgEABDkb3iyEOl1xKq1wxyRzf2L8qfPXAQw71FxMbgHArM+a +e44wJGO3mZgPH0trOaJ+tuN6erB5YbZfsoX+xFacwskj9pKyb4QJLr/ENmJZRgyL +wp5P6PB6IUJhvryvy/GxrG938YGmFtYQ+ikeJw5PWhB6218C1aZ9hsi94wZ1Zzrc +Ry0q0D4QvIEZ0X2HL1Harc7gerE3VqhgQ7EWyImM+lCRtNDduwDQnZauwhr2r6cW +XG4VTe1RCNsDA0xinXQE2Xf9voCd0Zf6wOOXJseQtrXpf+tG4N13cy5heF5ihed1 +hDxSeki0KjTM+18kVVfVm4fzxf1Zg0gm54UlzWceIWh9EtnWMUV08H0D1M9YNmW8 +hWTupk7M+jAw8Y+suHOe6/RLi0+fb9NSJpIpq4GqJ5UF2kerXHX0SvuAavoXyB0j +CQrUXkRScEKOO2KAbVExSG56Ff7Ee8cRUAQ6rLC5pQRACq/R0sa6RcUsFPXul3Yv +vbO2rTuArAUPkNVFknwkndheN4lOslRd1If02HunZETmsnal6p+nmuMWt2pQ2fDA +vIguG54FyQ1T1IbF/QhfTEY62CQAebcgutnqqJHt9qe7Jr6ev57hMrJDEjotSzkY +OhOVrcYqgLldr1nBqNVlIK/4VrDaWH8H5dNJ72gA9aMNVH4/bSTJhuO7cJkLnHw= +-----END CERTIFICATE----- diff --git a/authentik/enterprise/stages/mtls/tests/test_stage.py b/authentik/enterprise/stages/mtls/tests/test_stage.py new file mode 100644 index 0000000000..caf8b828b1 --- /dev/null +++ b/authentik/enterprise/stages/mtls/tests/test_stage.py @@ -0,0 +1,213 @@ +from unittest.mock import MagicMock, patch +from urllib.parse import quote_plus + +from django.urls import reverse +from guardian.shortcuts import assign_perm + +from authentik.core.models import User +from authentik.core.tests.utils import create_test_brand, create_test_flow, create_test_user +from authentik.crypto.models import CertificateKeyPair +from authentik.enterprise.stages.mtls.models import ( + CertAttributes, + MutualTLSStage, + TLSMode, + UserAttributes, +) +from authentik.enterprise.stages.mtls.stage import PLAN_CONTEXT_CERTIFICATE +from authentik.flows.models import FlowDesignation, FlowStageBinding +from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER +from authentik.flows.tests import FlowTestCase +from authentik.lib.generators import generate_id +from authentik.lib.tests.utils import load_fixture +from authentik.outposts.models import Outpost, OutpostType +from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT + + +class MTLSStageTests(FlowTestCase): + + def setUp(self): + super().setUp() + self.flow = create_test_flow(FlowDesignation.AUTHENTICATION) + self.ca = CertificateKeyPair.objects.create( + name=generate_id(), + certificate_data=load_fixture("fixtures/ca.pem"), + ) + self.stage = MutualTLSStage.objects.create( + name=generate_id(), + mode=TLSMode.REQUIRED, + cert_attribute=CertAttributes.COMMON_NAME, + user_attribute=UserAttributes.USERNAME, + ) + + self.stage.certificate_authorities.add(self.ca) + self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=0) + self.client_cert = load_fixture("fixtures/cert_client.pem") + # User matching the certificate + User.objects.filter(username="client").delete() + self.cert_user = create_test_user(username="client") + + def test_parse_xfcc(self): + """Test authentik Proxy/Envoy's XFCC format""" + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-Client-Cert": f"Cert={quote_plus(self.client_cert)}"}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_nginx(self): + """Test nginx's format""" + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"SSL-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_traefik(self): + """Test traefik's format""" + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_outpost_object(self): + """Test outposts's format""" + outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY) + assign_perm("pass_outpost_certificate", outpost.user, self.stage) + with patch( + "authentik.root.middleware.ClientIPMiddleware.get_outpost_user", + MagicMock(return_value=outpost.user), + ): + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_outpost_global(self): + """Test outposts's format""" + outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY) + assign_perm("authentik_stages_mtls.pass_outpost_certificate", outpost.user) + with patch( + "authentik.root.middleware.ClientIPMiddleware.get_outpost_user", + MagicMock(return_value=outpost.user), + ): + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_parse_outpost_no_perm(self): + """Test outposts's format""" + outpost = Outpost.objects.create(name=generate_id(), type=OutpostType.PROXY) + with patch( + "authentik.root.middleware.ClientIPMiddleware.get_outpost_user", + MagicMock(return_value=outpost.user), + ): + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Authentik-Outpost-Certificate": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageResponse(res, self.flow, component="ak-stage-access-denied") + + def test_auth_no_user(self): + """Test auth with no user""" + User.objects.filter(username="client").delete() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageResponse(res, self.flow, component="ak-stage-access-denied") + + def test_brand_ca(self): + """Test using a CA from the brand""" + self.stage.certificate_authorities.clear() + + brand = create_test_brand() + brand.client_certificates.add(self.ca) + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PENDING_USER], self.cert_user) + + def test_no_ca_optional(self): + """Test using no CA Set""" + self.stage.mode = TLSMode.OPTIONAL + self.stage.certificate_authorities.clear() + self.stage.save() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + + def test_no_ca_required(self): + """Test using no CA Set""" + self.stage.certificate_authorities.clear() + self.stage.save() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageResponse(res, self.flow, component="ak-stage-access-denied") + + def test_no_cert_optional(self): + """Test using no cert Set""" + self.stage.mode = TLSMode.OPTIONAL + self.stage.save() + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + + def test_enroll(self): + """Test Enrollment flow""" + self.flow.designation = FlowDesignation.ENROLLMENT + self.flow.save() + with self.assertFlowFinishes() as plan: + res = self.client.get( + reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}), + headers={"X-Forwarded-TLS-Client-Cert": quote_plus(self.client_cert)}, + ) + self.assertEqual(res.status_code, 200) + self.assertStageRedirects(res, reverse("authentik_core:root-redirect")) + self.assertEqual(plan().context[PLAN_CONTEXT_PROMPT], {"email": None, "name": "client"}) + self.assertEqual( + plan().context[PLAN_CONTEXT_CERTIFICATE], + { + "fingerprint_sha1": ( + "08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc" + ), + "fingerprint_sha256": ( + "08:d4:a4:79:25:ca:c3:51:28:88:bb:30:c2:96:c3:44:5a:eb:18:07:84:ca:b4:75:27:74:61:19:8a:6a:af:fc" + ), + "issuer": "OU=Self-signed,O=authentik,CN=authentik Test CA", + "serial_number": "630532384467334865093173111400266136879266564943", + "subject": "CN=client", + }, + ) diff --git a/authentik/enterprise/stages/mtls/urls.py b/authentik/enterprise/stages/mtls/urls.py new file mode 100644 index 0000000000..28941718bd --- /dev/null +++ b/authentik/enterprise/stages/mtls/urls.py @@ -0,0 +1,5 @@ +"""API URLs""" + +from authentik.enterprise.stages.mtls.api import MutualTLSStageViewSet + +api_urlpatterns = [("stages/mtls", MutualTLSStageViewSet)] diff --git a/authentik/flows/tests/__init__.py b/authentik/flows/tests/__init__.py index cef2436566..cb861a70b6 100644 --- a/authentik/flows/tests/__init__.py +++ b/authentik/flows/tests/__init__.py @@ -1,7 +1,10 @@ """Test helpers""" +from collections.abc import Callable, Generator +from contextlib import contextmanager from json import loads from typing import Any +from unittest.mock import MagicMock, patch from django.http.response import HttpResponse from django.urls.base import reverse @@ -9,6 +12,8 @@ from rest_framework.test import APITestCase from authentik.core.models import User from authentik.flows.models import Flow +from authentik.flows.planner import FlowPlan +from authentik.flows.views.executor import SESSION_KEY_PLAN class FlowTestCase(APITestCase): @@ -44,3 +49,12 @@ class FlowTestCase(APITestCase): def assertStageRedirects(self, response: HttpResponse, to: str) -> dict[str, Any]: """Wrapper around assertStageResponse that checks for a redirect""" return self.assertStageResponse(response, component="xak-flow-redirect", to=to) + + @contextmanager + def assertFlowFinishes(self) -> Generator[Callable[[], FlowPlan]]: + """Capture the flow plan before the flow finishes and return it""" + try: + with patch("authentik.flows.views.executor.FlowExecutorView.cancel", MagicMock()): + yield lambda: self.client.session.get(SESSION_KEY_PLAN) + finally: + pass diff --git a/blueprints/schema.json b/blueprints/schema.json index 27b434bd62..437f23172e 100644 --- a/blueprints/schema.json +++ b/blueprints/schema.json @@ -3921,6 +3921,46 @@ } } }, + { + "type": "object", + "required": [ + "model", + "identifiers" + ], + "properties": { + "model": { + "const": "authentik_stages_mtls.mutualtlsstage" + }, + "id": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "absent", + "present", + "created", + "must_created" + ], + "default": "present" + }, + "conditions": { + "type": "array", + "items": { + "type": "boolean" + } + }, + "permissions": { + "$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage_permissions" + }, + "attrs": { + "$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage" + }, + "identifiers": { + "$ref": "#/$defs/model_authentik_stages_mtls.mutualtlsstage" + } + } + }, { "type": "object", "required": [ @@ -4867,6 +4907,7 @@ "authentik.enterprise.providers.microsoft_entra", "authentik.enterprise.providers.ssf", "authentik.enterprise.stages.authenticator_endpoint_gdtc", + "authentik.enterprise.stages.mtls", "authentik.enterprise.stages.source", "authentik.events" ], @@ -4977,6 +5018,7 @@ "authentik_providers_microsoft_entra.microsoftentraprovidermapping", "authentik_providers_ssf.ssfprovider", "authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage", + "authentik_stages_mtls.mutualtlsstage", "authentik_stages_source.sourcestage", "authentik_events.event", "authentik_events.notificationtransport", @@ -7477,6 +7519,11 @@ "authentik_stages_invitation.delete_invitationstage", "authentik_stages_invitation.view_invitation", "authentik_stages_invitation.view_invitationstage", + "authentik_stages_mtls.add_mutualtlsstage", + "authentik_stages_mtls.change_mutualtlsstage", + "authentik_stages_mtls.delete_mutualtlsstage", + "authentik_stages_mtls.pass_outpost_certificate", + "authentik_stages_mtls.view_mutualtlsstage", "authentik_stages_password.add_passwordstage", "authentik_stages_password.change_passwordstage", "authentik_stages_password.delete_passwordstage", @@ -13422,6 +13469,16 @@ "title": "Web certificate", "description": "Web Certificate used by the authentik Core webserver." }, + "client_certificates": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "description": "Certificates used for client authentication." + }, + "title": "Client certificates", + "description": "Certificates used for client authentication." + }, "attributes": { "type": "object", "additionalProperties": true, @@ -14185,6 +14242,11 @@ "authentik_stages_invitation.delete_invitationstage", "authentik_stages_invitation.view_invitation", "authentik_stages_invitation.view_invitationstage", + "authentik_stages_mtls.add_mutualtlsstage", + "authentik_stages_mtls.change_mutualtlsstage", + "authentik_stages_mtls.delete_mutualtlsstage", + "authentik_stages_mtls.pass_outpost_certificate", + "authentik_stages_mtls.view_mutualtlsstage", "authentik_stages_password.add_passwordstage", "authentik_stages_password.change_passwordstage", "authentik_stages_password.delete_passwordstage", @@ -15088,6 +15150,161 @@ } } }, + "model_authentik_stages_mtls.mutualtlsstage": { + "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" + }, + "mode": { + "type": "string", + "enum": [ + "optional", + "required" + ], + "title": "Mode" + }, + "certificate_authorities": { + "type": "array", + "items": { + "type": "string", + "format": "uuid", + "description": "Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`." + }, + "title": "Certificate authorities", + "description": "Configure certificate authorities to validate the certificate against. This option has a higher priority than the `client_certificate` option on `Brand`." + }, + "cert_attribute": { + "type": "string", + "enum": [ + "subject", + "common_name", + "email" + ], + "title": "Cert attribute" + }, + "user_attribute": { + "type": "string", + "enum": [ + "username", + "email" + ], + "title": "User attribute" + } + }, + "required": [] + }, + "model_authentik_stages_mtls.mutualtlsstage_permissions": { + "type": "array", + "items": { + "type": "object", + "required": [ + "permission" + ], + "properties": { + "permission": { + "type": "string", + "enum": [ + "pass_outpost_certificate", + "add_mutualtlsstage", + "change_mutualtlsstage", + "delete_mutualtlsstage", + "view_mutualtlsstage" + ] + }, + "user": { + "type": "integer" + }, + "role": { + "type": "string" + } + } + } + }, "model_authentik_stages_source.sourcestage": { "type": "object", "properties": { diff --git a/cmd/server/server.go b/cmd/server/server.go index a09451d125..4091c20957 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -19,7 +19,6 @@ import ( sentryutils "goauthentik.io/internal/utils/sentry" webutils "goauthentik.io/internal/utils/web" "goauthentik.io/internal/web" - "goauthentik.io/internal/web/brand_tls" ) var rootCmd = &cobra.Command{ @@ -67,12 +66,12 @@ var rootCmd = &cobra.Command{ } ws := web.NewWebServer() - ws.Core().HealthyCallback = func() { + ws.Core().AddHealthyCallback(func() { if config.Get().Outposts.DisableEmbeddedOutpost { return } go attemptProxyStart(ws, u) - } + }) ws.Start() <-ex l.Info("shutting down webserver") @@ -95,13 +94,8 @@ func attemptProxyStart(ws *web.WebServer, u *url.URL) { } continue } - // Init brand_tls here too since it requires an API Client, - // so we just reuse the same one as the outpost uses - tw := brand_tls.NewWatcher(ac.Client) - go tw.Start() - ws.BrandTLS = tw ac.AddRefreshHandler(func() { - tw.Check() + ws.BrandTLS.Check() }) srv := proxyv2.NewProxyServer(ac) diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 785ee99917..6b533450de 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -21,10 +21,14 @@ func FullVersion() string { return ver } -func OutpostUserAgent() string { +func UserAgentOutpost() string { return fmt.Sprintf("goauthentik.io/outpost/%s", FullVersion()) } +func UserAgentIPC() string { + return fmt.Sprintf("goauthentik.io/ipc/%s", FullVersion()) +} + func UserAgent() string { return fmt.Sprintf("authentik@%s", FullVersion()) } diff --git a/internal/gounicorn/gounicorn.go b/internal/gounicorn/gounicorn.go index 6478b91981..3013d6daa6 100644 --- a/internal/gounicorn/gounicorn.go +++ b/internal/gounicorn/gounicorn.go @@ -18,8 +18,8 @@ import ( ) type GoUnicorn struct { - Healthcheck func() bool - HealthyCallback func() + Healthcheck func() bool + healthyCallbacks []func() log *log.Entry p *exec.Cmd @@ -32,12 +32,12 @@ type GoUnicorn struct { func New(healthcheck func() bool) *GoUnicorn { logger := log.WithField("logger", "authentik.router.unicorn") g := &GoUnicorn{ - Healthcheck: healthcheck, - log: logger, - started: false, - killed: false, - alive: false, - HealthyCallback: func() {}, + Healthcheck: healthcheck, + log: logger, + started: false, + killed: false, + alive: false, + healthyCallbacks: []func(){}, } g.initCmd() c := make(chan os.Signal, 1) @@ -79,6 +79,10 @@ func (g *GoUnicorn) initCmd() { g.p.Stderr = os.Stderr } +func (g *GoUnicorn) AddHealthyCallback(cb func()) { + g.healthyCallbacks = append(g.healthyCallbacks, cb) +} + func (g *GoUnicorn) IsRunning() bool { return g.alive } @@ -101,7 +105,9 @@ func (g *GoUnicorn) healthcheck() { if g.Healthcheck() { g.alive = true g.log.Debug("backend is alive, backing off with healthchecks") - g.HealthyCallback() + for _, cb := range g.healthyCallbacks { + cb() + } break } g.log.Debug("backend not alive yet") diff --git a/internal/outpost/ak/api.go b/internal/outpost/ak/api.go index 11a69fba30..0d18fc6d1b 100644 --- a/internal/outpost/ak/api.go +++ b/internal/outpost/ak/api.go @@ -62,7 +62,7 @@ func NewAPIController(akURL url.URL, token string) *APIController { apiConfig.Scheme = akURL.Scheme apiConfig.HTTPClient = &http.Client{ Transport: web.NewUserAgentTransport( - constants.OutpostUserAgent(), + constants.UserAgentOutpost(), web.NewTracingTransport( rsp.Context(), GetTLSTransport(), diff --git a/internal/outpost/ak/api_ws.go b/internal/outpost/ak/api_ws.go index 62f4e9ea48..a45738f6c1 100644 --- a/internal/outpost/ak/api_ws.go +++ b/internal/outpost/ak/api_ws.go @@ -38,7 +38,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error { header := http.Header{ "Authorization": []string{authHeader}, - "User-Agent": []string{constants.OutpostUserAgent()}, + "User-Agent": []string{constants.UserAgentOutpost()}, } dialer := websocket.Dialer{ diff --git a/internal/outpost/ak/crypto.go b/internal/outpost/ak/crypto.go index 824bd526ef..afab8015d3 100644 --- a/internal/outpost/ak/crypto.go +++ b/internal/outpost/ak/crypto.go @@ -3,6 +3,8 @@ package ak import ( "context" "crypto/tls" + "crypto/x509" + "encoding/pem" log "github.com/sirupsen/logrus" "goauthentik.io/api/v3" @@ -67,16 +69,34 @@ func (cs *CryptoStore) Fetch(uuid string) error { return err } - x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data)) - if err != nil { - return err + var tcert tls.Certificate + if key.Data != "" { + x509cert, err := tls.X509KeyPair([]byte(cert.Data), []byte(key.Data)) + if err != nil { + return err + } + tcert = x509cert + } else { + p, _ := pem.Decode([]byte(cert.Data)) + x509cert, err := x509.ParseCertificate(p.Bytes) + if err != nil { + return err + } + tcert = tls.Certificate{ + Certificate: [][]byte{x509cert.Raw}, + Leaf: x509cert, + } } - cs.certificates[uuid] = &x509cert + cs.certificates[uuid] = &tcert cs.fingerprints[uuid] = cfp return nil } func (cs *CryptoStore) Get(uuid string) *tls.Certificate { + c, ok := cs.certificates[uuid] + if ok { + return c + } err := cs.Fetch(uuid) if err != nil { cs.log.WithError(err).Warning("failed to fetch certificate") diff --git a/internal/outpost/ak/global.go b/internal/outpost/ak/global.go index 19047fa5d9..4c1072082e 100644 --- a/internal/outpost/ak/global.go +++ b/internal/outpost/ak/global.go @@ -55,7 +55,7 @@ func doGlobalSetup(outpost api.Outpost, globalConfig *api.Config) { EnableTracing: true, TracesSampler: sentryutils.SamplerFunc(float64(globalConfig.ErrorReporting.TracesSampleRate)), Release: fmt.Sprintf("authentik@%s", constants.VERSION), - HTTPTransport: webutils.NewUserAgentTransport(constants.OutpostUserAgent(), http.DefaultTransport), + HTTPTransport: webutils.NewUserAgentTransport(constants.UserAgentOutpost(), http.DefaultTransport), IgnoreErrors: []string{ http.ErrAbortHandler.Error(), }, diff --git a/internal/outpost/flow/executor.go b/internal/outpost/flow/executor.go index 162aafb7e6..465715472e 100644 --- a/internal/outpost/flow/executor.go +++ b/internal/outpost/flow/executor.go @@ -61,7 +61,7 @@ func NewFlowExecutor(ctx context.Context, flowSlug string, refConfig *api.Config l.WithError(err).Warning("Failed to create cookiejar") panic(err) } - transport := web.NewUserAgentTransport(constants.OutpostUserAgent(), web.NewTracingTransport(rsp.Context(), ak.GetTLSTransport())) + transport := web.NewUserAgentTransport(constants.UserAgentOutpost(), web.NewTracingTransport(rsp.Context(), ak.GetTLSTransport())) fe := &FlowExecutor{ Params: url.Values{}, Answers: make(map[StageComponent]string), diff --git a/internal/outpost/proxyv2/application/mode_common.go b/internal/outpost/proxyv2/application/mode_common.go index 3014241092..7972b490ba 100644 --- a/internal/outpost/proxyv2/application/mode_common.go +++ b/internal/outpost/proxyv2/application/mode_common.go @@ -52,7 +52,7 @@ func (a *Application) addHeaders(headers http.Header, c *Claims) { headers.Set("X-authentik-meta-outpost", a.outpostName) headers.Set("X-authentik-meta-provider", a.proxyConfig.Name) headers.Set("X-authentik-meta-app", a.proxyConfig.AssignedApplicationSlug) - headers.Set("X-authentik-meta-version", constants.OutpostUserAgent()) + headers.Set("X-authentik-meta-version", constants.UserAgentOutpost()) if c.Proxy == nil { return diff --git a/internal/outpost/proxyv2/refresh.go b/internal/outpost/proxyv2/refresh.go index cddd4363c1..b4112b4316 100644 --- a/internal/outpost/proxyv2/refresh.go +++ b/internal/outpost/proxyv2/refresh.go @@ -31,7 +31,7 @@ func (ps *ProxyServer) Refresh() error { ua := fmt.Sprintf(" (provider=%s)", provider.Name) hc := &http.Client{ Transport: web.NewUserAgentTransport( - constants.OutpostUserAgent()+ua, + constants.UserAgentOutpost()+ua, web.NewTracingTransport( rsp.Context(), ak.GetTLSTransport(), diff --git a/internal/outpost/rac/connection/connection.go b/internal/outpost/rac/connection/connection.go index 53ca9ecb0b..714d7ccb61 100644 --- a/internal/outpost/rac/connection/connection.go +++ b/internal/outpost/rac/connection/connection.go @@ -61,7 +61,7 @@ func (c *Connection) initSocket(forChannel string) error { header := http.Header{ "Authorization": []string{authHeader}, - "User-Agent": []string{constants.OutpostUserAgent()}, + "User-Agent": []string{constants.UserAgentOutpost()}, } dialer := websocket.Dialer{ diff --git a/internal/utils/web/http_forwarded.go b/internal/utils/web/http_forwarded.go index becf32e94b..95571dead8 100644 --- a/internal/utils/web/http_forwarded.go +++ b/internal/utils/web/http_forwarded.go @@ -1,6 +1,7 @@ package web import ( + "context" "net" "net/http" @@ -9,6 +10,14 @@ import ( "goauthentik.io/internal/config" ) +type allowedProxyRequestContext string + +const allowedProxyRequest allowedProxyRequestContext = "" + +func IsRequestFromTrustedProxy(r *http.Request) bool { + return r.Context().Value(allowedProxyRequest) != nil +} + // ProxyHeaders Set proxy headers like X-Forwarded-For and such, but only if the direct connection // comes from a client that's in a list of trusted CIDRs func ProxyHeaders() func(http.Handler) http.Handler { @@ -20,7 +29,6 @@ func ProxyHeaders() func(http.Handler) http.Handler { } nets = append(nets, cidr) } - ph := handlers.ProxyHeaders return func(h http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { host, _, err := net.SplitHostPort(r.RemoteAddr) @@ -30,7 +38,8 @@ func ProxyHeaders() func(http.Handler) http.Handler { for _, allowedCidr := range nets { if remoteAddr != nil && allowedCidr.Contains(remoteAddr) { log.WithField("remoteAddr", remoteAddr).WithField("cidr", allowedCidr.String()).Trace("Setting proxy headers") - ph(h).ServeHTTP(w, r) + rr := r.WithContext(context.WithValue(r.Context(), allowedProxyRequest, true)) + handlers.ProxyHeaders(h).ServeHTTP(w, rr) return } } diff --git a/internal/web/brand_tls/brand_tls.go b/internal/web/brand_tls/brand_tls.go index 107cf904c5..670fc3b305 100644 --- a/internal/web/brand_tls/brand_tls.go +++ b/internal/web/brand_tls/brand_tls.go @@ -3,6 +3,7 @@ package brand_tls import ( "context" "crypto/tls" + "crypto/x509" "strings" "time" @@ -56,22 +57,37 @@ func (w *Watcher) Check() { return } for _, b := range brands { - kp := b.WebCertificate.Get() - if kp == nil { - continue + kp := b.GetWebCertificate() + if kp != "" { + err := w.cs.AddKeypair(kp) + if err != nil { + w.log.WithError(err).WithField("kp", kp).Warning("failed to add web certificate") + } } - err := w.cs.AddKeypair(*kp) - if err != nil { - w.log.WithError(err).Warning("failed to add certificate") + for _, crt := range b.GetClientCertificates() { + if crt != "" { + err := w.cs.AddKeypair(crt) + if err != nil { + w.log.WithError(err).WithField("kp", kp).Warning("failed to add client certificate") + } + } } } w.brands = brands } -func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { +type CertificateConfig struct { + Web *tls.Certificate + Client *x509.CertPool +} + +func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) *CertificateConfig { var bestSelection *api.Brand + config := CertificateConfig{ + Web: w.fallback, + } for _, t := range w.brands { - if t.WebCertificate.Get() == nil { + if !t.WebCertificate.IsSet() && len(t.GetClientCertificates()) < 1 { continue } if *t.Default { @@ -82,11 +98,20 @@ func (w *Watcher) GetCertificate(ch *tls.ClientHelloInfo) (*tls.Certificate, err } } if bestSelection == nil { - return w.fallback, nil + return &config } - cert := w.cs.Get(bestSelection.GetWebCertificate()) - if cert == nil { - return w.fallback, nil + if bestSelection.GetWebCertificate() != "" { + if cert := w.cs.Get(bestSelection.GetWebCertificate()); cert != nil { + config.Web = cert + } } - return cert, nil + if len(bestSelection.GetClientCertificates()) > 0 { + config.Client = x509.NewCertPool() + for _, kp := range bestSelection.GetClientCertificates() { + if cert := w.cs.Get(kp); cert != nil { + config.Client.AddCert(cert.Leaf) + } + } + } + return &config } diff --git a/internal/web/metrics.go b/internal/web/metrics.go index 21778ba99e..fff462f5ea 100644 --- a/internal/web/metrics.go +++ b/internal/web/metrics.go @@ -1,15 +1,11 @@ package web import ( - "encoding/base64" "fmt" "io" "net/http" - "os" - "path" "github.com/gorilla/mux" - "github.com/gorilla/securecookie" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -18,8 +14,6 @@ import ( "goauthentik.io/internal/utils/sentry" ) -const MetricsKeyFile = "authentik-core-metrics.key" - var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "authentik_main_request_duration_seconds", Help: "API request latencies in seconds", @@ -27,14 +21,6 @@ var Requests = promauto.NewHistogramVec(prometheus.HistogramOpts{ func (ws *WebServer) runMetricsServer() { l := log.WithField("logger", "authentik.router.metrics") - tmp := os.TempDir() - key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)) - keyPath := path.Join(tmp, MetricsKeyFile) - err := os.WriteFile(keyPath, []byte(key), 0o600) - if err != nil { - l.WithError(err).Warning("failed to save metrics key") - return - } m := mux.NewRouter() m.Use(sentry.SentryNoSampleMiddleware) @@ -51,7 +37,7 @@ func (ws *WebServer) runMetricsServer() { l.WithError(err).Warning("failed to get upstream metrics") return } - re.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key)) + re.Header.Set("Authorization", fmt.Sprintf("Bearer %s", ws.metricsKey)) res, err := ws.upstreamHttpClient().Do(re) if err != nil { l.WithError(err).Warning("failed to get upstream metrics") @@ -64,13 +50,9 @@ func (ws *WebServer) runMetricsServer() { } }) l.WithField("listen", config.Get().Listen.Metrics).Info("Starting Metrics server") - err = http.ListenAndServe(config.Get().Listen.Metrics, m) + err := http.ListenAndServe(config.Get().Listen.Metrics, m) if err != nil { l.WithError(err).Warning("Failed to start metrics server") } l.WithField("listen", config.Get().Listen.Metrics).Info("Stopping Metrics server") - err = os.Remove(keyPath) - if err != nil { - l.WithError(err).Warning("failed to remove metrics key file") - } } diff --git a/internal/web/proxy.go b/internal/web/proxy.go index 00e6761119..989c1a14d4 100644 --- a/internal/web/proxy.go +++ b/internal/web/proxy.go @@ -2,21 +2,29 @@ package web import ( "encoding/json" + "encoding/pem" "errors" "fmt" "net/http" "net/http/httputil" + "net/url" + "strings" "time" "github.com/prometheus/client_golang/prometheus" "goauthentik.io/internal/config" "goauthentik.io/internal/utils/sentry" + "goauthentik.io/internal/utils/web" ) var ( ErrAuthentikStarting = errors.New("authentik starting") ) +const ( + maxBodyBytes = 32 * 1024 * 1024 +) + func (ws *WebServer) configureProxy() { // Reverse proxy to the application server director := func(req *http.Request) { @@ -26,8 +34,25 @@ func (ws *WebServer) configureProxy() { // explicitly disable User-Agent so it's not set to default value req.Header.Set("User-Agent", "") } + if !web.IsRequestFromTrustedProxy(req) { + // If the request isn't coming from a trusted proxy, delete MTLS headers + req.Header.Del("SSL-Client-Cert") // nginx-ingress + req.Header.Del("X-Forwarded-TLS-Client-Cert") // traefik + req.Header.Del("X-Forwarded-Client-Cert") // envoy + } if req.TLS != nil { req.Header.Set("X-Forwarded-Proto", "https") + if len(req.TLS.PeerCertificates) > 0 { + pems := make([]string, len(req.TLS.PeerCertificates)) + for i, crt := range req.TLS.PeerCertificates { + pem := pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: crt.Raw, + }) + pems[i] = "Cert=" + url.QueryEscape(string(pem)) + } + req.Header.Set("X-Forwarded-Client-Cert", strings.Join(pems, ",")) + } } ws.log.WithField("url", req.URL.String()).WithField("headers", req.Header).Trace("tracing request to backend") } @@ -57,7 +82,7 @@ func (ws *WebServer) configureProxy() { Requests.With(prometheus.Labels{ "dest": "core", }).Observe(float64(elapsed) / float64(time.Second)) - r.Body = http.MaxBytesReader(rw, r.Body, 32*1024*1024) + r.Body = http.MaxBytesReader(rw, r.Body, maxBodyBytes) rp.ServeHTTP(rw, r) })) } diff --git a/internal/web/web.go b/internal/web/web.go index d25ee688c9..fcd16543e2 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -2,6 +2,7 @@ package web import ( "context" + "encoding/base64" "errors" "fmt" "net" @@ -13,17 +14,27 @@ import ( "github.com/gorilla/handlers" "github.com/gorilla/mux" + "github.com/gorilla/securecookie" "github.com/pires/go-proxyproto" log "github.com/sirupsen/logrus" + "goauthentik.io/api/v3" "goauthentik.io/internal/config" + "goauthentik.io/internal/constants" "goauthentik.io/internal/gounicorn" + "goauthentik.io/internal/outpost/ak" "goauthentik.io/internal/outpost/proxyv2" "goauthentik.io/internal/utils" "goauthentik.io/internal/utils/web" "goauthentik.io/internal/web/brand_tls" ) +const ( + IPCKeyFile = "authentik-core-ipc.key" + MetricsKeyFile = "authentik-core-metrics.key" + UnixSocketName = "authentik-core.sock" +) + type WebServer struct { Bind string BindTLS bool @@ -40,9 +51,10 @@ type WebServer struct { log *log.Entry upstreamClient *http.Client upstreamURL *url.URL -} -const UnixSocketName = "authentik-core.sock" + metricsKey string + ipcKey string +} func NewWebServer() *WebServer { l := log.WithField("logger", "authentik.router") @@ -76,7 +88,7 @@ func NewWebServer() *WebServer { mainRouter: mainHandler, loggingRouter: loggingHandler, log: l, - gunicornReady: true, + gunicornReady: false, upstreamClient: upstreamClient, upstreamURL: u, } @@ -103,7 +115,59 @@ func NewWebServer() *WebServer { return ws } +func (ws *WebServer) prepareKeys() { + tmp := os.TempDir() + key := base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)) + err := os.WriteFile(path.Join(tmp, MetricsKeyFile), []byte(key), 0o600) + if err != nil { + ws.log.WithError(err).Warning("failed to save metrics key") + return + } + ws.metricsKey = key + + key = base64.StdEncoding.EncodeToString(securecookie.GenerateRandomKey(64)) + err = os.WriteFile(path.Join(tmp, IPCKeyFile), []byte(key), 0o600) + if err != nil { + ws.log.WithError(err).Warning("failed to save ipc key") + return + } + ws.ipcKey = key +} + func (ws *WebServer) Start() { + ws.prepareKeys() + + u, err := url.Parse(fmt.Sprintf("http://%s%s", config.Get().Listen.HTTP, config.Get().Web.Path)) + if err != nil { + panic(err) + } + apiConfig := api.NewConfiguration() + apiConfig.Host = u.Host + apiConfig.Scheme = u.Scheme + apiConfig.HTTPClient = &http.Client{ + Transport: web.NewUserAgentTransport( + constants.UserAgentIPC(), + ak.GetTLSTransport(), + ), + } + apiConfig.Servers = api.ServerConfigurations{ + { + URL: fmt.Sprintf("%sapi/v3", u.Path), + }, + } + apiConfig.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", ws.ipcKey)) + + // create the API client, with the transport + apiClient := api.NewAPIClient(apiConfig) + + // Init brand_tls here too since it requires an API Client, + // so we just reuse the same one as the outpost uses + tw := brand_tls.NewWatcher(apiClient) + ws.BrandTLS = tw + ws.g.AddHealthyCallback(func() { + go tw.Start() + }) + go ws.runMetricsServer() go ws.attemptStartBackend() go ws.listenPlain() @@ -112,23 +176,23 @@ func (ws *WebServer) Start() { func (ws *WebServer) attemptStartBackend() { for { - if !ws.gunicornReady { + if ws.gunicornReady { return } err := ws.g.Start() - log.WithField("logger", "authentik.router").WithError(err).Warning("gunicorn process died, restarting") + ws.log.WithError(err).Warning("gunicorn process died, restarting") if err != nil { - log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn failed to start, restarting") + ws.log.WithError(err).Error("gunicorn failed to start, restarting") continue } failedChecks := 0 for range time.NewTicker(30 * time.Second).C { if !ws.g.IsRunning() { - log.WithField("logger", "authentik.router").Warningf("gunicorn process failed healthcheck %d times", failedChecks) + ws.log.Warningf("gunicorn process failed healthcheck %d times", failedChecks) failedChecks += 1 } if failedChecks >= 3 { - log.WithField("logger", "authentik.router").WithError(err).Error("gunicorn process failed healthcheck three times, restarting") + ws.log.WithError(err).Error("gunicorn process failed healthcheck three times, restarting") break } } @@ -146,6 +210,15 @@ func (ws *WebServer) upstreamHttpClient() *http.Client { func (ws *WebServer) Shutdown() { ws.log.Info("shutting down gunicorn") ws.g.Kill() + tmp := os.TempDir() + err := os.Remove(path.Join(tmp, MetricsKeyFile)) + if err != nil { + ws.log.WithError(err).Warning("failed to remove metrics key file") + } + err = os.Remove(path.Join(tmp, IPCKeyFile)) + if err != nil { + ws.log.WithError(err).Warning("failed to remove ipc key file") + } ws.stop <- struct{}{} } diff --git a/internal/web/web_tls.go b/internal/web/web_tls.go index 9e006fbdd8..7c4818ea1e 100644 --- a/internal/web/web_tls.go +++ b/internal/web/web_tls.go @@ -12,40 +12,57 @@ import ( "goauthentik.io/internal/utils/web" ) -func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { - cert, err := crypto.GenerateSelfSignedCert() +func (ws *WebServer) GetCertificate() func(ch *tls.ClientHelloInfo) (*tls.Config, error) { + fallback, err := crypto.GenerateSelfSignedCert() if err != nil { ws.log.WithError(err).Error("failed to generate default cert") } - return func(ch *tls.ClientHelloInfo) (*tls.Certificate, error) { + return func(ch *tls.ClientHelloInfo) (*tls.Config, error) { + cfg := utils.GetTLSConfig() if ch.ServerName == "" { - return &cert, nil + cfg.Certificates = []tls.Certificate{fallback} + return cfg, nil } if ws.ProxyServer != nil { appCert := ws.ProxyServer.GetCertificate(ch.ServerName) if appCert != nil { - return appCert, nil + cfg.Certificates = []tls.Certificate{*appCert} + return cfg, nil } } if ws.BrandTLS != nil { - return ws.BrandTLS.GetCertificate(ch) + bcert := ws.BrandTLS.GetCertificate(ch) + cfg.Certificates = []tls.Certificate{*bcert.Web} + ws.log.Trace("using brand web Certificate") + if bcert.Client != nil { + cfg.ClientCAs = bcert.Client + cfg.ClientAuth = tls.RequestClientCert + ws.log.Trace("using brand client Certificate") + } + return cfg, nil } ws.log.Trace("using default, self-signed certificate") - return &cert, nil + cfg.Certificates = []tls.Certificate{fallback} + return cfg, nil } } // ServeHTTPS constructs a net.Listener and starts handling HTTPS requests func (ws *WebServer) listenTLS() { tlsConfig := utils.GetTLSConfig() - tlsConfig.GetCertificate = ws.GetCertificate() + tlsConfig.GetConfigForClient = ws.GetCertificate() ln, err := net.Listen("tcp", config.Get().Listen.HTTPS) if err != nil { ws.log.WithError(err).Warning("failed to listen (TLS)") return } - proxyListener := &proxyproto.Listener{Listener: web.TCPKeepAliveListener{TCPListener: ln.(*net.TCPListener)}, ConnPolicy: utils.GetProxyConnectionPolicy()} + proxyListener := &proxyproto.Listener{ + Listener: web.TCPKeepAliveListener{ + TCPListener: ln.(*net.TCPListener), + }, + ConnPolicy: utils.GetProxyConnectionPolicy(), + } defer func() { err := proxyListener.Close() if err != nil { diff --git a/schema.yml b/schema.yml index 160fcb5f52..8974c0f0dc 100644 --- a/schema.yml +++ b/schema.yml @@ -4460,6 +4460,15 @@ paths: name: branding_title schema: type: string + - in: query + name: client_certificates + schema: + type: array + items: + type: string + format: uuid + explode: true + style: form - in: query name: default schema: @@ -24978,6 +24987,7 @@ paths: - authentik_stages_identification.identificationstage - authentik_stages_invitation.invitation - authentik_stages_invitation.invitationstage + - authentik_stages_mtls.mutualtlsstage - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage @@ -25226,6 +25236,7 @@ paths: - authentik_stages_identification.identificationstage - authentik_stages_invitation.invitation - authentik_stages_invitation.invitationstage + - authentik_stages_mtls.mutualtlsstage - authentik_stages_password.passwordstage - authentik_stages_prompt.prompt - authentik_stages_prompt.promptstage @@ -37718,6 +37729,311 @@ paths: schema: $ref: '#/components/schemas/GenericError' description: '' + /stages/mtls/: + get: + operationId: stages_mtls_list + description: MutualTLSStage Viewset + parameters: + - in: query + name: cert_attribute + schema: + type: string + enum: + - common_name + - email + - subject + - in: query + name: certificate_authorities + schema: + type: array + items: + type: string + format: uuid + explode: true + style: form + - in: query + name: mode + schema: + type: string + enum: + - optional + - required + - in: query + name: name + schema: + type: string + - name: ordering + required: false + in: query + description: Which field to use when ordering the results. + schema: + type: string + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - name: search + required: false + in: query + description: A search term. + schema: + type: string + - in: query + name: stage_uuid + schema: + type: string + format: uuid + - in: query + name: user_attribute + schema: + type: string + enum: + - email + - username + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedMutualTLSStageList' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + post: + operationId: stages_mtls_create + description: MutualTLSStage Viewset + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStageRequest' + required: true + security: + - authentik: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/mtls/{stage_uuid}/: + get: + operationId: stages_mtls_retrieve + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + put: + operationId: stages_mtls_update + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStageRequest' + required: true + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + patch: + operationId: stages_mtls_partial_update + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedMutualTLSStageRequest' + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MutualTLSStage' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + delete: + operationId: stages_mtls_destroy + description: MutualTLSStage Viewset + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '204': + description: No response body + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' + /stages/mtls/{stage_uuid}/used_by/: + get: + operationId: stages_mtls_used_by_list + description: Get a list of all objects that use this object + parameters: + - in: path + name: stage_uuid + schema: + type: string + format: uuid + description: A UUID string identifying this Mutual TLS Stage. + required: true + tags: + - stages + security: + - authentik: [] + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/UsedBy' + description: '' + '400': + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationError' + description: '' + '403': + content: + application/json: + schema: + $ref: '#/components/schemas/GenericError' + description: '' /stages/password/: get: operationId: stages_password_list @@ -40946,6 +41262,7 @@ components: - authentik.enterprise.providers.microsoft_entra - authentik.enterprise.providers.ssf - authentik.enterprise.stages.authenticator_endpoint_gdtc + - authentik.enterprise.stages.mtls - authentik.enterprise.stages.source - authentik.events type: string @@ -42609,6 +42926,12 @@ components: format: uuid nullable: true description: Web Certificate used by the authentik Core webserver. + client_certificates: + type: array + items: + type: string + format: uuid + description: Certificates used for client authentication. attributes: {} required: - brand_uuid @@ -42673,6 +42996,12 @@ components: format: uuid nullable: true description: Web Certificate used by the authentik Core webserver. + client_certificates: + type: array + items: + type: string + format: uuid + description: Certificates used for client authentication. attributes: {} required: - domain @@ -42842,6 +43171,12 @@ components: - name - private_key - public_key + CertAttributeEnum: + enum: + - subject + - common_name + - email + type: string CertificateData: type: object description: Get CertificateKeyPair's data @@ -48368,6 +48703,7 @@ components: - authentik_providers_microsoft_entra.microsoftentraprovidermapping - authentik_providers_ssf.ssfprovider - authentik_stages_authenticator_endpoint_gdtc.authenticatorendpointgdtcstage + - authentik_stages_mtls.mutualtlsstage - authentik_stages_source.sourcestage - authentik_events.event - authentik_events.notificationtransport @@ -48375,6 +48711,96 @@ components: - authentik_events.notificationrule - authentik_events.notificationwebhookmapping type: string + MutualTLSStage: + type: object + description: MutualTLSStage Serializer + properties: + pk: + type: string + format: uuid + readOnly: true + title: Stage uuid + name: + type: string + component: + type: string + description: Get object type so that we know how to edit the object + readOnly: true + verbose_name: + type: string + description: Return object's verbose_name + readOnly: true + verbose_name_plural: + type: string + description: Return object's plural verbose_name + readOnly: true + meta_model_name: + type: string + description: Return internal model name + readOnly: true + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSet' + mode: + $ref: '#/components/schemas/MutualTLSStageModeEnum' + certificate_authorities: + type: array + items: + type: string + format: uuid + description: Configure certificate authorities to validate the certificate + against. This option has a higher priority than the `client_certificate` + option on `Brand`. + cert_attribute: + $ref: '#/components/schemas/CertAttributeEnum' + user_attribute: + $ref: '#/components/schemas/UserAttributeEnum' + required: + - cert_attribute + - component + - meta_model_name + - mode + - name + - pk + - user_attribute + - verbose_name + - verbose_name_plural + MutualTLSStageModeEnum: + enum: + - optional + - required + type: string + MutualTLSStageRequest: + type: object + description: MutualTLSStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + mode: + $ref: '#/components/schemas/MutualTLSStageModeEnum' + certificate_authorities: + type: array + items: + type: string + format: uuid + description: Configure certificate authorities to validate the certificate + against. This option has a higher priority than the `client_certificate` + option on `Brand`. + cert_attribute: + $ref: '#/components/schemas/CertAttributeEnum' + user_attribute: + $ref: '#/components/schemas/UserAttributeEnum' + required: + - cert_attribute + - mode + - name + - user_attribute NameIdPolicyEnum: enum: - urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress @@ -50220,6 +50646,18 @@ components: required: - pagination - results + PaginatedMutualTLSStageList: + type: object + properties: + pagination: + $ref: '#/components/schemas/Pagination' + results: + type: array + items: + $ref: '#/components/schemas/MutualTLSStage' + required: + - pagination + - results PaginatedNotificationList: type: object properties: @@ -51896,6 +52334,12 @@ components: format: uuid nullable: true description: Web Certificate used by the authentik Core webserver. + client_certificates: + type: array + items: + type: string + format: uuid + description: Certificates used for client authentication. attributes: {} PatchedCaptchaStageRequest: type: object @@ -53079,6 +53523,31 @@ components: type: boolean description: When enabled, provider will not modify or create objects in the remote system. + PatchedMutualTLSStageRequest: + type: object + description: MutualTLSStage Serializer + properties: + name: + type: string + minLength: 1 + flow_set: + type: array + items: + $ref: '#/components/schemas/FlowSetRequest' + mode: + $ref: '#/components/schemas/MutualTLSStageModeEnum' + certificate_authorities: + type: array + items: + type: string + format: uuid + description: Configure certificate authorities to validate the certificate + against. This option has a higher priority than the `client_certificate` + option on `Brand`. + cert_attribute: + $ref: '#/components/schemas/CertAttributeEnum' + user_attribute: + $ref: '#/components/schemas/UserAttributeEnum' PatchedNotificationRequest: type: object description: Notification Serializer @@ -59793,6 +60262,11 @@ components: - pk - uid - username + UserAttributeEnum: + enum: + - username + - email + type: string UserConsent: type: object description: UserConsent Serializer diff --git a/web/src/admin/brands/BrandForm.ts b/web/src/admin/brands/BrandForm.ts index fa79fdb53c..b3e9a3d1fc 100644 --- a/web/src/admin/brands/BrandForm.ts +++ b/web/src/admin/brands/BrandForm.ts @@ -1,9 +1,12 @@ +import { certificateProvider, certificateSelector } from "@goauthentik/admin/brands/Certificates"; import "@goauthentik/admin/common/ak-crypto-certificate-search"; import "@goauthentik/admin/common/ak-flow-search/ak-flow-search"; import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; import { DefaultBrand } from "@goauthentik/common/ui/config"; import "@goauthentik/elements/CodeMirror"; import { CodeMirrorMode } from "@goauthentik/elements/CodeMirror"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; import "@goauthentik/elements/forms/FormGroup"; import "@goauthentik/elements/forms/HorizontalFormElement"; import { ModelForm } from "@goauthentik/elements/forms/ModelForm"; @@ -303,6 +306,17 @@ export class BrandForm extends ModelForm { .certificate=${this.instance?.webCertificate} > + + + [s.pk, s.name, s.name, s]; + +export async function certificateProvider(page = 1, search = "") { + const certificates = await new CryptoApi(DEFAULT_CONFIG).cryptoCertificatekeypairsList({ + ordering: "name", + pageSize: 20, + search: search.trim(), + page, + hasKey: undefined, + }); + return { + pagination: certificates.pagination, + options: certificates.results.map(certToSelect), + }; +} + +export function certificateSelector(instanceMappings?: string[]) { + if (!instanceMappings) { + return []; + } + + return async () => { + const pm = new CryptoApi(DEFAULT_CONFIG); + const mappings = await Promise.allSettled( + instanceMappings.map((instanceId) => + pm.cryptoCertificatekeypairsRetrieve({ kpUuid: instanceId }), + ), + ); + + return mappings + .filter((s) => s.status === "fulfilled") + .map((s) => s.value) + .map(certToSelect); + }; +} diff --git a/web/src/admin/stages/StageListPage.ts b/web/src/admin/stages/StageListPage.ts index 3056d41d03..0b0cfabbaa 100644 --- a/web/src/admin/stages/StageListPage.ts +++ b/web/src/admin/stages/StageListPage.ts @@ -16,6 +16,7 @@ import "@goauthentik/admin/stages/dummy/DummyStageForm"; import "@goauthentik/admin/stages/email/EmailStageForm"; import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; +import "@goauthentik/admin/stages/mtls/MTLSStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; import "@goauthentik/admin/stages/redirect/RedirectStageForm"; diff --git a/web/src/admin/stages/StageWizard.ts b/web/src/admin/stages/StageWizard.ts index 46d99f4a16..7244fada79 100644 --- a/web/src/admin/stages/StageWizard.ts +++ b/web/src/admin/stages/StageWizard.ts @@ -14,6 +14,7 @@ import "@goauthentik/admin/stages/dummy/DummyStageForm"; import "@goauthentik/admin/stages/email/EmailStageForm"; import "@goauthentik/admin/stages/identification/IdentificationStageForm"; import "@goauthentik/admin/stages/invitation/InvitationStageForm"; +import "@goauthentik/admin/stages/mtls/MTLSStageForm"; import "@goauthentik/admin/stages/password/PasswordStageForm"; import "@goauthentik/admin/stages/prompt/PromptStageForm"; import "@goauthentik/admin/stages/redirect/RedirectStageForm"; diff --git a/web/src/admin/stages/mtls/MTLSStageForm.ts b/web/src/admin/stages/mtls/MTLSStageForm.ts new file mode 100644 index 0000000000..420947219e --- /dev/null +++ b/web/src/admin/stages/mtls/MTLSStageForm.ts @@ -0,0 +1,162 @@ +import { certificateProvider, certificateSelector } from "@goauthentik/admin/brands/Certificates"; +import "@goauthentik/admin/common/ak-crypto-certificate-search"; +import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm"; +import { DEFAULT_CONFIG } from "@goauthentik/common/api/config"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js"; +import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider.js"; +import "@goauthentik/elements/forms/FormGroup"; +import "@goauthentik/elements/forms/HorizontalFormElement"; +import "@goauthentik/elements/forms/Radio"; + +import { msg } from "@lit/localize"; +import { TemplateResult, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { ifDefined } from "lit/directives/if-defined.js"; + +import { + CertAttributeEnum, + MutualTLSStage, + MutualTLSStageModeEnum, + StagesApi, + UserAttributeEnum, +} from "@goauthentik/api"; + +@customElement("ak-stage-mtls-form") +export class MTLSStageForm extends BaseStageForm { + loadInstance(pk: string): Promise { + return new StagesApi(DEFAULT_CONFIG).stagesMtlsRetrieve({ + stageUuid: pk, + }); + } + + async send(data: MutualTLSStage): Promise { + if (this.instance) { + return new StagesApi(DEFAULT_CONFIG).stagesMtlsUpdate({ + stageUuid: this.instance.pk || "", + mutualTLSStageRequest: data, + }); + } else { + return new StagesApi(DEFAULT_CONFIG).stagesMtlsCreate({ + mutualTLSStageRequest: data, + }); + } + } + + renderForm(): TemplateResult { + return html` + ${msg("Client-certificate/mTLS authentication/enrollment.")} + + + + + ${msg("Stage-specific settings")} +
+ + + + + + +

+ ${msg( + "Configure the certificate authority client certificates are validated against. The certificate authority can also be configured on a brand, which allows for different certificate authorities for different domains.", + )} +

+
+ + + +

+ ${msg( + "Configure the attribute of the certificate used to look for a user.", + )} +

+
+ + + +

+ ${msg("Configure the attribute of the user used to look for a user.")} +

+
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-stage-mtls-form": MTLSStageForm; + } +}