feat: move user management to protected config file
This commit is contained in:
Regular → Executable
+224
-42
@@ -1,56 +1,238 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create password hashes or user config snippets for RollCalc Basic Auth users.
|
||||
"""
|
||||
"""Interactive RollCalc user management."""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
from getpass import getpass
|
||||
from pathlib import Path
|
||||
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parents[1]
|
||||
USER_FILE = ROOT_DIR / "config" / "users.json"
|
||||
HASH_METHOD = "pbkdf2:sha256:600000"
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate RollCalc password hashes or user config entries."
|
||||
|
||||
class UserManagementError(Exception):
|
||||
"""Expected user-management error."""
|
||||
|
||||
|
||||
def ensure_user_file(path=USER_FILE):
|
||||
"""Create an empty user file if needed."""
|
||||
path = Path(path)
|
||||
if path.exists():
|
||||
return
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("{}\n", encoding="utf-8")
|
||||
set_restrictive_permissions(path)
|
||||
|
||||
|
||||
def set_restrictive_permissions(path):
|
||||
"""Set user-only read/write permissions where supported."""
|
||||
try:
|
||||
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError as exc:
|
||||
print(f"Warning: could not set restrictive permissions: {exc}")
|
||||
|
||||
|
||||
def load_users(path=USER_FILE):
|
||||
"""Load user hashes from JSON."""
|
||||
path = Path(path)
|
||||
ensure_user_file(path)
|
||||
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
raise UserManagementError(f"Invalid JSON in {path}: {exc}") from exc
|
||||
except OSError as exc:
|
||||
raise UserManagementError(f"Could not read {path}: {exc}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise UserManagementError(f"Invalid user file format in {path}: expected object")
|
||||
|
||||
users = {}
|
||||
for username, password_hash in data.items():
|
||||
if not isinstance(username, str) or not username.strip():
|
||||
raise UserManagementError(f"Invalid username in {path}")
|
||||
if not isinstance(password_hash, str) or not password_hash.strip():
|
||||
raise UserManagementError(f"Invalid password hash for user {username!r}")
|
||||
users[username.strip()] = password_hash.strip()
|
||||
|
||||
return users
|
||||
|
||||
|
||||
def save_users(users, path=USER_FILE):
|
||||
"""Persist user hashes to JSON."""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
existing_mode = None
|
||||
if path.exists():
|
||||
try:
|
||||
existing_mode = stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError:
|
||||
existing_mode = None
|
||||
|
||||
temp_path = path.with_name(f".{path.name}.tmp")
|
||||
try:
|
||||
temp_path.write_text(
|
||||
json.dumps(users, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8"
|
||||
)
|
||||
if existing_mode is not None:
|
||||
os.chmod(temp_path, existing_mode)
|
||||
else:
|
||||
os.chmod(temp_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
os.replace(temp_path, path)
|
||||
except OSError as exc:
|
||||
try:
|
||||
temp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise UserManagementError(f"Could not write {path}: {exc}") from exc
|
||||
if existing_mode is None:
|
||||
set_restrictive_permissions(path)
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
"""Hash a password with RollCalc's established hash method."""
|
||||
return generate_password_hash(password, method=HASH_METHOD)
|
||||
|
||||
|
||||
def add_user(users, username, password):
|
||||
"""Add a user to a user dictionary."""
|
||||
username = username.strip()
|
||||
if not username:
|
||||
raise UserManagementError("Username must not be empty.")
|
||||
if username in users:
|
||||
raise UserManagementError(f'User "{username}" already exists.')
|
||||
users[username] = hash_password(password)
|
||||
|
||||
|
||||
def update_password(users, username, password):
|
||||
"""Update an existing user's password hash."""
|
||||
username = username.strip()
|
||||
if username not in users:
|
||||
raise UserManagementError(f'User "{username}" does not exist.')
|
||||
users[username] = hash_password(password)
|
||||
|
||||
|
||||
def remove_user(users, username):
|
||||
"""Remove an existing user."""
|
||||
username = username.strip()
|
||||
if username not in users:
|
||||
raise UserManagementError(f'User "{username}" does not exist.')
|
||||
del users[username]
|
||||
|
||||
|
||||
def prompt_password_pair():
|
||||
"""Read and validate a repeated password."""
|
||||
password = getpass("Password: ")
|
||||
repeat = getpass("Repeat password: ")
|
||||
if password != repeat:
|
||||
raise UserManagementError("Passwords do not match.")
|
||||
if not password:
|
||||
raise UserManagementError("Password must not be empty.")
|
||||
return password
|
||||
|
||||
|
||||
def list_users(users):
|
||||
"""Print existing usernames without hashes."""
|
||||
print("\nExisting users\n")
|
||||
if not users:
|
||||
print("(none)")
|
||||
else:
|
||||
for username in sorted(users):
|
||||
print(f"- {username}")
|
||||
print()
|
||||
|
||||
|
||||
def create_user_interactive(users):
|
||||
"""Create a user from prompts."""
|
||||
username = input("Username: ").strip()
|
||||
password = prompt_password_pair()
|
||||
add_user(users, username, password)
|
||||
print(f'User "{username}" created.')
|
||||
|
||||
|
||||
def change_password_interactive(users):
|
||||
"""Change a user's password from prompts."""
|
||||
username = input("Username: ").strip()
|
||||
password = prompt_password_pair()
|
||||
update_password(users, username, password)
|
||||
print(f'Password for "{username}" changed.')
|
||||
|
||||
|
||||
def delete_user_interactive(users):
|
||||
"""Delete a user after confirmation."""
|
||||
username = input("Username: ").strip()
|
||||
if username not in users:
|
||||
raise UserManagementError(f'User "{username}" does not exist.')
|
||||
|
||||
confirmation = input(f'Delete user "{username}"? yes/no: ').strip().lower()
|
||||
if confirmation != "yes":
|
||||
print("Delete cancelled.")
|
||||
return
|
||||
|
||||
remove_user(users, username)
|
||||
print(f'User "{username}" deleted.')
|
||||
|
||||
|
||||
def print_menu():
|
||||
"""Print the main menu."""
|
||||
print(
|
||||
"\n"
|
||||
"--------------------------------------------------\n\n"
|
||||
"RollCalc User Management\n\n"
|
||||
"1 - List users\n"
|
||||
"2 - Create user\n"
|
||||
"3 - Change password\n"
|
||||
"4 - Delete user\n"
|
||||
"5 - Exit\n\n"
|
||||
"--------------------------------------------------"
|
||||
)
|
||||
parser.add_argument(
|
||||
"username",
|
||||
nargs="?",
|
||||
help="Optional username for a generated user config snippet.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--password",
|
||||
help="Password to hash. Omit to enter it securely via prompt.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--json",
|
||||
action="store_true",
|
||||
help="Output a JSON object for the user entry.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def run_menu(path=USER_FILE):
|
||||
"""Run the interactive menu."""
|
||||
while True:
|
||||
try:
|
||||
users = load_users(path)
|
||||
except UserManagementError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print_menu()
|
||||
choice = input("Select option: ").strip()
|
||||
|
||||
try:
|
||||
if choice == "1":
|
||||
list_users(users)
|
||||
continue
|
||||
if choice == "2":
|
||||
create_user_interactive(users)
|
||||
elif choice == "3":
|
||||
change_password_interactive(users)
|
||||
elif choice == "4":
|
||||
delete_user_interactive(users)
|
||||
elif choice == "5":
|
||||
print("Exit.")
|
||||
return 0
|
||||
else:
|
||||
print("Unknown option.")
|
||||
continue
|
||||
|
||||
save_users(users, path)
|
||||
except UserManagementError as exc:
|
||||
print(f"Error: {exc}")
|
||||
|
||||
|
||||
def main():
|
||||
args = build_parser().parse_args()
|
||||
password = args.password
|
||||
|
||||
if password is None:
|
||||
password = getpass.getpass("Password: ")
|
||||
|
||||
password_hash = generate_password_hash(password)
|
||||
|
||||
if args.username:
|
||||
entry = {args.username: {"password_hash": password_hash}}
|
||||
if args.json:
|
||||
print(json.dumps(entry, indent=2))
|
||||
else:
|
||||
print(f'"{args.username}": {{')
|
||||
print(f' "password_hash": "{password_hash}"')
|
||||
print("}")
|
||||
else:
|
||||
print(password_hash)
|
||||
"""CLI entry point."""
|
||||
return run_menu()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
Reference in New Issue
Block a user