__init__.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. # This file is part of Radicale - CalDAV and CardDAV server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2017 Guillaume Ayoub
  5. # Copyright © 2017-2022 Unrud <unrud@outlook.com>
  6. # Copyright © 2024-2025 Peter Bieringer <pb@bieringer.de>
  7. #
  8. # This library is free software: you can redistribute it and/or modify
  9. # it under the terms of the GNU General Public License as published by
  10. # the Free Software Foundation, either version 3 of the License, or
  11. # (at your option) any later version.
  12. #
  13. # This library is distributed in the hope that it will be useful,
  14. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. # GNU General Public License for more details.
  17. #
  18. # You should have received a copy of the GNU General Public License
  19. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  20. """
  21. Authentication module.
  22. Authentication is based on usernames and passwords. If something more
  23. advanced is needed an external WSGI server or reverse proxy can be used
  24. (see ``remote_user``, ``http_remote_user`` or ``http_x_remote_user`` backend).
  25. Take a look at the class ``BaseAuth`` if you want to implement your own.
  26. """
  27. import hashlib
  28. import os
  29. import threading
  30. import time
  31. from typing import List, Sequence, Set, Tuple, Union, final
  32. from urllib.parse import unquote
  33. from radicale import config, types, utils
  34. from radicale.log import logger
  35. INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
  36. "http_remote_user",
  37. "denyall",
  38. "htpasswd",
  39. "ldap",
  40. "imap",
  41. "oauth2",
  42. "pam",
  43. "dovecot")
  44. CACHE_LOGIN_TYPES: Sequence[str] = (
  45. "dovecot",
  46. "ldap",
  47. "htpasswd",
  48. "imap",
  49. "oauth2",
  50. "pam",
  51. )
  52. INSECURE_IF_NO_LOOPBACK_TYPES: Sequence[str] = (
  53. "remote_user",
  54. "http_remote_user",
  55. "http_x_remote_user",
  56. )
  57. AUTH_SOCKET_FAMILY: Sequence[str] = ("AF_UNIX", "AF_INET", "AF_INET6")
  58. REMOTE_ADDR_SOURCE: Sequence[str] = ("REMOTE_ADDR", "X-Remote-Addr")
  59. def load(configuration: "config.Configuration") -> "BaseAuth":
  60. """Load the authentication module chosen in configuration."""
  61. _type = configuration.get("auth", "type")
  62. if _type == "none":
  63. logger.warning("No user authentication is selected: '[auth] type=none' (INSECURE)")
  64. elif _type == "denyall":
  65. logger.warning("All user authentication is blocked by: '[auth] type=denyall'")
  66. elif _type in INSECURE_IF_NO_LOOPBACK_TYPES:
  67. sgi = os.environ.get('SERVER_GATEWAY_INTERFACE') or None
  68. if not sgi:
  69. hosts: List[Tuple[str, int]] = configuration.get("server", "hosts")
  70. localhost_only = True
  71. address_lo = []
  72. address = []
  73. for address_port in hosts:
  74. if address_port[0] in ["localhost", "localhost6", "127.0.0.1", "::1"]:
  75. address_lo.append(utils.format_address(address_port))
  76. else:
  77. address.append(utils.format_address(address_port))
  78. localhost_only = False
  79. if localhost_only is False:
  80. logger.warning("User authentication '[auth] type=%s' is selected but server is not only listen on loopback address (potentially INSECURE): %s", _type, " ".join(address))
  81. return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth,
  82. configuration)
  83. class AuthContext:
  84. remote_addr: str
  85. x_remote_addr: str
  86. def __init__(self):
  87. self.remote_addr = None
  88. self.x_remote_addr = None
  89. class BaseAuth:
  90. _ldap_groups: Set[str] = set([])
  91. _urldecode_username: bool
  92. _lc_username: bool
  93. _uc_username: bool
  94. _strip_domain: bool
  95. _auth_delay: float
  96. _failed_auth_delay: float
  97. _type: str
  98. _cache_logins: bool
  99. _cache_successful: dict # login -> (digest, time_ns)
  100. _cache_successful_logins_expiry: int
  101. _cache_failed: dict # digest_failed -> (time_ns, login)
  102. _cache_failed_logins_expiry: int
  103. _cache_failed_logins_salt_ns: int # persistent over runtime
  104. _lock: threading.Lock
  105. def __init__(self, configuration: "config.Configuration") -> None:
  106. """Initialize BaseAuth.
  107. ``configuration`` see ``radicale.config`` module.
  108. The ``configuration`` must not change during the lifetime of
  109. this object, it is kept as an internal reference.
  110. """
  111. self.configuration = configuration
  112. self._lc_username = configuration.get("auth", "lc_username")
  113. self._uc_username = configuration.get("auth", "uc_username")
  114. self._strip_domain = configuration.get("auth", "strip_domain")
  115. self._urldecode_username = configuration.get("auth", "urldecode_username")
  116. logger.info("auth.strip_domain: %s", self._strip_domain)
  117. logger.info("auth.lc_username: %s", self._lc_username)
  118. logger.info("auth.uc_username: %s", self._uc_username)
  119. logger.info("auth.urldecode_username: %s", self._urldecode_username)
  120. if self._lc_username is True and self._uc_username is True:
  121. raise RuntimeError("auth.lc_username and auth.uc_username cannot be enabled together")
  122. self._auth_delay = configuration.get("auth", "delay")
  123. logger.info("auth.delay: %f seconds", self._auth_delay)
  124. self._failed_auth_delay = 0
  125. self._lock = threading.Lock()
  126. # cache_successful_logins
  127. self._cache_logins = configuration.get("auth", "cache_logins")
  128. self._type = configuration.get("auth", "type")
  129. if (self._type in CACHE_LOGIN_TYPES) or (self._cache_logins is False):
  130. logger.info("auth.cache_logins: %s", self._cache_logins)
  131. else:
  132. logger.info("auth.cache_logins: %s (but not required for type '%s' and disabled therefore)", self._cache_logins, self._type)
  133. self._cache_logins = False
  134. if self._cache_logins is True:
  135. self._cache_successful_logins_expiry = configuration.get("auth", "cache_successful_logins_expiry")
  136. if self._cache_successful_logins_expiry < 0:
  137. raise RuntimeError("self._cache_successful_logins_expiry cannot be < 0")
  138. self._cache_failed_logins_expiry = configuration.get("auth", "cache_failed_logins_expiry")
  139. if self._cache_failed_logins_expiry < 0:
  140. raise RuntimeError("self._cache_failed_logins_expiry cannot be < 0")
  141. logger.info("auth.cache_successful_logins_expiry: %s seconds", self._cache_successful_logins_expiry)
  142. logger.info("auth.cache_failed_logins_expiry: %s seconds", self._cache_failed_logins_expiry)
  143. # cache init
  144. self._cache_successful = dict()
  145. self._cache_failed = dict()
  146. self._cache_failed_logins_salt_ns = time.time_ns()
  147. def _cache_digest(self, login: str, password: str, salt: str) -> str:
  148. h = hashlib.sha3_512()
  149. h.update(salt.encode())
  150. h.update(login.encode())
  151. h.update(password.encode())
  152. return str(h.digest())
  153. def get_external_login(self, environ: types.WSGIEnviron) -> Union[
  154. Tuple[()], Tuple[str, str]]:
  155. """Optionally provide the login and password externally.
  156. ``environ`` a dict with the WSGI environment
  157. If ``()`` is returned, Radicale handles HTTP authentication.
  158. Otherwise, returns a tuple ``(login, password)``. For anonymous users
  159. ``login`` must be ``""``.
  160. """
  161. return ()
  162. def _login(self, login: str, password: str) -> str:
  163. """Check credentials and map login to internal user
  164. ``login`` the login name
  165. ``password`` the password
  166. Returns the username or ``""`` for invalid credentials.
  167. """
  168. raise NotImplementedError
  169. def _login_ext(self, login: str, password: str, context: AuthContext) -> str:
  170. """Check credentials and map login to internal user
  171. ``login`` the login name
  172. ``password`` the password
  173. ``context`` additional data for the login, e.g. IP address used
  174. Returns the username or ``""`` for invalid credentials.
  175. """
  176. # override this method instead of _login() if you want the context
  177. return self._login(login, password)
  178. def _sleep_for_constant_exec_time(self, time_ns_begin: int):
  179. """Sleep some time to reach a constant execution time for failed logins
  180. Independent of time required by external backend or used digest methods
  181. Increase final execution time in case initial limit exceeded
  182. See also issue 591
  183. """
  184. time_delta = (time.time_ns() - time_ns_begin) / 1000 / 1000 / 1000
  185. with self._lock:
  186. # avoid that another thread is changing global value at the same time
  187. failed_auth_delay = self._failed_auth_delay
  188. failed_auth_delay_old = failed_auth_delay
  189. if time_delta > failed_auth_delay:
  190. # set new
  191. failed_auth_delay = time_delta
  192. # store globally
  193. self._failed_auth_delay = failed_auth_delay
  194. if (failed_auth_delay_old != failed_auth_delay):
  195. logger.debug("Failed login constant execution time need increase of failed_auth_delay: %.9f -> %.9f sec", failed_auth_delay_old, failed_auth_delay)
  196. # sleep == 0
  197. else:
  198. sleep = failed_auth_delay - time_delta
  199. logger.debug("Failed login constant exection time alignment, sleeping: %.9f sec", sleep)
  200. time.sleep(sleep)
  201. @final
  202. def login(self, login: str, password: str, context: AuthContext) -> Tuple[str, str]:
  203. time_ns_begin = time.time_ns()
  204. result_from_cache = False
  205. if self._lc_username:
  206. login = login.lower()
  207. if self._uc_username:
  208. login = login.upper()
  209. if self._urldecode_username:
  210. login = unquote(login)
  211. if self._strip_domain:
  212. login = login.split('@')[0]
  213. if self._cache_logins is True:
  214. # time_ns is also used as salt
  215. result = ""
  216. digest = ""
  217. time_ns = time.time_ns()
  218. # cleanup failed login cache to avoid out-of-memory
  219. cache_failed_entries = len(self._cache_failed)
  220. if cache_failed_entries > 0:
  221. logger.debug("Login failed cache investigation start (entries: %d)", cache_failed_entries)
  222. self._lock.acquire()
  223. cache_failed_cleanup = dict()
  224. for digest in self._cache_failed:
  225. (time_ns_cache, login_cache) = self._cache_failed[digest]
  226. age_failed = int((time_ns - time_ns_cache) / 1000 / 1000 / 1000)
  227. if age_failed > self._cache_failed_logins_expiry:
  228. cache_failed_cleanup[digest] = (login_cache, age_failed)
  229. cache_failed_cleanup_entries = len(cache_failed_cleanup)
  230. logger.debug("Login failed cache cleanup start (entries: %d)", cache_failed_cleanup_entries)
  231. if cache_failed_cleanup_entries > 0:
  232. for digest in cache_failed_cleanup:
  233. (login, age_failed) = cache_failed_cleanup[digest]
  234. logger.debug("Login failed cache entry for user+password expired: '%s' (age: %d > %d sec)", login_cache, age_failed, self._cache_failed_logins_expiry)
  235. del self._cache_failed[digest]
  236. self._lock.release()
  237. logger.debug("Login failed cache investigation finished")
  238. # check for cache failed login
  239. digest_failed = login + ":" + self._cache_digest(login, password, str(self._cache_failed_logins_salt_ns))
  240. if self._cache_failed.get(digest_failed):
  241. # login+password found in cache "failed" -> shortcut return
  242. (time_ns_cache, login_cache) = self._cache_failed[digest]
  243. age_failed = int((time_ns - time_ns_cache) / 1000 / 1000 / 1000)
  244. logger.debug("Login failed cache entry for user+password found: '%s' (age: %d sec)", login_cache, age_failed)
  245. self._sleep_for_constant_exec_time(time_ns_begin)
  246. return ("", self._type + " / cached")
  247. if self._cache_successful.get(login):
  248. # login found in cache "successful"
  249. (digest_cache, time_ns_cache) = self._cache_successful[login]
  250. digest = self._cache_digest(login, password, str(time_ns_cache))
  251. if digest == digest_cache:
  252. age_success = int((time_ns - time_ns_cache) / 1000 / 1000 / 1000)
  253. if age_success > self._cache_successful_logins_expiry:
  254. logger.debug("Login successful cache entry for user+password found but expired: '%s' (age: %d > %d sec)", login, age_success, self._cache_successful_logins_expiry)
  255. # delete expired success from cache
  256. del self._cache_successful[login]
  257. digest = ""
  258. else:
  259. logger.debug("Login successful cache entry for user+password found: '%s' (age: %d sec)", login, age_success)
  260. result = login
  261. result_from_cache = True
  262. else:
  263. logger.debug("Login successful cache entry for user+password not matching: '%s'", login)
  264. else:
  265. # login not found in cache, caculate always to avoid timing attacks
  266. digest = self._cache_digest(login, password, str(time_ns))
  267. if result == "":
  268. # verify login+password via configured backend
  269. logger.debug("Login verification for user+password via backend: '%s'", login)
  270. result = self._login_ext(login, password, context)
  271. if result != "":
  272. logger.debug("Login successful for user+password via backend: '%s'", login)
  273. if digest == "":
  274. # successful login, but expired, digest must be recalculated
  275. digest = self._cache_digest(login, password, str(time_ns))
  276. # store successful login in cache
  277. self._lock.acquire()
  278. self._cache_successful[login] = (digest, time_ns)
  279. self._lock.release()
  280. logger.debug("Login successful cache for user set: '%s'", login)
  281. if self._cache_failed.get(digest_failed):
  282. logger.debug("Login failed cache for user cleared: '%s'", login)
  283. del self._cache_failed[digest_failed]
  284. else:
  285. logger.debug("Login failed for user+password via backend: '%s'", login)
  286. self._lock.acquire()
  287. self._cache_failed[digest_failed] = (time_ns, login)
  288. self._lock.release()
  289. logger.debug("Login failed cache for user set: '%s'", login)
  290. if result_from_cache is True:
  291. if result == "":
  292. self._sleep_for_constant_exec_time(time_ns_begin)
  293. return (result, self._type + " / cached")
  294. else:
  295. if result == "":
  296. self._sleep_for_constant_exec_time(time_ns_begin)
  297. return (result, self._type)
  298. else:
  299. # self._cache_logins is False
  300. result = self._login_ext(login, password, context)
  301. if result == "":
  302. self._sleep_for_constant_exec_time(time_ns_begin)
  303. return (result, self._type)