Files
RollCalcPython/scripts/manage_users.py

239 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Interactive RollCalc user management."""
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"
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"
"--------------------------------------------------"
)
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():
"""CLI entry point."""
return run_menu()
if __name__ == "__main__":
raise SystemExit(main())