htpasswd.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  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. #
  7. # This library is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation, either version 3 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This library is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License
  18. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  19. """
  20. Authentication backend that checks credentials with a htpasswd file.
  21. Apache's htpasswd command (httpd.apache.org/docs/programs/htpasswd.html)
  22. manages a file for storing user credentials. It can encrypt passwords using
  23. different the methods BCRYPT or MD5-APR1 (a version of MD5 modified for
  24. Apache). MD5-APR1 provides medium security as of 2015. Only BCRYPT can be
  25. considered secure by current standards.
  26. MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it
  27. is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer.
  28. The `is_authenticated(user, password)` function provided by this module
  29. verifies the user-given credentials by parsing the htpasswd credential file
  30. pointed to by the ``htpasswd_filename`` configuration value while assuming
  31. the password encryption method specified via the ``htpasswd_encryption``
  32. configuration value.
  33. The following htpasswd password encrpytion methods are supported by Radicale
  34. out-of-the-box:
  35. - plain-text (created by htpasswd -p...) -- INSECURE
  36. - MD5-APR1 (htpasswd -m...) -- htpasswd's default method
  37. When passlib[bcrypt] is installed:
  38. - BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x
  39. """
  40. import functools
  41. import hmac
  42. from typing import Any
  43. from passlib.hash import apr_md5_crypt
  44. from radicale import auth, config
  45. class Auth(auth.BaseAuth):
  46. _filename: str
  47. _encoding: str
  48. def __init__(self, configuration: config.Configuration) -> None:
  49. super().__init__(configuration)
  50. self._filename = configuration.get("auth", "htpasswd_filename")
  51. self._encoding = configuration.get("encoding", "stock")
  52. encryption: str = configuration.get("auth", "htpasswd_encryption")
  53. if encryption == "plain":
  54. self._verify = self._plain
  55. elif encryption == "md5":
  56. self._verify = self._md5apr1
  57. elif encryption == "bcrypt":
  58. try:
  59. from passlib.hash import bcrypt
  60. except ImportError as e:
  61. raise RuntimeError(
  62. "The htpasswd encryption method 'bcrypt' requires "
  63. "the passlib[bcrypt] module.") from e
  64. # A call to `encrypt` raises passlib.exc.MissingBackendError with a
  65. # good error message if bcrypt backend is not available. Trigger
  66. # this here.
  67. bcrypt.hash("test-bcrypt-backend")
  68. self._verify = functools.partial(self._bcrypt, bcrypt)
  69. else:
  70. raise RuntimeError("The htpasswd encryption method %r is not "
  71. "supported." % encryption)
  72. def _plain(self, hash_value: str, password: str) -> bool:
  73. """Check if ``hash_value`` and ``password`` match, plain method."""
  74. return hmac.compare_digest(hash_value.encode(), password.encode())
  75. def _bcrypt(self, bcrypt: Any, hash_value: str, password: str) -> bool:
  76. return bcrypt.verify(password, hash_value.strip())
  77. def _md5apr1(self, hash_value: str, password: str) -> bool:
  78. return apr_md5_crypt.verify(password, hash_value.strip())
  79. def login(self, login: str, password: str) -> str:
  80. """Validate credentials.
  81. Iterate through htpasswd credential file until login matches, extract
  82. hash (encrypted password) and check hash against password,
  83. using the method specified in the Radicale config.
  84. The content of the file is not cached because reading is generally a
  85. very cheap operation, and it's useful to get live updates of the
  86. htpasswd file.
  87. """
  88. try:
  89. with open(self._filename, encoding=self._encoding) as f:
  90. for line in f:
  91. line = line.rstrip("\n")
  92. if line.lstrip() and not line.lstrip().startswith("#"):
  93. try:
  94. hash_login, hash_value = line.split(
  95. ":", maxsplit=1)
  96. # Always compare both login and password to avoid
  97. # timing attacks, see #591.
  98. login_ok = hmac.compare_digest(
  99. hash_login.encode(), login.encode())
  100. password_ok = self._verify(hash_value, password)
  101. if login_ok and password_ok:
  102. return login
  103. except ValueError as e:
  104. raise RuntimeError("Invalid htpasswd file %r: %s" %
  105. (self._filename, e)) from e
  106. except OSError as e:
  107. raise RuntimeError("Failed to load htpasswd file %r: %s" %
  108. (self._filename, e)) from e
  109. return ""