diff --git a/CHANGELOG.md b/CHANGELOG.md index 620073c1..3a1d896e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 3.4.2.dev + +* Add: option [auth] type oauth2 by code migration from https://gitlab.mim-libre.fr/alphabet/radicale_oauth/-/blob/dev/oauth2/ + ## 3.4.1 * Add: option [auth] dovecot_connection_type / dovecot_host / dovecot_port * Add: option [auth] type imap by code migration from https://github.com/Unrud/RadicaleIMAP/ diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 84b3a0cf..c2e586ef 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -824,7 +824,10 @@ Available backends: : Use a Dovecot server to authenticate users. `imap` -: Use a IMAP server to authenticate users. +: Use an IMAP server to authenticate users. + +`oauth2` +: Use an OAuth2 server to authenticate users. Default: `none` @@ -1019,6 +1022,12 @@ Secure the IMAP connection: tls | starttls | none Default: `tls` +##### oauth2_token_endpoint + +OAuth2 token endpoint URL + +Default: + ##### lc_username Сonvert username to lowercase, must be true for case-insensitive auth diff --git a/config b/config index c775a3c1..ba53ef3e 100644 --- a/config +++ b/config @@ -125,6 +125,9 @@ # Value: tls | starttls | none #imap_security = tls +# OAuth2 token endpoint URL +#oauth2_token_endpoint = + # Htpasswd filename #htpasswd_filename = /etc/radicale/users diff --git a/pyproject.toml b/pyproject.toml index 5784971a..eac75049 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "Radicale" # When the version is updated, a new section in the CHANGELOG.md file must be # added too. readme = "README.md" -version = "3.4.1" +version = "3.4.2.dev" authors = [{name = "Guillaume Ayoub", email = "guillaume.ayoub@kozea.fr"}, {name = "Unrud", email = "unrud@outlook.com"}, {name = "Peter Bieringer", email = "pb@bieringer.de"}] license = {text = "GNU GPL v3"} description = "CalDAV and CardDAV Server" @@ -72,7 +72,7 @@ skip_install = true [tool.tox.env.mypy] deps = ["mypy==1.11.0"] -commands = [["mypy", "."]] +commands = [["mypy", "--install-types", "--non-interactive", "."]] skip_install = true diff --git a/radicale/auth/__init__.py b/radicale/auth/__init__.py index 71854e2a..e92272f8 100644 --- a/radicale/auth/__init__.py +++ b/radicale/auth/__init__.py @@ -42,6 +42,7 @@ INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user", "htpasswd", "ldap", "imap", + "oauth2", "dovecot") CACHE_LOGIN_TYPES: Sequence[str] = ( @@ -49,6 +50,7 @@ CACHE_LOGIN_TYPES: Sequence[str] = ( "ldap", "htpasswd", "imap", + "oauth2", ) AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6") diff --git a/radicale/auth/oauth2.py b/radicale/auth/oauth2.py new file mode 100644 index 00000000..838a786e --- /dev/null +++ b/radicale/auth/oauth2.py @@ -0,0 +1,66 @@ +# This file is part of Radicale Server - Calendar Server +# +# Original from https://gitlab.mim-libre.fr/alphabet/radicale_oauth/ +# Copyright © 2021-2022 Bruno Boiget +# Copyright © 2022-2022 Daniel Dehennin +# +# Since migration into upstream +# Copyright © 2025-2025 Peter Bieringer +# +# 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 . + +""" +Authentication backend that checks credentials against an oauth2 server auth endpoint +""" + +import requests + +from radicale import auth +from radicale.log import logger + + +class Auth(auth.BaseAuth): + def __init__(self, configuration): + super().__init__(configuration) + self._endpoint = configuration.get("auth", "oauth2_token_endpoint") + if not self._endpoint: + logger.error("auth.oauth2_token_endpoint URL missing") + raise RuntimeError("OAuth2 token endpoint URL is required") + logger.info("auth OAuth2 token endpoint: %s" % (self._endpoint)) + + def _login(self, login, password): + """Validate credentials. + Sends login credentials to oauth token endpoint and checks that a token is returned + """ + try: + # authenticate to authentication endpoint and return login if ok, else "" + req_params = { + "username": login, + "password": password, + "grant_type": "password", + "client_id": "radicale", + } + req_headers = {"Content-Type": "application/x-www-form-urlencoded"} + response = requests.post( + self._endpoint, data=req_params, headers=req_headers + ) + if ( + response.status_code == requests.codes.ok + and "access_token" in response.json() + ): + return login + except OSError as e: + logger.critical("Failed to authenticate against OAuth2 server %s: %s" % (self._endpoint, e)) + logger.warning("User failed to authenticate using OAuth2: %r" % login) + return "" diff --git a/radicale/config.py b/radicale/config.py index 9b4e9af4..5f46022e 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -307,6 +307,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "tls", "help": "Secure the IMAP connection: *tls*|starttls|none", "type": imap_security}), + ("oauth2_token_endpoint", { + "value": "", + "help": "OAuth2 token endpoint URL", + "type": str}), ("strip_domain", { "value": "False", "help": "strip domain from username", diff --git a/setup.cfg.legacy b/setup.cfg.legacy index 94a39915..e27241b4 100644 --- a/setup.cfg.legacy +++ b/setup.cfg.legacy @@ -24,7 +24,7 @@ skip_install = True [testenv:mypy] deps = mypy==1.11.0 -commands = mypy . +commands = mypy --install-types --non-interactive . skip_install = True [tool:isort] diff --git a/setup.py.legacy b/setup.py.legacy index 547f9dda..09d323a9 100644 --- a/setup.py.legacy +++ b/setup.py.legacy @@ -20,7 +20,7 @@ from setuptools import find_packages, setup # When the version is updated, a new section in the CHANGELOG.md file must be # added too. -VERSION = "3.4.1" +VERSION = "3.4.2.dev" with open("README.md", encoding="utf-8") as f: long_description = f.read()