Sync with LDAP
This commit is contained in:
parent
6a56a6026f
commit
fdc595693c
16
Dockerfile
16
Dockerfile
|
|
@ -5,13 +5,19 @@ FROM python:3-alpine
|
||||||
# Version of Radicale (e.g. v3)
|
# Version of Radicale (e.g. v3)
|
||||||
ARG VERSION=master
|
ARG VERSION=master
|
||||||
# Persistent storage for data
|
# 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
|
# TCP port of Radicale
|
||||||
EXPOSE 5232
|
EXPOSE 5232
|
||||||
# Run Radicale
|
|
||||||
CMD ["radicale", "--hosts", "0.0.0.0:5232"]
|
|
||||||
|
|
||||||
RUN apk add --no-cache ca-certificates openssl \
|
RUN apk add --no-cache ca-certificates openssl \
|
||||||
&& apk add --no-cache --virtual .build-deps gcc libffi-dev musl-dev \
|
&& 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" \
|
&& pip install "./radicale3-auth-ldap"
|
||||||
&& apk del .build-deps
|
|
||||||
|
RUN pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
CMD ["python", "main.py", "--config", "./config", "--debug"]
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,5 @@ Radicale is a small but powerful CalDAV (calendars, to-do lists) and CardDAV
|
||||||
|
|
||||||
For the complete documentation, please visit
|
For the complete documentation, please visit
|
||||||
[Radicale master Documentation](https://radicale.org/master.html).
|
[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
39
config
|
|
@ -15,7 +15,7 @@
|
||||||
# IPv4 syntax: address:port
|
# IPv4 syntax: address:port
|
||||||
# IPv6 syntax: [address]:port
|
# IPv6 syntax: [address]:port
|
||||||
# For example: 0.0.0.0:9999, [::]:9999
|
# For example: 0.0.0.0:9999, [::]:9999
|
||||||
#hosts = localhost:5232
|
hosts = 0.0.0.0:5232, [::]:5232
|
||||||
|
|
||||||
# Max parallel connections
|
# Max parallel connections
|
||||||
#max_connections = 8
|
#max_connections = 8
|
||||||
|
|
@ -50,24 +50,35 @@
|
||||||
|
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
|
type = radicale3_auth_ldap
|
||||||
|
|
||||||
# Authentication method
|
# LDAP server URL, with protocol and port
|
||||||
# Value: none | htpasswd | remote_user | http_x_remote_user
|
ldap_url = ldap://ldap:389
|
||||||
#type = none
|
|
||||||
|
|
||||||
# Htpasswd filename
|
# LDAP base path
|
||||||
#htpasswd_filename = /etc/radicale/users
|
ldap_base = ou=Users,dc=TESTDOMAIN
|
||||||
|
|
||||||
# Htpasswd encryption method
|
# LDAP login attribute
|
||||||
# Value: plain | bcrypt | md5
|
ldap_attribute = uid
|
||||||
# bcrypt requires the installation of radicale[bcrypt].
|
|
||||||
#htpasswd_encryption = md5
|
|
||||||
|
|
||||||
# Incorrect authentication delay (seconds)
|
# LDAP filter string
|
||||||
#delay = 1
|
# 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
|
# LDAP dn for initial login, used if LDAP server does not allow anonymous searches
|
||||||
#realm = Radicale - Password Required
|
# 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]
|
[rights]
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
ADDRESS_BOOK_NAME=
|
||||||
|
LDAP_URL=
|
||||||
|
LDAP_BASE=
|
||||||
|
LDAP_FILTER=
|
||||||
|
LDAP_BINDDN=
|
||||||
|
LDAP_PASSWORD=
|
||||||
|
REDIS_CACHE_LIVE_SECONDS=7200
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from radicale.__main__ import run
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(run())
|
||||||
|
|
@ -71,6 +71,11 @@ def xml_propfind(base_prefix: str, path: str,
|
||||||
multistatus.append(xml_propfind_response(
|
multistatus.append(xml_propfind_response(
|
||||||
base_prefix, path, item, props, user, encoding, write=write,
|
base_prefix, path, item, props, user, encoding, write=write,
|
||||||
allprop=allprop, propname=propname))
|
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
|
return multistatus
|
||||||
|
|
||||||
|
|
@ -282,10 +287,7 @@ def xml_propfind_response(
|
||||||
else:
|
else:
|
||||||
is404 = True
|
is404 = True
|
||||||
elif tag == xmlutils.make_clark("D:sync-token"):
|
elif tag == xmlutils.make_clark("D:sync-token"):
|
||||||
if is_leaf:
|
element.text = "1213"
|
||||||
element.text, _ = collection.sync()
|
|
||||||
else:
|
|
||||||
is404 = True
|
|
||||||
else:
|
else:
|
||||||
human_tag = xmlutils.make_human_tag(tag)
|
human_tag = xmlutils.make_human_tag(tag)
|
||||||
tag_text = collection.get_meta(human_tag)
|
tag_text = collection.get_meta(human_tag)
|
||||||
|
|
@ -323,7 +325,7 @@ def xml_propfind_response(
|
||||||
class ApplicationPartPropfind(ApplicationBase):
|
class ApplicationPartPropfind(ApplicationBase):
|
||||||
|
|
||||||
def _collect_allowed_items(
|
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]]:
|
) -> Iterator[Tuple[types.CollectionOrItem, str]]:
|
||||||
"""Get items from request that user is allowed to access."""
|
"""Get items from request that user is allowed to access."""
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|
@ -352,6 +354,10 @@ class ApplicationPartPropfind(ApplicationBase):
|
||||||
else:
|
else:
|
||||||
permission = ""
|
permission = ""
|
||||||
status = "NO"
|
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(
|
logger.debug(
|
||||||
"%s has %s access to %s",
|
"%s has %s access to %s",
|
||||||
repr(user) if user else "anonymous user", status, target)
|
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,
|
def do_PROPFIND(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||||
path: str, user: str) -> types.WSGIResponse:
|
path: str, user: str) -> types.WSGIResponse:
|
||||||
"""Manage PROPFIND request."""
|
"""Manage PROPFIND request."""
|
||||||
access = Access(self._rights, user, path)
|
if path not in ("/", "", "/touchin/", "/touchin"):
|
||||||
if not access.check("r"):
|
attributes = path.split("/")
|
||||||
return httputils.NOT_ALLOWED
|
attributes[1] = "touchin"
|
||||||
|
path = "/".join(attributes)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
xml_content = self._read_xml_request_body(environ)
|
xml_content = self._read_xml_request_body(environ)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
|
@ -380,11 +388,11 @@ class ApplicationPartPropfind(ApplicationBase):
|
||||||
item = next(items_iter, None)
|
item = next(items_iter, None)
|
||||||
if not item:
|
if not item:
|
||||||
return httputils.NOT_FOUND
|
return httputils.NOT_FOUND
|
||||||
if not access.check("r", item):
|
# if access and not access.check("r", item):
|
||||||
return httputils.NOT_ALLOWED
|
# return httputils.NOT_ALLOWED
|
||||||
# put item back
|
# put item back
|
||||||
items_iter = itertools.chain([item], items_iter)
|
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,
|
headers = {"DAV": httputils.DAV_HEADERS,
|
||||||
"Content-Type": "text/xml; charset=%s" % self._encoding}
|
"Content-Type": "text/xml; charset=%s" % self._encoding}
|
||||||
xml_answer = xml_propfind(base_prefix, path, xml_content,
|
xml_answer = xml_propfind(base_prefix, path, xml_content,
|
||||||
|
|
|
||||||
|
|
@ -45,75 +45,20 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element],
|
||||||
if xml_request is None:
|
if xml_request is None:
|
||||||
return client.MULTI_STATUS, multistatus
|
return client.MULTI_STATUS, multistatus
|
||||||
root = xml_request
|
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"))
|
prop_element = root.find(xmlutils.make_clark("D:prop"))
|
||||||
props = ([prop.tag for prop in prop_element]
|
props = ([prop.tag for prop in prop_element]
|
||||||
if prop_element is not None else [])
|
if prop_element is not None else [])
|
||||||
|
|
||||||
hreferences: Iterable[str]
|
hreferences: Iterable[str]
|
||||||
if root.tag in (
|
if root.tag == xmlutils.make_clark("D:sync-collection"):
|
||||||
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)
|
|
||||||
# Append current sync token to response
|
# Append current sync token to response
|
||||||
sync_token_element = ET.Element(xmlutils.make_clark("D:sync-token"))
|
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)
|
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.
|
# Retrieve everything required for finishing the request.
|
||||||
retrieved_items = list(retrieve_items(
|
retrieved_items = list(retrieve_items(collection))
|
||||||
base_prefix, path, collection, hreferences, filters, multistatus))
|
|
||||||
collection_tag = collection.tag
|
collection_tag = collection.tag
|
||||||
# !!! Don't access storage after this !!!
|
# !!! Don't access storage after this !!!
|
||||||
unlock_storage_fn()
|
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
|
# Don't keep reference to ``item``, because VObject requires a lot of
|
||||||
# memory.
|
# memory.
|
||||||
item, filters_matched = retrieved_items.pop(0)
|
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 = []
|
found_props = []
|
||||||
not_found_props = []
|
not_found_props = []
|
||||||
|
|
@ -194,47 +128,12 @@ def xml_item_response(base_prefix: str, href: str,
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
|
||||||
def retrieve_items(
|
def retrieve_items( collection: storage.BaseCollection) -> Iterator[Tuple[radicale_item.Item, bool]]:
|
||||||
base_prefix: str, path: str, collection: storage.BaseCollection,
|
|
||||||
hreferences: Iterable[str], filters: Sequence[ET.Element],
|
|
||||||
multistatus: ET.Element) -> Iterator[Tuple[radicale_item.Item, bool]]:
|
|
||||||
"""Retrieves all items that are referenced in ``hreferences`` from
|
"""Retrieves all items that are referenced in ``hreferences`` from
|
||||||
``collection`` and adds 404 responses for missing and invalid items
|
``collection`` and adds 404 responses for missing and invalid items
|
||||||
to ``multistatus``."""
|
to ``multistatus``."""
|
||||||
collection_requested = False
|
for item in collection.get_all():
|
||||||
|
yield item, True
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def test_filter(collection_tag: str, item: radicale_item.Item,
|
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,
|
def do_REPORT(self, environ: types.WSGIEnviron, base_prefix: str,
|
||||||
path: str, user: str) -> types.WSGIResponse:
|
path: str, user: str) -> types.WSGIResponse:
|
||||||
"""Manage REPORT request."""
|
"""Manage REPORT request."""
|
||||||
access = Access(self._rights, user, path)
|
|
||||||
if not access.check("r"):
|
|
||||||
return httputils.NOT_ALLOWED
|
|
||||||
try:
|
try:
|
||||||
xml_content = self._read_xml_request_body(environ)
|
xml_content = self._read_xml_request_body(environ)
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
|
|
@ -288,8 +184,6 @@ class ApplicationPartReport(ApplicationBase):
|
||||||
item = next(iter(self._storage.discover(path)), None)
|
item = next(iter(self._storage.discover(path)), None)
|
||||||
if not item:
|
if not item:
|
||||||
return httputils.NOT_FOUND
|
return httputils.NOT_FOUND
|
||||||
if not access.check("r", item):
|
|
||||||
return httputils.NOT_ALLOWED
|
|
||||||
if isinstance(item, storage.BaseCollection):
|
if isinstance(item, storage.BaseCollection):
|
||||||
collection = item
|
collection = item
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
@ -22,7 +22,7 @@ Storage backend that stores data in the file system.
|
||||||
Uses one folder per collection and one file per collection entry.
|
Uses one folder per collection and one file per collection entry.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import ClassVar, Iterator, Optional, Type
|
from typing import ClassVar, Iterator, Optional, Type
|
||||||
|
|
@ -63,14 +63,7 @@ class Collection(
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def last_modified(self) -> str:
|
def last_modified(self) -> str:
|
||||||
def relevant_files_iter() -> Iterator[str]:
|
return datetime.datetime.now().strftime("%a, %d %b %Y %H:%M:%S GMT")
|
||||||
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))
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def etag(self) -> str:
|
def etag(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -39,66 +39,26 @@ class StoragePartDiscover(StorageBase):
|
||||||
Callable[[str, Optional[str]], ContextManager[None]]] = None
|
Callable[[str, Optional[str]], ContextManager[None]]] = None
|
||||||
) -> Iterator[types.CollectionOrItem]:
|
) -> Iterator[types.CollectionOrItem]:
|
||||||
# assert isinstance(self, multifilesystem.Storage)
|
# 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()
|
collection = multifilesystem.Collection(
|
||||||
# Create the root collection
|
storage_=cast(multifilesystem.Storage, self),
|
||||||
self._makedirs_synced(folder)
|
path="/default/",
|
||||||
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
|
|
||||||
|
|
||||||
yield collection
|
yield collection
|
||||||
|
|
||||||
if depth == "0":
|
if depth == "0":
|
||||||
return
|
return
|
||||||
|
|
||||||
for href in collection._list():
|
address_book = multifilesystem.Collection(
|
||||||
with child_context_manager(sane_path, href):
|
storage_=cast(multifilesystem.Storage, self),
|
||||||
item = collection._get(href)
|
path="/default/address_book.vcf",
|
||||||
if item is not None:
|
)
|
||||||
yield item
|
address_book.set_meta(
|
||||||
|
{
|
||||||
|
"D:displayname": os.getenv("ADDRESS_BOOK_NAME", "default"),
|
||||||
|
"tag": "VADDRESSBOOK"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
for entry in os.scandir(filesystem_path):
|
yield address_book
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,12 @@
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from dateutil.parser import parse
|
||||||
|
|
||||||
from typing import Iterable, Iterator, Optional, Tuple
|
from typing import Iterable, Iterator, Optional, Tuple
|
||||||
|
|
||||||
|
from radicale.ldap import LdapService
|
||||||
import radicale.item as radicale_item
|
import radicale.item as radicale_item
|
||||||
from radicale import pathutils
|
from radicale import pathutils
|
||||||
from radicale.log import logger
|
from radicale.log import logger
|
||||||
|
|
@ -32,7 +36,6 @@ from radicale.storage.multifilesystem.lock import CollectionPartLock
|
||||||
|
|
||||||
class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
||||||
CollectionBase):
|
CollectionBase):
|
||||||
|
|
||||||
_item_cache_cleaned: bool
|
_item_cache_cleaned: bool
|
||||||
|
|
||||||
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
||||||
|
|
@ -140,9 +143,41 @@ class CollectionPartGet(CollectionPartCache, CollectionPartLock,
|
||||||
yield (href, self._get(href, verify_href=False))
|
yield (href, self._get(href, verify_href=False))
|
||||||
|
|
||||||
def get_all(self) -> Iterator[radicale_item.Item]:
|
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
|
for ldap_user in LdapService().sync():
|
||||||
# are from os.listdir.
|
if not ldap_user.ruSn and not ldap_user.ruGivenName:
|
||||||
item = self._get(href, verify_href=False)
|
continue
|
||||||
if item is not None:
|
|
||||||
yield item
|
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
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ class CollectionPartMeta(CollectionBase):
|
||||||
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
def __init__(self, storage_: "multifilesystem.Storage", path: str,
|
||||||
filesystem_path: Optional[str] = None) -> None:
|
filesystem_path: Optional[str] = None) -> None:
|
||||||
super().__init__(storage_, path, filesystem_path)
|
super().__init__(storage_, path, filesystem_path)
|
||||||
self._meta_cache = None
|
self._meta_cache = {}
|
||||||
self._props_path = os.path.join(
|
self._props_path = os.path.join(
|
||||||
self._filesystem_path, ".Radicale.props")
|
self._filesystem_path, ".Radicale.props")
|
||||||
|
|
||||||
|
|
@ -45,22 +45,8 @@ class CollectionPartMeta(CollectionBase):
|
||||||
|
|
||||||
def get_meta(self, key: Optional[str] = None) -> Union[Mapping[str, str],
|
def get_meta(self, key: Optional[str] = None) -> Union[Mapping[str, str],
|
||||||
Optional[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)
|
return self._meta_cache if key is None else self._meta_cache.get(key)
|
||||||
|
|
||||||
def set_meta(self, props: Mapping[str, str]) -> None:
|
def set_meta(self, props: Mapping[str, str]) -> None:
|
||||||
with self._atomic_write(self._props_path, "w") as fo:
|
self._meta_cache = dict(self._meta_cache, **props)
|
||||||
f = cast(TextIO, fo)
|
|
||||||
json.dump(props, f, sort_keys=True)
|
|
||||||
|
|
|
||||||
|
|
@ -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/
|
||||||
|
|
@ -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
|
||||||
|
```
|
||||||
|
|
@ -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 ""
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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"])
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
56
setup.cfg
56
setup.cfg
|
|
@ -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__.:
|
|
||||||
73
setup.py
73
setup.py
|
|
@ -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"])
|
|
||||||
Loading…
Reference in New Issue