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 ## Secrets and configuration
LAPP does not store runtime secrets in the source code. Configure them with LAPP stores its private configuration in `instance/secrets.yaml`. On first
environment variables in production: startup, missing values are generated automatically and the file permissions are
set to `0600`.
```sh The file contains the Flask session key, API application key, database URI,
export LAPP_SECRET_KEY="replace-with-a-long-random-secret" initial account credentials, initial group secrets, and the Fernet key used by
export LAPP_APPLICATION_KEY="replace-with-another-long-random-secret" `test.py`. To change the database, edit `runtime.database_uri` in this file.
export LAPP_DATABASE_URI="sqlite:///app.db"
```
`LAPP_SECRET_KEY` signs Flask sessions and auto-login cookies. Keep it stable: Keep `runtime.secret_key` stable: changing it logs users out and invalidates
changing it logs users out and invalidates existing auto-login cookies. existing auto-login cookies. `runtime.application_key` is used by
`/api/validate_key`.
`LAPP_APPLICATION_KEY` is used by `/api/validate_key`. The entire `instance/` directory is ignored by Git. Back up `secrets.yaml`
securely and never commit or share it.
`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.
## Initial data secrets ## Initial data secrets
`initialize_data.py` creates initial users and groups only when the database has `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: 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
```sh location of the credentials when it creates the initial data.
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.
+7 -14
View File
@@ -1,11 +1,7 @@
from lapp import create_app from lapp import create_app
import os
import secrets
import sqlalchemy as sa import sqlalchemy as sa
from models import db, User, ListOfItems, Shared, Item, Group from models import db, User, ListOfItems, Shared, Item, Group
from secrets_config import load_secrets
def initial_secret(env_name):
return os.environ.get(env_name) or secrets.token_urlsafe(18)
if __name__ == "__main__": if __name__ == "__main__":
app = create_app() app = create_app()
@@ -13,10 +9,11 @@ if __name__ == "__main__":
query = sa.select(User) query = sa.select(User)
users = db.session.scalars(query).all() users = db.session.scalars(query).all()
if len(users) == 0: if len(users) == 0:
admin_group_secret = initial_secret("LAPP_INITIAL_ADMIN_GROUP") initial_data = load_secrets(app.instance_path)["initial_data"]
user_group_secret = initial_secret("LAPP_INITIAL_USER_GROUP") admin_group_secret = initial_data["admin_group"]
admin_password = initial_secret("LAPP_INITIAL_ADMIN_PASSWORD") user_group_secret = initial_data["user_group"]
user_password = initial_secret("LAPP_INITIAL_USER_PASSWORD") admin_password = initial_data["admin_password"]
user_password = initial_data["user_password"]
g = Group(secret=admin_group_secret) g = Group(secret=admin_group_secret)
db.session.add(g) db.session.add(g)
@@ -30,11 +27,7 @@ if __name__ == "__main__":
db.session.add(user1) db.session.add(user1)
db.session.commit() db.session.commit()
print("Created initial credentials:") print("Created initial credentials from instance/secrets.yaml")
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}")
lol1 = ListOfItems(owner_user_id = user1.id, name="Supermarkt", is_active=True) lol1 = ListOfItems(owner_user_id = user1.id, name="Supermarkt", is_active=True)
db.session.add(lol1) 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 import Flask, send_from_directory, url_for
from flask_login import LoginManager from flask_login import LoginManager
from models import db from models import db
@@ -11,31 +8,16 @@ from lists import lists_bp
from items import items_bp from items import items_bp
from api import api_bp from api import api_bp
from groups import groups_bp from groups import groups_bp
from secrets_config import load_secrets
login_manager = LoginManager() 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(): def create_app():
app = Flask(__name__, instance_relative_config=True) app = Flask(__name__, instance_relative_config=True)
app.config["SECRET_KEY"] = load_secret(app, "LAPP_SECRET_KEY", "secret_key") secret_values = load_secrets(app.instance_path)
app.config["APPLICATION_KEY"] = load_secret(app, "LAPP_APPLICATION_KEY", "application_key") app.config["SECRET_KEY"] = secret_values["runtime"]["secret_key"]
app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("LAPP_DATABASE_URI", "sqlite:///app.db") app.config["APPLICATION_KEY"] = secret_values["runtime"]["application_key"]
app.config["SQLALCHEMY_DATABASE_URI"] = secret_values["runtime"]["database_uri"]
db.init_app(app) db.init_app(app)
login_manager.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-Login>=0.6,<1.0
Flask-SQLAlchemy>=3.1,<4.0 Flask-SQLAlchemy>=3.1,<4.0
cryptography>=42,<46 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
+6 -3
View File
@@ -1,11 +1,14 @@
import pickle import pickle
from pathlib import Path
from cryptography.fernet import Fernet from cryptography.fernet import Fernet
from secrets_config import load_secrets
# ===================================================== # =====================================================
# SECRET KEY (only your program has this) # SECRET KEY (only your program has this)
# ===================================================== # =====================================================
# Generate once using: Fernet.generate_key() # The key is generated once and stored outside Git in instance/secrets.yaml.
_SECRET_KEY = b'YFK7QCyTzhyLO4vqrnRxvDAI5uu8mXEYrInEjbRoQgs=' _SECRET_KEY = load_secrets(Path(__file__).parent / "instance")["fernet"]["key"].encode("ascii")
fernet = Fernet(_SECRET_KEY) fernet = Fernet(_SECRET_KEY)
@@ -56,4 +59,4 @@ print("Type Before :- ",type(bytes_data))
print(string_data) print(string_data)
print("Type After :- ",type(string_data)) print("Type After :- ",type(string_data))