From 3b54f5ecc0d06a6767fff0fd5924a208a1733da6 Mon Sep 17 00:00:00 2001 From: Martin Tazl Date: Tue, 28 Jul 2026 13:46:05 +0200 Subject: [PATCH] feat: move user management to protected config file --- .gitignore | 3 + PROJECT_KNOWLEDGE.md | 35 +++-- README.md | 64 +++++++-- app.py | 83 ++++++++---- config/users.example.json | 3 + scripts/manage_users.py | 266 ++++++++++++++++++++++++++++++++------ 6 files changed, 368 insertions(+), 86 deletions(-) create mode 100644 config/users.example.json mode change 100644 => 100755 scripts/manage_users.py diff --git a/.gitignore b/.gitignore index b7c7228..1ccc4dd 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ node_modules/ # runtime-generated files access_log.json + +# User and passwords +config/users.json diff --git a/PROJECT_KNOWLEDGE.md b/PROJECT_KNOWLEDGE.md index 7ccc804..90b9c08 100644 --- a/PROJECT_KNOWLEDGE.md +++ b/PROJECT_KNOWLEDGE.md @@ -14,7 +14,7 @@ Backend: - `app.py` creates the Flask app. - HTTP Basic Auth is implemented with `Flask-HTTPAuth`. -- Users are currently configured in `BETA_USERS` with Werkzeug password hashes. +- Users are configured in local deployment file `config/users.json` with Werkzeug password hashes. This file is ignored by Git. - `/` renders the active calculator template. - `/help` renders the authenticated Help landing page. - `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout. @@ -104,21 +104,40 @@ This JSON is a compact summary for the dialog, not the complete release history. ## Authentication -RollCalc uses HTTP Basic Auth. User entries in `BETA_USERS` have this shape: +RollCalc uses HTTP Basic Auth. User entries are stored in the local deployment file: -```python -"username": { - "password_hash": "..." +```text +config/users.json +``` + +This file is intentionally ignored by Git. The versioned file `config/users.example.json` documents the JSON format only and must not contain real usernames, hashes, or passwords. + +The file stores username-to-password-hash mappings: + +```json +{ + "username": "" } ``` -Password verification uses `werkzeug.security.check_password_hash`. New hashes or config snippets can be generated with: +Password verification uses `werkzeug.security.check_password_hash`. Users are managed with: ```bash -python scripts/manage_users.py username +python3 scripts/manage_users.py ``` -Passwords and hashes must not be logged. +The management tool provides a text menu for listing users, creating users, changing passwords, and deleting users. New hashes use the existing RollCalc method `pbkdf2:sha256:600000`. + +The Flask app requires a readable, valid, non-empty `config/users.json` at startup and does not silently create an empty user file. If the file is missing or invalid, startup should fail with a message pointing to `python3 scripts/manage_users.py`. + +Passwords must not be logged. Password hashes should only be stored in `config/users.json` or a future protected deployment-specific secret source. + +Deployment notes: + +- The systemd/service user must be able to read `config/users.json`. +- Suitable server permissions can be owner `martin`, group `www-data`, mode `640`, adjusted to the actual deployment users. +- Do not hard-code deployment owners or groups in Python. +- Back up the productive `config/users.json` before deploying or replacing a server instance. ## Roll Geometry diff --git a/README.md b/README.md index 4a375b0..6a2a051 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ If port `5000` is already occupied, start through Flask's CLI without changing f flask --app app run --host 127.0.0.1 --port 5001 ``` -The app uses HTTP Basic Auth. Current users are defined in `BETA_USERS` in `app.py` with Werkzeug password hashes. +The app uses HTTP Basic Auth. Current users are defined in a local deployment file, `config/users.json`, with Werkzeug password hashes. This file is intentionally not versioned. ## Project Layout @@ -100,23 +100,57 @@ Implemented routes: | `/api/health` | `GET` | Basic Auth | Returns app health and version. | | `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. | -Authentication is implemented with `Flask-HTTPAuth`. The current code checks `BETA_USERS` with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application. +Authentication is implemented with `Flask-HTTPAuth`. The current code loads the local `config/users.json` deployment file and checks password hashes with `werkzeug.security.check_password_hash`; plaintext passwords are not stored in the application. -Generate a password hash or user entry with: +## User Management + +Manage users with the interactive CLI: ```bash -python scripts/manage_users.py username -python scripts/manage_users.py username --json +python3 scripts/manage_users.py ``` -For non-interactive local maintenance only: +The menu supports: -```bash -python scripts/manage_users.py username --password 'new-password' +- List users +- Create user +- Change password +- Delete user + +User data is stored in: + +```text +config/users.json +``` + +`config/users.json` is local deployment data and is ignored by Git. The versioned file `config/users.example.json` documents the format only and must not contain real usernames, hashes, or passwords. + +The file contains only username-to-password-hash mappings: + +```json +{ + "username": "" +} +``` + +Passwords are entered with `getpass`, are never echoed, and are never stored in plaintext. New and changed passwords use the existing RollCalc hash method: + +```text +pbkdf2:sha256:600000 ``` Do not commit real passwords or print them in logs. +On a server, ensure that the process running RollCalc can read `config/users.json`. A suitable deployment setup can be: + +```text +owner: martin +group: www-data +mode: 640 +``` + +The exact owner and group depend on the deployment. Do not hard-code them in the application. Before deploying or replacing a server instance, back up the productive `config/users.json` file. + Access logging is handled by `log_access()`, which reads `access_log.json`, appends a record, and writes the whole file back. ## Help and User Manual @@ -180,6 +214,16 @@ static/recent_changes.json Keep `docs/de/recent_changes.md` as the complete release-note source and `static/recent_changes.json` as the short 3-5 item summary for the startup dialog. +## User Manual PDF Export + +Generate the current PDF version of the German user manual with: + +```bash +./scripts/build_user_manual_pdf.sh +``` + +The script renders `docs/de/user_manual.md` to `docs/de/user_manual.pdf` with Pandoc. The Markdown file remains the leading source. + ## Frontend Entry Point The active UI is `templates/roll_calculator.html`. @@ -373,11 +417,11 @@ Utility script that updates relative frontend fetch/register paths to Flask-styl - There are duplicated or legacy-looking files with similar names. Before editing JavaScript under `static/`, confirm it is actually referenced by the active template. - The documentation under `docs/` contains deployment and feature notes, but some filenames and route assumptions may not match the current app exactly. - The Flask dev server is used for local development only. Production should use a WSGI server. -- Use `scripts/manage_users.py` to create password hashes when adding or rotating Basic Auth users. +- Use `scripts/manage_users.py` to add users, rotate passwords, or delete users. ## Known Risks and Maintenance Items -- Password hashes are currently configured in `app.py`; user configuration should eventually move to environment variables, a protected config file, or a secrets manager. +- Password hashes are currently stored in `config/users.json`; a secrets manager or protected deployment-specific config would be more robust for production. - `access_log.json` is not safe for concurrent writes. - `access_log.json` grows without rotation or retention limits. - The custom `/static/` route is intended to protect static files, but Flask also creates a default static route unless disabled. Verify effective route behavior before relying on static-file protection. diff --git a/app.py b/app.py index da95ea3..9845130 100644 --- a/app.py +++ b/app.py @@ -32,31 +32,9 @@ auth = HTTPBasicAuth() # CONFIGURATION # ============================================================================ -# Benutzer für Beta-Phase (in Produktion aus env-Variablen laden!) -BETA_USERS = { - "beta": { - "password_hash": "pbkdf2:sha256:600000$HNtF3VdZKdmtg8Vw$8f976a99a457c5e924916dc6f735dd631042c8c126af8d4bda9abcd7532d904f" - }, - "naue": { - "password_hash": "pbkdf2:sha256:600000$RVPiW2mYXJJIp3d7$0f838b8619d386e2da57e60ae8ccb944bb0f9f63d81ce3d174ec3034b0abcd19" - }, - "cniehues": { - "password_hash": "pbkdf2:sha256:600000$iNmhakf34xk26xrz$ae78846461370c52b7f56fad5ac0ae8e33860d6f00075ac78e3a5b8c31d51a3d" - }, - "lvollmert": { - "password_hash": "pbkdf2:sha256:600000$8FGop5ymmHpuIqfK$8ba223e20c514cf6bc1297435a2e7e2be81668951297f0e01ad6a931718ad7de" - }, - "mtazl": { - "password_hash": "pbkdf2:sha256:600000$H19skoalhWxlLnY5$d30bfeae38470b19900648fd110978b3677607c3a161d663a7e5d5c2167a3710" - }, - "controlling": { - "password_hash": "pbkdf2:sha256:600000$ksj86lrKz6nnSpXz$5161e0b5c76bd72d6b2cd04866ef49e69f463f474d7e47075171894bf3366f29" - } -} - -# Logging für Auditing LOG_FILE = "access_log.json" BUILD_INFO_FILE = "build_info.json" +USER_CONFIG_FILE = os.path.join(app.root_path, "config", "users.json") DOCS_DIR = os.path.join(app.root_path, "docs") DEFAULT_DOC_LANGUAGE = "de" USER_MANUAL_FILENAME = "user_manual.md" @@ -129,14 +107,67 @@ def inject_build_info(): # AUTHENTICATION # ============================================================================ +class UserConfigError(RuntimeError): + """Raised when the user configuration is missing or invalid.""" + + +def load_user_hashes(path=USER_CONFIG_FILE): + """Load user password hashes from config/users.json.""" + command_hint = "Run python3 scripts/manage_users.py to initialize or repair it." + + if not os.path.exists(path): + raise UserConfigError( + f"User config file is missing: {path}. {command_hint}" + ) + + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + except json.JSONDecodeError as e: + raise UserConfigError( + f"Invalid JSON in user config file {path}: {e}. {command_hint}" + ) from e + except Exception as e: + raise UserConfigError( + f"Could not read user config file {path}: {e}. {command_hint}" + ) from e + + if not isinstance(data, dict): + raise UserConfigError( + f"Invalid user config file {path}: root element must be an object. " + f"{command_hint}" + ) + + if not data: + raise UserConfigError( + f"User config file {path} does not contain any users. {command_hint}" + ) + + users = {} + for username, value in data.items(): + if not isinstance(username, str) or not username.strip(): + raise UserConfigError( + f"Invalid user config file {path}: usernames must be strings. " + f"{command_hint}" + ) + if not isinstance(value, str) or not value.strip(): + raise UserConfigError( + f"Invalid user config file {path}: password hash for " + f"user {username!r} must be a string. {command_hint}" + ) + users[username.strip()] = value.strip() + return users + + +USER_PASSWORD_HASHES = load_user_hashes() + @auth.verify_password def verify_password(username, password): """Verify HTTP Basic Auth credentials""" - user_config = BETA_USERS.get(username) - if not user_config: + password_hash = USER_PASSWORD_HASHES.get(username) + if not password_hash: return None - password_hash = user_config.get("password_hash") if password_hash and check_password_hash(password_hash, password): return username return None diff --git a/config/users.example.json b/config/users.example.json new file mode 100644 index 0000000..0b68a32 --- /dev/null +++ b/config/users.example.json @@ -0,0 +1,3 @@ +{ + "example-user": "pbkdf2:sha256:600000$example-salt$example-hash" +} diff --git a/scripts/manage_users.py b/scripts/manage_users.py old mode 100644 new mode 100755 index 2321f27..eccee7a --- a/scripts/manage_users.py +++ b/scripts/manage_users.py @@ -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())