04/18 Enhance app (security, performance)

This commit is contained in:
2026-04-18 13:20:04 -04:00
parent b51468661e
commit 4d3f9844f0
14 changed files with 529 additions and 7 deletions
+33 -1
View File
@@ -26,7 +26,39 @@ def create_app(config_name: str = 'development') -> Flask:
login_manager.init_app(app)
csrf.init_app(app)
limiter.init_app(app)
CORS(app, resources={r'/api/*': {'origins': '*'}})
# Restrict CORS to the configured origin (locked to production domain in prod)
cors_origins = app.config.get('CORS_ORIGINS', '*')
CORS(app, resources={r'/api/*': {'origins': cors_origins}})
# Attach security headers to every response
@app.after_request
def set_security_headers(response):
# Strict-Transport-Security: enforce HTTPS for 1 year, include subdomains
response.headers['Strict-Transport-Security'] = (
'max-age=31536000; includeSubDomains'
)
# Prevent clickjacking
response.headers['X-Frame-Options'] = 'DENY'
# Prevent MIME-type sniffing
response.headers['X-Content-Type-Options'] = 'nosniff'
# Control referrer information leakage
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
# Permissions policy — disable features the app does not use
response.headers['Permissions-Policy'] = (
'geolocation=(), camera=(), microphone=()'
)
# CSP via HTTP header (authoritative — overrides the meta tag for all resources)
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self'; "
"img-src 'self' data:; "
"font-src 'self'; "
"connect-src 'self'; "
"frame-ancestors 'none';"
)
return response
# Ensure all models are imported so SQLAlchemy knows about them
from .models.user import User
+11 -1
View File
@@ -29,7 +29,17 @@ class BaseConfig:
ARGON2_MEMORY_COST = int(os.environ.get('ARGON2_MEMORY_COST', 65536))
ARGON2_PARALLELISM = int(os.environ.get('ARGON2_PARALLELISM', 4))
RATELIMIT_STORAGE_URI = 'memory://'
# 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', '*')
class DevelopmentConfig(BaseConfig):
+5 -1
View File
@@ -22,7 +22,11 @@ class User(db.Model, UserMixin):
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
last_login = db.Column(db.DateTime, nullable=True)
# TOTP / MFA
totp_secret = db.Column(db.String(64), nullable=True)
# totp_secret: AES-256-GCM ciphertext of the base32 TOTP secret, base64-encoded.
# Encrypted server-side with the TOTP_ENCRYPTION_KEY from config.
# totp_iv: base64-encoded 12-byte GCM nonce for the above.
totp_secret = db.Column(db.String(255), nullable=True)
totp_iv = db.Column(db.String(64), nullable=True)
totp_enabled = db.Column(db.Boolean, default=False, nullable=False)
# ECDH P-256 sharing keypair
# Public key: raw uncompressed point (65 bytes), base64-encoded (~88 chars), stored plaintext
+20 -3
View File
@@ -13,6 +13,8 @@ from app.services.auth_service import (
decode_token,
blacklist_token,
require_jwt,
encrypt_totp_secret,
decrypt_totp_secret,
)
auth_bp = Blueprint('auth', __name__)
@@ -75,6 +77,16 @@ def login():
user = User.query.filter_by(email=email).first()
if not user or not verify_auth_token(auth_hash, user.master_hash):
if user:
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail='Failed login attempt — invalid password',
ip_address=_client_ip(),
)
db.session.commit()
return jsonify({'error': 'Invalid email or password'}), 401
from datetime import datetime
@@ -196,7 +208,9 @@ def mfa_enable():
if not pyotp.TOTP(secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
user.totp_secret = secret
totp_secret_enc, totp_iv = encrypt_totp_secret(secret)
user.totp_secret = totp_secret_enc
user.totp_iv = totp_iv
user.totp_enabled = True
AuditLog.log(
@@ -224,10 +238,12 @@ def mfa_disable():
totp_code = (data.get('totp_code') or '').strip()
import pyotp
if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1):
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
user.totp_secret = None
user.totp_iv = None
user.totp_enabled = False
AuditLog.log(
@@ -264,7 +280,8 @@ def mfa_verify():
return jsonify({'error': 'MFA not configured for this account'}), 400
import pyotp
if not pyotp.TOTP(user.totp_secret).verify(totp_code, valid_window=1):
plaintext_secret = decrypt_totp_secret(user.totp_secret, user.totp_iv)
if not pyotp.TOTP(plaintext_secret).verify(totp_code, valid_window=1):
return jsonify({'error': 'Invalid verification code'}), 400
# One-time use: blacklist the mfa_token
+4 -1
View File
@@ -50,7 +50,10 @@ def create_emergency():
"""Grantor creates an emergency access invitation for a trusted contact."""
data = request.get_json(silent=True) or {}
grantee_email = (data.get('grantee_email') or '').strip().lower()
wait_days = int(data.get('wait_days', 7))
try:
wait_days = int(data.get('wait_days', 7))
except (TypeError, ValueError):
return jsonify({'error': 'wait_days must be an integer'}), 400
if not grantee_email:
return jsonify({'error': 'grantee_email is required'}), 400
+41
View File
@@ -1,9 +1,12 @@
import base64
import os
import uuid
import time
from datetime import datetime, timedelta
from functools import wraps
import jwt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from flask import current_app, request, g, jsonify
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError, VerificationError, InvalidHashError
@@ -27,6 +30,44 @@ def verify_auth_token(auth_hash: str, stored_hash: str) -> bool:
return False
def _get_totp_key() -> bytes:
"""
Return the 32-byte AES key used for server-side TOTP secret encryption.
The key is stored as a 64-char hex string in TOTP_ENCRYPTION_KEY config.
"""
hex_key = current_app.config.get('TOTP_ENCRYPTION_KEY', '')
if not hex_key or len(hex_key) != 64:
raise RuntimeError(
'TOTP_ENCRYPTION_KEY must be set to a 64-character hex string (32 bytes). '
'Generate one with: python -c "import secrets; print(secrets.token_hex(32))"'
)
return bytes.fromhex(hex_key)
def encrypt_totp_secret(plaintext_secret: str) -> tuple[str, str]:
"""
Encrypt a plaintext TOTP base32 secret with AES-256-GCM.
Returns (ciphertext_b64, iv_b64).
"""
key = _get_totp_key()
iv = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(iv, plaintext_secret.encode(), None)
return base64.b64encode(ciphertext).decode(), base64.b64encode(iv).decode()
def decrypt_totp_secret(ciphertext_b64: str, iv_b64: str) -> str:
"""
Decrypt a base64-encoded AES-256-GCM TOTP secret ciphertext.
Returns the plaintext base32 secret string.
"""
key = _get_totp_key()
iv = base64.b64decode(iv_b64)
ciphertext = base64.b64decode(ciphertext_b64)
aesgcm = AESGCM(key)
return aesgcm.decrypt(iv, ciphertext, None).decode()
def generate_tokens(user_id: int) -> dict:
"""Return access_token and refresh_token JWTs, each with a unique jti."""
now = datetime.utcnow()