dovecot.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2014 Giel van Schijndel
  3. # Copyright © 2019 (GalaxyMaster)
  4. # Copyright © 2025-2025 Peter Bieringer <pb@bieringer.de>
  5. #
  6. # This library is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  18. import base64
  19. import itertools
  20. import os
  21. import re
  22. import socket
  23. from contextlib import closing
  24. from radicale import auth
  25. from radicale.log import logger
  26. class Auth(auth.BaseAuth):
  27. def __init__(self, configuration):
  28. super().__init__(configuration)
  29. self.timeout = 5
  30. self.request_id_gen = itertools.count(1)
  31. remote_ip_source = configuration.get("auth", "remote_ip_source")
  32. self.use_x_remote_addr = remote_ip_source == 'X-Remote-Addr'
  33. config_family = configuration.get("auth", "dovecot_connection_type")
  34. if config_family == "AF_UNIX":
  35. self.family = socket.AF_UNIX
  36. self.address = configuration.get("auth", "dovecot_socket")
  37. logger.info("auth dovecot socket: %r", self.address)
  38. return
  39. self.address = configuration.get("auth", "dovecot_host"), configuration.get("auth", "dovecot_port")
  40. logger.warning("auth dovecot address: %r (INSECURE, credentials are transmitted in clear text)", self.address)
  41. if config_family == "AF_INET":
  42. self.family = socket.AF_INET
  43. else:
  44. self.family = socket.AF_INET6
  45. def _login_ext(self, login, password, context):
  46. """Validate credentials.
  47. Check if the ``login``/``password`` pair is valid according to Dovecot.
  48. This implementation communicates with a Dovecot server through the
  49. Dovecot Authentication Protocol v1.1.
  50. https://dovecot.org/doc/auth-protocol.txt
  51. """
  52. logger.info("Authentication request (dovecot): '{}'".format(login))
  53. if not login or not password:
  54. return ""
  55. with closing(socket.socket(
  56. self.family,
  57. socket.SOCK_STREAM)
  58. ) as sock:
  59. try:
  60. sock.settimeout(self.timeout)
  61. sock.connect(self.address)
  62. buf = bytes()
  63. supported_mechs = []
  64. done = False
  65. seen_part = [0, 0, 0]
  66. # Upon the initial connection we only care about the
  67. # handshake, which is usually just around 100 bytes long,
  68. # e.g.
  69. #
  70. # VERSION 1 2
  71. # MECH PLAIN plaintext
  72. # SPID 22901
  73. # CUID 1
  74. # COOKIE 2dbe4116a30fb4b8a8719f4448420af7
  75. # DONE
  76. #
  77. # Hence, we try to read just once with a buffer big
  78. # enough to hold all of it.
  79. buf = sock.recv(1024)
  80. while b'\n' in buf and not done:
  81. line, buf = buf.split(b'\n', 1)
  82. parts = line.split(b'\t')
  83. first, parts = parts[0], parts[1:]
  84. if first == b'VERSION':
  85. if seen_part[0]:
  86. logger.warning(
  87. "Server presented multiple VERSION "
  88. "tokens, ignoring"
  89. )
  90. continue
  91. version = parts
  92. logger.debug("Dovecot server version: '{}'".format(
  93. (b'.'.join(version)).decode()
  94. ))
  95. if int(version[0]) != 1:
  96. logger.fatal(
  97. "Only Dovecot 1.x versions are supported!"
  98. )
  99. return ""
  100. seen_part[0] += 1
  101. elif first == b'MECH':
  102. supported_mechs.append(parts[0])
  103. seen_part[1] += 1
  104. elif first == b'DONE':
  105. seen_part[2] += 1
  106. if not (seen_part[0] and seen_part[1]):
  107. logger.fatal(
  108. "An unexpected end of the server "
  109. "handshake received!"
  110. )
  111. return ""
  112. done = True
  113. if not done:
  114. logger.fatal("Encountered a broken server handshake!")
  115. return ""
  116. logger.debug(
  117. "Supported auth methods: '{}'"
  118. .format((b"', '".join(supported_mechs)).decode())
  119. )
  120. if b'PLAIN' not in supported_mechs:
  121. logger.info(
  122. "Authentication method 'PLAIN' is not supported, "
  123. "but is required!"
  124. )
  125. return ""
  126. # Handshake
  127. logger.debug("Sending auth handshake")
  128. sock.send(b'VERSION\t1\t1\n')
  129. sock.send(b'CPID\t%u\n' % os.getpid())
  130. request_id = next(self.request_id_gen)
  131. logger.debug(
  132. "Authenticating with request id: '{}'"
  133. .format(request_id)
  134. )
  135. rip = b''
  136. if self.use_x_remote_addr and context.x_remote_addr:
  137. rip = context.x_remote_addr.encode('ascii')
  138. elif context.remote_addr:
  139. rip = context.remote_addr.encode('ascii')
  140. # squash all whitespace - shouldn't be there and auth protocol
  141. # is sensitive to whitespace (in particular \t and \n)
  142. if rip:
  143. rip = b'\trip=' + re.sub(br'\s', b'', rip)
  144. sock.send(
  145. b'AUTH\t%u\tPLAIN\tservice=radicale%s\tresp=%b\n' %
  146. (
  147. request_id, rip, base64.b64encode(
  148. b'\0%b\0%b' %
  149. (login.encode(), password.encode())
  150. )
  151. )
  152. )
  153. logger.debug("Processing auth response")
  154. buf = sock.recv(1024)
  155. line = buf.split(b'\n', 1)[0]
  156. parts = line.split(b'\t')[:2]
  157. resp, reply_id, params = (
  158. parts[0], int(parts[1]),
  159. dict(part.split('=', 1) for part in parts[2:])
  160. )
  161. logger.debug(
  162. "Auth response: result='{}', id='{}', parameters={}"
  163. .format(resp.decode(), reply_id, params)
  164. )
  165. if request_id != reply_id:
  166. logger.fatal(
  167. "Unexpected reply ID {} received (expected {})"
  168. .format(
  169. reply_id, request_id
  170. )
  171. )
  172. return ""
  173. if resp == b'OK':
  174. return login
  175. except socket.error as e:
  176. logger.fatal(
  177. "Failed to communicate with Dovecot: %s" %
  178. (e)
  179. )
  180. return ""