"""passlib.apache - apache password support""" # XXX: relocate this to passlib.ext.apache? #============================================================================= # imports #============================================================================= from __future__ import with_statement # core import logging; log = logging.getLogger(__name__) import os from warnings import warn # site # pkg from passlib import exc, registry from passlib.context import CryptContext from passlib.exc import ExpectedStringError from passlib.hash import htdigest from passlib.utils import render_bytes, to_bytes, is_ascii_codec from passlib.utils.decor import deprecated_method from passlib.utils.compat import join_bytes, unicode, BytesIO, PY3 # local __all__ = [ 'HtpasswdFile', 'HtdigestFile', ] #============================================================================= # constants & support #============================================================================= _UNSET = object() _BCOLON = b":" _BHASH = b"#" # byte values that aren't allowed in fields. _INVALID_FIELD_CHARS = b":\n\r\t\x00" #: _CommonFile._source token types _SKIPPED = "skipped" _RECORD = "record" #============================================================================= # common helpers #============================================================================= class _CommonFile(object): """common framework for HtpasswdFile & HtdigestFile""" #=================================================================== # instance attrs #=================================================================== # charset encoding used by file (defaults to utf-8) encoding = None # whether users() and other public methods should return unicode or bytes? # (defaults to False under PY2, True under PY3) return_unicode = None # if bound to local file, these will be set. _path = None # local file path _mtime = None # mtime when last loaded, or 0 # if true, automatically save to local file after changes are made. autosave = False # dict mapping key -> value for all records in database. # (e.g. user => hash for Htpasswd) _records = None #: list of tokens for recreating original file contents when saving. if present, #: will be sequence of (_SKIPPED, b"whitespace/comments") and (_RECORD, ) tuples. _source = None #=================================================================== # alt constuctors #=================================================================== @classmethod def from_string(cls, data, **kwds): """create new object from raw string. :type data: unicode or bytes :arg data: database to load, as single string. :param \*\*kwds: all other keywords are the same as in the class constructor """ if 'path' in kwds: raise TypeError("'path' not accepted by from_string()") self = cls(**kwds) self.load_string(data) return self @classmethod def from_path(cls, path, **kwds): """create new object from file, without binding object to file. :type path: str :arg path: local filepath to load from :param \*\*kwds: all other keywords are the same as in the class constructor """ self = cls(**kwds) self.load(path) return self #=================================================================== # init #=================================================================== def __init__(self, path=None, new=False, autoload=True, autosave=False, encoding="utf-8", return_unicode=PY3, ): # set encoding if not encoding: warn("``encoding=None`` is deprecated as of Passlib 1.6, " "and will cause a ValueError in Passlib 1.8, " "use ``return_unicode=False`` instead.", DeprecationWarning, stacklevel=2) encoding = "utf-8" return_unicode = False elif not is_ascii_codec(encoding): # htpasswd/htdigest files assumes 1-byte chars, and use ":" separator, # so only ascii-compatible encodings are allowed. raise ValueError("encoding must be 7-bit ascii compatible") self.encoding = encoding # set other attrs self.return_unicode = return_unicode self.autosave = autosave self._path = path self._mtime = 0 # init db if not autoload: warn("``autoload=False`` is deprecated as of Passlib 1.6, " "and will be removed in Passlib 1.8, use ``new=True`` instead", DeprecationWarning, stacklevel=2) new = True if path and not new: self.load() else: self._records = {} self._source = [] def __repr__(self): tail = '' if self.autosave: tail += ' autosave=True' if self._path: tail += ' path=%r' % self._path if self.encoding != "utf-8": tail += ' encoding=%r' % self.encoding return "<%s 0x%0x%s>" % (self.__class__.__name__, id(self), tail) # NOTE: ``path`` is a property so that ``_mtime`` is wiped when it's set. @property def path(self): return self._path @path.setter def path(self, value): if value != self._path: self._mtime = 0 self._path = value @property def mtime(self): """modify time when last loaded (if bound to a local file)""" return self._mtime #=================================================================== # loading #=================================================================== def load_if_changed(self): """Reload from ``self.path`` only if file has changed since last load""" if not self._path: raise RuntimeError("%r is not bound to a local file" % self) if self._mtime and self._mtime == os.path.getmtime(self._path): return False self.load() return True def load(self, path=None, force=True): """Load state from local file. If no path is specified, attempts to load from ``self.path``. :type path: str :arg path: local file to load from :type force: bool :param force: if ``force=False``, only load from ``self.path`` if file has changed since last load. .. deprecated:: 1.6 This keyword will be removed in Passlib 1.8; Applications should use :meth:`load_if_changed` instead. """ if path is not None: with open(path, "rb") as fh: self._mtime = 0 self._load_lines(fh) elif not force: warn("%(name)s.load(force=False) is deprecated as of Passlib 1.6," "and will be removed in Passlib 1.8; " "use %(name)s.load_if_changed() instead." % dict(name=self.__class__.__name__), DeprecationWarning, stacklevel=2) return self.load_if_changed() elif self._path: with open(self._path, "rb") as fh: self._mtime = os.path.getmtime(self._path) self._load_lines(fh) else: raise RuntimeError("%s().path is not set, an explicit path is required" % self.__class__.__name__) return True def load_string(self, data): """Load state from unicode or bytes string, replacing current state""" data = to_bytes(data, self.encoding, "data") self._mtime = 0 self._load_lines(BytesIO(data)) def _load_lines(self, lines): """load from sequence of lists""" parse = self._parse_record records = {} source = [] skipped = b'' for idx, line in enumerate(lines): # NOTE: per htpasswd source (https://github.com/apache/httpd/blob/trunk/support/htpasswd.c), # lines with only whitespace, or with "#" as first non-whitespace char, # are left alone / ignored. tmp = line.lstrip() if not tmp or tmp.startswith(_BHASH): skipped += line continue # parse valid line key, value = parse(line, idx+1) # NOTE: if multiple entries for a key, we use the first one, # which seems to match htpasswd source if key in records: log.warning("username occurs multiple times in source file: %r" % key) skipped += line continue # flush buffer of skipped whitespace lines if skipped: source.append((_SKIPPED, skipped)) skipped = b'' # store new user line records[key] = value source.append((_RECORD, key)) # don't bother preserving trailing whitespace, but do preserve trailing comments if skipped.rstrip(): source.append((_SKIPPED, skipped)) # NOTE: not replacing ._records until parsing succeeds, so loading is atomic. self._records = records self._source = source def _parse_record(self, record, lineno): # pragma: no cover - abstract method """parse line of file into (key, value) pair""" raise NotImplementedError("should be implemented in subclass") def _set_record(self, key, value): """ helper for setting record which takes care of inserting source line if needed; :returns: bool if key already present """ records = self._records existing = (key in records) records[key] = value if not existing: self._source.append((_RECORD, key)) return existing #=================================================================== # saving #=================================================================== def _autosave(self): """subclass helper to call save() after any changes""" if self.autosave and self._path: self.save() def save(self, path=None): """Save current state to file. If no path is specified, attempts to save to ``self.path``. """ if path is not None: with open(path, "wb") as fh: fh.writelines(self._iter_lines()) elif self._path: self.save(self._path) self._mtime = os.path.getmtime(self._path) else: raise RuntimeError("%s().path is not set, cannot autosave" % self.__class__.__name__) def to_string(self): """Export current state as a string of bytes""" return join_bytes(self._iter_lines()) # def clean(self): # """ # discard any comments or whitespace that were being preserved from the source file, # and re-sort keys in alphabetical order # """ # self._source = [(_RECORD, key) for key in sorted(self._records)] # self._autosave() def _iter_lines(self): """iterator yielding lines of database""" # NOTE: this relies on being an OrderedDict so that it outputs # records in a deterministic order. records = self._records if __debug__: pending = set(records) for action, content in self._source: if action == _SKIPPED: # 'content' is whitespace/comments to write yield content else: assert action == _RECORD # 'content' is record key if content not in records: # record was deleted # NOTE: doing it lazily like this so deleting & re-adding user # preserves their original location in the file. continue yield self._render_record(content, records[content]) if __debug__: pending.remove(content) if __debug__: # sanity check that we actually wrote all the records # (otherwise _source & _records are somehow out of sync) assert not pending, "failed to write all records: missing=%r" % (pending,) def _render_record(self, key, value): # pragma: no cover - abstract method """given key/value pair, encode as line of file""" raise NotImplementedError("should be implemented in subclass") #=================================================================== # field encoding #=================================================================== def _encode_user(self, user): """user-specific wrapper for _encode_field()""" return self._encode_field(user, "user") def _encode_realm(self, realm): # pragma: no cover - abstract method """realm-specific wrapper for _encode_field()""" return self._encode_field(realm, "realm") def _encode_field(self, value, param="field"): """convert field to internal representation. internal representation is always bytes. byte strings are left as-is, unicode strings encoding using file's default encoding (or ``utf-8`` if no encoding has been specified). :raises UnicodeEncodeError: if unicode value cannot be encoded using default encoding. :raises ValueError: if resulting byte string contains a forbidden character, or is too long (>255 bytes). :returns: encoded identifer as bytes """ if isinstance(value, unicode): value = value.encode(self.encoding) elif not isinstance(value, bytes): raise ExpectedStringError(value, param) if len(value) > 255: raise ValueError("%s must be at most 255 characters: %r" % (param, value)) if any(c in _INVALID_FIELD_CHARS for c in value): raise ValueError("%s contains invalid characters: %r" % (param, value,)) return value def _decode_field(self, value): """decode field from internal representation to format returns by users() method, etc. :raises UnicodeDecodeError: if unicode value cannot be decoded using default encoding. (usually indicates wrong encoding set for file). :returns: field as unicode or bytes, as appropriate. """ assert isinstance(value, bytes), "expected value to be bytes" if self.return_unicode: return value.decode(self.encoding) else: return value # FIXME: htpasswd doc says passwords limited to 255 chars under Windows & MPE, # and that longer ones are truncated. this may be side-effect of those # platforms supporting the 'plaintext' scheme. these classes don't currently # check for this. #=================================================================== # eoc #=================================================================== #============================================================================= # htpasswd context # # This section sets up a CryptContexts to mimic what schemes Apache # (and the htpasswd tool) should support on the current system. # # Apache has long-time supported some basic builtin schemes (listed below), # as well as the host's crypt() method -- though it's limited to being able # to *verify* any scheme using that method, but can only generate "des_crypt" hashes. # # Apache 2.4 added builtin bcrypt support (even for platforms w/o native support). # c.f. http://httpd.apache.org/docs/2.4/programs/htpasswd.html vs the 2.2 docs. #============================================================================= #: set of default schemes that (if chosen) should be using bcrypt, #: but can't due to lack of bcrypt. _warn_no_bcrypt = set() def _init_default_schemes(): #: pick strongest one for host host_best = None for name in ["bcrypt", "sha256_crypt"]: if registry.has_os_crypt_support(name): host_best = name break # check if we have a bcrypt backend -- otherwise issue warning # XXX: would like to not spam this unless the user *requests* apache 24 bcrypt = "bcrypt" if registry.has_backend("bcrypt") else None _warn_no_bcrypt.clear() if not bcrypt: _warn_no_bcrypt.update(["portable_apache_24", "host_apache_24", "linux_apache_24", "portable", "host"]) defaults = dict( # strongest hash builtin to specific apache version portable_apache_24=bcrypt or "apr_md5_crypt", portable_apache_22="apr_md5_crypt", # strongest hash across current host & specific apache version host_apache_24=bcrypt or host_best or "apr_md5_crypt", host_apache_22=host_best or "apr_md5_crypt", # strongest hash on a linux host linux_apache_24=bcrypt or "sha256_crypt", linux_apache_22="sha256_crypt", ) # set latest-apache version aliases # XXX: could check for apache install, and pick correct host 22/24 default? defaults.update( portable=defaults['portable_apache_24'], host=defaults['host_apache_24'], ) return defaults #: dict mapping default alias -> appropriate scheme htpasswd_defaults = _init_default_schemes() def _init_htpasswd_context(): # start with schemes built into apache schemes = [ # builtin support added in apache 2.4 # (https://bz.apache.org/bugzilla/show_bug.cgi?id=49288) "bcrypt", # support not "builtin" to apache, instead it requires support through host's crypt(). # adding them here to allow editing htpasswd under windows and then deploying under unix. "sha256_crypt", "sha512_crypt", "des_crypt", # apache default as of 2.2.18, and still default in 2.4 "apr_md5_crypt", # NOTE: apache says ONLY intended for transitioning htpasswd <-> ldap "ldap_sha1", # NOTE: apache says ONLY supported on Windows, Netware, TPF "plaintext" ] # apache can verify anything supported by the native crypt(), # though htpasswd tool can only generate a limited set of hashes. # (this list may overlap w/ builtin apache schemes) schemes.extend(registry.get_supported_os_crypt_schemes()) # hack to remove dups and sort into preferred order preferred = schemes[:3] + ["apr_md5_crypt"] + schemes schemes = sorted(set(schemes), key=preferred.index) # NOTE: default will change to "portable" in passlib 2.0 return CryptContext(schemes, default=htpasswd_defaults['portable_apache_22']) #: CryptContext configured to match htpasswd htpasswd_context = _init_htpasswd_context() #============================================================================= # htpasswd editing #============================================================================= class HtpasswdFile(_CommonFile): """class for reading & writing Htpasswd files. The class constructor accepts the following arguments: :type path: filepath :param path: Specifies path to htpasswd file, use to implicitly load from and save to. This class has two modes of operation: 1. It can be "bound" to a local file by passing a ``path`` to the class constructor. In this case it will load the contents of the file when created, and the :meth:`load` and :meth:`save` methods will automatically load from and save to that file if they are called without arguments. 2. Alternately, it can exist as an independant object, in which case :meth:`load` and :meth:`save` will require an explicit path to be provided whenever they are called. As well, ``autosave`` behavior will not be available. This feature is new in Passlib 1.6, and is the default if no ``path`` value is provided to the constructor. This is also exposed as a readonly instance attribute. :type new: bool :param new: Normally, if *path* is specified, :class:`HtpasswdFile` will immediately load the contents of the file. However, when creating a new htpasswd file, applications can set ``new=True`` so that the existing file (if any) will not be loaded. .. versionadded:: 1.6 This feature was previously enabled by setting ``autoload=False``. That alias has been deprecated, and will be removed in Passlib 1.8 :type autosave: bool :param autosave: Normally, any changes made to an :class:`HtpasswdFile` instance will not be saved until :meth:`save` is explicitly called. However, if ``autosave=True`` is specified, any changes made will be saved to disk immediately (assuming *path* has been set). This is also exposed as a writeable instance attribute. :type encoding: str :param encoding: Optionally specify character encoding used to read/write file and hash passwords. Defaults to ``utf-8``, though ``latin-1`` is the only other commonly encountered encoding. This is also exposed as a readonly instance attribute. :type default_scheme: str :param default_scheme: Optionally specify default scheme to use when encoding new passwords. This can be any of the schemes with builtin Apache support, OR natively supported by the host OS's :func:`crypt.crypt` function. * Builtin schemes include ``"bcrypt"`` (apache 2.4+), ``"apr_md5_crypt"`, and ``"des_crypt"``. * Schemes commonly supported by Unix hosts include ``"bcrypt"``, ``"sha256_crypt"``, and ``"des_crypt"``. In order to not have to sort out what you should use, passlib offers a number of aliases, that will resolve to the most appropriate scheme based on your needs: * ``"portable"``, ``"portable_apache_24"`` -- pick scheme that's portable across hosts running apache >= 2.4. **This will be the default as of Passlib 2.0**. * ``"portable_apache_22"`` -- pick scheme that's portable across hosts running apache >= 2.4. **This is the default up to Passlib 1.9**. * ``"host"``, ``"host_apache_24"`` -- pick strongest scheme supported by apache >= 2.4 and/or host OS. * ``"host_apache_22"`` -- pick strongest scheme supported by apache >= 2.2 and/or host OS. .. versionadded:: 1.6 This keyword was previously named ``default``. That alias has been deprecated, and will be removed in Passlib 1.8. .. versionchanged:: 1.6.3 Added support for ``"bcrypt"``, ``"sha256_crypt"``, and ``"portable"`` alias. .. versionchanged:: 1.7 Added apache 2.4 semantics, and additional aliases. :type context: :class:`~passlib.context.CryptContext` :param context: :class:`!CryptContext` instance used to create and verify the hashes found in the htpasswd file. The default value is a pre-built context which supports all of the hashes officially allowed in an htpasswd file. This is also exposed as a readonly instance attribute. .. warning:: This option may be used to add support for non-standard hash formats to an htpasswd file. However, the resulting file will probably not be usable by another application, and particularly not by Apache. :param autoload: Set to ``False`` to prevent the constructor from automatically loaded the file from disk. .. deprecated:: 1.6 This has been replaced by the *new* keyword. Instead of setting ``autoload=False``, you should use ``new=True``. Support for this keyword will be removed in Passlib 1.8. :param default: Change the default algorithm used to hash new passwords. .. deprecated:: 1.6 This has been renamed to *default_scheme* for clarity. Support for this alias will be removed in Passlib 1.8. Loading & Saving ================ .. automethod:: load .. automethod:: load_if_changed .. automethod:: load_string .. automethod:: save .. automethod:: to_string Inspection ================ .. automethod:: users .. automethod:: check_password .. automethod:: get_hash Modification ================ .. automethod:: set_password .. automethod:: delete Alternate Constructors ====================== .. automethod:: from_string Attributes ========== .. attribute:: path Path to local file that will be used as the default for all :meth:`load` and :meth:`save` operations. May be written to, initialized by the *path* constructor keyword. .. attribute:: autosave Writeable flag indicating whether changes will be automatically written to *path*. Errors ====== :raises ValueError: All of the methods in this class will raise a :exc:`ValueError` if any user name contains a forbidden character (one of ``:\\r\\n\\t\\x00``), or is longer than 255 characters. """ #=================================================================== # instance attrs #=================================================================== # NOTE: _records map stores for the key, and for the value, # both in bytes which use self.encoding #=================================================================== # init & serialization #=================================================================== def __init__(self, path=None, default_scheme=None, context=htpasswd_context, **kwds): if 'default' in kwds: warn("``default`` is deprecated as of Passlib 1.6, " "and will be removed in Passlib 1.8, it has been renamed " "to ``default_scheem``.", DeprecationWarning, stacklevel=2) default_scheme = kwds.pop("default") if default_scheme: if default_scheme in _warn_no_bcrypt: warn("HtpasswdFile: no bcrypt backends available, " "using fallback for default scheme %r" % default_scheme, exc.PasslibSecurityWarning) default_scheme = htpasswd_defaults.get(default_scheme, default_scheme) context = context.copy(default=default_scheme) self.context = context super(HtpasswdFile, self).__init__(path, **kwds) def _parse_record(self, record, lineno): # NOTE: should return (user, hash) tuple result = record.rstrip().split(_BCOLON) if len(result) != 2: raise ValueError("malformed htpasswd file (error reading line %d)" % lineno) return result def _render_record(self, user, hash): return render_bytes("%s:%s\n", user, hash) #=================================================================== # public methods #=================================================================== def users(self): """ Return list of all users in database """ return [self._decode_field(user) for user in self._records] ##def has_user(self, user): ## "check whether entry is present for user" ## return self._encode_user(user) in self._records ##def rename(self, old, new): ## """rename user account""" ## old = self._encode_user(old) ## new = self._encode_user(new) ## hash = self._records.pop(old) ## self._records[new] = hash ## self._autosave() def set_password(self, user, password): """Set password for user; adds user if needed. :returns: * ``True`` if existing user was updated. * ``False`` if user account was added. .. versionchanged:: 1.6 This method was previously called ``update``, it was renamed to prevent ambiguity with the dictionary method. The old alias is deprecated, and will be removed in Passlib 1.8. """ hash = self.context.hash(password) return self.set_hash(user, hash) @deprecated_method(deprecated="1.6", removed="1.8", replacement="set_password") def update(self, user, password): """set password for user""" return self.set_password(user, password) def get_hash(self, user): """Return hash stored for user, or ``None`` if user not found. .. versionchanged:: 1.6 This method was previously named ``find``, it was renamed for clarity. The old name is deprecated, and will be removed in Passlib 1.8. """ try: return self._records[self._encode_user(user)] except KeyError: return None def set_hash(self, user, hash): """ semi-private helper which allows writing a hash directly; adds user if needed. .. warning:: does not (currently) do any validation of the hash string .. versionadded:: 1.7 """ # assert self.context.identify(hash), "unrecognized hash format" if PY3 and isinstance(hash, str): hash = hash.encode(self.encoding) user = self._encode_user(user) existing = self._set_record(user, hash) self._autosave() return existing @deprecated_method(deprecated="1.6", removed="1.8", replacement="get_hash") def find(self, user): """return hash for user""" return self.get_hash(user) # XXX: rename to something more explicit, like delete_user()? def delete(self, user): """Delete user's entry. :returns: * ``True`` if user deleted. * ``False`` if user not found. """ try: del self._records[self._encode_user(user)] except KeyError: return False self._autosave() return True def check_password(self, user, password): """ Verify password for specified user. If algorithm marked as deprecated by CryptContext, will automatically be re-hashed. :returns: * ``None`` if user not found. * ``False`` if user found, but password does not match. * ``True`` if user found and password matches. .. versionchanged:: 1.6 This method was previously called ``verify``, it was renamed to prevent ambiguity with the :class:`!CryptContext` method. The old alias is deprecated, and will be removed in Passlib 1.8. """ user = self._encode_user(user) hash = self._records.get(user) if hash is None: return None if isinstance(password, unicode): # NOTE: encoding password to match file, making the assumption # that server will use same encoding to hash the password. password = password.encode(self.encoding) ok, new_hash = self.context.verify_and_update(password, hash) if ok and new_hash is not None: # rehash user's password if old hash was deprecated assert user in self._records # otherwise would have to use ._set_record() self._records[user] = new_hash self._autosave() return ok @deprecated_method(deprecated="1.6", removed="1.8", replacement="check_password") def verify(self, user, password): """verify password for user""" return self.check_password(user, password) #=================================================================== # eoc #=================================================================== #============================================================================= # htdigest editing #============================================================================= class HtdigestFile(_CommonFile): """class for reading & writing Htdigest files. The class constructor accepts the following arguments: :type path: filepath :param path: Specifies path to htdigest file, use to implicitly load from and save to. This class has two modes of operation: 1. It can be "bound" to a local file by passing a ``path`` to the class constructor. In this case it will load the contents of the file when created, and the :meth:`load` and :meth:`save` methods will automatically load from and save to that file if they are called without arguments. 2. Alternately, it can exist as an independant object, in which case :meth:`load` and :meth:`save` will require an explicit path to be provided whenever they are called. As well, ``autosave`` behavior will not be available. This feature is new in Passlib 1.6, and is the default if no ``path`` value is provided to the constructor. This is also exposed as a readonly instance attribute. :type default_realm: str :param default_realm: If ``default_realm`` is set, all the :class:`HtdigestFile` methods that require a realm will use this value if one is not provided explicitly. If unset, they will raise an error stating that an explicit realm is required. This is also exposed as a writeable instance attribute. .. versionadded:: 1.6 :type new: bool :param new: Normally, if *path* is specified, :class:`HtdigestFile` will immediately load the contents of the file. However, when creating a new htpasswd file, applications can set ``new=True`` so that the existing file (if any) will not be loaded. .. versionadded:: 1.6 This feature was previously enabled by setting ``autoload=False``. That alias has been deprecated, and will be removed in Passlib 1.8 :type autosave: bool :param autosave: Normally, any changes made to an :class:`HtdigestFile` instance will not be saved until :meth:`save` is explicitly called. However, if ``autosave=True`` is specified, any changes made will be saved to disk immediately (assuming *path* has been set). This is also exposed as a writeable instance attribute. :type encoding: str :param encoding: Optionally specify character encoding used to read/write file and hash passwords. Defaults to ``utf-8``, though ``latin-1`` is the only other commonly encountered encoding. This is also exposed as a readonly instance attribute. :param autoload: Set to ``False`` to prevent the constructor from automatically loaded the file from disk. .. deprecated:: 1.6 This has been replaced by the *new* keyword. Instead of setting ``autoload=False``, you should use ``new=True``. Support for this keyword will be removed in Passlib 1.8. Loading & Saving ================ .. automethod:: load .. automethod:: load_if_changed .. automethod:: load_string .. automethod:: save .. automethod:: to_string Inspection ========== .. automethod:: realms .. automethod:: users .. automethod:: check_password(user[, realm], password) .. automethod:: get_hash Modification ============ .. automethod:: set_password(user[, realm], password) .. automethod:: delete .. automethod:: delete_realm Alternate Constructors ====================== .. automethod:: from_string Attributes ========== .. attribute:: default_realm The default realm that will be used if one is not provided to methods that require it. By default this is ``None``, in which case an explicit realm must be provided for every method call. Can be written to. .. attribute:: path Path to local file that will be used as the default for all :meth:`load` and :meth:`save` operations. May be written to, initialized by the *path* constructor keyword. .. attribute:: autosave Writeable flag indicating whether changes will be automatically written to *path*. Errors ====== :raises ValueError: All of the methods in this class will raise a :exc:`ValueError` if any user name or realm contains a forbidden character (one of ``:\\r\\n\\t\\x00``), or is longer than 255 characters. """ #=================================================================== # instance attrs #=================================================================== # NOTE: _records map stores (,) for the key, # and as the value, all as bytes. # NOTE: unlike htpasswd, this class doesn't use a CryptContext, # as only one hash format is supported: htdigest. # optionally specify default realm that will be used if none # is provided to a method call. otherwise realm is always required. default_realm = None #=================================================================== # init & serialization #=================================================================== def __init__(self, path=None, default_realm=None, **kwds): self.default_realm = default_realm super(HtdigestFile, self).__init__(path, **kwds) def _parse_record(self, record, lineno): result = record.rstrip().split(_BCOLON) if len(result) != 3: raise ValueError("malformed htdigest file (error reading line %d)" % lineno) user, realm, hash = result return (user, realm), hash def _render_record(self, key, hash): user, realm = key return render_bytes("%s:%s:%s\n", user, realm, hash) def _require_realm(self, realm): if realm is None: realm = self.default_realm if realm is None: raise TypeError("you must specify a realm explicitly, " "or set the default_realm attribute") return realm def _encode_realm(self, realm): realm = self._require_realm(realm) return self._encode_field(realm, "realm") def _encode_key(self, user, realm): return self._encode_user(user), self._encode_realm(realm) #=================================================================== # public methods #=================================================================== def realms(self): """Return list of all realms in database""" realms = set(key[1] for key in self._records) return [self._decode_field(realm) for realm in realms] def users(self, realm=None): """Return list of all users in specified realm. * uses ``self.default_realm`` if no realm explicitly provided. * returns empty list if realm not found. """ realm = self._encode_realm(realm) return [self._decode_field(key[0]) for key in self._records if key[1] == realm] ##def has_user(self, user, realm=None): ## "check if user+realm combination exists" ## return self._encode_key(user,realm) in self._records ##def rename_realm(self, old, new): ## """rename all accounts in realm""" ## old = self._encode_realm(old) ## new = self._encode_realm(new) ## keys = [key for key in self._records if key[1] == old] ## for key in keys: ## hash = self._records.pop(key) ## self._set_record((key[0], new), hash) ## self._autosave() ## return len(keys) ##def rename(self, old, new, realm=None): ## """rename user account""" ## old = self._encode_user(old) ## new = self._encode_user(new) ## realm = self._encode_realm(realm) ## hash = self._records.pop((old,realm)) ## self._set_record((new, realm), hash) ## self._autosave() def set_password(self, user, realm=None, password=_UNSET): """Set password for user; adds user & realm if needed. If ``self.default_realm`` has been set, this may be called with the syntax ``set_password(user, password)``, otherwise it must be called with all three arguments: ``set_password(user, realm, password)``. :returns: * ``True`` if existing user was updated * ``False`` if user account added. """ if password is _UNSET: # called w/ two args - (user, password), use default realm realm, password = None, realm realm = self._require_realm(realm) hash = htdigest.hash(password, user, realm, encoding=self.encoding) return self.set_hash(user, realm, hash) @deprecated_method(deprecated="1.6", removed="1.8", replacement="set_password") def update(self, user, realm, password): """set password for user""" return self.set_password(user, realm, password) def get_hash(self, user, realm=None): """Return :class:`~passlib.hash.htdigest` hash stored for user. * uses ``self.default_realm`` if no realm explicitly provided. * returns ``None`` if user or realm not found. .. versionchanged:: 1.6 This method was previously named ``find``, it was renamed for clarity. The old name is deprecated, and will be removed in Passlib 1.8. """ key = self._encode_key(user, realm) hash = self._records.get(key) if hash is None: return None if PY3: hash = hash.decode(self.encoding) return hash def set_hash(self, user, realm=None, hash=_UNSET): """ semi-private helper which allows writing a hash directly; adds user & realm if needed. If ``self.default_realm`` has been set, this may be called with the syntax ``set_hash(user, hash)``, otherwise it must be called with all three arguments: ``set_hash(user, realm, hash)``. .. warning:: does not (currently) do any validation of the hash string .. versionadded:: 1.7 """ if hash is _UNSET: # called w/ two args - (user, hash), use default realm realm, hash = None, realm # assert htdigest.identify(hash), "unrecognized hash format" if PY3 and isinstance(hash, str): hash = hash.encode(self.encoding) key = self._encode_key(user, realm) existing = self._set_record(key, hash) self._autosave() return existing @deprecated_method(deprecated="1.6", removed="1.8", replacement="get_hash") def find(self, user, realm): """return hash for user""" return self.get_hash(user, realm) # XXX: rename to something more explicit, like delete_user()? def delete(self, user, realm=None): """Delete user's entry for specified realm. if realm is not specified, uses ``self.default_realm``. :returns: * ``True`` if user deleted, * ``False`` if user not found in realm. """ key = self._encode_key(user, realm) try: del self._records[key] except KeyError: return False self._autosave() return True def delete_realm(self, realm): """Delete all users for specified realm. if realm is not specified, uses ``self.default_realm``. :returns: number of users deleted (0 if realm not found) """ realm = self._encode_realm(realm) records = self._records keys = [key for key in records if key[1] == realm] for key in keys: del records[key] self._autosave() return len(keys) def check_password(self, user, realm=None, password=_UNSET): """Verify password for specified user + realm. If ``self.default_realm`` has been set, this may be called with the syntax ``check_password(user, password)``, otherwise it must be called with all three arguments: ``check_password(user, realm, password)``. :returns: * ``None`` if user or realm not found. * ``False`` if user found, but password does not match. * ``True`` if user found and password matches. .. versionchanged:: 1.6 This method was previously called ``verify``, it was renamed to prevent ambiguity with the :class:`!CryptContext` method. The old alias is deprecated, and will be removed in Passlib 1.8. """ if password is _UNSET: # called w/ two args - (user, password), use default realm realm, password = None, realm user = self._encode_user(user) realm = self._encode_realm(realm) hash = self._records.get((user,realm)) if hash is None: return None return htdigest.verify(password, hash, user, realm, encoding=self.encoding) @deprecated_method(deprecated="1.6", removed="1.8", replacement="check_password") def verify(self, user, realm, password): """verify password for user""" return self.check_password(user, realm, password) #=================================================================== # eoc #=================================================================== #============================================================================= # eof #=============================================================================