better secrets admin

This commit is contained in:
2026-07-19 09:58:27 +02:00
parent 768ccab09f
commit 0c03348c88
6 changed files with 118 additions and 75 deletions
+14 -35
View File
@@ -10,45 +10,24 @@ install
## Secrets and configuration
LAPP does not store runtime secrets in the source code. Configure them with
environment variables in production:
LAPP stores its private configuration in `instance/secrets.yaml`. On first
startup, missing values are generated automatically and the file permissions are
set to `0600`.
```sh
export LAPP_SECRET_KEY="replace-with-a-long-random-secret"
export LAPP_APPLICATION_KEY="replace-with-another-long-random-secret"
export LAPP_DATABASE_URI="sqlite:///app.db"
```
The file contains the Flask session key, API application key, database URI,
initial account credentials, initial group secrets, and the Fernet key used by
`test.py`. To change the database, edit `runtime.database_uri` in this file.
`LAPP_SECRET_KEY` signs Flask sessions and auto-login cookies. Keep it stable:
changing it logs users out and invalidates existing auto-login cookies.
Keep `runtime.secret_key` stable: changing it logs users out and invalidates
existing auto-login cookies. `runtime.application_key` is used by
`/api/validate_key`.
`LAPP_APPLICATION_KEY` is used by `/api/validate_key`.
`LAPP_DATABASE_URI` is optional. If it is not set, LAPP uses `sqlite:///app.db`.
For local development, if `LAPP_SECRET_KEY` or `LAPP_APPLICATION_KEY` is not set,
the app creates stable random secrets in:
```text
instance/secret_key
instance/application_key
```
The `instance/` directory is ignored by git, so these generated secrets should
not be committed.
The entire `instance/` directory is ignored by Git. Back up `secrets.yaml`
securely and never commit or share it.
## Initial data secrets
`initialize_data.py` creates initial users and groups only when the database has
no users yet. You can provide the initial passwords and group secrets with:
```sh
export LAPP_INITIAL_ADMIN_PASSWORD="replace-me"
export LAPP_INITIAL_ADMIN_GROUP="replace-me"
export LAPP_INITIAL_USER_PASSWORD="replace-me"
export LAPP_INITIAL_USER_GROUP="replace-me"
./venv/bin/python initialize_data.py
```
If these variables are not set, `initialize_data.py` generates random values and
prints them once when it creates the initial data.
no users yet. Set the values under `initial_data` in `instance/secrets.yaml`
before running it, or use the securely generated defaults. The script prints the
location of the credentials when it creates the initial data.
+7 -14
View File
@@ -1,11 +1,7 @@
from lapp import create_app
import os
import secrets
import sqlalchemy as sa
from models import db, User, ListOfItems, Shared, Item, Group
def initial_secret(env_name):
return os.environ.get(env_name) or secrets.token_urlsafe(18)
from secrets_config import load_secrets
if __name__ == "__main__":
app = create_app()
@@ -13,10 +9,11 @@ if __name__ == "__main__":
query = sa.select(User)
users = db.session.scalars(query).all()
if len(users) == 0:
admin_group_secret = initial_secret("LAPP_INITIAL_ADMIN_GROUP")
user_group_secret = initial_secret("LAPP_INITIAL_USER_GROUP")
admin_password = initial_secret("LAPP_INITIAL_ADMIN_PASSWORD")
user_password = initial_secret("LAPP_INITIAL_USER_PASSWORD")
initial_data = load_secrets(app.instance_path)["initial_data"]
admin_group_secret = initial_data["admin_group"]
user_group_secret = initial_data["user_group"]
admin_password = initial_data["admin_password"]
user_password = initial_data["user_password"]
g = Group(secret=admin_group_secret)
db.session.add(g)
@@ -30,11 +27,7 @@ if __name__ == "__main__":
db.session.add(user1)
db.session.commit()
print("Created initial credentials:")
print(f"admin password: {admin_password}")
print(f"admin group secret: {admin_group_secret}")
print(f"ignace password: {user_password}")
print(f"ignace group secret: {user_group_secret}")
print("Created initial credentials from instance/secrets.yaml")
lol1 = ListOfItems(owner_user_id = user1.id, name="Supermarkt", is_active=True)
db.session.add(lol1)
+5 -23
View File
@@ -1,6 +1,3 @@
import os
import secrets
from pathlib import Path
from flask import Flask, send_from_directory, url_for
from flask_login import LoginManager
from models import db
@@ -11,31 +8,16 @@ from lists import lists_bp
from items import items_bp
from api import api_bp
from groups import groups_bp
from secrets_config import load_secrets
login_manager = LoginManager()
def load_secret(app, env_name, filename):
secret = os.environ.get(env_name)
if secret:
return secret
instance_path = Path(app.instance_path)
instance_path.mkdir(parents=True, exist_ok=True)
secret_path = instance_path / filename
if secret_path.exists():
return secret_path.read_text(encoding="utf-8").strip()
secret = secrets.token_urlsafe(48)
secret_path.write_text(secret, encoding="utf-8")
secret_path.chmod(0o600)
return secret
def create_app():
app = Flask(__name__, instance_relative_config=True)
app.config["SECRET_KEY"] = load_secret(app, "LAPP_SECRET_KEY", "secret_key")
app.config["APPLICATION_KEY"] = load_secret(app, "LAPP_APPLICATION_KEY", "application_key")
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("LAPP_DATABASE_URI", "sqlite:///app.db")
secret_values = load_secrets(app.instance_path)
app.config["SECRET_KEY"] = secret_values["runtime"]["secret_key"]
app.config["APPLICATION_KEY"] = secret_values["runtime"]["application_key"]
app.config["SQLALCHEMY_DATABASE_URI"] = secret_values["runtime"]["database_uri"]
db.init_app(app)
login_manager.init_app(app)
+1
View File
@@ -2,3 +2,4 @@ Flask>=3.0,<4.0
Flask-Login>=0.6,<1.0
Flask-SQLAlchemy>=3.1,<4.0
cryptography>=42,<46
PyYAML>=6.0,<7.0
+85
View File
@@ -0,0 +1,85 @@
import base64
import secrets
from pathlib import Path
import yaml
def _fernet_key():
return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii")
def _default_secrets():
return {
"runtime": {
"secret_key": secrets.token_urlsafe(48),
"application_key": secrets.token_urlsafe(48),
"database_uri": "sqlite:///app.db",
},
"initial_data": {
"admin_password": secrets.token_urlsafe(18),
"admin_group": secrets.token_urlsafe(18),
"user_password": secrets.token_urlsafe(18),
"user_group": secrets.token_urlsafe(18),
},
"fernet": {
"key": _fernet_key(),
},
}
def _merge_missing(target, defaults):
changed = False
for key, value in defaults.items():
if isinstance(value, dict):
current = target.get(key)
if not isinstance(current, dict):
target[key] = {}
current = target[key]
changed = True
if _merge_missing(current, value):
changed = True
elif not target.get(key):
target[key] = value
changed = True
return changed
def load_secrets(instance_path):
instance_dir = Path(instance_path)
instance_dir.mkdir(parents=True, exist_ok=True)
secrets_path = instance_dir / "secrets.yaml"
existed = secrets_path.exists()
if existed:
secrets_path.chmod(0o600)
else:
secrets_path.touch(mode=0o600)
if existed:
loaded = yaml.safe_load(secrets_path.read_text(encoding="utf-8")) or {}
if not isinstance(loaded, dict):
raise ValueError(f"{secrets_path} must contain a YAML mapping")
else:
loaded = {}
defaults = _default_secrets()
legacy_files = {
"secret_key": instance_dir / "secret_key",
"application_key": instance_dir / "application_key",
}
migrated_paths = []
for key, legacy_path in legacy_files.items():
if legacy_path.exists() and not loaded.get("runtime", {}).get(key):
defaults["runtime"][key] = legacy_path.read_text(encoding="utf-8").strip()
migrated_paths.append(legacy_path)
changed = _merge_missing(loaded, defaults)
if changed or not existed:
secrets_path.write_text(
yaml.safe_dump(loaded, sort_keys=False),
encoding="utf-8",
)
secrets_path.chmod(0o600)
for legacy_path in migrated_paths:
legacy_path.unlink()
return loaded
+5 -2
View File
@@ -1,11 +1,14 @@
import pickle
from pathlib import Path
from cryptography.fernet import Fernet
from secrets_config import load_secrets
# =====================================================
# SECRET KEY (only your program has this)
# =====================================================
# Generate once using: Fernet.generate_key()
_SECRET_KEY = b'YFK7QCyTzhyLO4vqrnRxvDAI5uu8mXEYrInEjbRoQgs='
# The key is generated once and stored outside Git in instance/secrets.yaml.
_SECRET_KEY = load_secrets(Path(__file__).parent / "instance")["fernet"]["key"].encode("ascii")
fernet = Fernet(_SECRET_KEY)