htpasswd.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. # This file is part of Radicale Server - Calendar Server
  2. # Copyright © 2008 Nicolas Kandel
  3. # Copyright © 2008 Pascal Halter
  4. # Copyright © 2008-2017 Guillaume Ayoub
  5. # Copyright © 2017-2018 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. import base64
  20. import functools
  21. import hashlib
  22. import hmac
  23. import os
  24. from radicale import auth
  25. class Auth(auth.BaseAuth):
  26. def __init__(self, configuration):
  27. super().__init__(configuration)
  28. self.filename = os.path.expanduser(
  29. configuration.get("auth", "htpasswd_filename"))
  30. self.encryption = configuration.get("auth", "htpasswd_encryption")
  31. if self.encryption == "ssha":
  32. self.verify = self._ssha
  33. elif self.encryption == "sha1":
  34. self.verify = self._sha1
  35. elif self.encryption == "plain":
  36. self.verify = self._plain
  37. elif self.encryption == "md5":
  38. try:
  39. from passlib.hash import apr_md5_crypt
  40. except ImportError as e:
  41. raise RuntimeError(
  42. "The htpasswd encryption method 'md5' requires "
  43. "the passlib module.") from e
  44. self.verify = functools.partial(self._md5apr1, apr_md5_crypt)
  45. elif self.encryption == "bcrypt":
  46. try:
  47. from passlib.hash import bcrypt
  48. except ImportError as e:
  49. raise RuntimeError(
  50. "The htpasswd encryption method 'bcrypt' requires "
  51. "the passlib module with bcrypt support.") from e
  52. # A call to `encrypt` raises passlib.exc.MissingBackendError with a
  53. # good error message if bcrypt backend is not available. Trigger
  54. # this here.
  55. bcrypt.hash("test-bcrypt-backend")
  56. self.verify = functools.partial(self._bcrypt, bcrypt)
  57. elif self.encryption == "crypt":
  58. try:
  59. import crypt
  60. except ImportError as e:
  61. raise RuntimeError(
  62. "The htpasswd encryption method 'crypt' requires "
  63. "the crypt() system support.") from e
  64. self.verify = functools.partial(self._crypt, crypt)
  65. else:
  66. raise RuntimeError(
  67. "The htpasswd encryption method %r is not "
  68. "supported." % self.encryption)
  69. def _plain(self, hash_value, password):
  70. """Check if ``hash_value`` and ``password`` match, plain method."""
  71. return hmac.compare_digest(hash_value, password)
  72. def _crypt(self, crypt, hash_value, password):
  73. """Check if ``hash_value`` and ``password`` match, crypt method."""
  74. hash_value = hash_value.strip()
  75. return hmac.compare_digest(crypt.crypt(password, hash_value),
  76. hash_value)
  77. def _sha1(self, hash_value, password):
  78. """Check if ``hash_value`` and ``password`` match, sha1 method."""
  79. hash_value = base64.b64decode(hash_value.strip().replace(
  80. "{SHA}", "").encode("ascii"))
  81. password = password.encode(self.configuration.get("encoding", "stock"))
  82. sha1 = hashlib.sha1()
  83. sha1.update(password)
  84. return hmac.compare_digest(sha1.digest(), hash_value)
  85. def _ssha(self, hash_value, password):
  86. """Check if ``hash_value`` and ``password`` match, salted sha1 method.
  87. This method is not directly supported by htpasswd, but it can be
  88. written with e.g. openssl, and nginx can parse it.
  89. """
  90. hash_value = base64.b64decode(hash_value.strip().replace(
  91. "{SSHA}", "").encode("ascii"))
  92. password = password.encode(self.configuration.get("encoding", "stock"))
  93. salt_value = hash_value[20:]
  94. hash_value = hash_value[:20]
  95. sha1 = hashlib.sha1()
  96. sha1.update(password)
  97. sha1.update(salt_value)
  98. return hmac.compare_digest(sha1.digest(), hash_value)
  99. def _bcrypt(self, bcrypt, hash_value, password):
  100. hash_value = hash_value.strip()
  101. return bcrypt.verify(password, hash_value)
  102. def _md5apr1(self, md5_apr1, hash_value, password):
  103. hash_value = hash_value.strip()
  104. return md5_apr1.verify(password, hash_value)
  105. def login(self, login, password):
  106. """Validate credentials.
  107. Iterate through htpasswd credential file until login matches, extract
  108. hash (encrypted password) and check hash against password,
  109. using the method specified in the Radicale config.
  110. The content of the file is not cached because reading is generally a
  111. very cheap operation, and it's useful to get live updates of the
  112. htpasswd file.
  113. """
  114. try:
  115. with open(self.filename) as f:
  116. for line in f:
  117. line = line.rstrip("\n")
  118. if line.lstrip() and not line.lstrip().startswith("#"):
  119. try:
  120. hash_login, hash_value = line.split(
  121. ":", maxsplit=1)
  122. # Always compare both login and password to avoid
  123. # timing attacks, see #591.
  124. login_ok = hmac.compare_digest(hash_login, login)
  125. password_ok = self.verify(hash_value, password)
  126. if login_ok and password_ok:
  127. return login
  128. except ValueError as e:
  129. raise RuntimeError("Invalid htpasswd file %r: %s" %
  130. (self.filename, e)) from e
  131. except OSError as e:
  132. raise RuntimeError("Failed to load htpasswd file %r: %s" %
  133. (self.filename, e)) from e
  134. return ""