htpasswd.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  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-2019 Unrud <unrud@outlook.com>
  6. # Copyright © 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 backend that checks credentials with a htpasswd file.
  22. Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html)
  23. manages a file for storing user credentials. It can encrypt passwords using
  24. different the methods BCRYPT/SHA256/SHA512 or MD5-APR1 (a version of MD5 modified for
  25. Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT/SHA256/SHA512 can be
  26. considered secure by current standards.
  27. MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it
  28. is the default, in fact), whereas BCRYPT/SHA256/SHA512 requires htpasswd 2.4.x or newer.
  29. The `is_authenticated(user, password)` function provided by this module
  30. verifies the user-given credentials by parsing the htpasswd credential file
  31. pointed to by the ``htpasswd_filename`` configuration value while assuming
  32. the password encryption method specified via the ``htpasswd_encryption``
  33. configuration value.
  34. The following htpasswd password encryption methods are supported by Radicale
  35. out-of-the-box:
  36. - plain-text (created by htpasswd -p ...) -- INSECURE
  37. - MD5-APR1 (htpasswd -m ...) -- htpasswd's default method, INSECURE
  38. - SHA256 (htpasswd -2 ...)
  39. - SHA512 (htpasswd -5 ...)
  40. When bcrypt is installed:
  41. - BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x
  42. """
  43. import os
  44. import time
  45. import functools
  46. import hmac
  47. import threading
  48. from typing import Any
  49. from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt
  50. from radicale import auth, config, logger
  51. class Auth(auth.BaseAuth):
  52. _filename: str
  53. _encoding: str
  54. _htpasswd: dict # login -> digest
  55. _htpasswd_mtime_ns: int
  56. _htpasswd_size: bytes
  57. _htpasswd_ok: bool
  58. _htpasswd_not_ok_seconds: int
  59. _htpasswd_not_ok_reminder_seconds: int
  60. _lock: threading.Lock
  61. def __init__(self, configuration: config.Configuration) -> None:
  62. super().__init__(configuration)
  63. self._filename = configuration.get("auth", "htpasswd_filename")
  64. self._encoding = configuration.get("encoding", "stock")
  65. encryption: str = configuration.get("auth", "htpasswd_encryption")
  66. logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", encryption)
  67. self._htpasswd_ok = False
  68. self._htpasswd_not_ok_reminder_seconds = 60 # currently hardcoded
  69. self._htpasswd_read = self._read_htpasswd(True)
  70. self._lock = threading.Lock()
  71. if encryption == "plain":
  72. self._verify = self._plain
  73. elif encryption == "md5":
  74. self._verify = self._md5apr1
  75. elif encryption == "sha256":
  76. self._verify = self._sha256
  77. elif encryption == "sha512":
  78. self._verify = self._sha512
  79. elif encryption == "bcrypt" or encryption == "autodetect":
  80. try:
  81. import bcrypt
  82. except ImportError as e:
  83. raise RuntimeError(
  84. "The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
  85. "the bcrypt module.") from e
  86. if encryption == "bcrypt":
  87. self._verify = functools.partial(self._bcrypt, bcrypt)
  88. else:
  89. self._verify = self._autodetect
  90. self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt)
  91. else:
  92. raise RuntimeError("The htpasswd encryption method %r is not "
  93. "supported." % encryption)
  94. def _plain(self, hash_value: str, password: str) -> tuple[str, bool]:
  95. """Check if ``hash_value`` and ``password`` match, plain method."""
  96. return ("PLAIN", hmac.compare_digest(hash_value.encode(), password.encode()))
  97. def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> tuple[str, bool]:
  98. return ("BCRYPT", bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode()))
  99. def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]:
  100. return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
  101. def _sha256(self, hash_value: str, password: str) -> tuple[str, bool]:
  102. return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
  103. def _sha512(self, hash_value: str, password: str) -> tuple[str, bool]:
  104. return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
  105. def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]:
  106. if hash_value.startswith("$apr1$", 0, 6) and len(hash_value) == 37:
  107. # MD5-APR1
  108. return self._md5apr1(hash_value, password)
  109. elif hash_value.startswith("$2y$", 0, 4) and len(hash_value) == 60:
  110. # BCRYPT
  111. return self._verify_bcrypt(hash_value, password)
  112. elif hash_value.startswith("$5$", 0, 3) and len(hash_value) == 63:
  113. # SHA-256
  114. return self._sha256(hash_value, password)
  115. elif hash_value.startswith("$6$", 0, 3) and len(hash_value) == 106:
  116. # SHA-512
  117. return self._sha512(hash_value, password)
  118. else:
  119. # assumed plaintext
  120. return self._plain(hash_value, password)
  121. def _read_htpasswd(self, init: bool) -> bool:
  122. """Read htpasswd file
  123. init == True: stop on error
  124. init == False: warn/skip on error and set mark to log reminder every interval
  125. """
  126. htpasswd_ok = True
  127. if init is True:
  128. info = "Read"
  129. else:
  130. info = "Re-read"
  131. logger.info("%s content of htpasswd file start: %r", info, self._filename)
  132. htpasswd = dict()
  133. try:
  134. with open(self._filename, encoding=self._encoding) as f:
  135. line_num = 0
  136. entries = 0
  137. duplicates = 0
  138. for line in f:
  139. line_num += 1
  140. line = line.rstrip("\n")
  141. if line.lstrip() and not line.lstrip().startswith("#"):
  142. try:
  143. login, digest = line.split( ":", maxsplit=1)
  144. if login == "" or digest == "":
  145. if init is True:
  146. raise ValueError("htpasswd file contains problematic line not matching <login>:<digest> in line: %d" % line_num)
  147. else:
  148. logger.warning("htpasswd file contains problematic line not matching <login>:<digest> in line: %d (ignored)", line_num)
  149. htpasswd_ok = False
  150. else:
  151. if htpasswd.get(login):
  152. duplicates += 1
  153. if init is True:
  154. raise ValueError("htpasswd file contains duplicate login: '%s'", login, line_num)
  155. else:
  156. logger.warning("htpasswd file contains duplicate login: '%s' (line: %d / ignored)", login, line_num)
  157. htpasswd_ok = False
  158. else:
  159. htpasswd[login] = digest
  160. entries += 1
  161. except ValueError as e:
  162. if init is True:
  163. raise RuntimeError("Invalid htpasswd file %r: %s" % (self._filename, e)) from e
  164. except OSError as e:
  165. if init is True:
  166. raise RuntimeError("Failed to load htpasswd file %r: %s" % (self._filename, e)) from e
  167. else:
  168. logger.warning("Failed to load htpasswd file on re-read: %r" % (self._filename, e))
  169. htpasswd_ok = False
  170. else:
  171. self._htpasswd_size = os.stat(self._filename).st_size
  172. self._htpasswd_time_ns = os.stat(self._filename).st_mtime_ns
  173. self._htpasswd = htpasswd
  174. logger.info("%s content of htpasswd file done: %r (entries: %d, duplicates: %d)", info, self._filename, entries, duplicates)
  175. if htpasswd_ok is True:
  176. self._htpasswd_not_ok_time = 0
  177. else:
  178. self._htpasswd_not_ok_time = time.time()
  179. return htpasswd_ok
  180. def _login(self, login: str, password: str) -> str:
  181. """Validate credentials.
  182. Iterate through htpasswd credential file until login matches, extract
  183. hash (encrypted password) and check hash against password,
  184. using the method specified in the Radicale config.
  185. The content of the file is cached and live updates will be detected by
  186. comparing mtime_ns and size
  187. """
  188. # check and re-read file if required
  189. htpasswd_size = os.stat(self._filename).st_size
  190. htpasswd_time_ns = os.stat(self._filename).st_mtime_ns
  191. if (htpasswd_size != self._htpasswd_size) or (htpasswd_time_ns != self._htpasswd_time_ns):
  192. with self._lock:
  193. self._htpasswd_ok = self._read_htpasswd(False)
  194. else:
  195. # log reminder of problemantic file every interval
  196. if (self._htpasswd_ok is False) and (self._htpasswd_not_ok_time > 0):
  197. current_time = time.time()
  198. if (current_time - self._htpasswd_not_ok_time) > self._htpasswd_not_ok_reminder_seconds:
  199. logger.warning("htpasswd file still contains issues (REMINDER, check warnings in the past): %r" % self._filename)
  200. self._htpasswd_not_ok_time = current_time
  201. if self._htpasswd.get(login):
  202. digest = self._htpasswd[login]
  203. (method, password_ok) = self._verify(digest, password)
  204. logger.debug("Login verification successful for user: '%s' (method '%s')", login, method)
  205. if password_ok:
  206. return login
  207. else:
  208. logger.debug("Login verification failed for user: '%s' ( method '%s')", login, method)
  209. else:
  210. logger.debug("Login verification user not found: '%s'", login)
  211. return ""