Просмотр исходного кода

Merge pull request #1708 from pbiering/merge-pam-auth-from-v1

Merge pam auth from v1
Peter Bieringer 1 год назад
Родитель
Сommit
5302863f53
6 измененных файлов с 140 добавлено и 1 удалено
  1. 16 0
      DOCUMENTATION.md
  2. 7 1
      config
  3. 2 0
      radicale/auth/__init__.py
  4. 105 0
      radicale/auth/pam.py
  5. 8 0
      radicale/config.py
  6. 2 0
      radicale/utils.py

+ 16 - 0
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

+ 7 - 1
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 = <URL>
 
+# PAM service
+#pam_serivce = radicale
+
+# PAM group user should be member of
+#pam_group_membership =
+
 # Htpasswd filename
 #htpasswd_filename = /etc/radicale/users
 

+ 2 - 0
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")

+ 105 - 0
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 <unrud@outlook.com>
+# Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
+#
+# 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 <http://www.gnu.org/licenses/>.
+
+"""
+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 ""

+ 8 - 0
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",

+ 2 - 0
radicale/utils.py

@@ -18,6 +18,7 @@
 # along with Radicale.  If not, see <http://www.gnu.org/licenses/>.
 
 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)