summaryrefslogtreecommitdiff
path: root/passlib/apache.py
diff options
context:
space:
mode:
Diffstat (limited to 'passlib/apache.py')
-rw-r--r--passlib/apache.py236
1 files changed, 183 insertions, 53 deletions
diff --git a/passlib/apache.py b/passlib/apache.py
index e910023..411e346 100644
--- a/passlib/apache.py
+++ b/passlib/apache.py
@@ -29,21 +29,28 @@ from __future__ import with_statement
from hashlib import md5
import logging; log = logging.getLogger(__name__)
import os
+import sys
#site
#libs
from passlib.context import CryptContext
+from passlib.utils import render_bytes, bjoin, bytes, b, to_unicode, to_bytes
#pkg
#local
__all__ = [
]
+BCOLON = b(":")
+
#=========================================================
#common helpers
#=========================================================
+DEFAULT_ENCODING = "utf-8" if sys.version_info >= (3,0) else None
+
class _CommonFile(object):
"helper for HtpasswdFile / HtdigestFile"
- #NOTE: 'path' is a property so that mtime is wiped if path is changed.
+ #NOTE: 'path' is a property instead of attr,
+ # so that .mtime is wiped whenever path is changed.
_path = None
def _get_path(self):
return self._path
@@ -53,7 +60,13 @@ class _CommonFile(object):
self._path = path
path = property(_get_path, _set_path)
- def __init__(self, path=None, autoload=True):
+ def __init__(self, path=None, autoload=True,
+ encoding=DEFAULT_ENCODING,
+ ):
+ if encoding and u":\n".encode(encoding) != b(":\n"):
+ #rest of file assumes ascii bytes, and uses ":" as separator.
+ raise ValueError, "encoding must be 7-bit ascii compatible"
+ self.encoding = encoding
self.path = path
##if autoload == "exists":
## autoload = bool(path and os.path.exists(path))
@@ -81,7 +94,7 @@ class _CommonFile(object):
raise RuntimeError("no load path specified")
if not force and self.mtime and self.mtime == os.path.getmtime(path):
return False
- with file(path, "rU") as fh:
+ with open(path, "rbU") as fh:
self.mtime = os.path.getmtime(path)
self._load_lines(fh)
return True
@@ -91,6 +104,8 @@ class _CommonFile(object):
entry_order = self._entry_order = []
entry_map = self._entry_map = {}
for line in lines:
+ #XXX: found mention that "#" comment lines may be supported by htpasswd,
+ # should verify this.
key, value = pl(line)
if key in entry_map:
#XXX: should we use data from first entry, or last entry?
@@ -109,17 +124,17 @@ class _CommonFile(object):
entry_order = self._entry_order
entry_map = self._entry_map
assert len(entry_order) == len(entry_map), "internal error in entry list"
- with file(self.path, "wb") as fh:
+ with open(self.path, "wb") as fh:
fh.writelines(rl(key, entry_map[key]) for key in entry_order)
self.mtime = os.path.getmtime(self.path)
def to_string(self):
- "export whole database as a string"
+ "export whole database as a byte string"
rl = self._render_line
entry_order = self._entry_order
entry_map = self._entry_map
assert len(entry_order) == len(entry_map), "internal error in entry list"
- return "".join(rl(key, entry_map[key]) for key in entry_order)
+ return bjoin(rl(key, entry_map[key]) for key in entry_order)
#subclass: _render_line(entry) -> line
@@ -142,27 +157,51 @@ class _CommonFile(object):
else:
return False
- invalid_chars = ":\n\r\t\x00"
-
- def _validate_user(self, user):
- if len(user) > 255:
- raise ValueError("user must be at most 255 characters: %r" % (user,))
- ic = self.invalid_chars
- if any(c in ic for c in user):
- raise ValueError("user contains invalid characters: %r" % (user,))
- return True
-
- def _validate_realm(self, realm):
- if len(realm) > 255:
- raise ValueError("realm must be at most 255 characters: %r" % (realm,))
- ic = self.invalid_chars
- if any(c in ic for c in realm):
- raise ValueError("realm contains invalid characters: %r" % (realm,))
- return True
+ invalid_chars = b(":\n\r\t\x00")
+
+ def _norm_user(self, user):
+ "encode user to bytes, validate against format requirements"
+ return self._norm_ident(user, errname="user")
+
+ def _norm_realm(self, realm):
+ "encode realm to bytes, validate against format requirements"
+ return self._norm_ident(realm, errname="realm")
+
+ def _norm_ident(self, ident, errname="user/realm"):
+ ident = self._encode_ident(ident, errname)
+ if len(ident) > 255:
+ raise ValueError("%s must be at most 255 characters: %r" % (errname, ident))
+ if any(c in self.invalid_chars for c in ident):
+ raise ValueError("%s contains invalid characters: %r" % (errname, ident,))
+ return ident
+
+ def _encode_ident(self, ident, errname="user/realm"):
+ "ensure identifier is bytes encoded using specified encoding, or rejected"
+ encoding = self.encoding
+ if encoding:
+ if isinstance(ident, unicode):
+ return ident.encode(encoding)
+ raise TypeError("%s must be unicode, not %s" %
+ (errname, type(ident)))
+ else:
+ if isinstance(ident, bytes):
+ return ident
+ raise TypeError("%s must be bytes, not %s" %
+ (errname, type(ident)))
+
+ def _decode_ident(self, ident, errname="user/realm"):
+ "decode an identifier (if encoding is specified, else return encoded bytes)"
+ assert isinstance(ident, bytes)
+ encoding = self.encoding
+ if encoding:
+ return ident.decode(encoding)
+ else:
+ return ident
#FIXME: htpasswd doc sez passwords limited to 255 chars under Windows & MPE,
- # longer ones are truncated.
-
+ # longer ones are truncated. may be side-effect of those platforms
+ # supporting plaintext. we don't currently check for this.
+
#=========================================================
#htpasswd editing
#=========================================================
@@ -194,6 +233,39 @@ class HtpasswdFile(_CommonFile):
Set to ``False`` to disable automatic loading (primarily used when
creating new htdigest file).
+ :param encoding:
+ optionally specify encoding used for usernames.
+
+ if set to ``None``,
+ user names must be specified as bytes,
+ and will be returned as bytes.
+
+ if set to an encoding,
+ user names must be specified as unicode,
+ and will be returned as unicode.
+ when stored, then will use the specified encoding.
+
+ for backwards compatibility with passlib 1.4,
+ this defaults to ``None`` under Python 2,
+ and ``utf-8`` under Python 3.
+
+ .. note::
+
+ this is not the encoding for the entire file,
+ just for the usernames within the file.
+ this must be an encoding which is compatible
+ with 7-bit ascii (which is used by rest of file).
+
+ :param context:
+ :class:`~passlib.context.CryptContext` instance used to handle
+ hashes in this file.
+
+ .. warning::
+
+ this should usually be left at the default,
+ though it can be overridden to implement non-standard hashes
+ within the htpasswd file.
+
Loading & Saving
================
.. automethod:: load
@@ -218,29 +290,29 @@ class HtpasswdFile(_CommonFile):
contains one of the forbidden characters ``:\\r\\n\\t\\x00``,
or is longer than 255 characters.
"""
- def __init__(self, path=None, default=None, **kwds):
- self.context = htpasswd_context
+ def __init__(self, path=None, default=None, context=htpasswd_context, **kwds):
+ self.context = context
if default:
self.context = self.context.replace(default=default)
super(HtpasswdFile, self).__init__(path, **kwds)
def _parse_line(self, line):
#should be user, hash
- return line.rstrip().split(":")
+ return line.rstrip().split(BCOLON)
def _render_line(self, user, hash):
- return "%s:%s\n" % (user, hash)
-
+ return render_bytes("%s:%s\n", user, hash)
+
def users(self):
"return list of all users in file"
- return list(self._entry_order)
+ return map(self._decode_ident, self._entry_order)
def update(self, user, password):
"""update password for user; adds user if needed.
:returns: ``True`` if existing user was updated, ``False`` if user added.
"""
- self._validate_user(user)
+ user = self._norm_user(user)
hash = self.context.encrypt(password)
return self._update_key(user, hash)
@@ -249,7 +321,7 @@ class HtpasswdFile(_CommonFile):
:returns: ``True`` if user deleted, ``False`` if user not found.
"""
- self._validate_user(user)
+ user = self._norm_user(user)
return self._delete_key(user)
def verify(self, user, password):
@@ -260,7 +332,7 @@ class HtpasswdFile(_CommonFile):
* ``False`` if password does not match
* ``True`` if password matches.
"""
- self._validate_user(user)
+ user = self._norm_user(user)
hash = self._entry_map.get(user)
if hash is None:
return None
@@ -271,7 +343,6 @@ class HtpasswdFile(_CommonFile):
#=========================================================
#htdigest editing
#=========================================================
-
class HtdigestFile(_CommonFile):
"""class for reading & writing Htdigest files
@@ -284,6 +355,29 @@ class HtdigestFile(_CommonFile):
Set to ``False`` to disable automatic loading (primarily used when
creating new htdigest file).
+ :param encoding:
+ optionally specify encoding used for usernames / realms.
+
+ if set to ``None``,
+ user names & realms must be specified as bytes,
+ and will be returned as bytes.
+
+ if set to an encoding,
+ user names & realms must be specified as unicode,
+ and will be returned as unicode.
+ when stored, then will use the specified encoding.
+
+ for backwards compatibility with passlib 1.4,
+ this defaults to ``None`` under Python 2,
+ and ``utf-8`` under Python 3.
+
+ .. note::
+
+ this is not the encoding for the entire file,
+ just for the usernames & realms within the file.
+ this must be an encoding which is compatible
+ with 7-bit ascii (which is used by rest of file).
+
Loading & Saving
================
.. automethod:: load
@@ -312,30 +406,58 @@ class HtdigestFile(_CommonFile):
or is longer than 255 characters.
"""
+ #XXX: don't want password encoding to change if user account encoding does.
+ # but also *can't* use unicode itself. setting this to utf-8 for now,
+ # until it causes problems - in which case stopgap of setting this attr
+ # per-instance can be used.
+ password_encoding = "utf-8"
+
+ #XXX: provide rename() & rename_realm() ?
+
def _parse_line(self, line):
- user, realm, hash = line.rstrip().split(":")
+ user, realm, hash = line.rstrip().split(BCOLON)
return (user, realm), hash
def _render_line(self, key, hash):
- return "%s:%s:%s\n" % (key[0], key[1], hash)
+ return render_bytes("%s:%s:%s\n", key[0], key[1], hash)
+
+ #TODO: would frontend to calc_digest be useful?
+ ##def encrypt(self, password, user, realm):
+ ## user = self._norm_user(user)
+ ## realm = self._norm_realm(realm)
+ ## hash = self._calc_digest(user, realm, password)
+ ## if self.encoding:
+ ## #decode hash if in unicode mode
+ ## hash = hash.decode("ascii")
+ ## return hash
+
+ def _calc_digest(self, user, realm, password):
+ "helper to calculate digest"
+ if isinstance(password, unicode):
+ password = password.encode(self.password_encoding)
+ #NOTE: encode('ascii') is noop under py2, required under py3
+ return md5(render_bytes("%s:%s:%s", user, realm, password)).hexdigest().encode("ascii")
def realms(self):
"return all realms listed in file"
- return list(set(key[1] for key in self._entry_order))
+ return map(self._decode_ident,
+ set(key[1] for key in self._entry_order))
def users(self, realm):
"return list of all users within specified realm"
- return [ key[0] for key in self._entry_order if key[1] == realm ]
+ realm = self._norm_realm(realm)
+ return map(self._decode_ident,
+ (key[0] for key in self._entry_order if key[1] == realm))
def update(self, user, realm, password):
"""update password for user under specified realm; adding user if needed
:returns: ``True`` if existing user was updated, ``False`` if user added.
"""
- self._validate_user(user)
- self._validate_realm(realm)
+ user = self._norm_user(user)
+ realm = self._norm_realm(realm)
key = (user,realm)
- hash = md5("%s:%s:%s" % (user,realm,password)).hexdigest()
+ hash = self._calc_digest(user, realm, password)
return self._update_key(key, hash)
def delete(self, user, realm):
@@ -343,8 +465,8 @@ class HtdigestFile(_CommonFile):
:returns: ``True`` if user deleted, ``False`` if user not found in realm.
"""
- self._validate_user(user)
- self._validate_realm(realm)
+ user = self._norm_user(user)
+ realm = self._norm_realm(realm)
return self._delete_key((user,realm))
def delete_realm(self, realm):
@@ -352,7 +474,7 @@ class HtdigestFile(_CommonFile):
:returns: number of users deleted
"""
- self._validate_realm(realm)
+ realm = self._norm_realm(realm)
keys = [
key for key in self._entry_map
if key[1] == realm
@@ -362,11 +484,19 @@ class HtdigestFile(_CommonFile):
return len(keys)
def find(self, user, realm):
- """return digest hash for specified user+realm; returns ``None`` if not found"""
- self._validate_user(user)
- self._validate_realm(realm)
- return self._entry_map.get((user,realm))
-
+ """return digest hash for specified user+realm; returns ``None`` if not found
+
+ :returns: htdigest hash or None
+ :rtype: bytes or None
+ """
+ user = self._norm_user(user)
+ realm = self._norm_realm(realm)
+ hash = self._entry_map.get((user,realm))
+ if hash is not None and self.encoding:
+ #decode hash if in unicode mode
+ hash = hash.decode("ascii")
+ return hash
+
def verify(self, user, realm, password):
"""verify password for specified user + realm.
@@ -375,12 +505,12 @@ class HtdigestFile(_CommonFile):
* ``False`` if password does not match
* ``True`` if password matches.
"""
- self._validate_user(user)
- self._validate_realm(realm)
+ user = self._norm_user(user)
+ realm = self._norm_realm(realm)
hash = self._entry_map.get((user,realm))
if hash is None:
return None
- return hash == md5("%s:%s:%s" % (user,realm,password)).hexdigest()
+ return hash == self._calc_digest(user, realm, password)
#=========================================================
# eof