1256 lines
46 KiB
Python
1256 lines
46 KiB
Python
"""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, <record key>) 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 <records> 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?
|
|
# could reuse _detect_htpasswd() helper in UTs
|
|
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)
|
|
|
|
# create context object
|
|
return CryptContext(
|
|
schemes=schemes,
|
|
|
|
# NOTE: default will change to "portable" in passlib 2.0
|
|
default=htpasswd_defaults['portable_apache_22'],
|
|
|
|
# NOTE: bcrypt "2y" is required, "2b" isn't recognized by libapr (issue 95)
|
|
bcrypt__ident="2y",
|
|
)
|
|
|
|
#: 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 <user> for the key, and <hash> 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 (<user>,<realm>) for the key,
|
|
# and <hash> as the value, all as <self.encoding> 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
|
|
#=============================================================================
|