diff --git a/Dockerfile b/Dockerfile index 1bfc82a..3b95563 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,13 +5,19 @@ FROM python:3-alpine # Version of Radicale (e.g. v3) ARG VERSION=master # Persistent storage for data -VOLUME /var/lib/radicale +COPY radicale ./radicale +COPY radicale3-auth-ldap ./radicale3-auth-ldap +COPY config ./config +COPY requirements.txt ./requirements.txt +COPY main.py ./main.py + # TCP port of Radicale EXPOSE 5232 -# Run Radicale -CMD ["radicale", "--hosts", "0.0.0.0:5232"] RUN apk add --no-cache ca-certificates openssl \ && apk add --no-cache --virtual .build-deps gcc libffi-dev musl-dev \ - && pip install --no-cache-dir "Radicale[bcrypt] @ https://github.com/Kozea/Radicale/archive/${VERSION}.tar.gz" \ - && apk del .build-deps + && pip install "./radicale3-auth-ldap" + +RUN pip3 install -r requirements.txt + +CMD ["python", "main.py", "--config", "./config", "--debug"] diff --git a/README.md b/README.md index aed74fc..4a94f21 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,5 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV For the complete documentation, please visit [Radicale master Documentation](https://radicale.org/master.html). + +This fork using LDAP for sync CardDAV contacts and check LDAP authorization with this plugin [radicale3-auth-ldap.](https://github.com/jdupouy/radicale3-auth-ldap) diff --git a/config b/config index 7c77f5f..02246fd 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, [::]:5232 # Max parallel connections #max_connections = 8 @@ -50,24 +50,35 @@ [auth] +type = radicale3_auth_ldap -# Authentication method -# Value: none | htpasswd | remote_user | http_x_remote_user -#type = none +# LDAP server URL, with protocol and port +ldap_url = ldap://ldap:389 -# Htpasswd filename -#htpasswd_filename = /etc/radicale/users +# LDAP base path +ldap_base = ou=Users,dc=TESTDOMAIN -# Htpasswd encryption method -# Value: plain | bcrypt | md5 -# bcrypt requires the installation of radicale[bcrypt]. -#htpasswd_encryption = md5 +# LDAP login attribute +ldap_attribute = uid -# Incorrect authentication delay (seconds) -#delay = 1 +# LDAP filter string +# placed as X in a query of the form (&(...)X) +# example: (objectCategory=Person)(objectClass=User)(memberOf=cn=calenderusers,ou=users,dc=example,dc=org) +ldap_filter = (objectClass=person) -# Message displayed in the client when a password is needed -#realm = Radicale - Password Required +# LDAP dn for initial login, used if LDAP server does not allow anonymous searches +# Leave empty if searches are anonymous +ldap_binddn = cn=admin,dc=TESTDOMAIN + +# LDAP password for initial login, used with ldap_binddn +ldap_password = verysecurepassword + +# LDAP scope of the search +ldap_scope = LEVEL + +# LDAP extended option +# If the server is samba, ldap_support_extended is should be no +ldap_support_extended = yes [rights] diff --git a/env b/env new file mode 100644 index 0000000..7495359 --- /dev/null +++ b/env @@ -0,0 +1,7 @@ +ADDRESS_BOOK_NAME= +LDAP_URL= +LDAP_BASE= +LDAP_FILTER= +LDAP_BINDDN= +LDAP_PASSWORD= +REDIS_CACHE_LIVE_SECONDS=7200 diff --git a/main.py b/main.py new file mode 100644 index 0000000..f82149c --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +import sys + +from radicale.__main__ import run + +if __name__ == '__main__': + sys.exit(run()) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 52d0b00..d33b5bb 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -71,6 +71,11 @@ def xml_propfind(base_prefix: str, path: str, multistatus.append(xml_propfind_response( base_prefix, path, item, props, user, encoding, write=write, allprop=allprop, propname=propname)) + if len(path) > len("/") and isinstance(item, storage.BaseCollection): + for i in item.get_all(): + multistatus.append(xml_propfind_response( + base_prefix, path, i, props, user, encoding, write=write, + allprop=allprop, propname=propname)) return multistatus @@ -282,10 +287,7 @@ def xml_propfind_response( else: is404 = True elif tag == xmlutils.make_clark("D:sync-token"): - if is_leaf: - element.text, _ = collection.sync() - else: - is404 = True + element.text = "1213" else: human_tag = xmlutils.make_human_tag(tag) tag_text = collection.get_meta(human_tag) @@ -323,7 +325,7 @@ def xml_propfind_response( class ApplicationPartPropfind(ApplicationBase): def _collect_allowed_items( - self, items: Iterable[types.CollectionOrItem], user: str + self, items: Iterable[types.CollectionOrItem], user: str, dept: str, ) -> Iterator[Tuple[types.CollectionOrItem, str]]: """Get items from request that user is allowed to access.""" for item in items: @@ -352,6 +354,10 @@ class ApplicationPartPropfind(ApplicationBase): else: permission = "" status = "NO" + if isinstance(item, storage.BaseCollection) \ + and ((item.path.startswith("touchin") and item.get_meta("D:displayname") == "touchin") or item.path == "touchin"): + permission = "r" + status = "read" logger.debug( "%s has %s access to %s", repr(user) if user else "anonymous user", status, target) @@ -361,9 +367,11 @@ class ApplicationPartPropfind(ApplicationBase): def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage PROPFIND request.""" - access = Access(self._rights, user, path) - if not access.check("r"): - return httputils.NOT_ALLOWED + if path not in ("/", "", "/touchin/", "/touchin"): + attributes = path.split("/") + attributes[1] = "touchin" + path = "/".join(attributes) + try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: @@ -380,11 +388,11 @@ class ApplicationPartPropfind(ApplicationBase): item = next(items_iter, None) if not item: return httputils.NOT_FOUND - if not access.check("r", item): - return httputils.NOT_ALLOWED + # if access and not access.check("r", item): + # return httputils.NOT_ALLOWED # put item back items_iter = itertools.chain([item], items_iter) - allowed_items = self._collect_allowed_items(items_iter, user) + allowed_items = self._collect_allowed_items(items_iter, user, environ.get("HTTP_DEPTH", "0")) headers = {"DAV": httputils.DAV_HEADERS, "Content-Type": "text/xml; charset=%s" % self._encoding} xml_answer = xml_propfind(base_prefix, path, xml_content, diff --git a/radicale/app/report.py b/radicale/app/report.py index 5807f6e..8dcd967 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -45,75 +45,20 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], if xml_request is None: return client.MULTI_STATUS, multistatus root = xml_request - if root.tag in (xmlutils.make_clark("D:principal-search-property-set"), - xmlutils.make_clark("D:principal-property-search"), - xmlutils.make_clark("D:expand-property")): - # We don't support searching for principals or indirect retrieving of - # properties, just return an empty result. - # InfCloud asks for expand-property reports (even if we don't announce - # support for them) and stops working if an error code is returned. - logger.warning("Unsupported REPORT method %r on %r requested", - xmlutils.make_human_tag(root.tag), path) - return client.MULTI_STATUS, multistatus - if (root.tag == xmlutils.make_clark("C:calendar-multiget") and - collection.tag != "VCALENDAR" or - root.tag == xmlutils.make_clark("CR:addressbook-multiget") and - collection.tag != "VADDRESSBOOK" or - root.tag == xmlutils.make_clark("D:sync-collection") and - collection.tag not in ("VADDRESSBOOK", "VCALENDAR")): - logger.warning("Invalid REPORT method %r on %r requested", - xmlutils.make_human_tag(root.tag), path) - return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") + prop_element = root.find(xmlutils.make_clark("D:prop")) props = ([prop.tag for prop in prop_element] if prop_element is not None else []) hreferences: Iterable[str] - if root.tag in ( - xmlutils.make_clark("C:calendar-multiget"), - xmlutils.make_clark("CR:addressbook-multiget")): - # Read rfc4791-7.9 for info - hreferences = set() - for href_element in root.findall(xmlutils.make_clark("D:href")): - temp_url_path = urlparse(href_element.text).path - assert isinstance(temp_url_path, str) - href_path = pathutils.sanitize_path(unquote(temp_url_path)) - if (href_path + "/").startswith(base_prefix + "/"): - hreferences.add(href_path[len(base_prefix):]) - else: - logger.warning("Skipping invalid path %r in REPORT request on " - "%r", href_path, path) - elif root.tag == xmlutils.make_clark("D:sync-collection"): - old_sync_token_element = root.find( - xmlutils.make_clark("D:sync-token")) - old_sync_token = "" - if old_sync_token_element is not None and old_sync_token_element.text: - old_sync_token = old_sync_token_element.text.strip() - logger.debug("Client provided sync token: %r", old_sync_token) - try: - sync_token, names = collection.sync(old_sync_token) - except ValueError as e: - # Invalid sync token - logger.warning("Client provided invalid sync token %r: %s", - old_sync_token, e, exc_info=True) - # client.CONFLICT doesn't work with some clients (e.g. InfCloud) - return (client.FORBIDDEN, - xmlutils.webdav_error("D:valid-sync-token")) - hreferences = (pathutils.unstrip_path( - posixpath.join(collection.path, n)) for n in names) + if root.tag == xmlutils.make_clark("D:sync-collection"): # Append current sync token to response sync_token_element = ET.Element(xmlutils.make_clark("D:sync-token")) - sync_token_element.text = sync_token + sync_token_element.text = "sync_token" multistatus.append(sync_token_element) - else: - hreferences = (path,) - filters = ( - root.findall(xmlutils.make_clark("C:filter")) + - root.findall(xmlutils.make_clark("CR:filter"))) # Retrieve everything required for finishing the request. - retrieved_items = list(retrieve_items( - base_prefix, path, collection, hreferences, filters, multistatus)) + retrieved_items = list(retrieve_items(collection)) collection_tag = collection.tag # !!! Don't access storage after this !!! unlock_storage_fn() @@ -123,17 +68,6 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], # Don't keep reference to ``item``, because VObject requires a lot of # memory. item, filters_matched = retrieved_items.pop(0) - if filters and not filters_matched: - try: - if not all(test_filter(collection_tag, item, filter_) - for filter_ in filters): - continue - except ValueError as e: - raise ValueError("Failed to filter item %r from %r: %s" % - (item.href, collection.path, e)) from e - except Exception as e: - raise RuntimeError("Failed to filter item %r from %r: %s" % - (item.href, collection.path, e)) from e found_props = [] not_found_props = [] @@ -194,47 +128,12 @@ def xml_item_response(base_prefix: str, href: str, return response -def retrieve_items( - base_prefix: str, path: str, collection: storage.BaseCollection, - hreferences: Iterable[str], filters: Sequence[ET.Element], - multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]: +def retrieve_items( collection: storage.BaseCollection) -> Iterator[Tuple[radicale_item.Item, bool]]: """Retrieves all items that are referenced in ``hreferences`` from ``collection`` and adds 404 responses for missing and invalid items to ``multistatus``.""" - collection_requested = False - - def get_names() -> Iterator[str]: - """Extracts all names from references in ``hreferences`` and adds - 404 responses for invalid references to ``multistatus``. - If the whole collections is referenced ``collection_requested`` - gets set to ``True``.""" - nonlocal collection_requested - for hreference in hreferences: - try: - name = pathutils.name_from_path(hreference, collection) - except ValueError as e: - logger.warning("Skipping invalid path %r in REPORT request on " - "%r: %s", hreference, path, e) - response = xml_item_response(base_prefix, hreference, - found_item=False) - multistatus.append(response) - continue - if name: - # Reference is an item - yield name - else: - # Reference is a collection - collection_requested = True - - for name, item in collection.get_multi(get_names()): - if not item: - uri = pathutils.unstrip_path(posixpath.join(collection.path, name)) - response = xml_item_response(base_prefix, uri, found_item=False) - multistatus.append(response) - else: - yield item, False - if collection_requested: - yield from collection.get_filtered(filters) + for item in collection.get_all(): + yield item, True def test_filter(collection_tag: str, item: radicale_item.Item, @@ -271,9 +170,6 @@ class ApplicationPartReport(ApplicationBase): def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: """Manage REPORT request.""" - access = Access(self._rights, user, path) - if not access.check("r"): - return httputils.NOT_ALLOWED try: xml_content = self._read_xml_request_body(environ) except RuntimeError as e: @@ -288,8 +184,6 @@ class ApplicationPartReport(ApplicationBase): item = next(iter(self._storage.discover(path)), None) if not item: return httputils.NOT_FOUND - if not access.check("r", item): - return httputils.NOT_ALLOWED if isinstance(item, storage.BaseCollection): collection = item else: diff --git a/radicale/ldap/__init__.py b/radicale/ldap/__init__.py new file mode 100644 index 0000000..7ff9beb --- /dev/null +++ b/radicale/ldap/__init__.py @@ -0,0 +1,78 @@ +import datetime +import json +import os + +import ldap3 + +from dateutil.parser import parse +from radicale import logger +from dataclasses import dataclass +from typing import Optional, List +from radicale.redis import client as redis_client + +LDAP_FIELD_NAMES = \ + ("uid", "ruSn", "ruGivenName", "mobile", "skype", "telegram", "birthdate", "mail", "title", "modifyTimestamp") + +REDIS_CACHE_KEY = "ldap_users" + + +@dataclass +class LdapUser: + uid: str + ruSn: Optional[str] + ruGivenName: Optional[str] + mobile: Optional[str] + skype: Optional[str] + telegram: Optional[str] + birthdate: Optional[str] + mail: Optional[str] + title: Optional[str] + modifyTimestamp: datetime.datetime + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + if key in LDAP_FIELD_NAMES: + if isinstance(value, List) and len(value) > 0: + setattr(self, key, value[0]) + elif not value: + setattr(self, key, None) + elif key == "modifyTimestamp": + setattr(self, key, parse(value)) + + else: + setattr(self, key, value) + + +class LdapService: + + @staticmethod + def _get_ldap_users() -> List: + server = ldap3.Server(os.getenv("LDAP_URL")) + conn = ldap3.Connection(server, os.getenv("LDAP_BINDDN"), password=os.getenv("LDAP_PASSWORD")) + + conn.bind() + + try: + logger.debug("LDAP whoami: %s" % conn.extend.standard.who_am_i()) + except Exception as err: + logger.debug("LDAP error: %s" % err) + + conn.search(search_base=os.getenv("LDAP_BASE"), + search_scope="LEVEL", + search_filter=os.getenv("LDAP_FILTER"), + attributes=LDAP_FIELD_NAMES) + + return conn.response + + def sync(self) -> List[LdapUser]: + cached = redis_client.get(REDIS_CACHE_KEY) + if cached: + users = json.loads(cached) + else: + users = self._get_ldap_users() + users = [dict(u["attributes"]) for u in users] + json_objects = json.dumps(users, default=str) + redis_client.setex(name=REDIS_CACHE_KEY, value=json_objects, + time=datetime.timedelta(seconds=int(os.getenv("REDIS_CACHE_LIVE_SECONDS", 7200)))) + + return [LdapUser(**u) for u in users] diff --git a/radicale/redis/__init__.py b/radicale/redis/__init__.py new file mode 100644 index 0000000..9b7e445 --- /dev/null +++ b/radicale/redis/__init__.py @@ -0,0 +1,29 @@ +import os +import sys +from datetime import timedelta + +import redis + +from radicale import logger + + +def redis_connect() -> redis.client.Redis: + try: + redis_client = redis.Redis( + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", 6379)), + password=os.getenv("REDIS_PASSWORD"), + db=0, + socket_timeout=5, + decode_responses=True, + ) + ping = redis_client.ping() + if ping is True: + return redis_client + except redis.AuthenticationError as e: + logger.debug("Redis auth error:\n%s", e) + sys.exit(1) + + +client = redis_connect() + diff --git a/radicale/storage/multifilesystem/__init__.py b/radicale/storage/multifilesystem/__init__.py index 67aa6a5..d4ebb55 100644 --- a/radicale/storage/multifilesystem/__init__.py +++ b/radicale/storage/multifilesystem/__init__.py @@ -22,7 +22,7 @@ Storage backend that stores data in the file system. Uses one folder per collection and one file per collection entry. """ - +import datetime import os import time from typing import ClassVar, Iterator, Optional, Type @@ -63,14 +63,7 @@ class Collection( @property def last_modified(self) -> str: - def relevant_files_iter() -> Iterator[str]: - yield self._filesystem_path - if os.path.exists(self._props_path): - yield self._props_path - for href in self._list(): - yield os.path.join(self._filesystem_path, href) - last = max(map(os.path.getmtime, relevant_files_iter())) - return time.strftime("%a, %d %b %Y %H:%M:%S GMT", time.gmtime(last)) + return datetime.datetime.now().strftime("%a, %d %b %Y %H:%M:%S GMT") @property def etag(self) -> str: diff --git a/radicale/storage/multifilesystem/discover.py b/radicale/storage/multifilesystem/discover.py index 0031614..f7d068c 100644 --- a/radicale/storage/multifilesystem/discover.py +++ b/radicale/storage/multifilesystem/discover.py @@ -39,66 +39,26 @@ class StoragePartDiscover(StorageBase): Callable[[str, Optional[str]], ContextManager[None]]] = None ) -> Iterator[types.CollectionOrItem]: # assert isinstance(self, multifilesystem.Storage) - if child_context_manager is None: - child_context_manager = _null_child_context_manager - # Path should already be sanitized - sane_path = pathutils.strip_path(path) - attributes = sane_path.split("/") if sane_path else [] - folder = self._get_collection_root_folder() - # Create the root collection - self._makedirs_synced(folder) - try: - filesystem_path = pathutils.path_to_filesystem(folder, sane_path) - except ValueError as e: - # Path is unsafe - logger.debug("Unsafe path %r requested from storage: %s", - sane_path, e, exc_info=True) - return - - # Check if the path exists and if it leads to a collection or an item - href: Optional[str] - if not os.path.isdir(filesystem_path): - if attributes and os.path.isfile(filesystem_path): - href = attributes.pop() - else: - return - else: - href = None - - sane_path = "/".join(attributes) - collection = self._collection_class( - cast(multifilesystem.Storage, self), - pathutils.unstrip_path(sane_path, True)) - - if href: - item = collection._get(href) - if item is not None: - yield item - return + collection = multifilesystem.Collection( + storage_=cast(multifilesystem.Storage, self), + path="/default/", + ) yield collection if depth == "0": return - for href in collection._list(): - with child_context_manager(sane_path, href): - item = collection._get(href) - if item is not None: - yield item + address_book = multifilesystem.Collection( + storage_=cast(multifilesystem.Storage, self), + path="/default/address_book.vcf", + ) + address_book.set_meta( + { + "D:displayname": os.getenv("ADDRESS_BOOK_NAME", "default"), + "tag": "VADDRESSBOOK" + } + ) - for entry in os.scandir(filesystem_path): - if not entry.is_dir(): - continue - href = entry.name - if not pathutils.is_safe_filesystem_path_component(href): - if not href.startswith(".Radicale"): - logger.debug("Skipping collection %r in %r", - href, sane_path) - continue - sane_child_path = posixpath.join(sane_path, href) - child_path = pathutils.unstrip_path(sane_child_path, True) - with child_context_manager(sane_child_path, None): - yield self._collection_class( - cast(multifilesystem.Storage, self), child_path) + yield address_book diff --git a/radicale/storage/multifilesystem/get.py b/radicale/storage/multifilesystem/get.py index 0a1fd73..e8859bc 100644 --- a/radicale/storage/multifilesystem/get.py +++ b/radicale/storage/multifilesystem/get.py @@ -19,8 +19,12 @@ import os import sys import time +from datetime import datetime +from dateutil.parser import parse + from typing import Iterable, Iterator, Optional, Tuple +from radicale.ldap import LdapService import radicale.item as radicale_item from radicale import pathutils from radicale.log import logger @@ -32,7 +36,6 @@ from radicale.storage.multifilesystem.lock import CollectionPartLock class CollectionPartGet(CollectionPartCache, CollectionPartLock, CollectionBase): - _item_cache_cleaned: bool def __init__(self, storage_: "multifilesystem.Storage", path: str, @@ -140,9 +143,41 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock, yield (href, self._get(href, verify_href=False)) def get_all(self) -> Iterator[radicale_item.Item]: - for href in self._list(): - # We don't need to check for collissions, because the file names - # are from os.listdir. - item = self._get(href, verify_href=False) - if item is not None: - yield item + + for ldap_user in LdapService().sync(): + if not ldap_user.ruSn and not ldap_user.ruGivenName: + continue + + if ldap_user.birthdate: + birthdate = parse(ldap_user.birthdate).strftime("%Y-%m-%d") + else: + birthdate = "-" + + vcard_text = "\n".join(("BEGIN:VCARD", + "VERSION:3.0", + f"UID:{ldap_user.uid}", + f"EMAIL;TYPE=INTERNET:{ldap_user.mail or '-'}", + f"FN:{ldap_user.ruGivenName or ''} {ldap_user.ruSn or ''}", + f"REV:{ldap_user.modifyTimestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')}", + f"N:{ldap_user.ruSn or ''};{ldap_user.ruGivenName or ''};;;", + f"ROLE:{ldap_user.title or '-'}", + f"TEL;TYPE=WORK,MSG:{ldap_user.mobile or '-'}", + f"BDAY:{birthdate}", + f"IMPP;X-SERVICE-TYPE=Skype;TYPE=WORK:skype:{ldap_user.skype or '-'}", + f"IMPP;X-SERVICE-TYPE=Telegram;TYPE=WORK:telegram:{ldap_user.telegram or '-'}", + f"END:VCARD", + )) + + item = radicale_item.Item( + collection_path=self.path, + collection=self, + vobject_item=None, + href=f"{ldap_user.uid}.vcf", + last_modified=ldap_user.modifyTimestamp.strftime("%a, %d %b %Y %H:%M:%S GMT"), + text=vcard_text, + etag=radicale_item.get_etag(vcard_text), + uid=ldap_user.uid, + name="VCARD", + component_name="VCARD", + ) + yield item diff --git a/radicale/storage/multifilesystem/meta.py b/radicale/storage/multifilesystem/meta.py index edce651..dbc193f 100644 --- a/radicale/storage/multifilesystem/meta.py +++ b/radicale/storage/multifilesystem/meta.py @@ -33,7 +33,7 @@ class CollectionPartMeta(CollectionBase): def __init__(self, storage_: "multifilesystem.Storage", path: str, filesystem_path: Optional[str] = None) -> None: super().__init__(storage_, path, filesystem_path) - self._meta_cache = None + self._meta_cache = {} self._props_path = os.path.join( self._filesystem_path, ".Radicale.props") @@ -45,22 +45,8 @@ class CollectionPartMeta(CollectionBase): def get_meta(self, key: Optional[str] = None) -> Union[Mapping[str, str], Optional[str]]: - # reuse cached value if the storage is read-only - if self._storage._lock.locked == "w" or self._meta_cache is None: - try: - try: - with open(self._props_path, encoding=self._encoding) as f: - temp_meta = json.load(f) - except FileNotFoundError: - temp_meta = {} - self._meta_cache = radicale_item.check_and_sanitize_props( - temp_meta) - except ValueError as e: - raise RuntimeError("Failed to load properties of collection " - "%r: %s" % (self.path, e)) from e return self._meta_cache if key is None else self._meta_cache.get(key) def set_meta(self, props: Mapping[str, str]) -> None: - with self._atomic_write(self._props_path, "w") as fo: - f = cast(TextIO, fo) - json.dump(props, f, sort_keys=True) + self._meta_cache = dict(self._meta_cache, **props) + diff --git a/radicale3-auth-ldap/.gitignore b/radicale3-auth-ldap/.gitignore new file mode 100644 index 0000000..24f4d7b --- /dev/null +++ b/radicale3-auth-ldap/.gitignore @@ -0,0 +1,103 @@ +# Created by .ignore support plugin (hsz.mobi) +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*,cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ + +# Spyder project settings +.spyderproject + +# Rope project settings +.ropeproject + +# Files used for local debugging (integration test) +test/configuration.py + +# IntelliJ +*.iml +.idea/ diff --git a/radicale3-auth-ldap/README.md b/radicale3-auth-ldap/README.md new file mode 100644 index 0000000..536c0db --- /dev/null +++ b/radicale3-auth-ldap/README.md @@ -0,0 +1,38 @@ +# What is this? +This is an authentication plugin for Radicale 3. It adds an LDAP authentication backend which can be used for authenticating users against an LDAP server. + +# How to configure +You will need to set a few options inside your radicale config file. Example: + +``` +[auth] +type = radicale3_auth_ldap + +# LDAP server URL, with protocol and port +ldap_url = ldap://ldap:389 + +# LDAP base path +ldap_base = ou=Users,dc=TESTDOMAIN + +# LDAP login attribute +ldap_attribute = uid + +# LDAP filter string +# placed as X in a query of the form (&(...)X) +# example: (objectCategory=Person)(objectClass=User)(memberOf=cn=calenderusers,ou=users,dc=example,dc=org) +ldap_filter = (objectClass=person) + +# LDAP dn for initial login, used if LDAP server does not allow anonymous searches +# Leave empty if searches are anonymous +ldap_binddn = cn=admin,dc=TESTDOMAIN + +# LDAP password for initial login, used with ldap_binddn +ldap_password = verysecurepassword + +# LDAP scope of the search +ldap_scope = LEVEL + +# LDAP extended option +# If the server is samba, ldap_support_extended is should be no +ldap_support_extended = yes +``` diff --git a/radicale3-auth-ldap/radicale3_auth_ldap/__init__.py b/radicale3-auth-ldap/radicale3_auth_ldap/__init__.py new file mode 100644 index 0000000..0d40c06 --- /dev/null +++ b/radicale3-auth-ldap/radicale3_auth_ldap/__init__.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# +# This file is part of Radicale Server - Calendar Server +# Copyright © 2011 Corentin Le Bail +# Copyright © 2011-2013 Guillaume Ayoub +# Copyright © 2015 Raoul Thill +# Copyright © 2017 Marco Huenseler +# +# 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 . + +""" +LDAP authentication. +Authentication based on the ``ldap3`` module +(https://github.com/cannatag/ldap3/). +""" + +import os + +import ldap3 +import ldap3.core.exceptions + +from radicale.auth import BaseAuth +from radicale.log import logger + +import radicale3_auth_ldap.ldap3imports + +PLUGIN_CONFIG_SCHEMA = {"auth": { + "ldap_url": { + "value": "ldap://localhost:389", + "type": str}, + "ldap_base": { + "value": "ou=Users", + "type": str}, + "ldap_attribute": { + "value": "uid", + "type": str}, + "ldap_filter": { + "value": "(objectClass=*)", + "type": str}, + "ldap_binddn": { + "value": None, + "type": str}, + "ldap_password": { + "value": None, + "type": str}, + "ldap_scope": { + "value": "SUBTREE", + "type": str}, + "ldap_support_extended": { + "value": True, + "type": bool}}} + + +class Auth(BaseAuth): + def __init__(self, configuration): + super().__init__(configuration.copy(PLUGIN_CONFIG_SCHEMA)) + + def login(self, user, password): + """Check if ``user``/``password`` couple is valid.""" + SERVER = ldap3.Server(os.getenv("LDAP_URL") or self.configuration.get("auth", "ldap_url")) + BASE = os.getenv("LDAP_BASE") or self.configuration.get("auth", "ldap_base") + ATTRIBUTE = os.getenv("LDAP_ATTRIBUTE") or self.configuration.get("auth", "ldap_attribute") + FILTER = os.getenv("LDAP_FILTER") or self.configuration.get("auth", "ldap_filter") + BINDDN = os.getenv("LDAP_BINDDN") or self.configuration.get("auth", "ldap_binddn") + PASSWORD = os.getenv("LDAP_PASSWORD") or self.configuration.get("auth", "ldap_password") + SCOPE = self.configuration.get("auth", "ldap_scope") + SUPPORT_EXTENDED = self.configuration.get("auth", "ldap_support_extended") + + if BINDDN and PASSWORD: + conn = ldap3.Connection(SERVER, BINDDN, PASSWORD) + else: + conn = ldap3.Connection(SERVER) + conn.bind() + + try: + logger.debug("LDAP whoami: %s" % conn.extend.standard.who_am_i()) + except Exception as err: + logger.debug("LDAP error: %s" % err) + + distinguished_name = "%s=%s" % (ATTRIBUTE, ldap3imports.escape_attribute_value(user)) + logger.debug("LDAP bind for %s in base %s" % (distinguished_name, BASE)) + + if FILTER: + filter_string = "(&(%s)%s)" % (distinguished_name, FILTER) + else: + filter_string = distinguished_name + logger.debug("LDAP filter: %s" % filter_string) + + conn.search(search_base=BASE, + search_scope=SCOPE, + search_filter=filter_string, + attributes=[ATTRIBUTE]) + + users = conn.response + + if users: + user_dn = users[0]['dn'] + uid = users[0]['attributes'][ATTRIBUTE] + logger.debug("LDAP user %s (%s) found" % (uid, user_dn)) + try: + conn = ldap3.Connection(SERVER, user_dn, password) + conn.bind() + logger.debug(conn.result) + if SUPPORT_EXTENDED: + whoami = conn.extend.standard.who_am_i() + logger.debug("LDAP whoami: %s" % whoami) + else: + logger.debug("LDAP skip extended: call whoami") + whoami = conn.result['result'] == 0 + if whoami: + logger.debug("LDAP bind OK") + return uid[0] + else: + logger.debug("LDAP bind failed") + return "" + except ldap3.core.exceptions.LDAPInvalidCredentialsResult: + logger.debug("LDAP invalid credentials") + except Exception as err: + logger.debug("LDAP error %s" % err) + return "" + else: + logger.debug("LDAP user %s not found" % user) + return "" diff --git a/radicale3-auth-ldap/radicale3_auth_ldap/ldap3imports.py b/radicale3-auth-ldap/radicale3_auth_ldap/ldap3imports.py new file mode 100644 index 0000000..840ff21 --- /dev/null +++ b/radicale3-auth-ldap/radicale3_auth_ldap/ldap3imports.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- + +""" +This code was imported from ldap3, because upstream obviously deprecated this. +Until I come around finding a better solution, this will stay here. +All this code was written by people from the ldap3 project and keeps the original license, of course. + +Original header below: + +# Created on 2014.09.08 +# +# Author: Giovanni Cannata +# +# Copyright 2014, 2015, 2016 Giovanni Cannata +# +# This file is part of ldap3. +# +# ldap3 is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# ldap3 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with ldap3 in the COPYING and COPYING.LESSER files. +# If not, see . +""" +from string import hexdigits + +STATE_ANY = 0 +STATE_ESCAPE = 1 +STATE_ESCAPE_HEX = 2 + + +def escape_attribute_value(attribute_value): + if not attribute_value: + return '' + + if attribute_value[0] == '#': # with leading SHARP only pairs of hex characters are valid + valid_hex = True + if len(attribute_value) % 2 == 0: # string must be # + HEX HEX (an odd number of chars) + valid_hex = False + + if valid_hex: + for c in attribute_value: + if c not in hexdigits: # allowed only hex digits as per RFC 4514 + valid_hex = False + break + + if valid_hex: + return attribute_value + + state = STATE_ANY + escaped = '' + tmp_buffer = '' + for c in attribute_value: + if state == STATE_ANY: + if c == '\\': + state = STATE_ESCAPE + elif c in '"#+,;<=>\00': + escaped += '\\' + c + else: + escaped += c + elif state == STATE_ESCAPE: + if c in hexdigits: + tmp_buffer = c + state = STATE_ESCAPE_HEX + elif c in ' "#+,;<=>\\\00': + escaped += '\\' + c + state = STATE_ANY + else: + escaped += '\\\\' + c + elif state == STATE_ESCAPE_HEX: + if c in hexdigits: + escaped += '\\' + tmp_buffer + c + else: + escaped += '\\\\' + tmp_buffer + c + tmp_buffer = '' + state = STATE_ANY + + # final state + if state == STATE_ESCAPE: + escaped += '\\\\' + elif state == STATE_ESCAPE_HEX: + escaped += '\\\\' + tmp_buffer + + if escaped[0] == ' ': # leading SPACE must be escaped + escaped = '\\' + escaped + + if escaped[-1] == ' ' and len(escaped) > 1 and escaped[-2] != '\\': # trailing SPACE must be escaped + escaped = escaped[:-1] + '\\ ' + + return escaped diff --git a/radicale3-auth-ldap/setup.py b/radicale3-auth-ldap/setup.py new file mode 100644 index 0000000..2d0fee9 --- /dev/null +++ b/radicale3-auth-ldap/setup.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 + +from setuptools import setup + +setup( + name="radicale3-auth-ldap", + version="3.0", + description="LDAP Authentication Plugin for Radicale 3", + author="Raoul Thill", + license="GNU GPL v3", + install_requires=["radicale >= 3.0", "ldap3 >= 2.3"], + packages=["radicale3_auth_ldap"]) diff --git a/radicale3-auth-ldap/test/__init__.py b/radicale3-auth-ldap/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/radicale3-auth-ldap/test/basic_tests.py b/radicale3-auth-ldap/test/basic_tests.py new file mode 100644 index 0000000..b152f0f --- /dev/null +++ b/radicale3-auth-ldap/test/basic_tests.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- + +import unittest + +import radicale3_auth_ldap + + +class References(unittest.TestCase): + def test_invalid_credentials_exception_exists(self): + successful_test = False + + try: + import ldap3.core.exceptions + except ImportError: + self.fail('ldap3 module was not found at all!') + try: + raise ldap3.core.exceptions.LDAPInvalidCredentialsResult() + except ldap3.core.exceptions.LDAPInvalidCredentialsResult: + successful_test = True + + self.assertTrue(successful_test) diff --git a/radicale3-auth-ldap/test/integration_tests.py b/radicale3-auth-ldap/test/integration_tests.py new file mode 100644 index 0000000..4ad649e --- /dev/null +++ b/radicale3-auth-ldap/test/integration_tests.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +import logging +import unittest + +import radicale3_auth_ldap +from test.configuration import TEST_CONFIGURATION, VALID_USER, VALID_PASS +from test.util import ConfigMock + + +class Authentication(unittest.TestCase): + configuration = None + + @classmethod + def setUpClass(cls): + cls.configuration = ConfigMock(TEST_CONFIGURATION) + + def test_authentication_works(self): + auth = radicale3_auth_ldap.Auth(self.__class__.configuration) + self.assertTrue(auth.login(VALID_USER, VALID_PASS)) + + +if __name__ == '__main__': + unittest.main() diff --git a/radicale3-auth-ldap/test/util.py b/radicale3-auth-ldap/test/util.py new file mode 100644 index 0000000..4d2084a --- /dev/null +++ b/radicale3-auth-ldap/test/util.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +from collections.abc import Mapping + + +class ConfigMock: + def __init__(self, configuration): + assert isinstance(configuration, Mapping) + assert all(isinstance(x, Mapping) for x in configuration.values()) + self.configuration = configuration + + def get(self, a, b): + return self.configuration[a][b] + + def copy(self, c): + return self diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..75f1720 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +defusedxml==0.7.1 +passlib==1.7.4 +vobject>=0.9.6 +python-dateutil==2.8.2 +redis==4.3.4 \ No newline at end of file diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a77b43b..0000000 --- a/setup.cfg +++ /dev/null @@ -1,56 +0,0 @@ -[tool:pytest] -addopts = --typeguard-packages=radicale - -[tox:tox] - -[testenv] -extras = test -deps = - flake8 - isort - # mypy installation fails with pypy<3.9 - mypy; implementation_name!='pypy' or python_version>='3.9' - types-setuptools - pytest-cov -commands = - flake8 . - isort --check --diff . - # Run mypy if it's installed - python -c 'import importlib.util, subprocess, sys; \ - importlib.util.find_spec("mypy") \ - and sys.exit(subprocess.run(["mypy", "."]).returncode) \ - or print("Skipped: mypy is not installed")' - pytest -r s --cov --cov-report=term --cov-report=xml . - -[tool:isort] -known_standard_library = _dummy_thread,_thread,abc,aifc,argparse,array,ast,asynchat,asyncio,asyncore,atexit,audioop,base64,bdb,binascii,binhex,bisect,builtins,bz2,cProfile,calendar,cgi,cgitb,chunk,cmath,cmd,code,codecs,codeop,collections,colorsys,compileall,concurrent,configparser,contextlib,contextvars,copy,copyreg,crypt,csv,ctypes,curses,dataclasses,datetime,dbm,decimal,difflib,dis,distutils,doctest,dummy_threading,email,encodings,ensurepip,enum,errno,faulthandler,fcntl,filecmp,fileinput,fnmatch,formatter,fpectl,fractions,ftplib,functools,gc,getopt,getpass,gettext,glob,grp,gzip,hashlib,heapq,hmac,html,http,imaplib,imghdr,imp,importlib,inspect,io,ipaddress,itertools,json,keyword,lib2to3,linecache,locale,logging,lzma,macpath,mailbox,mailcap,marshal,math,mimetypes,mmap,modulefinder,msilib,msvcrt,multiprocessing,netrc,nis,nntplib,ntpath,numbers,operator,optparse,os,ossaudiodev,parser,pathlib,pdb,pickle,pickletools,pipes,pkgutil,platform,plistlib,poplib,posix,posixpath,pprint,profile,pstats,pty,pwd,py_compile,pyclbr,pydoc,queue,quopri,random,re,readline,reprlib,resource,rlcompleter,runpy,sched,secrets,select,selectors,shelve,shlex,shutil,signal,site,smtpd,smtplib,sndhdr,socket,socketserver,spwd,sqlite3,sre,sre_compile,sre_constants,sre_parse,ssl,stat,statistics,string,stringprep,struct,subprocess,sunau,symbol,symtable,sys,sysconfig,syslog,tabnanny,tarfile,telnetlib,tempfile,termios,test,textwrap,threading,time,timeit,tkinter,token,tokenize,trace,traceback,tracemalloc,tty,turtle,turtledemo,types,typing,unicodedata,unittest,urllib,uu,uuid,venv,warnings,wave,weakref,webbrowser,winreg,winsound,wsgiref,xdrlib,xml,xmlrpc,zipapp,zipfile,zipimport,zlib -known_third_party = defusedxml,passlib,pkg_resources,pytest,vobject - -[flake8] -# Only enable default tests (https://github.com/PyCQA/flake8/issues/790#issuecomment-812823398) -select = E,F,W,C90,DOES-NOT-EXIST -ignore = E121,E123,E126,E226,E24,E704,W503,W504,DOES-NOT-EXIST -extend-exclude = build - -[mypy] -ignore_missing_imports = True -show_error_codes = True -exclude = (^|/)build($|/) - -[coverage:run] -branch = True -source = radicale -omit = tests/*,*/tests/* - -[coverage:report] -# Regexes for lines to exclude from consideration -exclude_lines = - # Have to re-enable the standard pragma - pragma: no cover - - # Don't complain if tests don't hit defensive assertion code: - raise AssertionError - raise NotImplementedError - - # Don't complain if non-runnable code isn't run: - if __name__ == .__main__.: diff --git a/setup.py b/setup.py deleted file mode 100644 index 7e14299..0000000 --- a/setup.py +++ /dev/null @@ -1,73 +0,0 @@ -# This file is part of Radicale - CalDAV and CardDAV server -# Copyright © 2009-2017 Guillaume Ayoub -# Copyright © 2017-2018 Unrud -# -# 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 . - -from setuptools import find_packages, setup - -# When the version is updated, a new section in the CHANGELOG.md file must be -# added too. -VERSION = "master" - -with open("README.md", encoding="utf-8") as f: - long_description = f.read() -web_files = ["web/internal_data/css/icon.png", - "web/internal_data/css/main.css", - "web/internal_data/fn.js", - "web/internal_data/index.html"] - -install_requires = ["defusedxml", "passlib", "vobject>=0.9.6", - "python-dateutil>=2.7.3", - "setuptools; python_version<'3.9'"] -bcrypt_requires = ["passlib[bcrypt]", "bcrypt"] -# typeguard requires pytest<7 -test_requires = ["pytest<7", "typeguard", "waitress", *bcrypt_requires] - -setup( - name="Radicale", - version=VERSION, - description="CalDAV and CardDAV Server", - long_description=long_description, - long_description_content_type="text/markdown", - author="Guillaume Ayoub", - author_email="guillaume.ayoub@kozea.fr", - url="https://radicale.org/", - license="GNU GPL v3", - platforms="Any", - packages=find_packages( - exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), - package_data={"radicale": [*web_files, "py.typed"]}, - entry_points={"console_scripts": ["radicale = radicale.__main__:run"]}, - install_requires=install_requires, - extras_require={"test": test_requires, "bcrypt": bcrypt_requires}, - keywords=["calendar", "addressbook", "CalDAV", "CardDAV"], - python_requires=">=3.6.0", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Environment :: Web Environment", - "Intended Audience :: End Users/Desktop", - "Intended Audience :: Information Technology", - "License :: OSI Approved :: GNU General Public License (GPL)", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "Topic :: Office/Business :: Groupware"])