better secrets admin
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user