Sync with LDAP

This commit is contained in:
Mikhail Yasnov 2022-08-01 18:27:41 +03:00
parent 6a56a6026f
commit fdc595693c
25 changed files with 696 additions and 360 deletions

View File

@ -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"]

View File

@ -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)

39
config
View File

@ -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]

7
env Normal file
View File

@ -0,0 +1,7 @@
ADDRESS_BOOK_NAME=
LDAP_URL=
LDAP_BASE=
LDAP_FILTER=
LDAP_BINDDN=
LDAP_PASSWORD=
REDIS_CACHE_LIVE_SECONDS=7200

6
main.py Normal file
View File

@ -0,0 +1,6 @@
import sys
from radicale.__main__ import run
if __name__ == '__main__':
sys.exit(run())

View File

@ -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,

View File

@ -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:

78
radicale/ldap/__init__.py Normal file
View File

@ -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]

View File

@ -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()

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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)

103
radicale3-auth-ldap/.gitignore vendored Normal file
View File

@ -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/

View File

@ -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
```

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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 ""

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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

View File

@ -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"])

View File

View File

@ -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)

View File

@ -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()

View File

@ -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

5
requirements.txt Normal file
View File

@ -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

View File

@ -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__.:

View File

@ -1,73 +0,0 @@
# This file is part of Radicale - CalDAV and CardDAV server
# Copyright © 2009-2017 Guillaume Ayoub
# Copyright © 2017-2018 Unrud <unrud@outlook.com>
#
# 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/>.
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"])