diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index b9c4c572..fb86db70 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -829,6 +829,10 @@ Available backends: `oauth2` : Use an OAuth2 server to authenticate users. +`pam` +: Use local PAM to authenticate users. + + Default: `none` ##### cache_logins @@ -1028,6 +1032,18 @@ OAuth2 token endpoint URL Default: +##### pam_service + +PAM service + +Default: radicale + +##### pam_group_membership + +PAM group user should be member of + +Default: + ##### lc_username Сonvert username to lowercase, must be true for case-insensitive auth diff --git a/config b/config index dfd8c4e2..e367083c 100644 --- a/config +++ b/config @@ -59,7 +59,7 @@ [auth] # Authentication method -# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | oauth2 | denyall +# Value: none | htpasswd | remote_user | http_x_remote_user | dovecot | ldap | oauth2 | pam | denyall #type = none # Cache logins for until expiration time @@ -128,6 +128,12 @@ # OAuth2 token endpoint URL #oauth2_token_endpoint = +# PAM service +#pam_serivce = radicale + +# PAM group user should be member of +#pam_group_membership = + # Htpasswd filename #htpasswd_filename = /etc/radicale/users diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index e92272f8..62a7b34f 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -43,6 +43,7 @@ INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", "ldap", "imap", "oauth2", + "pam", "dovecot") CACHE_LOGIN_TYPES: Sequence[str] = ( @@ -51,6 +52,7 @@ CACHE_LOGIN_TYPES: Sequence[str] = ( "htpasswd", "imap", "oauth2", + "pam", ) AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6") diff --git a/radicale/auth/pam.py b/radicale/auth/pam.py new file mode 100644 index 00000000..84cacb82 --- /dev/null +++ b/radicale/auth/pam.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Radicale Server - Calendar Server +# Copyright © 2011 Henry-Nicolas Tourneur +# Copyright © 2021-2021 Unrud +# Copyright © 2025-2025 Peter Bieringer +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Radicale. If not, see . + +""" +PAM authentication. + +Authentication using the ``pam-python`` module. + +Important: radicale user need access to /etc/shadow by e.g. + chgrp radicale /etc/shadow + chmod g+r +""" + +import grp +import pwd + +from radicale import auth +from radicale.log import logger + + +class Auth(auth.BaseAuth): + def __init__(self, configuration) -> None: + super().__init__(configuration) + try: + import pam + self.pam = pam + except ImportError as e: + raise RuntimeError("PAM authentication requires the Python pam module") from e + self._service = configuration.get("auth", "pam_service") + logger.info("auth.pam_service: %s" % self._service) + self._group_membership = configuration.get("auth", "pam_group_membership") + if (self._group_membership): + logger.info("auth.pam_group_membership: %s" % self._group_membership) + else: + logger.info("auth.pam_group_membership: (empty, nothing to check / INSECURE)") + + def pam_authenticate(self, *args, **kwargs): + return self.pam.authenticate(*args, **kwargs) + + def _login(self, login: str, password: str) -> str: + """Check if ``user``/``password`` couple is valid.""" + if login is None or password is None: + return "" + + # Check whether the user exists in the PAM system + try: + pwd.getpwnam(login).pw_uid + except KeyError: + logger.debug("PAM user not found: %r" % login) + return "" + else: + logger.debug("PAM user found: %r" % login) + + # Check whether the user has a primary group (mandatory) + try: + # Get user primary group + primary_group = grp.getgrgid(pwd.getpwnam(login).pw_gid).gr_name + logger.debug("PAM user %r has primary group: %r" % (login, primary_group)) + except KeyError: + logger.debug("PAM user has no primary group: %r" % login) + return "" + + # Obtain supplementary groups + members = [] + if (self._group_membership): + try: + members = grp.getgrnam(self._group_membership).gr_mem + except KeyError: + logger.debug( + "PAM membership required group doesn't exist: %r" % + self._group_membership) + return "" + + # Check whether the user belongs to the required group + # (primary or supplementary) + if (self._group_membership): + if (primary_group != self._group_membership) and (login not in members): + logger.warning("PAM user %r belongs not to the required group: %r" % (login, self._group_membership)) + return "" + else: + logger.debug("PAM user %r belongs to the required group: %r" % (login, self._group_membership)) + + # Check the password + if self.pam_authenticate(login, password, service=self._service): + return login + else: + logger.debug("PAM authentication not successful for user: %r (service %r)" % (login, self._service)) + return "" diff --git a/radicale/config.py b/radicale/config.py index 5f46022e..6a218160 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -311,6 +311,14 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "", "help": "OAuth2 token endpoint URL", "type": str}), + ("pam_group_membership", { + "value": "", + "help": "PAM group user should be member of", + "type": str}), + ("pam_service", { + "value": "radicale", + "help": "PAM service", + "type": str}), ("strip_domain", { "value": "False", "help": "strip domain from username", diff --git a/radicale/utils.py b/radicale/utils.py index 097be3fb..a75e5089 100644 --- a/radicale/utils.py +++ b/radicale/utils.py @@ -18,6 +18,7 @@ # along with Radicale. If not, see . import ssl +import sys from importlib import import_module, metadata from typing import Callable, Sequence, Type, TypeVar, Union @@ -55,6 +56,7 @@ def package_version(name): def packages_version(): versions = [] + versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2])) for pkg in RADICALE_MODULES: versions.append("%s=%s" % (pkg, package_version(pkg))) return " ".join(versions)