82 lines
2.5 KiB
Python
82 lines
2.5 KiB
Python
"""
|
|
A standalone, minimal htpasswd parser.
|
|
|
|
This implementation currently supports bcrypt and SHA1 passwords. SHA1 is insecure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
from pathlib import Path
|
|
|
|
import bcrypt
|
|
|
|
|
|
class HtpasswdFile:
|
|
def __init__(self, content: str):
|
|
"""
|
|
Create a HtpasswdFile from a string.
|
|
"""
|
|
self.users: dict[str, str] = {}
|
|
for line in content.splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if ":" not in line:
|
|
raise ValueError(f"Malformed htpasswd line: {line!r}")
|
|
user, pwhash = line.split(":", 1)
|
|
if not user:
|
|
raise ValueError(f"Malformed htpasswd line: {line!r}")
|
|
|
|
is_sha = pwhash.startswith("{SHA}")
|
|
is_bcrypt = pwhash.startswith(("$2y$", "$2b$", "$2a$"))
|
|
if not is_sha and not is_bcrypt:
|
|
raise ValueError(f"Unsupported htpasswd format for user {user!r}")
|
|
|
|
self.users[user] = pwhash
|
|
|
|
@classmethod
|
|
def from_file(cls, path: Path) -> HtpasswdFile:
|
|
"""
|
|
Initializes and loads an htpasswd file.
|
|
|
|
Args:
|
|
path: The path to the htpasswd file.
|
|
|
|
Raises:
|
|
OSError: If the file cannot be read.
|
|
ValueError: If the file is malformed.
|
|
"""
|
|
try:
|
|
content = path.read_text("utf-8")
|
|
except FileNotFoundError:
|
|
raise OSError(f"Htpasswd file not found: {path}") from None
|
|
return cls(content)
|
|
|
|
def check_password(self, username: str, password: str) -> bool:
|
|
"""
|
|
Checks if a username and password combination is valid.
|
|
|
|
Args:
|
|
username: The username to check.
|
|
password: The password to check.
|
|
|
|
Returns:
|
|
True if the password is valid, False otherwise.
|
|
"""
|
|
pwhash = self.users.get(username)
|
|
if pwhash is None:
|
|
return False
|
|
|
|
pwhash = pwhash.split(":", 1)[0]
|
|
|
|
if pwhash.startswith("{SHA}"):
|
|
# Apache's {SHA} is base64-encoded SHA-1.
|
|
# https://httpd.apache.org/docs/2.4/misc/password_encryptions.html
|
|
digest = hashlib.sha1(password.encode("utf-8")).digest()
|
|
expected = base64.b64encode(digest).decode("ascii")
|
|
return pwhash[5:] == expected
|
|
else: # pwhash.startswith(("$2y$", "$2b$", "$2a$")):
|
|
return bcrypt.checkpw(password.encode("utf-8"), pwhash.encode("utf-8"))
|