auth.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  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. #
  6. # This library is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This library is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. # GNU General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU General Public License
  17. # along with Radicale. If not, see <http://www.gnu.org/licenses/>.
  18. """
  19. Authentication management.
  20. Default is htpasswd authentication.
  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 methods, e.g. BCRYPT, MD5-APR1 (a version of MD5 modified for
  24. Apache), SHA1, or by using the system's CRYPT routine. The CRYPT and SHA1
  25. encryption methods implemented by htpasswd are considered as insecure. MD5-APR1
  26. provides medium security as of 2015. Only BCRYPT can be considered secure by
  27. current standards.
  28. MD5-APR1-encrypted credentials can be written by all versions of htpasswd (it
  29. is the default, in fact), whereas BCRYPT requires htpasswd 2.4.x or newer.
  30. The `is_authenticated(user, password)` function provided by this module
  31. verifies the user-given credentials by parsing the htpasswd credential file
  32. pointed to by the ``htpasswd_filename`` configuration value while assuming
  33. the password encryption method specified via the ``htpasswd_encryption``
  34. configuration value.
  35. The following htpasswd password encrpytion methods are supported by Radicale
  36. out-of-the-box:
  37. - plain-text (created by htpasswd -p...) -- INSECURE
  38. - CRYPT (created by htpasswd -d...) -- INSECURE
  39. - SHA1 (created by htpasswd -s...) -- INSECURE
  40. When passlib (https://pypi.python.org/pypi/passlib) is importable, the
  41. following significantly more secure schemes are parsable by Radicale:
  42. - MD5-APR1 (htpasswd -m...) -- htpasswd's default method
  43. - BCRYPT (htpasswd -B...) -- Requires htpasswd 2.4.x
  44. """
  45. import base64
  46. import functools
  47. import hashlib
  48. import hmac
  49. import os
  50. from importlib import import_module
  51. INTERNAL_TYPES = ("None", "none", "remote_user", "http_x_remote_user",
  52. "htpasswd")
  53. def load(configuration, logger):
  54. """Load the authentication manager chosen in configuration."""
  55. auth_type = configuration.get("auth", "type")
  56. if auth_type in ("None", "none"): # DEPRECATED: use "none"
  57. class_ = NoneAuth
  58. elif auth_type == "remote_user":
  59. class_ = RemoteUserAuth
  60. elif auth_type == "http_x_remote_user":
  61. class_ = HttpXRemoteUserAuth
  62. elif auth_type == "htpasswd":
  63. class_ = Auth
  64. else:
  65. try:
  66. class_ = import_module(auth_type).Auth
  67. except Exception as e:
  68. raise RuntimeError("Failed to load authentication module %r: %s" %
  69. (auth_type, e)) from e
  70. logger.info("Authentication type is %r", auth_type)
  71. return class_(configuration, logger)
  72. class BaseAuth:
  73. def __init__(self, configuration, logger):
  74. self.configuration = configuration
  75. self.logger = logger
  76. def get_external_login(self, environ):
  77. """Optionally provide the login and password externally.
  78. ``environ`` a dict with the WSGI environment
  79. If ``()`` is returned, Radicale handles HTTP authentication.
  80. Otherwise, returns a tuple ``(login, password)``. For anonymous users
  81. ``login`` must be ``""``.
  82. """
  83. return ()
  84. def is_authenticated2(self, login, user, password):
  85. """Validate credentials.
  86. ``login`` the login name
  87. ``user`` the user from ``map_login_to_user(login)``.
  88. ``password`` the login password
  89. """
  90. return self.is_authenticated(user, password)
  91. def is_authenticated(self, user, password):
  92. """Validate credentials.
  93. DEPRECATED: use ``is_authenticated2`` instead
  94. """
  95. raise NotImplementedError
  96. def map_login_to_user(self, login):
  97. """Map login name to internal user.
  98. ``login`` the login name, ``""`` for anonymous users
  99. Returns a string with the user name.
  100. If a login can't be mapped to an user, return ``login`` and
  101. return ``False`` in ``is_authenticated2(...)``.
  102. """
  103. return login
  104. class NoneAuth(BaseAuth):
  105. def is_authenticated(self, user, password):
  106. return True
  107. class Auth(BaseAuth):
  108. def __init__(self, configuration, logger):
  109. super().__init__(configuration, logger)
  110. self.filename = os.path.expanduser(
  111. configuration.get("auth", "htpasswd_filename"))
  112. self.encryption = configuration.get("auth", "htpasswd_encryption")
  113. if self.encryption == "ssha":
  114. self.verify = self._ssha
  115. elif self.encryption == "sha1":
  116. self.verify = self._sha1
  117. elif self.encryption == "plain":
  118. self.verify = self._plain
  119. elif self.encryption == "md5":
  120. try:
  121. from passlib.hash import apr_md5_crypt
  122. except ImportError as e:
  123. raise RuntimeError(
  124. "The htpasswd encryption method 'md5' requires "
  125. "the passlib module.") from e
  126. self.verify = functools.partial(self._md5apr1, apr_md5_crypt)
  127. elif self.encryption == "bcrypt":
  128. try:
  129. from passlib.hash import bcrypt
  130. except ImportError as e:
  131. raise RuntimeError(
  132. "The htpasswd encryption method 'bcrypt' requires "
  133. "the passlib module with bcrypt support.") from e
  134. # A call to `encrypt` raises passlib.exc.MissingBackendError with a
  135. # good error message if bcrypt backend is not available. Trigger
  136. # this here.
  137. bcrypt.encrypt("test-bcrypt-backend")
  138. self.verify = functools.partial(self._bcrypt, bcrypt)
  139. elif self.encryption == "crypt":
  140. try:
  141. import crypt
  142. except ImportError as e:
  143. raise RuntimeError(
  144. "The htpasswd encryption method 'crypt' requires "
  145. "the crypt() system support.") from e
  146. self.verify = functools.partial(self._crypt, crypt)
  147. else:
  148. raise RuntimeError(
  149. "The htpasswd encryption method %r is not "
  150. "supported." % self.encryption)
  151. def _plain(self, hash_value, password):
  152. """Check if ``hash_value`` and ``password`` match, plain method."""
  153. return hmac.compare_digest(hash_value, password)
  154. def _crypt(self, crypt, hash_value, password):
  155. """Check if ``hash_value`` and ``password`` match, crypt method."""
  156. hash_value = hash_value.strip()
  157. return hmac.compare_digest(crypt.crypt(password, hash_value),
  158. hash_value)
  159. def _sha1(self, hash_value, password):
  160. """Check if ``hash_value`` and ``password`` match, sha1 method."""
  161. hash_value = base64.b64decode(hash_value.strip().replace(
  162. "{SHA}", "").encode("ascii"))
  163. password = password.encode(self.configuration.get("encoding", "stock"))
  164. sha1 = hashlib.sha1()
  165. sha1.update(password)
  166. return hmac.compare_digest(sha1.digest(), hash_value)
  167. def _ssha(self, hash_value, password):
  168. """Check if ``hash_value`` and ``password`` match, salted sha1 method.
  169. This method is not directly supported by htpasswd, but it can be
  170. written with e.g. openssl, and nginx can parse it.
  171. """
  172. hash_value = base64.b64decode(hash_value.strip().replace(
  173. "{SSHA}", "").encode("ascii"))
  174. password = password.encode(self.configuration.get("encoding", "stock"))
  175. salt_value = hash_value[20:]
  176. hash_value = hash_value[:20]
  177. sha1 = hashlib.sha1()
  178. sha1.update(password)
  179. sha1.update(salt_value)
  180. return hmac.compare_digest(sha1.digest(), hash_value)
  181. def _bcrypt(self, bcrypt, hash_value, password):
  182. hash_value = hash_value.strip()
  183. return bcrypt.verify(password, hash_value)
  184. def _md5apr1(self, md5_apr1, hash_value, password):
  185. hash_value = hash_value.strip()
  186. return md5_apr1.verify(password, hash_value)
  187. def is_authenticated(self, user, password):
  188. """Validate credentials.
  189. Iterate through htpasswd credential file until user matches, extract
  190. hash (encrypted password) and check hash against user-given password,
  191. using the method specified in the Radicale config.
  192. The content of the file is not cached because reading is generally a
  193. very cheap operation, and it's useful to get live updates of the
  194. htpasswd file.
  195. """
  196. try:
  197. with open(self.filename) as f:
  198. for line in f:
  199. line = line.rstrip("\n")
  200. if line.lstrip() and not line.lstrip().startswith("#"):
  201. try:
  202. login, hash_value = line.split(":", maxsplit=1)
  203. # Always compare both login and password to avoid
  204. # timing attacks, see #591.
  205. login_ok = hmac.compare_digest(login, user)
  206. password_ok = self.verify(hash_value, password)
  207. if login_ok and password_ok:
  208. return True
  209. except ValueError as e:
  210. raise RuntimeError("Invalid htpasswd file %r: %s" %
  211. (self.filename, e)) from e
  212. except OSError as e:
  213. raise RuntimeError("Failed to load htpasswd file %r: %s" %
  214. (self.filename, e)) from e
  215. return False
  216. class RemoteUserAuth(NoneAuth):
  217. def get_external_login(self, environ):
  218. return environ.get("REMOTE_USER", ""), ""
  219. class HttpXRemoteUserAuth(NoneAuth):
  220. def get_external_login(self, environ):
  221. return environ.get("HTTP_X_REMOTE_USER", ""), ""