239 lines
6.8 KiB
Python
Executable File
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())
|