feat: move user management to protected config file
This commit is contained in:
@@ -35,3 +35,6 @@ node_modules/
|
||||
|
||||
# runtime-generated files
|
||||
access_log.json
|
||||
|
||||
# User and passwords
|
||||
config/users.json
|
||||
|
||||
+27
-8
@@ -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": "<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
|
||||
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
|
||||
|
||||
|
||||
@@ -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": "<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.
|
||||
|
||||
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/<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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"example-user": "pbkdf2:sha256:600000$example-salt$example-hash"
|
||||
}
|
||||
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