From 2dc0fd29dc541bfc76983cf19bab6dbe83c3d06d Mon Sep 17 00:00:00 2001 From: Peter Varkoly Date: Sat, 19 Feb 2022 11:57:58 +0100 Subject: [PATCH 01/15] Initial version of ldap authentication backend. --- DOCUMENTATION.md | 39 +++++++++++++++++ radicale/auth/__init__.py | 2 +- radicale/auth/ldap.py | 90 +++++++++++++++++++++++++++++++++++++++ radicale/config.py | 27 +++++++++++- 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 radicale/auth/ldap.py diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7214c1be2..c9efc6d40 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -667,6 +667,9 @@ Available backends: authentication. This can be used to provide the username from a reverse proxy. +`ldap` +: Use a LDAP or AD server to authenticate users. + Default: `none` ##### htpasswd_filename @@ -713,6 +716,42 @@ Message displayed in the client when a password is needed. Default: `Radicale - Password Required` +##### ldap_uri + +The URI to the ldap server + +Default: `ldap://localhost` + +##### ldap_base + +LDAP base DN of the ldap server. This parameter must be provided if auth type is ldap. + +Default: + +##### ldap_reader_dn + +The DN of a ldap user with read access to get the user accounts. This parameter must be provided if auth type is ldap. + +Default: + +##### ldap_secret + +The password of the ldap_reader_dn. This parameter must be provided if auth type is ldap. + +Default: + +##### ldap_filter + +The search filter to find the user DN to authenticate by the username. User '{0}' as placeholder for the user name. + +Default: `(cn={0})` + +##### ldap_load_groups + +Load the ldap groups of the authenticated user. These groups can be used later on to define rights. + +Default: False + #### rights ##### type diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 9c4dd1c04..1df601ec3 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -33,7 +33,7 @@ from radicale import config, types, utils INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", - "htpasswd") + "htpasswd", "ldap") def load(configuration: "config.Configuration") -> "BaseAuth": diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py new file mode 100644 index 000000000..0f9f97293 --- /dev/null +++ b/radicale/auth/ldap.py @@ -0,0 +1,90 @@ +# This file is part of Radicale - CalDAV and CardDAV server +# Copyright 2022 Peter Varkoly +# +# 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 . +""" +Authentication backend that checks credentials with a ldap server. +Following parameters are needed in the configuration + ldap_uri The ldap url to the server like ldap://localhost + ldap_base The baseDN of the ldap server + ldap_reader_dn The DN of a ldap user with read access to get the user accounts + ldap_secret The password of the ldap_reader_dn + ldap_filter The search filter to find the user to authenticate by the username + ldap_load_groups If the groups of the authenticated users need to be loaded +""" + +import ldap +from radicale import auth, config +from radicale.log import logger + +class Auth(auth.BaseAuth): + _ldap_uri: str + _ldap_base: str + _ldap_reader_dn: str + _ldap_secret: str + _ldap_filter: str + _ldap_load_groups: bool + _ldap_groups = [] + + def __init__(self, configuration: config.Configuration) -> None: + super().__init__(configuration) + self._ldap_uri = configuration.get("auth", "ldap_uri") + self._ldap_base = configuration.get("auth", "ldap_base") + self._ldap_reader_dn = configuration.get("auth", "ldap_reader_dn") + self._ldap_load_groups = configuration.get("auth", "ldap_load_groups") + self._ldap_secret = configuration.get("auth", "ldap_secret") + self._ldap_filter = configuration.get("auth", "ldap_filter") + + def login(self, login: str, password: str) -> str: + """Validate credentials. + In first step we make a connection to the ldap server with the ldap_reader_dn credential. + In next step the DN of the user to authenticate will be searched. + In the last step the authentication of the user will be proceeded. + + """ + try: + """Bind as reader dn""" + conn = ldap.initialize(self._ldap_uri) + conn.protocol_version = 3 + conn.set_option(ldap.OPT_REFERRALS, 0) + conn.simple_bind_s(self._ldap_reader_dn, self._ldap_secret) + """Search for the dn of user to authenticate""" + res = conn.search_s(self._ldap_base, ldap.SCOPE_SUBTREE, filterstr=self._ldap_filter.format(login), attrlist=['memberOf']) + if len(res) == 0: + """User could not be find""" + return "" + user_dn = res[0][0] + logger.debug("LDAP Auth user: %s",user_dn) + """Close ldap connection""" + conn.unbind() + except Exception: + raise RuntimeError("Invalide ldap configuration") + + try: + """Bind as user to authenticate""" + conn = ldap.initialize(self._ldap_uri) + conn.protocol_version = 3 + conn.set_option(ldap.OPT_REFERRALS, 0) + conn.simple_bind_s(user_dn,password) + if self._ldap_load_groups: + self._ldap_groups = [] + for t in res[0][1]['memberOf']: + self._ldap_groups.append(t.decode('utf-8').split(',')[0][3:]) + logger.debug("LDAP Auth groups of user: %s",",".join(self._ldap_groups)) + conn.unbind() + return login + except ldap.INVALID_CREDENTIALS: + return "" + + diff --git a/radicale/config.py b/radicale/config.py index a9b7d7f22..238bd3b6b 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -177,7 +177,32 @@ def _convert_to_bool(value: Any) -> bool: ("delay", { "value": "1", "help": "incorrect authentication delay", - "type": positive_float})])), + "type": positive_float}), + ("ldap_uri", { + "value": "ldap://localhost", + "help": "URI to the ldap server", + "type": str}), + ("ldap_base", { + "value": "none", + "help": "LDAP base DN of the ldap server", + "type": str}), + ("ldap_reader_dn", { + "value": "none", + "help": "the DN of a ldap user with read access to get the user accounts", + "type": str}), + ("ldap_secret", { + "value": "none", + "help": "the password of the ldap_reader_dn", + "type": str}), + ("ldap_filter", { + "value": "(cn={0})", + "help": "the search filter to find the user DN to authenticate by the username", + "type": str}), + ("ldap_load_groups", { + "value": "False", + "help": "load the ldap groups of the authenticated user", + "type": bool}), + ])), ("rights", OrderedDict([ ("type", { "value": "owner_only", From eda8309a04e307d61bf4f7b3497da6c20edb1345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Mon, 21 Feb 2022 08:36:10 +0100 Subject: [PATCH 02/15] Implementing group based collection matching. Optimize rights evaluation. --- radicale/auth/ldap.py | 8 ++++--- radicale/rights/from_file.py | 43 +++++++++++++++++++++++++----------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index 0f9f97293..3116c4b84 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -35,7 +35,7 @@ class Auth(auth.BaseAuth): _ldap_secret: str _ldap_filter: str _ldap_load_groups: bool - _ldap_groups = [] + _ldap_groups = set def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) @@ -77,10 +77,12 @@ def login(self, login: str, password: str) -> str: conn.protocol_version = 3 conn.set_option(ldap.OPT_REFERRALS, 0) conn.simple_bind_s(user_dn,password) + tmp = [] if self._ldap_load_groups: - self._ldap_groups = [] + tmp = [] for t in res[0][1]['memberOf']: - self._ldap_groups.append(t.decode('utf-8').split(',')[0][3:]) + tmp.append(t.decode('utf-8').split(',')[0][3:]) + self._ldap_groups = set(tmp) logger.debug("LDAP Auth groups of user: %s",",".join(self._ldap_groups)) conn.unbind() return login diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index 01fa2fb76..51f40433b 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -44,30 +44,42 @@ class Rights(rights.BaseRights): _filename: str + _rights_config def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._filename = configuration.get("rights", "file") + _rights_config = configparser.ConfigParser() + try: + with open(self._filename, "r") as f: + _rights_config.read_file(f) + except Exception as e: + raise RuntimeError("Failed to load rights file %r: %s" % + (self._filename, e)) from e def authorization(self, user: str, path: str) -> str: user = user or "" sane_path = pathutils.strip_path(path) # Prevent "regex injection" escaped_user = re.escape(user) - rights_config = configparser.ConfigParser() - try: - with open(self._filename, "r") as f: - rights_config.read_file(f) - except Exception as e: - raise RuntimeError("Failed to load rights file %r: %s" % - (self._filename, e)) from e - for section in rights_config.sections(): + + for section in _rights_config.sections(): + user_match = False + group_match = [] + collection_match = False try: - user_pattern = rights_config.get(section, "user") - collection_pattern = rights_config.get(section, "collection") + collection_pattern = _rights_config.get(section, "collection") + user_pattern = _rights_config.get(section, "user", fallback = "") + groups = _rights_config.get(section, "groups", fallback = "").split(",") + + try: + group_match = self._auth._ldap_groups & set(groups) + except NameError: + pass + # Use empty format() for harmonized handling of curly braces user_match = re.fullmatch(user_pattern.format(), user) - collection_match = user_match and re.fullmatch( + collection_match = re.fullmatch( collection_pattern.format( *(re.escape(s) for s in user_match.groups()), user=escaped_user), sane_path) @@ -75,10 +87,15 @@ def authorization(self, user: str, path: str) -> str: raise RuntimeError("Error in section %r of rights file %r: " "%s" % (section, self._filename, e)) from e if user_match and collection_match: - logger.debug("Rule %r:%r matches %r:%r from section %r", + logger.debug("User rule %r:%r matches %r:%r from section %r", user, sane_path, user_pattern, collection_pattern, section) - return rights_config.get(section, "permissions") + return _rights_config.get(section, "permissions") + if len(group_match) > 0 and collection_match: + logger.debug("Group rule %r:%r matches %r from section %r", + group_match, sane_path, + collection_pattern, section) + return _rights_config.get(section, "permissions") logger.debug("Rule %r:%r doesn't match %r:%r from section %r", user, sane_path, user_pattern, collection_pattern, section) From 8d19fd7a64adeda0d1ff35bea225af1b055080fd Mon Sep 17 00:00:00 2001 From: Peter Varkoly Date: Mon, 21 Feb 2022 17:15:21 +0100 Subject: [PATCH 03/15] Now rights can be add to user groups too. --- radicale/app/__init__.py | 5 +++++ radicale/auth/__init__.py | 2 ++ radicale/auth/ldap.py | 3 --- radicale/rights/from_file.py | 38 +++++++++++++++++++----------------- 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index 6896bc701..ffcb56ca6 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -237,6 +237,11 @@ def response(status: int, headers: types.WSGIResponseHeaders, authorization.encode("ascii"))).split(":", 1) user = self._auth.login(login, password) or "" if login else "" + try: + logger.debug("Groups %r",",".join(self._auth._ldap_groups)) + self._rights._user_groups = self._auth._ldap_groups + except AttributeError: + pass if user and login == user: logger.info("Successful login: %r", user) elif user: diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 1df601ec3..2ee64509d 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -44,6 +44,8 @@ def load(configuration: "config.Configuration") -> "BaseAuth": class BaseAuth: + _ldap_groups: set + def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseAuth. diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index 3116c4b84..a55ceb655 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -35,7 +35,6 @@ class Auth(auth.BaseAuth): _ldap_secret: str _ldap_filter: str _ldap_load_groups: bool - _ldap_groups = set def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) @@ -88,5 +87,3 @@ def login(self, login: str, password: str) -> str: return login except ldap.INVALID_CREDENTIALS: return "" - - diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index 51f40433b..b42987abf 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -34,7 +34,7 @@ """ -import configparser +from configparser import ConfigParser import re from radicale import config, pathutils, rights @@ -44,15 +44,17 @@ class Rights(rights.BaseRights): _filename: str - _rights_config + _rights_config: ConfigParser + _user_groups: set def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._filename = configuration.get("rights", "file") - _rights_config = configparser.ConfigParser() + self._rights_config = ConfigParser() try: with open(self._filename, "r") as f: - _rights_config.read_file(f) + self._rights_config.read_file(f) + logger.debug("Rights were read") except Exception as e: raise RuntimeError("Failed to load rights file %r: %s" % (self._filename, e)) from e @@ -62,40 +64,40 @@ def authorization(self, user: str, path: str) -> str: sane_path = pathutils.strip_path(path) # Prevent "regex injection" escaped_user = re.escape(user) + logger.debug("authorization called %r %r",user,path) - for section in _rights_config.sections(): - user_match = False + for section in self._rights_config.sections(): group_match = [] - collection_match = False try: - collection_pattern = _rights_config.get(section, "collection") - user_pattern = _rights_config.get(section, "user", fallback = "") - groups = _rights_config.get(section, "groups", fallback = "").split(",") - + collection_pattern = self._rights_config.get(section, "collection") + user_pattern = self._rights_config.get(section, "user", fallback = "") + groups = self._rights_config.get(section, "groups", fallback = "").split(",") try: - group_match = self._auth._ldap_groups & set(groups) - except NameError: + group_match = self._user_groups & set(groups) + logger.debug("Groups %r, %r",",".join(group_match),";".join(groups)) + except: pass # Use empty format() for harmonized handling of curly braces user_match = re.fullmatch(user_pattern.format(), user) - collection_match = re.fullmatch( + u_collection_match = user_match and re.fullmatch( collection_pattern.format( *(re.escape(s) for s in user_match.groups()), user=escaped_user), sane_path) + g_collection_match = re.fullmatch( collection_pattern.format(user=escaped_user), sane_path) except Exception as e: raise RuntimeError("Error in section %r of rights file %r: " "%s" % (section, self._filename, e)) from e - if user_match and collection_match: + if user_match and u_collection_match: logger.debug("User rule %r:%r matches %r:%r from section %r", user, sane_path, user_pattern, collection_pattern, section) - return _rights_config.get(section, "permissions") - if len(group_match) > 0 and collection_match: + return self._rights_config.get(section, "permissions") + if len(group_match) > 0 and g_collection_match: logger.debug("Group rule %r:%r matches %r from section %r", group_match, sane_path, collection_pattern, section) - return _rights_config.get(section, "permissions") + return self._rights_config.get(section, "permissions") logger.debug("Rule %r:%r doesn't match %r:%r from section %r", user, sane_path, user_pattern, collection_pattern, section) From c5b5910de4431cde09f0b625d074f8aa39336362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Tue, 22 Feb 2022 11:35:46 +0100 Subject: [PATCH 04/15] Adapt base configuration to use with cranix-server. Only the certificate must be adapted --- config | 21 ++++++++++++++++++--- rights | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/config b/config index 7c77f5f94..d16b5e9d8 100644 --- a/config +++ b/config @@ -15,7 +15,7 @@ # IPv4 syntax: address:port # IPv6 syntax: [address]:port # For example: 0.0.0.0:9999, [::]:9999 -#hosts = localhost:5232 +hosts = 0.0.0.0:5232 # Max parallel connections #max_connections = 8 @@ -53,7 +53,22 @@ # Authentication method # Value: none | htpasswd | remote_user | http_x_remote_user -#type = none +type = ldap + +# URI to the LDAP server +ldap_uri = ldap://localhost + +# The base DN of the LDAP server +ldap_base = ##BASE_DN## + +# The reader DN of the LDAP server +ldap_reader_dn = CN=ossreader,CN=Users,##BASE_DN## + +# Password of the reader DN +ldap_secret = ossreader + +# If the ldap groups of the user need to be loaded +ldap_load_groups = True # Htpasswd filename #htpasswd_filename = /etc/radicale/users @@ -77,7 +92,7 @@ #type = owner_only # File for rights management from_file -#file = /etc/radicale/rights +file = /etc/radicale/rights [storage] diff --git a/rights b/rights index 1425003e4..03a05e80a 100644 --- a/rights +++ b/rights @@ -1,5 +1,34 @@ -# -*- mode: conf -*- -# vim:ft=cfg +# Allow all rights for the Administrator +[root] +user: Administrator +collection: .* +permissions: RW + +# Allow reading principal collection (same as username) +[principal] +user: .+ +collection: {user} +permissions: R + +# Allow reading and writing private collection (same as username) +[private] +user: .+ +collection: {user}/private/ +permissions: RW + +# Allow reading and writing calendars and address books that are direct +# children of the principal collection for the member of some groups +[calendarsWriter] +groups: sysadmins,teachers,administration,administrators +collection: {user}/[^/]+ +permissions: rw + +# Allow reading calendars and address books that are direct +# children of the principal collection for other users +[calendarsReader] +user: .+ +collection: {user}/[^/]+ +permissions: r # Rights management file for Radicale - A simple calendar server # From b0f8d372946ae93fef426f9468398cc917d47832 Mon Sep 17 00:00:00 2001 From: Peter Varkoly Date: Thu, 24 Feb 2022 10:45:45 +0100 Subject: [PATCH 05/15] User the intersection built in function of set to make the code more readable. --- radicale/rights/from_file.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index b42987abf..9ce625d5c 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -70,10 +70,10 @@ def authorization(self, user: str, path: str) -> str: group_match = [] try: collection_pattern = self._rights_config.get(section, "collection") - user_pattern = self._rights_config.get(section, "user", fallback = "") - groups = self._rights_config.get(section, "groups", fallback = "").split(",") + user_pattern = self._rights_config.get(section, "user", fallback = "") + allowed_groups = self._rights_config.get(section, "groups", fallback = "").split(",") try: - group_match = self._user_groups & set(groups) + group_match = self._user_groups.intersection(allowed_groups) logger.debug("Groups %r, %r",",".join(group_match),";".join(groups)) except: pass From 5167f12624fac61b0226a61a527856e3a1637e6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Mon, 26 Aug 2024 11:21:53 +0200 Subject: [PATCH 06/15] Rebase rights/from_file.py. Apply proposed/asked changes. --- config | 18 +++++----- radicale/app/__init__.py | 11 +++--- radicale/rights/from_file.py | 65 +++++++++++++++--------------------- rights | 8 ++--- 4 files changed, 45 insertions(+), 57 deletions(-) diff --git a/config b/config index 9ffbd72f8..24e4c8455 100644 --- a/config +++ b/config @@ -14,8 +14,6 @@ # CalDAV server hostnames separated by a comma # IPv4 syntax: address:port # IPv6 syntax: [address]:port -# For example: 0.0.0.0:9999, [::]:9999 -hosts = 0.0.0.0:5232 # Hostname syntax (using "getaddrinfo" to resolve to IPv4/IPv6 adress(es)): hostname:port # For example: 0.0.0.0:9999, [::]:9999, localhost:9999 #hosts = localhost:5232 @@ -55,23 +53,23 @@ hosts = 0.0.0.0:5232 [auth] # Authentication method -# Value: none | htpasswd | remote_user | http_x_remote_user -type = ldap +# Value: none | htpasswd | remote_user | http_x_remote_user | ldap +#type = ldap # URI to the LDAP server -ldap_uri = ldap://localhost +#ldap_uri = ldap://localhost # The base DN of the LDAP server -ldap_base = ##BASE_DN## +#ldap_base = ##BASE_DN## # The reader DN of the LDAP server -ldap_reader_dn = CN=ossreader,CN=Users,##BASE_DN## +#ldap_reader_dn = CN=ldapreader,CN=Users,##BASE_DN## # Password of the reader DN -ldap_secret = ossreader +#ldap_secret = ldapreader-secret # If the ldap groups of the user need to be loaded -ldap_load_groups = True +#ldap_load_groups = True # Value: none | htpasswd | remote_user | http_x_remote_user | denyall #type = none @@ -103,7 +101,7 @@ ldap_load_groups = True #type = owner_only # File for rights management from_file -file = /etc/radicale/rights +#file = /etc/radicale/rights # Permit delete of a collection (global) #permit_delete_collection = True diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index bdb70772a..f1b5144f9 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -250,11 +250,12 @@ def response(status: int, headers: types.WSGIResponseHeaders, authorization.encode("ascii"))).split(":", 1) user = self._auth.login(login, password) or "" if login else "" - try: - logger.debug("Groups %r",",".join(self._auth._ldap_groups)) - self._rights._user_groups = self._auth._ldap_groups - except AttributeError: - pass + if self.configuration.get("auth", "type") == "ldap": + try: + logger.debug("Groups %r",",".join(self._auth._ldap_groups)) + self._rights._user_groups = self._auth._ldap_groups + except AttributeError: + pass if user and login == user: logger.info("Successful login: %r", user) elif user: diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index 74651a18f..d8e287af8 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -34,7 +34,7 @@ """ -from configparser import ConfigParser +import configparser import re from radicale import config, pathutils, rights @@ -44,66 +44,55 @@ class Rights(rights.BaseRights): _filename: str - _rights_config: ConfigParser - _user_groups: set def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) self._filename = configuration.get("rights", "file") - self._rights_config = ConfigParser() - try: - with open(self._filename, "r") as f: - self._rights_config.read_file(f) - logger.debug("Rights were read") - except Exception as e: - raise RuntimeError("Failed to load rights file %r: %s" % - (self._filename, e)) from e def authorization(self, user: str, path: str) -> str: user = user or "" sane_path = pathutils.strip_path(path) # Prevent "regex injection" escaped_user = re.escape(user) - logger.debug("authorization called %r %r",user,path) - - for section in self._rights_config.sections(): - group_match = [] + rights_config = configparser.ConfigParser() + try: + with open(self._filename, "r") as f: + rights_config.read_file(f) + except Exception as e: + raise RuntimeError("Failed to load rights file %r: %s" % + (self._filename, e)) from e + for section in rights_config.sections(): + group_match = False try: - collection_pattern = self._rights_config.get(section, "collection") - user_pattern = self._rights_config.get(section, "user", fallback = "") - allowed_groups = self._rights_config.get(section, "groups", fallback = "").split(",") + user_pattern = rights_config.get(section, "user") + collection_pattern = rights_config.get(section, "collection") + allowed_groups = rights_config.get(section, "groups", fallback = "").split(",") try: - group_match = self._user_groups.intersection(allowed_groups) - logger.debug("Groups %r, %r",",".join(group_match),";".join(groups)) + group_match = self._user_groups.intersection(allowed_groups) > 0 except: pass - # Use empty format() for harmonized handling of curly braces user_match = re.fullmatch(user_pattern.format(), user) - u_collection_match = user_match and re.fullmatch( + user_collection_match = user_match and re.fullmatch( collection_pattern.format( *(re.escape(s) for s in user_match.groups()), user=escaped_user), sane_path) - g_collection_match = re.fullmatch( collection_pattern.format(user=escaped_user), sane_path) + group_collection_match = re.fullmatch(collection_pattern.format(user=escaped_user), sane_path) except Exception as e: raise RuntimeError("Error in section %r of rights file %r: " "%s" % (section, self._filename, e)) from e - if user_match and u_collection_match: - logger.debug("User rule %r:%r matches %r:%r from section %r", + if user_match and user_collection_match: + permission = rights_config.get(section, "permissions") + logger.debug("Rule %r:%r matches %r:%r from section %r permission %r", + user, sane_path, user_pattern, + collection_pattern, section, permission) + return permission + if group_match and group_collection_match: + permission = rights_config.get(section, "permissions") + logger.debug("Rule %r:%r matches %r:%r from section %r permission %r by group membership", user, sane_path, user_pattern, - collection_pattern, section) - return self._rights_config.get(section, "permissions") - if len(group_match) > 0 and g_collection_match: - logger.debug("Group rule %r:%r matches %r from section %r", - group_match, sane_path, - collection_pattern, section) - return self._rights_config.get(section, "permissions") -#if user_match and collection_match: -# permission = rights_config.get(section, "permissions") -# logger.debug("Rule %r:%r matches %r:%r from section %r permission %r", -# user, sane_path, user_pattern, -# collection_pattern, section, permission) -# return permission + collection_pattern, section, permission) + return permission logger.debug("Rule %r:%r doesn't match %r:%r from section %r", user, sane_path, user_pattern, collection_pattern, section) diff --git a/rights b/rights index 03a05e80a..d05895942 100644 --- a/rights +++ b/rights @@ -25,10 +25,10 @@ permissions: rw # Allow reading calendars and address books that are direct # children of the principal collection for other users -[calendarsReader] -user: .+ -collection: {user}/[^/]+ -permissions: r +#[calendarsReader] +#user: .+ +#collection: {user}/[^/]+ +#permissions: r # Rights management file for Radicale - A simple calendar server # From 8b8d7729a24ae72783ce7f61f1e9cdb7d22ca83c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Mon, 26 Aug 2024 14:16:40 +0200 Subject: [PATCH 07/15] Now ldap auth can use ldap and ldap3 also. --- radicale/auth/ldap.py | 72 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index a55ceb655..79feccd68 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -24,7 +24,6 @@ ldap_load_groups If the groups of the authenticated users need to be loaded """ -import ldap from radicale import auth, config from radicale.log import logger @@ -35,9 +34,18 @@ class Auth(auth.BaseAuth): _ldap_secret: str _ldap_filter: str _ldap_load_groups: bool + _ldap_version: 3 def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) + try: + import ldap3 + except ImportError as e: + try: + import ldap + self._ldap_version = 2 + except ImportError as e: + raise RuntimeError("LDAP authentication requires the ldap3 module") from e self._ldap_uri = configuration.get("auth", "ldap_uri") self._ldap_base = configuration.get("auth", "ldap_base") self._ldap_reader_dn = configuration.get("auth", "ldap_reader_dn") @@ -45,13 +53,7 @@ def __init__(self, configuration: config.Configuration) -> None: self._ldap_secret = configuration.get("auth", "ldap_secret") self._ldap_filter = configuration.get("auth", "ldap_filter") - def login(self, login: str, password: str) -> str: - """Validate credentials. - In first step we make a connection to the ldap server with the ldap_reader_dn credential. - In next step the DN of the user to authenticate will be searched. - In the last step the authentication of the user will be proceeded. - - """ + def _login2(self, login: str, password: str) -> str: try: """Bind as reader dn""" conn = ldap.initialize(self._ldap_uri) @@ -87,3 +89,57 @@ def login(self, login: str, password: str) -> str: return login except ldap.INVALID_CREDENTIALS: return "" + + def _login3(self, login: str, password: str) -> str: + """Connect the server""" + try: + server = ldap3.Server(self._ldap_uri) + conn = ldap3.Connection(server, self._ldap_reader_dn, password=self._ldap_secret) + except self.ldap3.core.exceptions.LDAPSocketOpenError: + raise RuntimeError("Unable to reach ldap server") + except Exception: + pass + + if not conn.bind(): + raise RuntimeError("Unable to read from ldap server") + + """Search the user dn""" + conn.search( + search_base = self._ldap_base, + search_filter = self._ldap_filter.format(login) + search_scope = 'SUBTREE', + attributes = ['memberOf'] + ) + if len(conn.entries) == 0: + """User could not be find""" + return "" + + user_entry = conn.entries[0].entry_to_json() + conn.unbind() + user_dn = user_entry['dn'] + try: + """Try to bind as the user itself""" + conn = ldap3.Connection(server, user_dn, password=password) + if not conn.bind(): + return "" + if self._ldap_load_groups: + tmp = [] + for g in user_entry['attributes']['memberOf']: + tmp.append(g) + self._ldap_groups = set(tmp) + conn.unbind() + return login + except Exception: + pass + return "" + + def login(self, login: str, password: str) -> str: + """Validate credentials. + In first step we make a connection to the ldap server with the ldap_reader_dn credential. + In next step the DN of the user to authenticate will be searched. + In the last step the authentication of the user will be proceeded. + """ + if self._ldap_version == 2: + return _login2(self, login, password) + return _login3(self, login, password) + From 13d56f09185e0845046ac2749f7d5315e4646dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Tue, 27 Aug 2024 17:04:15 +0200 Subject: [PATCH 08/15] Adapt based on additional comments. --- config | 2 +- rights | 33 ++++++++++++++------------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/config b/config index 24e4c8455..d5f7c6423 100644 --- a/config +++ b/config @@ -54,7 +54,7 @@ # Authentication method # Value: none | htpasswd | remote_user | http_x_remote_user | ldap -#type = ldap +#type = none # URI to the LDAP server #ldap_uri = ldap://localhost diff --git a/rights b/rights index d05895942..834d2b7c2 100644 --- a/rights +++ b/rights @@ -1,27 +1,22 @@ +# -*- mode: conf -*- +# vim:ft=cfg # Allow all rights for the Administrator -[root] -user: Administrator -collection: .* -permissions: RW +#[root] +#user: Administrator +#collection: .* +#permissions: RW # Allow reading principal collection (same as username) -[principal] -user: .+ -collection: {user} -permissions: R +#[principal] +#user: .+ +#collection: {user} +#permissions: R # Allow reading and writing private collection (same as username) -[private] -user: .+ -collection: {user}/private/ -permissions: RW - -# Allow reading and writing calendars and address books that are direct -# children of the principal collection for the member of some groups -[calendarsWriter] -groups: sysadmins,teachers,administration,administrators -collection: {user}/[^/]+ -permissions: rw +#[private] +#user: .+ +#collection: {user}/private/ +#permissions: RW # Allow reading calendars and address books that are direct # children of the principal collection for other users From d7fa90a97658beff37b64cc3c13b3474bb0cf678 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Tue, 27 Aug 2024 17:06:11 +0200 Subject: [PATCH 09/15] Adapt based on additional comments. --- config | 1 + 1 file changed, 1 insertion(+) diff --git a/config b/config index d5f7c6423..67ede2b7b 100644 --- a/config +++ b/config @@ -196,3 +196,4 @@ # When returning a free-busy report, limit the number of returned # occurences per event to prevent DOS attacks. #max_freebusy_occurrence = 10000 + From 5cb16a3a2da779bb4116b5af25d662ca6ab8d607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Mon, 9 Sep 2024 09:42:30 +0200 Subject: [PATCH 10/15] Fix syntax --- radicale/auth/ldap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index 79feccd68..7c0e27d62 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -106,7 +106,7 @@ def _login3(self, login: str, password: str) -> str: """Search the user dn""" conn.search( search_base = self._ldap_base, - search_filter = self._ldap_filter.format(login) + search_filter = self._ldap_filter.format(login), search_scope = 'SUBTREE', attributes = ['memberOf'] ) From d75b071fecce6e3e7652066c611f87e351b5ce35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Wed, 11 Sep 2024 08:12:08 +0200 Subject: [PATCH 11/15] Fix the problems found by flake8. --- radicale/app/__init__.py | 2 +- radicale/auth/ldap.py | 48 +++++++++++++++++++----------------- radicale/rights/from_file.py | 4 +-- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/radicale/app/__init__.py b/radicale/app/__init__.py index ea7ec12b6..ee958ad47 100644 --- a/radicale/app/__init__.py +++ b/radicale/app/__init__.py @@ -252,7 +252,7 @@ def response(status: int, headers: types.WSGIResponseHeaders, user = self._auth.login(login, password) or "" if login else "" if self.configuration.get("auth", "type") == "ldap": try: - logger.debug("Groups %r",",".join(self._auth._ldap_groups)) + logger.debug("Groups %r", ",".join(self._auth._ldap_groups)) self._rights._user_groups = self._auth._ldap_groups except AttributeError: pass diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index 7c0e27d62..9093fbf03 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -27,6 +27,7 @@ from radicale import auth, config from radicale.log import logger + class Auth(auth.BaseAuth): _ldap_uri: str _ldap_base: str @@ -40,33 +41,35 @@ def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) try: import ldap3 - except ImportError as e: + self.ldap3 = ldap3 + except ImportError: try: import ldap self._ldap_version = 2 + self.ldap = ldap except ImportError as e: raise RuntimeError("LDAP authentication requires the ldap3 module") from e - self._ldap_uri = configuration.get("auth", "ldap_uri") + self._ldap_uri = configuration.get("auth", "ldap_uri") self._ldap_base = configuration.get("auth", "ldap_base") self._ldap_reader_dn = configuration.get("auth", "ldap_reader_dn") self._ldap_load_groups = configuration.get("auth", "ldap_load_groups") - self._ldap_secret = configuration.get("auth", "ldap_secret") - self._ldap_filter = configuration.get("auth", "ldap_filter") + self._ldap_secret = configuration.get("auth", "ldap_secret") + self._ldap_filter = configuration.get("auth", "ldap_filter") def _login2(self, login: str, password: str) -> str: try: """Bind as reader dn""" - conn = ldap.initialize(self._ldap_uri) + conn = self.ldap.initialize(self._ldap_uri) conn.protocol_version = 3 - conn.set_option(ldap.OPT_REFERRALS, 0) + conn.set_option(self.ldap.OPT_REFERRALS, 0) conn.simple_bind_s(self._ldap_reader_dn, self._ldap_secret) """Search for the dn of user to authenticate""" - res = conn.search_s(self._ldap_base, ldap.SCOPE_SUBTREE, filterstr=self._ldap_filter.format(login), attrlist=['memberOf']) + res = conn.search_s(self._ldap_base, self.ldap.SCOPE_SUBTREE, filterstr=self._ldap_filter.format(login), attrlist=['memberOf']) if len(res) == 0: """User could not be find""" return "" user_dn = res[0][0] - logger.debug("LDAP Auth user: %s",user_dn) + logger.debug("LDAP Auth user: %s", user_dn) """Close ldap connection""" conn.unbind() except Exception: @@ -74,27 +77,27 @@ def _login2(self, login: str, password: str) -> str: try: """Bind as user to authenticate""" - conn = ldap.initialize(self._ldap_uri) + conn = self.ldap.initialize(self._ldap_uri) conn.protocol_version = 3 - conn.set_option(ldap.OPT_REFERRALS, 0) - conn.simple_bind_s(user_dn,password) + conn.set_option(self.ldap.OPT_REFERRALS, 0) + conn.simple_bind_s(user_dn, password) tmp = [] if self._ldap_load_groups: tmp = [] for t in res[0][1]['memberOf']: tmp.append(t.decode('utf-8').split(',')[0][3:]) self._ldap_groups = set(tmp) - logger.debug("LDAP Auth groups of user: %s",",".join(self._ldap_groups)) + logger.debug("LDAP Auth groups of user: %s", ",".join(self._ldap_groups)) conn.unbind() return login - except ldap.INVALID_CREDENTIALS: + except self.ldap.INVALID_CREDENTIALS: return "" def _login3(self, login: str, password: str) -> str: """Connect the server""" try: - server = ldap3.Server(self._ldap_uri) - conn = ldap3.Connection(server, self._ldap_reader_dn, password=self._ldap_secret) + server = self.ldap3.Server(self._ldap_uri) + conn = self.ldap3.Connection(server, self._ldap_reader_dn, password=self._ldap_secret) except self.ldap3.core.exceptions.LDAPSocketOpenError: raise RuntimeError("Unable to reach ldap server") except Exception: @@ -105,10 +108,10 @@ def _login3(self, login: str, password: str) -> str: """Search the user dn""" conn.search( - search_base = self._ldap_base, - search_filter = self._ldap_filter.format(login), - search_scope = 'SUBTREE', - attributes = ['memberOf'] + search_base=self._ldap_base, + search_filter=self._ldap_filter.format(login), + search_scope='SUBTREE', + attributes=['memberOf'] ) if len(conn.entries) == 0: """User could not be find""" @@ -119,7 +122,7 @@ def _login3(self, login: str, password: str) -> str: user_dn = user_entry['dn'] try: """Try to bind as the user itself""" - conn = ldap3.Connection(server, user_dn, password=password) + conn = self.ldap3.Connection(server, user_dn, password=password) if not conn.bind(): return "" if self._ldap_load_groups: @@ -140,6 +143,5 @@ def login(self, login: str, password: str) -> str: In the last step the authentication of the user will be proceeded. """ if self._ldap_version == 2: - return _login2(self, login, password) - return _login3(self, login, password) - + return self._login2(self, login, password) + return self._login3(self, login, password) diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index 810c28e9b..91d487da5 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -69,10 +69,10 @@ def authorization(self, user: str, path: str) -> str: try: user_pattern = rights_config.get(section, "user") collection_pattern = rights_config.get(section, "collection") - allowed_groups = rights_config.get(section, "groups", fallback = "").split(",") + allowed_groups = rights_config.get(section, "groups", fallback="").split(",") try: group_match = self._user_groups.intersection(allowed_groups) > 0 - except: + except Exception: pass # Use empty format() for harmonized handling of curly braces user_match = re.fullmatch(user_pattern.format(), user) From e05fbeb950aa1a835636a7226850de5040f061e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Wed, 11 Sep 2024 09:13:26 +0200 Subject: [PATCH 12/15] Apply suggestions of mypy --- radicale/auth/__init__.py | 2 +- radicale/auth/ldap.py | 8 ++++---- radicale/rights/__init__.py | 2 ++ radicale/rights/from_file.py | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index f15bd020e..b4cc7bb53 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -52,7 +52,7 @@ def load(configuration: "config.Configuration") -> "BaseAuth": class BaseAuth: - _ldap_groups: set + _ldap_groups: set[str] = set([]) _lc_username: bool _strip_domain: bool diff --git a/radicale/auth/ldap.py b/radicale/auth/ldap.py index 9093fbf03..b536fa684 100644 --- a/radicale/auth/ldap.py +++ b/radicale/auth/ldap.py @@ -35,7 +35,7 @@ class Auth(auth.BaseAuth): _ldap_secret: str _ldap_filter: str _ldap_load_groups: bool - _ldap_version: 3 + _ldap_version: int = 3 def __init__(self, configuration: config.Configuration) -> None: super().__init__(configuration) @@ -81,7 +81,7 @@ def _login2(self, login: str, password: str) -> str: conn.protocol_version = 3 conn.set_option(self.ldap.OPT_REFERRALS, 0) conn.simple_bind_s(user_dn, password) - tmp = [] + tmp: list[str] = [] if self._ldap_load_groups: tmp = [] for t in res[0][1]['memberOf']: @@ -143,5 +143,5 @@ def login(self, login: str, password: str) -> str: In the last step the authentication of the user will be proceeded. """ if self._ldap_version == 2: - return self._login2(self, login, password) - return self._login3(self, login, password) + return self._login2(login, password) + return self._login3(login, password) diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index 1b8986592..cc714ceaa 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -57,6 +57,8 @@ def intersect(a: str, b: str) -> str: class BaseRights: + _user_groups: set[str] = set([]) + def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseRights. diff --git a/radicale/rights/from_file.py b/radicale/rights/from_file.py index 91d487da5..03c1799c0 100644 --- a/radicale/rights/from_file.py +++ b/radicale/rights/from_file.py @@ -71,7 +71,7 @@ def authorization(self, user: str, path: str) -> str: collection_pattern = rights_config.get(section, "collection") allowed_groups = rights_config.get(section, "groups", fallback="").split(",") try: - group_match = self._user_groups.intersection(allowed_groups) > 0 + group_match = len(self._user_groups.intersection(allowed_groups)) > 0 except Exception: pass # Use empty format() for harmonized handling of curly braces From da04d95b75bf0bf918d9aa5baf620268630b432f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Wed, 11 Sep 2024 14:13:06 +0200 Subject: [PATCH 13/15] Fixing type definition error. --- radicale/auth/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index b4cc7bb53..baf9839be 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -33,6 +33,7 @@ from radicale import config, types, utils from radicale.log import logger +from typing import Set INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", "denyall", @@ -52,7 +53,7 @@ def load(configuration: "config.Configuration") -> "BaseAuth": class BaseAuth: - _ldap_groups: set[str] = set([]) + _ldap_groups: Set[str] = set([]) _lc_username: bool _strip_domain: bool From b47c76e9ca74947f8e07fdd5d4bbf8b8b69a3e7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Thu, 12 Sep 2024 00:59:26 +0200 Subject: [PATCH 14/15] Fix definition of _user_groups in rights --- radicale/rights/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index cc714ceaa..5503ea9d7 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -36,6 +36,9 @@ from radicale import config, utils +from typing import Set + + INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only", "from_file") @@ -57,7 +60,7 @@ def intersect(a: str, b: str) -> str: class BaseRights: - _user_groups: set[str] = set([]) + _user_groups: Set[str] = set([]) def __init__(self, configuration: "config.Configuration") -> None: """Initialize BaseRights. From a7f33c8795218a3620470cb412bd2a963ee171ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dipl=2E=20Ing=2E=20P=C3=A9ter=20Varkoly?= Date: Thu, 12 Sep 2024 12:17:34 +0200 Subject: [PATCH 15/15] Reorder imports. --- radicale/auth/__init__.py | 3 +-- radicale/rights/__init__.py | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index baf9839be..623b20645 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -29,11 +29,10 @@ """ -from typing import Sequence, Tuple, Union +from typing import Sequence, Set, Tuple, Union from radicale import config, types, utils from radicale.log import logger -from typing import Set INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", "denyall", diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index 5503ea9d7..7aec9d4e2 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -32,13 +32,10 @@ """ -from typing import Sequence +from typing import Sequence, Set from radicale import config, utils -from typing import Set - - INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only", "from_file")