htpasswd.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  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-2026 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 (bcrypt >= 5.0.0 requires passlib(libpass) >= 1.9.3):
  41. - BCRYPT (htpasswd -B ...) -- Requires htpasswd 2.4.x
  42. When argon2 is installed:
  43. - ARGON2 (python -c 'from passlib.hash import argon2; print(argon2.using(type="ID").hash("password"))')
  44. """
  45. import functools
  46. import hmac
  47. import os
  48. import re
  49. import threading
  50. import time
  51. from typing import Any, Tuple
  52. from passlib.hash import apr_md5_crypt, sha256_crypt, sha512_crypt
  53. from radicale import auth, config, logger, utils
  54. class Auth(auth.BaseAuth):
  55. _filename: str
  56. _encoding: str
  57. _htpasswd: dict # login -> digest
  58. _htpasswd_mtime_ns: int
  59. _htpasswd_size: int
  60. _htpasswd_ok: bool
  61. _htpasswd_not_ok_time: float
  62. _htpasswd_not_ok_reminder_seconds: int
  63. _htpasswd_bcrypt_use: int
  64. _htpasswd_argon2_use: int
  65. _htpasswd_cache: bool
  66. _has_bcrypt: bool
  67. _has_argon2: bool
  68. _encryption: str
  69. _lock: threading.Lock
  70. def __init__(self, configuration: config.Configuration) -> None:
  71. super().__init__(configuration)
  72. self._filename = configuration.get("auth", "htpasswd_filename")
  73. logger.info("auth htpasswd file: %r", self._filename)
  74. self._encoding = configuration.get("encoding", "stock")
  75. logger.info("auth htpasswd file encoding: %r", self._encoding)
  76. self._htpasswd_cache = configuration.get("auth", "htpasswd_cache")
  77. logger.info("auth htpasswd cache: %s", self._htpasswd_cache)
  78. self._encryption: str = configuration.get("auth", "htpasswd_encryption")
  79. logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s'", self._encryption)
  80. self._has_bcrypt = False
  81. self._has_argon2 = False
  82. self._htpasswd_ok = False
  83. self._htpasswd_not_ok_reminder_seconds = 60 # currently hardcoded
  84. (self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd_argon2_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(True, False)
  85. self._lock = threading.Lock()
  86. if self._encryption == "plain":
  87. self._verify = self._plain
  88. elif self._encryption == "md5":
  89. self._verify = self._md5apr1
  90. elif self._encryption == "sha256":
  91. self._verify = self._sha256
  92. elif self._encryption == "sha512":
  93. self._verify = self._sha512
  94. if self._encryption == "bcrypt" or self._encryption == "autodetect":
  95. try:
  96. import bcrypt
  97. except ImportError as e:
  98. if (self._encryption == "autodetect") and (self._htpasswd_bcrypt_use == 0):
  99. logger.warning("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' which can require bycrypt module, but currently no entries found", self._encryption)
  100. else:
  101. raise RuntimeError(
  102. "The htpasswd encryption method 'bcrypt' or 'autodetect' requires "
  103. "the bcrypt module (entries found: %d)." % self._htpasswd_bcrypt_use) from e
  104. else:
  105. [bcrypt_usable, info] = utils.passlib_libpass_supports_bcrypt()
  106. if bcrypt_usable:
  107. self._has_bcrypt = True
  108. logger.debug(info)
  109. else:
  110. logger.warning(info)
  111. if self._encryption == "autodetect":
  112. if self._htpasswd_bcrypt_use == 0:
  113. logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found, but currently not required", self._encryption)
  114. else:
  115. logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and bcrypt module found (bcrypt entries found: %d)", self._encryption, self._htpasswd_bcrypt_use)
  116. if not bcrypt_usable:
  117. raise RuntimeError("The htpasswd encryption 'autodetect' requires the bcrypt module but not usuable")
  118. else:
  119. if not bcrypt_usable:
  120. raise RuntimeError("The htpasswd encryption method 'bcrypt' requires the bcrypt module but not usuable")
  121. if self._encryption == "bcrypt":
  122. self._verify = functools.partial(self._bcrypt, bcrypt)
  123. else:
  124. self._verify = self._autodetect
  125. if self._htpasswd_bcrypt_use:
  126. self._verify_bcrypt = functools.partial(self._bcrypt, bcrypt)
  127. if self._encryption == "argon2" or self._encryption == "autodetect":
  128. try:
  129. import argon2
  130. from passlib.hash import argon2 # noqa: F811
  131. except ImportError as e:
  132. if (self._encryption == "autodetect") and (self._htpasswd_argon2_use == 0):
  133. logger.warning("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' which can require argon2 module, but currently no entries found", self._encryption)
  134. else:
  135. raise RuntimeError(
  136. "The htpasswd encryption method 'argon2' or 'autodetect' requires "
  137. "the argon2 module (entries found: %d)." % self._htpasswd_argon2_use) from e
  138. else:
  139. self._has_argon2 = True
  140. if self._encryption == "autodetect":
  141. if self._htpasswd_argon2_use == 0:
  142. logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and argon2 module found, but currently not required", self._encryption)
  143. else:
  144. logger.info("auth htpasswd encryption is 'radicale.auth.htpasswd_encryption.%s' and argon2 module found (argon2 entries found: %d)", self._encryption, self._htpasswd_argon2_use)
  145. if self._encryption == "argon2":
  146. self._verify = functools.partial(self._argon2, argon2)
  147. else:
  148. self._verify = self._autodetect
  149. if self._htpasswd_argon2_use:
  150. self._verify_argon2 = functools.partial(self._argon2, argon2)
  151. if not hasattr(self, '_verify'):
  152. raise RuntimeError("The htpasswd encryption method %r is not "
  153. "supported." % self._encryption)
  154. def _plain(self, hash_value: str, password: str) -> tuple[str, bool]:
  155. """Check if ``hash_value`` and ``password`` match, plain method."""
  156. return ("PLAIN", hmac.compare_digest(hash_value.encode(), password.encode()))
  157. def _plain_fallback(self, method_orig, hash_value: str, password: str) -> tuple[str, bool]:
  158. """Check if ``hash_value`` and ``password`` match, plain method / fallback in case of hash length is not matching on autodetection."""
  159. info = "PLAIN/fallback as hash length not matching for " + method_orig + ": " + str(len(hash_value))
  160. return (info, hmac.compare_digest(hash_value.encode(), password.encode()))
  161. def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> tuple[str, bool]:
  162. if self._encryption == "autodetect" and len(hash_value) != 60:
  163. return self._plain_fallback("BCRYPT", hash_value, password)
  164. else:
  165. return ("BCRYPT", bcrypt.checkpw(password=password.encode('utf-8'), hashed_password=hash_value.encode()))
  166. def _argon2(self, argon2: Any, hash_value: str, password: str) -> tuple[str, bool]:
  167. return ("ARGON2", argon2.verify(password, hash_value.strip()))
  168. def _md5apr1(self, hash_value: str, password: str) -> tuple[str, bool]:
  169. return ("MD5-APR1", apr_md5_crypt.verify(password, hash_value.strip()))
  170. def _sha256(self, hash_value: str, password: str) -> tuple[str, bool]:
  171. return ("SHA-256", sha256_crypt.verify(password, hash_value.strip()))
  172. def _sha512(self, hash_value: str, password: str) -> tuple[str, bool]:
  173. return ("SHA-512", sha512_crypt.verify(password, hash_value.strip()))
  174. def _autodetect(self, hash_value: str, password: str) -> tuple[str, bool]:
  175. if re.match(r"^\$apr1\$[A-Za-z0-9/.]{8}\$[A-Za-z0-9/.]{22}", hash_value):
  176. # MD5-APR1
  177. return self._md5apr1(hash_value, password)
  178. elif re.match(r"^\$2(a|b|x|y)?\$[0-9]{2}\$[A-Za-z0-9/.]{53}", hash_value):
  179. # BCRYPT
  180. return self._verify_bcrypt(hash_value, password)
  181. elif re.match(r"^\$argon2(i|d|id)\$", hash_value):
  182. # ARGON2
  183. return self._verify_argon2(hash_value, password)
  184. elif re.match(r"^\$5\$(rounds=[0-9]+\$)?[A-Za-z0-9/.]{16}\$[A-Za-z0-9/.]{42}", hash_value):
  185. # SHA-256
  186. return self._sha256(hash_value, password)
  187. elif re.match(r"^\$6\$(rounds=[0-9]+\$)?[A-Za-z0-9/.]{16}\$[A-Za-z0-9/.]{85}", hash_value):
  188. # SHA-512
  189. return self._sha512(hash_value, password)
  190. else:
  191. return self._plain(hash_value, password)
  192. def _read_htpasswd(self, init: bool, suppress: bool) -> Tuple[bool, int, int, dict, int, int]:
  193. """Read htpasswd file
  194. init == True: stop on error
  195. init == False: warn/skip on error and set mark to log reminder every interval
  196. suppress == True: suppress warnings, change info to debug (used in non-caching mode)
  197. suppress == False: do not suppress warnings (used in caching mode)
  198. """
  199. htpasswd_ok = True
  200. bcrypt_use = 0
  201. argon2_use = 0
  202. if (init is True) or (suppress is True):
  203. info = "Read"
  204. else:
  205. info = "Re-read"
  206. if suppress is False:
  207. logger.info("%s content of htpasswd file start: %r", info, self._filename)
  208. else:
  209. logger.debug("%s content of htpasswd file start: %r", info, self._filename)
  210. htpasswd: dict[str, str] = dict()
  211. entries = 0
  212. duplicates = 0
  213. errors = 0
  214. try:
  215. with open(self._filename, encoding=self._encoding) as f:
  216. line_num = 0
  217. for line in f:
  218. line_num += 1
  219. line = line.rstrip("\n")
  220. if line.lstrip() and not line.lstrip().startswith("#"):
  221. try:
  222. login, digest = line.split(":", maxsplit=1)
  223. skip = False
  224. if login == "" or digest == "":
  225. if init is True:
  226. raise ValueError("htpasswd file contains problematic line not matching <login>:<digest> in line: %d" % line_num)
  227. else:
  228. errors += 1
  229. logger.warning("htpasswd file contains problematic line not matching <login>:<digest> in line: %d (ignored)", line_num)
  230. htpasswd_ok = False
  231. skip = True
  232. else:
  233. if htpasswd.get(login):
  234. duplicates += 1
  235. if init is True:
  236. raise ValueError("htpasswd file contains duplicate login: '%s'", login, line_num)
  237. else:
  238. logger.warning("htpasswd file contains duplicate login: '%s' (line: %d / ignored)", login, line_num)
  239. htpasswd_ok = False
  240. skip = True
  241. else:
  242. if re.match(r"^\$2(a|b|x|y)?\$", digest) and len(digest) == 60:
  243. if init is True:
  244. bcrypt_use += 1
  245. else:
  246. if self._has_bcrypt is False:
  247. logger.warning("htpasswd file contains bcrypt digest login: '%s' (line: %d / ignored because module is not loaded)", login, line_num)
  248. skip = True
  249. htpasswd_ok = False
  250. if re.match(r"^\$argon2(i|d|id)\$", digest):
  251. if init is True:
  252. argon2_use += 1
  253. else:
  254. if self._has_argon2 is False:
  255. logger.warning("htpasswd file contains argon2 digest login: '%s' (line: %d / ignored because module is not loaded)", login, line_num)
  256. skip = True
  257. htpasswd_ok = False
  258. if skip is False:
  259. htpasswd[login] = digest
  260. entries += 1
  261. except ValueError as e:
  262. if init is True:
  263. raise RuntimeError("Invalid htpasswd file %r: %s" % (self._filename, e)) from e
  264. except OSError as e:
  265. if init is True:
  266. raise RuntimeError("Failed to load htpasswd file %r: %s" % (self._filename, e)) from e
  267. else:
  268. logger.warning("Failed to load htpasswd file on re-read: %r" % self._filename)
  269. htpasswd_ok = False
  270. htpasswd_size = os.stat(self._filename).st_size
  271. htpasswd_mtime_ns = os.stat(self._filename).st_mtime_ns
  272. if suppress is False:
  273. logger.info("%s content of htpasswd file done: %r (entries: %d, duplicates: %d, errors: %d)", info, self._filename, entries, duplicates, errors)
  274. else:
  275. logger.debug("%s content of htpasswd file done: %r (entries: %d, duplicates: %d, errors: %d)", info, self._filename, entries, duplicates, errors)
  276. if htpasswd_ok is True:
  277. self._htpasswd_not_ok_time = 0
  278. else:
  279. self._htpasswd_not_ok_time = time.time()
  280. return (htpasswd_ok, bcrypt_use, argon2_use, htpasswd, htpasswd_size, htpasswd_mtime_ns)
  281. def _login(self, login: str, password: str) -> str:
  282. """Validate credentials.
  283. Iterate through htpasswd credential file until login matches, extract
  284. hash (encrypted password) and check hash against password,
  285. using the method specified in the Radicale config.
  286. Optional: the content of the file is cached and live updates will be detected by
  287. comparing mtime_ns and size
  288. """
  289. login_ok = False
  290. digest: str
  291. if self._htpasswd_cache is True:
  292. # check and re-read file if required
  293. with self._lock:
  294. htpasswd_size = os.stat(self._filename).st_size
  295. htpasswd_mtime_ns = os.stat(self._filename).st_mtime_ns
  296. if (htpasswd_size != self._htpasswd_size) or (htpasswd_mtime_ns != self._htpasswd_mtime_ns):
  297. (self._htpasswd_ok, self._htpasswd_bcrypt_use, self._htpasswd_argon2_use, self._htpasswd, self._htpasswd_size, self._htpasswd_mtime_ns) = self._read_htpasswd(False, False)
  298. self._htpasswd_not_ok_time = 0
  299. # log reminder of problemantic file every interval
  300. current_time = time.time()
  301. if (self._htpasswd_ok is False):
  302. if (self._htpasswd_not_ok_time > 0):
  303. if (current_time - self._htpasswd_not_ok_time) > self._htpasswd_not_ok_reminder_seconds:
  304. logger.warning("htpasswd file still contains issues (REMINDER, check warnings in the past): %r" % self._filename)
  305. self._htpasswd_not_ok_time = current_time
  306. else:
  307. self._htpasswd_not_ok_time = current_time
  308. if self._htpasswd.get(login):
  309. digest = self._htpasswd[login]
  310. login_ok = True
  311. else:
  312. # read file on every request
  313. (htpasswd_ok, htpasswd_bcrypt_use, htpasswd_argon2_use, htpasswd, htpasswd_size, htpasswd_mtime_ns) = self._read_htpasswd(False, True)
  314. if htpasswd.get(login):
  315. digest = htpasswd[login]
  316. login_ok = True
  317. if login_ok is True:
  318. try:
  319. (method, password_ok) = self._verify(digest, password)
  320. except ValueError as e:
  321. logger.error("Login verification failed for user: '%s' (htpasswd/%s) with error '%s'", login, self._encryption, e)
  322. return ""
  323. if password_ok:
  324. logger.debug("Login verification successful for user: '%s' (htpasswd/%s/%s)", login, self._encryption, method)
  325. return login
  326. else:
  327. logger.warning("Login verification failed for user: '%s' (htpasswd/%s/%s)", login, self._encryption, method)
  328. else:
  329. logger.warning("Login verification user not found (htpasswd): '%s'", login)
  330. return ""