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