__init__.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  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-2024 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`` 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 time
  29. from typing import Sequence, Set, Tuple, Union, final
  30. from radicale import config, types, utils
  31. from radicale.log import logger
  32. INTERNAL_TYPES: Sequence[str] = ("none", "remote_user", "http_x_remote_user",
  33. "denyall",
  34. "htpasswd",
  35. "ldap",
  36. "dovecot")
  37. def load(configuration: "config.Configuration") -> "BaseAuth":
  38. """Load the authentication module chosen in configuration."""
  39. if configuration.get("auth", "type") == "none":
  40. logger.warning("No user authentication is selected: '[auth] type=none' (insecure)")
  41. if configuration.get("auth", "type") == "denyall":
  42. logger.warning("All access is blocked by: '[auth] type=denyall'")
  43. return utils.load_plugin(INTERNAL_TYPES, "auth", "Auth", BaseAuth,
  44. configuration)
  45. class BaseAuth:
  46. _ldap_groups: Set[str] = set([])
  47. _lc_username: bool
  48. _uc_username: bool
  49. _strip_domain: bool
  50. _type: str
  51. _cache_logins: bool
  52. _cache_successful: dict # login -> (digest, time_ns)
  53. _cache_successful_logins_expiry: int
  54. _cache_failed: dict # digest_failed -> (time_ns)
  55. _cache_failed_logins_expiry: int
  56. _cache_failed_logins_salt_ns: int # persistent over runtime
  57. def __init__(self, configuration: "config.Configuration") -> None:
  58. """Initialize BaseAuth.
  59. ``configuration`` see ``radicale.config`` module.
  60. The ``configuration`` must not change during the lifetime of
  61. this object, it is kept as an internal reference.
  62. """
  63. self.configuration = configuration
  64. self._lc_username = configuration.get("auth", "lc_username")
  65. self._uc_username = configuration.get("auth", "uc_username")
  66. self._strip_domain = configuration.get("auth", "strip_domain")
  67. logger.info("auth.strip_domain: %s", self._strip_domain)
  68. logger.info("auth.lc_username: %s", self._lc_username)
  69. logger.info("auth.uc_username: %s", self._uc_username)
  70. if self._lc_username is True and self._uc_username is True:
  71. raise RuntimeError("auth.lc_username and auth.uc_username cannot be enabled together")
  72. # cache_successful_logins
  73. self._cache_logins = configuration.get("auth", "cache_logins")
  74. self._type = configuration.get("auth", "type")
  75. if (self._type in [ "dovecot", "ldap", "htpasswd" ]) or (self._cache_logins is False):
  76. logger.info("auth.cache_logins: %s", self._cache_logins)
  77. else:
  78. logger.info("auth.cache_logins: %s (but not required for type '%s' and disabled therefore)", self._cache_logins, self._type)
  79. self._cache_logins = False
  80. if self._cache_logins is True:
  81. self._cache_successful_logins_expiry = configuration.get("auth", "cache_successful_logins_expiry")
  82. if self._cache_successful_logins_expiry < 0:
  83. raise RuntimeError("self._cache_successful_logins_expiry cannot be < 0")
  84. self._cache_failed_logins_expiry = configuration.get("auth", "cache_failed_logins_expiry")
  85. if self._cache_failed_logins_expiry < 0:
  86. raise RuntimeError("self._cache_failed_logins_expiry cannot be < 0")
  87. logger.info("auth.cache_successful_logins_expiry: %s seconds", self._cache_successful_logins_expiry)
  88. logger.info("auth.cache_failed_logins_expiry: %s seconds", self._cache_failed_logins_expiry)
  89. # cache init
  90. self._cache_successful = dict()
  91. self._cache_failed = dict()
  92. self._cache_failed_logins_salt_ns = time.time_ns()
  93. def _cache_digest(self, login: str, password: str, salt: str) -> str:
  94. h = hashlib.sha3_512()
  95. h.update(salt.encode())
  96. h.update(login.encode())
  97. h.update(password.encode())
  98. return str(h.digest())
  99. def get_external_login(self, environ: types.WSGIEnviron) -> Union[
  100. Tuple[()], Tuple[str, str]]:
  101. """Optionally provide the login and password externally.
  102. ``environ`` a dict with the WSGI environment
  103. If ``()`` is returned, Radicale handles HTTP authentication.
  104. Otherwise, returns a tuple ``(login, password)``. For anonymous users
  105. ``login`` must be ``""``.
  106. """
  107. return ()
  108. def _login(self, login: str, password: str) -> str:
  109. """Check credentials and map login to internal user
  110. ``login`` the login name
  111. ``password`` the password
  112. Returns the username or ``""`` for invalid credentials.
  113. """
  114. raise NotImplementedError
  115. @final
  116. def login(self, login: str, password: str) -> str:
  117. if self._lc_username:
  118. login = login.lower()
  119. if self._uc_username:
  120. login = login.upper()
  121. if self._strip_domain:
  122. login = login.split('@')[0]
  123. if self._cache_logins is True:
  124. # time_ns is also used as salt
  125. result = ""
  126. digest = ""
  127. time_ns = time.time_ns()
  128. digest_failed = login + ":" + self._cache_digest(login, password, str(self._cache_failed_logins_salt_ns))
  129. if self._cache_failed.get(digest_failed):
  130. # login+password found in cache "failed"
  131. time_ns_cache = self._cache_failed[digest_failed]
  132. age_failed = int((time_ns - time_ns_cache) / 1000 / 1000 / 1000)
  133. if age_failed > self._cache_failed_logins_expiry:
  134. logger.debug("Login failed cache entry for user+password found but expired: '%s' (age: %d > %d sec)", login, age_failed, self._cache_failed_logins_expiry)
  135. # delete expired failed from cache
  136. del self._cache_failed[digest_failed]
  137. else:
  138. # shortcut return
  139. logger.debug("Login failed cache entry for user+password found: '%s' (age: %d sec)", login, age_failed)
  140. return ""
  141. if self._cache_successful.get(login):
  142. # login found in cache "successful"
  143. (digest_cache, time_ns_cache) = self._cache_successful[login]
  144. digest = self._cache_digest(login, password, str(time_ns_cache))
  145. if digest == digest_cache:
  146. age_success = int((time_ns - time_ns_cache) / 1000 / 1000 / 1000)
  147. if age_success > self._cache_successful_logins_expiry:
  148. 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)
  149. # delete expired success from cache
  150. del self._cache_successful[login]
  151. digest = ""
  152. else:
  153. logger.debug("Login successful cache entry for user+password found: '%s' (age: %d sec)", login, age_success)
  154. result = login
  155. else:
  156. logger.debug("Login successful cache entry for user+password not matching: '%s'", login)
  157. else:
  158. # login not found in cache, caculate always to avoid timing attacks
  159. digest = self._cache_digest(login, password, str(time_ns))
  160. if result == "":
  161. # verify login+password via configured backend
  162. logger.debug("Login verification for user+password via backend: '%s'", login)
  163. result = self._login(login, password)
  164. if result != "":
  165. logger.debug("Login successful for user+password via backend: '%s'", login)
  166. if digest == "":
  167. # successful login, but expired, digest must be recalculated
  168. digest = self._cache_digest(login, password, str(time_ns))
  169. # store successful login in cache
  170. self._cache_successful[login] = (digest, time_ns)
  171. logger.debug("Login successful cache for user set: '%s'", login)
  172. if self._cache_failed.get(digest_failed):
  173. logger.debug("Login failed cache for user cleared: '%s'", login)
  174. del self._cache_failed[digest_failed]
  175. else:
  176. logger.debug("Login failed for user+password via backend: '%s'", login)
  177. self._cache_failed[digest_failed] = time_ns
  178. logger.debug("Login failed cache for user set: '%s'", login)
  179. return result
  180. else:
  181. return self._login(login, password)