diff --git a/kanidm_rlm_python/Dockerfile b/kanidm_rlm_python/Dockerfile index c9db9163b..ab8b6d648 100644 --- a/kanidm_rlm_python/Dockerfile +++ b/kanidm_rlm_python/Dockerfile @@ -2,11 +2,10 @@ FROM opensuse/tumbleweed:latest EXPOSE 1812 1813 -# TODO: remove this once the freeradius python fix has been rolled into tumbleweed main -RUN zypper ar -f obs://home:firstyear:branches:network home:firstyear:branches:network - -RUN zypper --gpg-auto-import-keys refresh --force -RUN zypper install -y \ +# These all need to be on one line else the rpm cache ends +# up in the layers. +RUN zypper refresh --force && \ + zypper install -y \ freeradius-client \ freeradius-server \ freeradius-server-python3 \ @@ -18,8 +17,9 @@ RUN zypper install -y \ timezone \ iproute2 \ iputils \ - curl -RUN zypper clean + openssl \ + curl && \ + zypper clean ADD kanidm_rlm_python/mods-available/ /etc/raddb/mods-available/ COPY kanidm_rlm_python/sites-available/ /etc/raddb/sites-available/ @@ -29,6 +29,7 @@ WORKDIR /etc/raddb # Enable the python and cache module. RUN ln -s /etc/raddb/mods-available/python3 /etc/raddb/mods-enabled/python3 +RUN ln -s /etc/raddb/sites-available/check-eap-tls /etc/raddb/sites-enabled/check-eap-tls # disable auth via methods we don't support! RUN rm /etc/raddb/mods-available/sql @@ -54,6 +55,7 @@ RUN python3 -m pip install --no-cache-dir --no-warn-script-location /pkg/kanidmr RUN rm -rf /pkg/* USER radiusd +ENV LD_PRELOAD=/usr/lib64/libpython3.so COPY kanidm_rlm_python/entrypoint.py /entrypoint.py CMD [ "/usr/bin/python3", "/entrypoint.py" ] diff --git a/kanidm_rlm_python/entrypoint.py b/kanidm_rlm_python/entrypoint.py index eda987737..4b2efd077 100644 --- a/kanidm_rlm_python/entrypoint.py +++ b/kanidm_rlm_python/entrypoint.py @@ -21,6 +21,7 @@ CONFIG_FILE_PATH = "/data/kanidm" CERT_SERVER_DEST = "/etc/raddb/certs/server.pem" CERT_CA_DEST = "/etc/raddb/certs/ca.pem" +CERT_CA_DIR = "/etc/raddb/certs/" CERT_DH_DEST = "/etc/raddb/certs/dh.pem" # pylint: disable=unused-argument @@ -60,6 +61,20 @@ def setup_certs( print(f"Copying {cert_ca} to {CERT_CA_DEST}") shutil.copyfile(cert_ca, CERT_CA_DEST) + # This dir can also contain crls! + if kanidm_config_object.radius_ca_dir: + cert_ca_dir = Path(kanidm_config_object.radius_ca_dir).expanduser().resolve() + if not cert_ca_dir.exists(): + print(f"Failed to find radiusd ca dir ({cert_ca_dir}), quitting!", file=sys.stderr) + sys.exit(1) + if cert_ca_dir != CERT_CA_DIR: + print(f"Copying {cert_ca_dir} to {CERT_CA_DIR}") + shutil.copytree(cert_ca_dir, CERT_CA_DIR, dirs_exist_ok=True) + + # Setup the ca-dir correctly now. We do this before we add server.pem so that it's + # not hashed as a ca. + subprocess.check_call(["openssl", "rehash", CERT_CA_DIR]) + # let's put some dhparams in place if kanidm_config_object.radius_dh_path is not None: cert_dh = Path(kanidm_config_object.radius_dh_path).expanduser().resolve() diff --git a/kanidm_rlm_python/kanidmradius/__init__.py b/kanidm_rlm_python/kanidmradius/__init__.py index f8fbe8ef1..d9d3046be 100644 --- a/kanidm_rlm_python/kanidmradius/__init__.py +++ b/kanidm_rlm_python/kanidmradius/__init__.py @@ -138,17 +138,25 @@ def authorize( dargs = dict(args) logging.error("Authorise: %s", json.dumps(dargs)) + cn_uuid = dargs.get('TLS-Client-Cert-Common-Name', None) username = dargs['User-Name'] + if cn_uuid is not None: + logging.debug("Using TLS-Client-Cert-Common-Name") + user_id = cn_uuid + else: + logging.debug("Using User-Name") + user_id = username + tok = None try: loop = asyncio.get_event_loop() - tok = loop.run_until_complete(_get_radius_token(username=username)) + tok = loop.run_until_complete(_get_radius_token(username=user_id)) logging.debug("radius_token: %s", tok) except NoMatchingEntries as error_message: logging.info( - 'kanidm RLM_MODULE_NOTFOUND after NoMatchingEntries for user %s: %s', - username, + 'kanidm RLM_MODULE_NOTFOUND after NoMatchingEntries for user_id %s: %s', + user_id, error_message, ) return radiusd.RLM_MODULE_NOTFOUND @@ -158,14 +166,19 @@ def authorize( logging.info('kanidm RLM_MODULE_NOTFOUND due to no auth token') return radiusd.RLM_MODULE_NOTFOUND + # Get values out of the token + name = tok["name"] + secret = tok["secret"] + uuid = tok["uuid"] + # Are they in the required group? req_sat = False for group in tok["groups"]: if group['name'] in kanidm_client.config.radius_required_groups: req_sat = True - logging.info("User %s has a required group (%s)", username, group['name']) + logging.info("User %s has a required group (%s)", name, group['name']) if req_sat is not True: - logging.info("User %s doesn't have a group from the required list.", username) + logging.info("User %s doesn't have a group from the required list.", name) return radiusd.RLM_MODULE_NOTFOUND # look up them in config for group vlan if possible. @@ -179,14 +192,11 @@ def authorize( logging.info("Invalid uservlan of 0") - logging.info("selected vlan %s:%s", username, uservlan) - # Convert the tok groups to groups. - name = tok["name"] - secret = tok["secret"] + logging.info("selected vlan %s:%s", name, uservlan) reply = ( ('User-Name', str(name)), - ('Reply-Message', 'Welcome'), + ('Reply-Message', f"Kanidm-Uuid: {uuid}"), ('Tunnel-Type', '13'), ('Tunnel-Medium-Type', '6'), ('Tunnel-Private-Group-ID', str(uservlan)), @@ -195,5 +205,5 @@ def authorize( ('Cleartext-Password', str(secret)), ) - logging.info("OK! Returning details to radius for %s ...", username) + logging.info("OK! Returning details to radius for %s ...", name) return (radiusd.RLM_MODULE_OK, reply, config_object) diff --git a/kanidm_rlm_python/mods-available/eap b/kanidm_rlm_python/mods-available/eap index a7f024f6b..768cab822 100644 --- a/kanidm_rlm_python/mods-available/eap +++ b/kanidm_rlm_python/mods-available/eap @@ -195,7 +195,7 @@ eap { # In that case, this CA file should contain # *one* CA certificate. # - ca_file = ${cadir}/ca.pem + # ca_file = ${cadir}/ca.pem # OpenSSL will automatically create certificate chains, # unless we tell it to not do that. The problem is that @@ -287,7 +287,7 @@ eap { # Accept an expired Certificate Revocation List # -# allow_expired_crl = no + allow_expired_crl = no # # If check_cert_issuer is set, the value will @@ -456,7 +456,7 @@ eap { # # This feature REQUIRES "name" option be set above. # - #persist_dir = "${logdir}/tlscache" + persist_dir = "${logdir}/tlscache" } # @@ -595,7 +595,7 @@ eap { # virtual server has access to these attributes, and can # be used to accept or reject the request. # - # virtual_server = check-eap-tls + virtual_server = check-eap-tls } diff --git a/kanidm_rlm_python/sites-available/check-eap-tls b/kanidm_rlm_python/sites-available/check-eap-tls new file mode 100644 index 000000000..b1578bd50 --- /dev/null +++ b/kanidm_rlm_python/sites-available/check-eap-tls @@ -0,0 +1,134 @@ +# This virtual server allows EAP-TLS to reject access requests +# based on some attributes of the certificates involved. +# +# To use this virtual server, you must enable it in the tls +# section of mods-enabled/eap as well as adding a link to this +# file in sites-enabled/. +# +# +# Value-pairs that are available for checking include: +# +# TLS-Client-Cert-Subject +# TLS-Client-Cert-Issuer +# TLS-Client-Cert-Common-Name +# TLS-Client-Cert-Subject-Alt-Name-Email +# +# To see a full list of attributes, run the server in debug mode +# with this virtual server configured, and look at the attributes +# passed in to this virtual server. +# +# +# This virtual server is also useful when using EAP-TLS as it is +# only called once, just before the final Accept is about to be +# returned from eap, whereas the outer authorize section is called +# multiple times for each challenge / response. For this reason, +# here may be a good location to put authentication logging, and +# modules that check for further authorization, especially if they +# hit external services such as sql or ldap. + + +server check-eap-tls { + + +# Authorize - this is the only section required. +# +# To accept the access request, set Auth-Type = Accept, otherwise +# set it to Reject. + +authorize { + + # + # By default, we just accept the request: + # + update config { + &Auth-Type := Accept + } + + + # + # Check the client certificate matches a string, and reject otherwise + # + +# if ("%{TLS-Client-Cert-Common-Name}" == 'client.example.com') { +# update config { +# &Auth-Type := Accept +# } +# } +# else { +# update config { +# &Auth-Type := Reject +# } +# update reply { +# &Reply-Message := "Your certificate is not valid." +# } +# } + + + # + # Check the client certificate common name against the supplied User-Name + # +# if (&User-Name == "host/%{TLS-Client-Cert-Common-Name}") { +# update config { +# &Auth-Type := Accept +# } +# } +# else { +# update config { +# &Auth-Type := Reject +# } +# } + + # + # This is a convenient place to call LDAP, for example, when using + # EAP-TLS, as it will only be called once, after all certificates as + # part of the EAP-TLS challenge process have been verified. + # + # An example could be to use LDAP to check that the connecting host, as + # well as presenting a valid certificate, is also in a group based on + # the User-Name (assuming this contains the service principal name). + # Settings such as the following could be used in the ldap module + # configuration: + # + # basedn = "dc=example, dc=com" + # filter = "(servicePrincipalName=%{User-Name})" + # base_filter = "(objectClass=computer)" + # groupname_attribute = cn + # groupmembership_filter = "(&(objectClass=group)(member=%{control:Ldap-UserDn}))" + +# ldap + + # Now let's test membership of an LDAP group (the ldap bind user will + # need permission to read this group membership): + +# if (!(Ldap-Group == "Permitted-Laptops")) { +# update config { +# &Auth-Type := Reject +# } +# } + + # or, to be more specific, you could use the group's full DN: + # if (!(Ldap-Group == "CN=Permitted-Laptops,OU=Groups,DC=example,DC=org")) { + + python3 + + # + # This may be a better place to call the files modules when using + # EAP-TLS, as it will only be called once, after the challenge-response + # iteration has completed. + # + +# files + + + # + # Log all request attributes, plus TLS certificate details, to the + # auth_log file. Again, this is just once per connection request, so + # may be preferable than in the outer authorize section. It is + # suggested that 'auth_log' also be in the outer post-auth and + # Post-Auth REJECT sections to log reply packet details, too. + # + + auth_log + +} +} diff --git a/pykanidm/kanidm/types.py b/pykanidm/kanidm/types.py index cbabba627..eab7d0711 100644 --- a/pykanidm/kanidm/types.py +++ b/pykanidm/kanidm/types.py @@ -143,10 +143,11 @@ class KanidmClientConfig(BaseModel): username: Optional[str] = None password: Optional[str] = None - radius_cert_path: str = "/etc/raddb/certs/cert.pem" - radius_key_path: str = "/etc/raddb/certs/key.pem" # the signing key for radius TLS - radius_dh_path: str = "/etc/raddb/certs/dh.pem" # the diffie-hellman output - radius_ca_path: str = "/etc/raddb/certs/ca.pem" # the diffie-hellman output + radius_cert_path: str = "/data/cert.pem" + radius_key_path: str = "/data/key.pem" # the signing key for radius TLS + radius_dh_path: str = "/data/dh.pem" # the diffie-hellman output + radius_ca_path: Optional[str] = None + radius_ca_dir: Optional[str] = None radius_required_groups: List[str] = [] radius_default_vlan: int = 1