Skip to content

Commit

Permalink
Merge pull request #1610 from sysnux/master
Browse files Browse the repository at this point in the history
Rebase galaxy4public patch on top of bf4f583
  • Loading branch information
pbiering authored Oct 31, 2024
2 parents bf4f583 + a1b8c65 commit 56c375f
Show file tree
Hide file tree
Showing 5 changed files with 306 additions and 1 deletion.
9 changes: 9 additions & 0 deletions DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -746,6 +746,9 @@ Available backends:
`ldap`
: Use a LDAP or AD server to authenticate users.

`dovecot`
: Use a local Dovecot server to authenticate users.

Default: `none`

##### htpasswd_filename
Expand Down Expand Up @@ -858,6 +861,12 @@ The path to the CA file in pem format which is used to certificate the server ce

Default:

##### dovecot_socket

The path to the Dovecot client authentication socket (eg. /run/dovecot/auth-client on Fedora). Radicale must have read / write access to the socket.

Default:

##### lc_username

Сonvert username to lowercase, must be true for case-insensitive auth
Expand Down
3 changes: 2 additions & 1 deletion radicale/auth/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
"denyall",
"htpasswd",
"ldap")
"ldap",
"dovecot")


def load(configuration: "config.Configuration") -> "BaseAuth":
Expand Down
178 changes: 178 additions & 0 deletions radicale/auth/dovecot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# This file is part of Radicale Server - Calendar Server
# Copyright © 2014 Giel van Schijndel
# Copyright © 2019 (GalaxyMaster)
#
# 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/>.

import base64
import itertools
import os
import socket
from contextlib import closing

from radicale import auth
from radicale.log import logger


class Auth(auth.BaseAuth):
def __init__(self, configuration):
super().__init__(configuration)
self.socket = configuration.get("auth", "dovecot_socket")
self.timeout = 5
self.request_id_gen = itertools.count(1)

def login(self, login, password):
"""Validate credentials.
Check if the ``login``/``password`` pair is valid according to Dovecot.
This implementation communicates with a Dovecot server through the
Dovecot Authentication Protocol v1.1.
https://dovecot.org/doc/auth-protocol.txt
"""

logger.info("Authentication request (dovecot): '{}'".format(login))
if not login or not password:
return ""

with closing(socket.socket(
socket.AF_UNIX,
socket.SOCK_STREAM)
) as sock:
try:
sock.settimeout(self.timeout)
sock.connect(self.socket)

buf = bytes()
supported_mechs = []
done = False
seen_part = [0, 0, 0]
# Upon the initial connection we only care about the
# handshake, which is usually just around 100 bytes long,
# e.g.
#
# VERSION 1 2
# MECH PLAIN plaintext
# SPID 22901
# CUID 1
# COOKIE 2dbe4116a30fb4b8a8719f4448420af7
# DONE
#
# Hence, we try to read just once with a buffer big
# enough to hold all of it.
buf = sock.recv(1024)
while b'\n' in buf and not done:
line, buf = buf.split(b'\n', 1)
parts = line.split(b'\t')
first, parts = parts[0], parts[1:]

if first == b'VERSION':
if seen_part[0]:
logger.warning(
"Server presented multiple VERSION "
"tokens, ignoring"
)
continue
version = parts
logger.debug("Dovecot server version: '{}'".format(
(b'.'.join(version)).decode()
))
if int(version[0]) != 1:
logger.fatal(
"Only Dovecot 1.x versions are supported!"
)
return ""
seen_part[0] += 1
elif first == b'MECH':
supported_mechs.append(parts[0])
seen_part[1] += 1
elif first == b'DONE':
seen_part[2] += 1
if not (seen_part[0] and seen_part[1]):
logger.fatal(
"An unexpected end of the server "
"handshake received!"
)
return ""
done = True

if not done:
logger.fatal("Encountered a broken server handshake!")
return ""

logger.debug(
"Supported auth methods: '{}'"
.format((b"', '".join(supported_mechs)).decode())
)
if b'PLAIN' not in supported_mechs:
logger.info(
"Authentication method 'PLAIN' is not supported, "
"but is required!"
)
return ""

# Handshake
logger.debug("Sending auth handshake")
sock.send(b'VERSION\t1\t1\n')
sock.send(b'CPID\t%u\n' % os.getpid())

request_id = next(self.request_id_gen)
logger.debug(
"Authenticating with request id: '{}'"
.format(request_id)
)
sock.send(
b'AUTH\t%u\tPLAIN\tservice=radicale\tresp=%b\n' %
(
request_id, base64.b64encode(
b'\0%b\0%b' %
(login.encode(), password.encode())
)
)
)

logger.debug("Processing auth response")
buf = sock.recv(1024)
line = buf.split(b'\n', 1)[0]
parts = line.split(b'\t')[:2]
resp, reply_id, params = (
parts[0], int(parts[1]),
dict(part.split('=', 1) for part in parts[2:])
)

logger.debug(
"Auth response: result='{}', id='{}', parameters={}"
.format(resp.decode(), reply_id, params)
)
if request_id != reply_id:
logger.fatal(
"Unexpected reply ID {} received (expected {})"
.format(
reply_id, request_id
)
)
return ""

if resp == b'OK':
return login

except socket.error as e:
logger.fatal(
"Failed to communicate with Dovecot socket %r: %s" %
(self.socket, e)
)

return ""
4 changes: 4 additions & 0 deletions radicale/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ def json_str(value: Any) -> dict:
"value": "autodetect",
"help": "htpasswd encryption method",
"type": str}),
("dovecot_socket", {
"value": "/var/run/dovecot/auth-client",
"help": "dovecot auth socket",
"type": str}),
("realm", {
"value": "Radicale - Password Required",
"help": "message displayed when a password is needed",
Expand Down
113 changes: 113 additions & 0 deletions radicale/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"""

import base64
import os
import sys
from typing import Iterable, Tuple, Union
Expand Down Expand Up @@ -159,6 +160,118 @@ def test_http_x_remote_user(self) -> None:
href_element = prop.find(xmlutils.make_clark("D:href"))
assert href_element is not None and href_element.text == "/test/"

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def _test_dovecot(
self, user, password, expected_status,
response=b'FAIL\n1\n', mech=[b'PLAIN'], broken=None):
import socket
from unittest.mock import DEFAULT, patch

self.configure({"auth": {"type": "dovecot",
"dovecot_socket": "./dovecot.sock"}})

if broken is None:
broken = []

handshake = b''
if "version" not in broken:
handshake += b'VERSION\t'
if "incompatible" in broken:
handshake += b'2'
else:
handshake += b'1'
handshake += b'\t2\n'

if "mech" not in broken:
handshake += b'MECH\t%b\n' % b' '.join(mech)

if "duplicate" in broken:
handshake += b'VERSION\t1\t2\n'

if "done" not in broken:
handshake += b'DONE\n'

with patch.multiple(
'socket.socket',
connect=DEFAULT,
send=DEFAULT,
recv=DEFAULT
) as mock_socket:
if "socket" in broken:
mock_socket["connect"].side_effect = socket.error(
"Testing error with the socket"
)
mock_socket["recv"].side_effect = [handshake, response]
status, _, answer = self.request(
"PROPFIND", "/",
HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(
("%s:%s" % (user, password)).encode()).decode())
assert status == expected_status

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_no_user(self):
self._test_dovecot("", "", 401)

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_no_password(self):
self._test_dovecot("user", "", 401)

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_broken_handshake_no_version(self):
self._test_dovecot("user", "password", 401, broken=["version"])

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_broken_handshake_incompatible(self):
self._test_dovecot("user", "password", 401, broken=["incompatible"])

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_broken_handshake_duplicate(self):
self._test_dovecot(
"user", "password", 207, response=b'OK\t1',
broken=["duplicate"]
)

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_broken_handshake_no_mech(self):
self._test_dovecot("user", "password", 401, broken=["mech"])

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_broken_handshake_unsupported_mech(self):
self._test_dovecot("user", "password", 401, mech=[b'ONE', b'TWO'])

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_broken_handshake_no_done(self):
self._test_dovecot("user", "password", 401, broken=["done"])

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_broken_socket(self):
self._test_dovecot("user", "password", 401, broken=["socket"])

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_auth_good1(self):
self._test_dovecot("user", "password", 207, response=b'OK\t1')

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_auth_good2(self):
self._test_dovecot(
"user", "password", 207, response=b'OK\t1',
mech=[b'PLAIN\nEXTRA\tTERM']
)

self._test_dovecot("user", "password", 207, response=b'OK\t1')

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_auth_bad1(self):
self._test_dovecot("user", "password", 401, response=b'FAIL\t1')

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_auth_bad2(self):
self._test_dovecot("user", "password", 401, response=b'CONT\t1')

@pytest.mark.skipif(sys.platform == 'win32', reason="Not supported on Windows")
def test_dovecot_auth_id_mismatch(self):
self._test_dovecot("user", "password", 401, response=b'OK\t2')

def test_custom(self) -> None:
"""Custom authentication."""
self.configure({"auth": {"type": "radicale.tests.custom.auth"}})
Expand Down

0 comments on commit 56c375f

Please sign in to comment.