CI / Python lint (flake8) (push) Has been cancelled
CI / Python syntax check (push) Has been cancelled
CI / Alembic migration chain (push) Has been cancelled
CI / JavaScript syntax check (push) Has been cancelled
CI / Pytest (push) Has been cancelled
CI / Build extension zip (push) Has been cancelled
179 lines
7.5 KiB
Python
179 lines
7.5 KiB
Python
import os
|
|
from datetime import timedelta
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
# Shared set of known-insecure placeholder values that must never reach production.
|
|
_INSECURE_SECRET_DEFAULTS = {'dev-secret-change-me', 'jwt-secret-change-me', '', 'change-me'}
|
|
|
|
|
|
class BaseConfig:
|
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-secret-change-me')
|
|
JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY', 'jwt-secret-change-me')
|
|
|
|
# WebAuthn / Passkey
|
|
# RP_ID must be the effective domain of the site (no scheme, no port).
|
|
# In development this is "localhost"; in production use your actual domain.
|
|
WEBAUTHN_RP_ID = os.environ.get('WEBAUTHN_RP_ID', 'localhost')
|
|
WEBAUTHN_RP_NAME = os.environ.get('WEBAUTHN_RP_NAME', 'PassKeeper')
|
|
# Allowed origins for WebAuthn ceremonies (comma-separated in env).
|
|
# Must include the full origin (scheme + host + optional port).
|
|
WEBAUTHN_ORIGINS = [
|
|
o.strip()
|
|
for o in os.environ.get(
|
|
'WEBAUTHN_ORIGINS',
|
|
'http://localhost:5000,https://localhost',
|
|
).split(',')
|
|
if o.strip()
|
|
]
|
|
JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=15)
|
|
JWT_REFRESH_TOKEN_EXPIRES = timedelta(days=7)
|
|
|
|
SQLALCHEMY_DATABASE_URI = (
|
|
'mysql+pymysql://{user}:{password}@{host}:{port}/{db}?charset=utf8mb4'.format(
|
|
user=os.environ.get('MYSQL_USER', 'passkeeper'),
|
|
password=os.environ.get('MYSQL_PASSWORD', ''),
|
|
host=os.environ.get('MYSQL_HOST', '127.0.0.1'),
|
|
port=os.environ.get('MYSQL_PORT', '3306'),
|
|
db=os.environ.get('MYSQL_DB', 'passkeeper'),
|
|
)
|
|
)
|
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
|
|
|
# MySQL closes idle connections after wait_timeout (default 8 hours).
|
|
# pool_recycle ensures SQLAlchemy replaces connections before that deadline.
|
|
# pool_pre_ping sends a cheap SELECT 1 before each checkout so stale
|
|
# connections are detected and recycled rather than causing "MySQL has gone
|
|
# away" errors on the first request after a long idle period.
|
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
|
'pool_recycle': 3600, # recycle connections after 1 hour
|
|
'pool_pre_ping': True, # test each connection before use
|
|
'pool_timeout': 30, # raise after 30 s if no connection available
|
|
'pool_size': 10, # base pool size per worker
|
|
'max_overflow': 5, # allow up to 5 extra connections under load
|
|
}
|
|
|
|
WTF_CSRF_ENABLED = True
|
|
WTF_CSRF_TIME_LIMIT = 3600
|
|
|
|
ARGON2_TIME_COST = int(os.environ.get('ARGON2_TIME_COST', 3))
|
|
ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536))
|
|
ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4))
|
|
|
|
# Server-side AES-256-GCM key for encrypting TOTP secrets at rest.
|
|
# Must be a 64-character hex string (32 bytes).
|
|
# Generate: python -c "import secrets; print(secrets.token_hex(32))"
|
|
TOTP_ENCRYPTION_KEY = os.environ.get('TOTP_ENCRYPTION_KEY', '')
|
|
|
|
# Rate limiting — use Redis in production so all Gunicorn workers share one counter.
|
|
# Falls back to memory (per-worker, dev only).
|
|
RATELIMIT_STORAGE_URI = os.environ.get('RATELIMIT_STORAGE_URI', 'memory://')
|
|
|
|
# CORS — restrict to the production origin in production config.
|
|
CORS_ORIGINS = os.environ.get('CORS_ORIGINS', '*')
|
|
|
|
# Cache-busting version string appended as ?v=... to all static assets.
|
|
# Bump this value on every deploy to force browsers to reload CSS/JS.
|
|
# Set via .env: STATIC_VERSION=20260418
|
|
STATIC_VERSION = os.environ.get('STATIC_VERSION', '1')
|
|
|
|
# Background cleanup scheduler (token_blacklist / recovery_challenges /
|
|
# totp_used_codes / expired shares). Disabled under test so the suite does
|
|
# not spawn a daemon thread per app fixture.
|
|
SCHEDULER_ENABLED = True
|
|
|
|
# Session cookie defaults — applied in all environments.
|
|
# SECURE is intentionally left out of BaseConfig so dev HTTP still works.
|
|
# See ProductionConfig below for the full hardened set.
|
|
SESSION_COOKIE_HTTPONLY = True
|
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
|
|
|
|
|
class DevelopmentConfig(BaseConfig):
|
|
DEBUG = True
|
|
RATELIMIT_ENABLED = False
|
|
|
|
|
|
class TestingConfig(BaseConfig):
|
|
"""
|
|
In-memory SQLite, no rate limiting, no background threads.
|
|
|
|
SQLite is viable here because the only MySQL-specific construct in the
|
|
models is mysql.INTEGER(unsigned=True), which SQLAlchemy renders as a plain
|
|
INTEGER on other dialects. That means these tests cover application logic
|
|
and flow, NOT MySQL-specific behaviour (collation, ON UPDATE NOW(), unsigned
|
|
range) — schema changes still need a real migration run against MySQL.
|
|
"""
|
|
TESTING = True
|
|
DEBUG = False
|
|
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
|
SQLALCHEMY_ENGINE_OPTIONS = {}
|
|
RATELIMIT_ENABLED = False
|
|
SCHEDULER_ENABLED = False
|
|
WTF_CSRF_ENABLED = False
|
|
SECRET_KEY = 'test-secret-not-used-in-production'
|
|
JWT_SECRET_KEY = 'test-jwt-secret-not-used-in-production'
|
|
TOTP_ENCRYPTION_KEY = '00' * 32
|
|
CORS_ORIGINS = 'http://localhost'
|
|
WEBAUTHN_RP_ID = 'localhost'
|
|
WEBAUTHN_ORIGINS = ['http://localhost']
|
|
# Keep Argon2 cheap so the suite is not dominated by password hashing.
|
|
ARGON2_TIME_COST = 1
|
|
ARGON2_MEMORY_COST = 8
|
|
ARGON2_PARALLELISM = 1
|
|
|
|
|
|
class ProductionConfig(BaseConfig):
|
|
DEBUG = False
|
|
RATELIMIT_ENABLED = True
|
|
# Force HTTPS in production — marks cookie Secure so it is never sent over HTTP.
|
|
SESSION_COOKIE_SECURE = True
|
|
SESSION_COOKIE_HTTPONLY = True
|
|
SESSION_COOKIE_SAMESITE = 'Lax'
|
|
|
|
@classmethod
|
|
def validate(cls):
|
|
"""
|
|
Fail loudly on insecure production configuration.
|
|
|
|
Called from create_app() when this config is selected — NOT at class
|
|
definition time. Running it in the class body meant merely *importing*
|
|
app.config raised unless production secrets were present in the
|
|
environment, which broke the test suite and any local tooling that
|
|
imports the app (including `flask db upgrade` on a dev box).
|
|
|
|
The fail-loud property is preserved: create_app('production') raises
|
|
before the app is returned, so the process still never serves a request
|
|
under an insecure configuration.
|
|
"""
|
|
secret_key = os.environ.get('SECRET_KEY', '')
|
|
if not secret_key or secret_key in _INSECURE_SECRET_DEFAULTS:
|
|
raise RuntimeError(
|
|
'[PassKeeper] SECRET_KEY is not set or uses an insecure placeholder. '
|
|
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
|
|
'and add SECRET_KEY=<value> to your production .env file.'
|
|
)
|
|
|
|
jwt_secret = os.environ.get('JWT_SECRET_KEY', '')
|
|
if not jwt_secret or jwt_secret in _INSECURE_SECRET_DEFAULTS:
|
|
raise RuntimeError(
|
|
'[PassKeeper] JWT_SECRET_KEY is not set or uses an insecure placeholder. '
|
|
'Generate a strong key with: python -c "import secrets; print(secrets.token_hex(32))" '
|
|
'and add JWT_SECRET_KEY=<value> to your production .env file.'
|
|
)
|
|
|
|
cors = os.environ.get('CORS_ORIGINS', '')
|
|
if not cors or cors.strip() == '*':
|
|
raise RuntimeError(
|
|
'[PassKeeper] CORS_ORIGINS must be set to a specific origin in production '
|
|
'(e.g. CORS_ORIGINS=https://pwkeeper.ngodanguyen.tech). '
|
|
'A wildcard "*" is not permitted in production.'
|
|
)
|
|
|
|
|
|
config = {
|
|
'development': DevelopmentConfig,
|
|
'testing': TestingConfig,
|
|
'production': ProductionConfig,
|
|
} |