feat: move user management to protected config file

This commit is contained in:
2026-07-28 13:46:05 +02:00
parent 2276a124b8
commit 3b54f5ecc0
6 changed files with 368 additions and 86 deletions
+3
View File
@@ -35,3 +35,6 @@ node_modules/
# runtime-generated files # runtime-generated files
access_log.json access_log.json
# User and passwords
config/users.json
+27 -8
View File
@@ -14,7 +14,7 @@ Backend:
- `app.py` creates the Flask app. - `app.py` creates the Flask app.
- HTTP Basic Auth is implemented with `Flask-HTTPAuth`. - 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. - `/` renders the active calculator template.
- `/help` renders the authenticated Help landing page. - `/help` renders the authenticated Help landing page.
- `/help/user-manual` renders `docs/de/user_manual.md` server-side inside the RollCalc Help layout. - `/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 ## 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 ```text
"username": { config/users.json
"password_hash": "..." ```
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": "<hash>"
} }
``` ```
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 ```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 ## Roll Geometry
+54 -10
View File
@@ -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 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 ## Project Layout
@@ -100,23 +100,57 @@ Implemented routes:
| `/api/health` | `GET` | Basic Auth | Returns app health and version. | | `/api/health` | `GET` | Basic Auth | Returns app health and version. |
| `/api/user` | `GET` | Basic Auth | Returns current authenticated user info. | | `/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 ```bash
python scripts/manage_users.py username python3 scripts/manage_users.py
python scripts/manage_users.py username --json
``` ```
For non-interactive local maintenance only: The menu supports:
```bash - List users
python scripts/manage_users.py username --password 'new-password' - 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": "<hash>"
}
```
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. 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. 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 ## 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. 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 ## Frontend Entry Point
The active UI is `templates/roll_calculator.html`. 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. - 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 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. - 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 ## 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` is not safe for concurrent writes.
- `access_log.json` grows without rotation or retention limits. - `access_log.json` grows without rotation or retention limits.
- The custom `/static/<path:filename>` 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. - The custom `/static/<path:filename>` 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.
+57 -26
View File
@@ -32,31 +32,9 @@ auth = HTTPBasicAuth()
# CONFIGURATION # 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" LOG_FILE = "access_log.json"
BUILD_INFO_FILE = "build_info.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") DOCS_DIR = os.path.join(app.root_path, "docs")
DEFAULT_DOC_LANGUAGE = "de" DEFAULT_DOC_LANGUAGE = "de"
USER_MANUAL_FILENAME = "user_manual.md" USER_MANUAL_FILENAME = "user_manual.md"
@@ -129,14 +107,67 @@ def inject_build_info():
# AUTHENTICATION # 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 @auth.verify_password
def verify_password(username, password): def verify_password(username, password):
"""Verify HTTP Basic Auth credentials""" """Verify HTTP Basic Auth credentials"""
user_config = BETA_USERS.get(username) password_hash = USER_PASSWORD_HASHES.get(username)
if not user_config: if not password_hash:
return None return None
password_hash = user_config.get("password_hash")
if password_hash and check_password_hash(password_hash, password): if password_hash and check_password_hash(password_hash, password):
return username return username
return None return None
+3
View File
@@ -0,0 +1,3 @@
{
"example-user": "pbkdf2:sha256:600000$example-salt$example-hash"
}
Regular → Executable
+224 -42
View File
@@ -1,56 +1,238 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """Interactive RollCalc user management."""
Create password hashes or user config snippets for RollCalc Basic Auth users.
"""
import argparse
import getpass
import json import json
import os
import stat
import sys
from getpass import getpass
from pathlib import Path
from werkzeug.security import generate_password_hash 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( class UserManagementError(Exception):
description="Generate RollCalc password hashes or user config entries." """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="?", def run_menu(path=USER_FILE):
help="Optional username for a generated user config snippet.", """Run the interactive menu."""
) while True:
parser.add_argument( try:
"--password", users = load_users(path)
help="Password to hash. Omit to enter it securely via prompt.", except UserManagementError as exc:
) print(f"Error: {exc}", file=sys.stderr)
parser.add_argument( return 1
"--json",
action="store_true", print_menu()
help="Output a JSON object for the user entry.", choice = input("Select option: ").strip()
)
return parser 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(): def main():
args = build_parser().parse_args() """CLI entry point."""
password = args.password return run_menu()
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)
if __name__ == "__main__": if __name__ == "__main__":
main() raise SystemExit(main())