feat: move user management to protected config file
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
#!/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())
|
||||||
|
|||||||
Reference in New Issue
Block a user