security.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. """
  2. Password generation for the Jupyter Server.
  3. """
  4. import getpass
  5. import hashlib
  6. import json
  7. import os
  8. import random
  9. import traceback
  10. import warnings
  11. from contextlib import contextmanager
  12. from jupyter_core.paths import jupyter_config_dir
  13. from traitlets.config import Config
  14. from traitlets.config.loader import ConfigFileNotFound, JSONFileConfigLoader
  15. # Length of the salt in nr of hex chars, which implies salt_len * 4
  16. # bits of randomness.
  17. salt_len = 12
  18. def passwd(passphrase=None, algorithm="argon2"):
  19. """Generate hashed password and salt for use in server configuration.
  20. In the server configuration, set `c.ServerApp.password` to
  21. the generated string.
  22. Parameters
  23. ----------
  24. passphrase : str
  25. Password to hash. If unspecified, the user is asked to input
  26. and verify a password.
  27. algorithm : str
  28. Hashing algorithm to use (e.g, 'sha1' or any argument supported
  29. by :func:`hashlib.new`, or 'argon2').
  30. Returns
  31. -------
  32. hashed_passphrase : str
  33. Hashed password, in the format 'hash_algorithm:salt:passphrase_hash'.
  34. Examples
  35. --------
  36. >>> passwd("mypassword") # doctest: +ELLIPSIS
  37. 'argon2:...'
  38. """
  39. if passphrase is None:
  40. for _ in range(3):
  41. p0 = getpass.getpass("Enter password: ")
  42. p1 = getpass.getpass("Verify password: ")
  43. if p0 == p1:
  44. passphrase = p0
  45. break
  46. warnings.warn("Passwords do not match.", stacklevel=2)
  47. else:
  48. msg = "No matching passwords found. Giving up."
  49. raise ValueError(msg)
  50. if algorithm == "argon2":
  51. import argon2
  52. ph = argon2.PasswordHasher(
  53. memory_cost=10240,
  54. time_cost=10,
  55. parallelism=8,
  56. )
  57. h_ph = ph.hash(passphrase)
  58. return f"{algorithm}:{h_ph}"
  59. h = hashlib.new(algorithm)
  60. salt = ("%0" + str(salt_len) + "x") % random.getrandbits(4 * salt_len)
  61. h.update(passphrase.encode("utf-8") + salt.encode("ascii"))
  62. return f"{algorithm}:{salt}:{h.hexdigest()}"
  63. def passwd_check(hashed_passphrase, passphrase):
  64. """Verify that a given passphrase matches its hashed version.
  65. Parameters
  66. ----------
  67. hashed_passphrase : str
  68. Hashed password, in the format returned by `passwd`.
  69. passphrase : str
  70. Passphrase to validate.
  71. Returns
  72. -------
  73. valid : bool
  74. True if the passphrase matches the hash.
  75. Examples
  76. --------
  77. >>> myhash = passwd("mypassword")
  78. >>> passwd_check(myhash, "mypassword")
  79. True
  80. >>> passwd_check(myhash, "otherpassword")
  81. False
  82. >>> passwd_check("sha1:0e112c3ddfce:a68df677475c2b47b6e86d0467eec97ac5f4b85a", "mypassword")
  83. True
  84. """
  85. if hashed_passphrase.startswith("argon2:"):
  86. import argon2
  87. import argon2.exceptions
  88. ph = argon2.PasswordHasher()
  89. try:
  90. return ph.verify(hashed_passphrase[7:], passphrase)
  91. except argon2.exceptions.VerificationError:
  92. return False
  93. try:
  94. algorithm, salt, pw_digest = hashed_passphrase.split(":", 2)
  95. except (ValueError, TypeError):
  96. return False
  97. try:
  98. h = hashlib.new(algorithm)
  99. except ValueError:
  100. return False
  101. if len(pw_digest) == 0:
  102. return False
  103. h.update(passphrase.encode("utf-8") + salt.encode("ascii"))
  104. return h.hexdigest() == pw_digest
  105. @contextmanager
  106. def persist_config(config_file=None, mode=0o600):
  107. """Context manager that can be used to modify a config object
  108. On exit of the context manager, the config will be written back to disk,
  109. by default with user-only (600) permissions.
  110. """
  111. if config_file is None:
  112. config_file = os.path.join(jupyter_config_dir(), "jupyter_server_config.json")
  113. os.makedirs(os.path.dirname(config_file), exist_ok=True)
  114. loader = JSONFileConfigLoader(os.path.basename(config_file), os.path.dirname(config_file))
  115. try:
  116. config = loader.load_config()
  117. except ConfigFileNotFound:
  118. config = Config()
  119. yield config
  120. with open(config_file, "w", encoding="utf8") as f:
  121. f.write(json.dumps(config, indent=2))
  122. try:
  123. os.chmod(config_file, mode)
  124. except Exception:
  125. tb = traceback.format_exc()
  126. warnings.warn(
  127. f"Failed to set permissions on {config_file}:\n{tb}", RuntimeWarning, stacklevel=2
  128. )
  129. def set_password(password=None, config_file=None):
  130. """Ask user for password, store it in JSON configuration file"""
  131. hashed_password = passwd(password)
  132. with persist_config(config_file) as config:
  133. config.IdentityProvider.hashed_password = hashed_password
  134. return hashed_password