Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial support for Radicale 3 #13

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# What is this?
This is an authentication plugin for Radicale 2. It adds an LDAP authentication backend which can be used for authenticating users against an LDAP server.
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.

Use the `2-final` git tag for Radicale 2 support.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should reference the 2 branch as there has been an unexpected additional contribution to the Radicale 2 plugin since the creation of the 2-final tag :) Maybe there are more to come


# How to configure
You will need to set a few options inside your radicale config file. Example:
Expand Down
182 changes: 115 additions & 67 deletions radicale_auth_ldap/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
# Copyright © 2011-2013 Guillaume Ayoub
# Copyright © 2015 Raoul Thill
# Copyright © 2017 Marco Huenseler
# Copyright © 2020 Johannes Zellner
#
# 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
Expand All @@ -25,79 +26,126 @@
(https://github.com/cannatag/ldap3/).
"""


import ldap3
import ldap3.core.exceptions

from radicale.auth import BaseAuth
from radicale.log import logger

import radicale_auth_ldap.ldap3imports
PLUGIN_CONFIG_SCHEMA = {
"auth": {
"password": {
"value": "",
"type": str
},
"ldap_url": {
"value": "ldap://localhost:389",
"help": "LDAP server URL, with protocol and port",
"type": str
},
"ldap_base": {
"value": "ou=users,dc=example",
"help": "LDAP base DN for users",
"type": str
},
"ldap_filter": {
"value": "(&(objectclass=user)(username=%username))",
"help": "LDAP search filter to find login user",
"type": str
},
"ldap_attribute": {
"value": "username",
"help": "LDAP attribute to uniquely identify the user",
"type": str
},
"ldap_binddn": {
"value": "",
"help": "LDAP dn used if server does not allow anonymous search",
"type": str
},
"ldap_password": {
"value": "",
"help": "LDAP password used with ldap_binddn",
"type": str
}
}
}


class Auth(BaseAuth):
def is_authenticated(self, user, password):
"""Check if ``user``/``password`` couple is valid."""
SERVER = ldap3.Server(self.configuration.get("auth", "ldap_url"))
BASE = self.configuration.get("auth", "ldap_base")
ATTRIBUTE = self.configuration.get("auth", "ldap_attribute")
FILTER = self.configuration.get("auth", "ldap_filter")
BINDDN = self.configuration.get("auth", "ldap_binddn")
PASSWORD = self.configuration.get("auth", "ldap_password")
SCOPE = self.configuration.get("auth", "ldap_scope")
SUPPORT_EXTENDED = self.configuration.getboolean("auth", "ldap_support_extended", fallback=True)

if BINDDN and PASSWORD:
conn = ldap3.Connection(SERVER, BINDDN, PASSWORD)
else:
conn = ldap3.Connection(SERVER)
ldap_url = ""
ldap_base = ""
ldap_filter = ""
ldap_attribute = ""
ldap_binddn = ""
ldap_password = ""

def __init__(self, configuration):
super().__init__(configuration.copy(PLUGIN_CONFIG_SCHEMA))

options = configuration.options("auth")

if "ldap_url" not in options: raise RuntimeError("The ldap_url configuration for ldap auth is required.")
if "ldap_base" not in options: raise RuntimeError("The ldap_base configuration for ldap auth is required.")
if "ldap_filter" not in options: raise RuntimeError("The ldap_filter configuration for ldap auth is required.")
if "ldap_attribute" not in options: raise RuntimeError("The ldap_attribute configuration for ldap auth is required.")
if "ldap_binddn" not in options: raise RuntimeError("The ldap_binddn configuration for ldap auth is required.")
if "ldap_password" not in options: raise RuntimeError("The ldap_password configuration for ldap auth is required.")

# also get rid of trailing slashes which are typical for uris
self.ldap_url = configuration.get("auth", "ldap_url").rstrip("/")
self.ldap_base = configuration.get("auth", "ldap_base")
self.ldap_filter = configuration.get("auth", "ldap_filter")
self.ldap_attribute = configuration.get("auth", "ldap_attribute")
self.ldap_binddn = configuration.get("auth", "ldap_binddn")
self.ldap_password = configuration.get("auth", "ldap_password")

logger.info("LDAP auth configuration:")
logger.info(" %r is %r", "ldap_url", self.ldap_url)
logger.info(" %r is %r", "ldap_base", self.ldap_base)
logger.info(" %r is %r", "ldap_filter", self.ldap_filter)
logger.info(" %r is %r", "ldap_attribute", self.ldap_attribute)
logger.info(" %r is %r", "ldap_binddn", self.ldap_binddn)
logger.info(" %r is %r", "ldap_password", self.ldap_password)

def login(self, login, password):
if login == "" or password == "":
return ""

server = ldap3.Server(self.ldap_url, get_info=ldap3.ALL)
conn = ldap3.Connection(server=server, user=self.ldap_binddn,
password=self.ldap_password, check_names=True,
lazy=False, raise_exceptions=False)
conn.open()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this actually needed? The old code didn't open the connection and worked just fine. I just had a quick look at ldap3's connection.py and it seems like open is called automatically whenever the connection isn't open when it should be. I don't know a lot about ldap and ldap3 though, so I could be wrong! Could you check whether we need this?

conn.bind()

try:
self.logger.debug("LDAP whoami: %s" % conn.extend.standard.who_am_i())
except Exception as err:
self.logger.debug("LDAP error: %s" % err)

distinguished_name = "%s=%s" % (ATTRIBUTE, ldap3imports.escape_attribute_value(user))
self.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
self.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]
self.logger.debug("LDAP user %s (%s) found" % (uid, user_dn))
try:
conn = ldap3.Connection(SERVER, user_dn, password)
conn.bind()
self.logger.debug(conn.result)
if SUPPORT_EXTENDED:
whoami = conn.extend.standard.who_am_i()
self.logger.debug("LDAP whoami: %s" % whoami)
else:
self.logger.debug("LDAP skip extended: call whoami")
whoami = conn.result['result'] == 0
if whoami:
self.logger.debug("LDAP bind OK")
return True
else:
self.logger.debug("LDAP bind failed")
return False
except ldap3.core.exceptions.LDAPInvalidCredentialsResult:
self.logger.debug("LDAP invalid credentials")
except Exception as err:
self.logger.debug("LDAP error %s" % err)
return False
else:
self.logger.debug("LDAP user %s not found" % user)
return False
if conn.result["result"] != 0:
logger.error(conn.result)
return ""

final_search_filter = self.ldap_filter.replace("%username", login)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I understand it, login is user-specified, right (I don't know a lot about radicale 3's plugin interface either 😆 )? In case my assumption is right, this looks very dangerous. I'm not so sure about escaping ldap-queries but I'm almost sure submitting user-controlled content directly inside the query string is a bad idea. Could you have a look at #4 ? According to ldap3's documentation it seems like escape_filter_chars() from ldap3.utils.conv would be the right tool for this.

conn.search(search_base=self.ldap_base,
search_filter=final_search_filter,
attributes=ldap3.ALL_ATTRIBUTES)

if conn.result["result"] != 0:
logger.error(conn.result)
return ""

if len(conn.response) == 0:
return ""

final_user_dn = conn.response[0]["dn"]
conn.unbind()

# new connection to check the password as we cannot rebind here
conn = ldap3.Connection(server=server, user=final_user_dn,
password=password, check_names=True,
lazy=False, raise_exceptions=False)
conn.open()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

conn.bind()

if conn.result["result"] != 0:
logger.error(conn.result)
return ""

return login
8 changes: 4 additions & 4 deletions setup.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
#!/usr/bin/env python3

from setuptools import setup
from distutils.core import setup

setup(
name="radicale-auth-ldap",
version="0.1",
description="LDAP Authentication Plugin for Radicale 2",
version="0.2",
description="LDAP Authentication Plugin for Radicale 3",
author="Raoul Thill",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case you wrote this all by yourself feel free to take the credits ;-)

license="GNU GPL v3",
install_requires=["radicale >= 2.0", "ldap3 >= 2.3"],
install_requires=["radicale >= 3.0", "ldap3 >= 2.3"],
packages=["radicale_auth_ldap"])