diff --git a/CHANGELOG.md b/CHANGELOG.md index f0b8cbc8..cb7952a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## 3.4.1.dev * Add: option [auth] dovecot_connection_type / dovecot_host / dovecot_port +* Add: option [auth] type imap by code migration from https://github.com/Unrud/RadicaleIMAP/ ## 3.4.0 * Add: option [auth] cache_logins/cache_successful_logins_expiry/cache_failed_logins for caching logins diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index a68cc2c4..30566c33 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -810,7 +810,10 @@ Available backends: : Use a LDAP or AD server to authenticate users. `dovecot` -: Use a local Dovecot server to authenticate users. +: Use a Dovecot server to authenticate users. + +`imap` +: Use a IMAP server to authenticate users. Default: `none` @@ -993,6 +996,18 @@ Port of via network exposed dovecot socket Default: `12345` +##### imap_host + +IMAP server hostname: address | address:port | [address]:port | imap.server.tld + +Default: `localhost` + +##### imap_security + +Secure the IMAP connection: tls | starttls | none + +Default: `tls` + ##### lc_username Сonvert username to lowercase, must be true for case-insensitive auth diff --git a/config b/config index a0f6cfa7..c775a3c1 100644 --- a/config +++ b/config @@ -117,6 +117,14 @@ # Port of via network exposed dovecot socket #dovecot_port = 12345 +# IMAP server hostname +# Syntax: address | address:port | [address]:port | imap.server.tld +#imap_host = localhost + +# Secure the IMAP connection +# Value: tls | starttls | none +#imap_security = tls + # Htpasswd filename #htpasswd_filename = /etc/radicale/users diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 8bc8ffad..71854e2a 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -41,8 +41,16 @@ "denyall", "htpasswd", "ldap", + "imap", "dovecot") +CACHE_LOGIN_TYPES: Sequence[str] = ( + "dovecot", + "ldap", + "htpasswd", + "imap", + ) + AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6") @@ -97,7 +105,7 @@ def __init__(self, configuration: "config.Configuration") -> None: # cache_successful_logins self._cache_logins = configuration.get("auth", "cache_logins") self._type = configuration.get("auth", "type") - if (self._type in ["dovecot", "ldap", "htpasswd"]) or (self._cache_logins is False): + if (self._type in CACHE_LOGIN_TYPES) or (self._cache_logins is False): logger.info("auth.cache_logins: %s", self._cache_logins) else: logger.info("auth.cache_logins: %s (but not required for type '%s' and disabled therefore)", self._cache_logins, self._type) diff --git a/radicale/auth/imap.py b/radicale/auth/imap.py new file mode 100644 index 00000000..8b3c2972 --- /dev/null +++ b/radicale/auth/imap.py @@ -0,0 +1,70 @@ +# RadicaleIMAP IMAP authentication plugin for Radicale. +# Copyright © 2017, 2020 Unrud +# Copyright © 2025-2025 Peter Bieringer +# +# This program 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 program 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 this program. If not, see . + +import imaplib +import ssl + +from radicale import auth +from radicale.log import logger + + +class Auth(auth.BaseAuth): + """Authenticate user with IMAP.""" + + def __init__(self, configuration) -> None: + super().__init__(configuration) + self._host, self._port = self.configuration.get("auth", "imap_host") + logger.info("auth imap host: %r", self._host) + self._security = self.configuration.get("auth", "imap_security") + if self._security == "none": + logger.info("auth imap security: %s (INSECURE, credentials are transmitted in clear text)", self._security) + else: + logger.info("auth imap security: %s", self._security) + if self._security == "tls": + if self._port is None: + self._port = 993 + logger.info("auth imap port (autoselected): %d", self._port) + else: + logger.info("auth imap port: %d", self._port) + else: + if self._port is None: + self._port = 143 + logger.info("auth imap port (autoselected): %d", self._port) + else: + logger.info("auth imap port: %d", self._port) + + def _login(self, login, password) -> str: + try: + connection: imaplib.IMAP4 | imaplib.IMAP4_SSL + if self._security == "tls": + connection = imaplib.IMAP4_SSL( + host=self._host, port=self._port, + ssl_context=ssl.create_default_context()) + else: + connection = imaplib.IMAP4(host=self._host, port=self._port) + if self._security == "starttls": + connection.starttls(ssl.create_default_context()) + try: + connection.login(login, password) + except imaplib.IMAP4.error as e: + logger.warning("IMAP authentication failed for user %r: %s", login, e, exc_info=False) + return "" + connection.logout() + return login + except (OSError, imaplib.IMAP4.error) as e: + logger.error("Failed to communicate with IMAP server %r: %s" % ("[%s]:%d" % (self._host, self._port), e)) + return "" diff --git a/radicale/config.py b/radicale/config.py index 86970732..9b4e9af4 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -104,6 +104,29 @@ def _convert_to_bool(value: Any) -> bool: return RawConfigParser.BOOLEAN_STATES[value.lower()] +def imap_address(value): + if "]" in value: + pre_address, pre_address_port = value.rsplit("]", 1) + else: + pre_address, pre_address_port = "", value + if ":" in pre_address_port: + pre_address2, port = pre_address_port.rsplit(":", 1) + address = pre_address + pre_address2 + else: + address, port = pre_address + pre_address_port, None + try: + return (address.strip(string.whitespace + "[]"), + None if port is None else int(port)) + except ValueError: + raise ValueError("malformed IMAP address: %r" % value) + + +def imap_security(value): + if value not in ("tls", "starttls", "none"): + raise ValueError("unsupported IMAP security: %r" % value) + return value + + def json_str(value: Any) -> dict: if not value: return {} @@ -276,6 +299,14 @@ def json_str(value: Any) -> dict: "value": "", "help": "The path to the CA file in pem format which is used to certificate the server certificate", "type": str}), + ("imap_host", { + "value": "localhost", + "help": "IMAP server hostname: address|address:port|[address]:port|*localhost*", + "type": imap_address}), + ("imap_security", { + "value": "tls", + "help": "Secure the IMAP connection: *tls*|starttls|none", + "type": imap_security}), ("strip_domain", { "value": "False", "help": "strip domain from username",