04/18 Enhance app (security, performance)
This commit is contained in:
+33
-1
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user