05/02/2026 updated code for security 2
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
from flask import Flask, render_template
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_wtf.csrf import CSRFProtect
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from flask_cors import CORS
|
||||
|
||||
from .config import config
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
# APScheduler is used for the background token-blacklist cleanup job.
|
||||
# Imported here so it is available at module level; started inside create_app().
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
_scheduler_available = True
|
||||
except ImportError: # pragma: no cover — optional dependency
|
||||
_scheduler_available = False
|
||||
|
||||
|
||||
def create_app(config_name: str = 'development') -> Flask:
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config[config_name])
|
||||
|
||||
# Extensions
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
login_manager.init_app(app)
|
||||
csrf.init_app(app)
|
||||
limiter.init_app(app)
|
||||
|
||||
# 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}})
|
||||
|
||||
# Inject static asset version into every template for cache-busting.
|
||||
# Usage in templates: {{ url_for('static', filename='css/app.css') }}?v={{ sv }}
|
||||
app.jinja_env.globals['sv'] = app.config.get('STATIC_VERSION', '1')
|
||||
|
||||
# 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' https://api.pwnedpasswords.com; "
|
||||
"frame-ancestors 'none';"
|
||||
)
|
||||
return response
|
||||
|
||||
# Ensure all models are imported so SQLAlchemy knows about them
|
||||
from .models.user import User
|
||||
from .models.folder import Folder
|
||||
from .models.vault_item import VaultItem
|
||||
from .models.token_blacklist import TokenBlacklist
|
||||
from .models.shared_item import SharedItem
|
||||
from .models.emergency_access import EmergencyAccess
|
||||
from .models.audit_log import AuditLog
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
# Blueprints
|
||||
from .routes.auth import auth_bp
|
||||
from .routes.vault import vault_bp
|
||||
from .routes.folders import folders_bp
|
||||
from .routes.sharing import sharing_bp
|
||||
from .routes.emergency import emergency_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
||||
app.register_blueprint(vault_bp, url_prefix='/api/vault')
|
||||
app.register_blueprint(folders_bp, url_prefix='/api/folders')
|
||||
app.register_blueprint(sharing_bp, url_prefix='/api/sharing')
|
||||
app.register_blueprint(emergency_bp, url_prefix='/api/emergency')
|
||||
|
||||
# Exempt all API blueprints from CSRF — JWT bearer tokens make CSRF irrelevant
|
||||
csrf.exempt(auth_bp)
|
||||
csrf.exempt(vault_bp)
|
||||
csrf.exempt(folders_bp)
|
||||
csrf.exempt(sharing_bp)
|
||||
csrf.exempt(emergency_bp)
|
||||
|
||||
# Page-serving routes
|
||||
@app.route('/')
|
||||
@app.route('/login')
|
||||
def login_page():
|
||||
return render_template('auth/login.html')
|
||||
|
||||
@app.route('/register')
|
||||
def register_page():
|
||||
return render_template('auth/register.html')
|
||||
|
||||
@app.route('/vault')
|
||||
def vault_page():
|
||||
return render_template('vault/index.html')
|
||||
|
||||
@app.route('/recover')
|
||||
def recover_page():
|
||||
return render_template('auth/recover.html')
|
||||
|
||||
# ── Background scheduler — token blacklist cleanup ─────────────────────────
|
||||
# Runs cleanup_expired() every hour so the token_blacklist table never
|
||||
# accumulates unbounded rows. Runs in a daemon thread — no request context.
|
||||
if _scheduler_available:
|
||||
def _cleanup_expired_tokens():
|
||||
with app.app_context():
|
||||
try:
|
||||
from app.models.token_blacklist import TokenBlacklist
|
||||
TokenBlacklist.cleanup_expired()
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(
|
||||
'[PassKeeper] token_blacklist cleanup completed'
|
||||
)
|
||||
except Exception as exc: # pragma: no cover
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
'[PassKeeper] token_blacklist cleanup failed: %s', exc
|
||||
)
|
||||
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
scheduler.add_job(
|
||||
_cleanup_expired_tokens,
|
||||
trigger='interval',
|
||||
hours=1,
|
||||
id='token_blacklist_cleanup',
|
||||
replace_existing=True,
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
# ── Production safety checks ───────────────────────────────────────────────
|
||||
# Warn loudly at startup when running in production with settings that are
|
||||
# only appropriate for development.
|
||||
if not app.config.get('DEBUG', False):
|
||||
import logging
|
||||
_log = logging.getLogger(__name__)
|
||||
storage_uri = app.config.get('RATELIMIT_STORAGE_URI', 'memory://')
|
||||
if storage_uri.startswith('memory://'):
|
||||
_log.warning(
|
||||
'[PassKeeper] WARNING: RATELIMIT_STORAGE_URI is set to "memory://" '
|
||||
'in a production environment. Rate limits are tracked per-worker '
|
||||
'and will not be shared across Gunicorn processes. '
|
||||
'Set RATELIMIT_STORAGE_URI to a Redis URL (e.g. redis://localhost:6379) '
|
||||
'in your production .env to enforce global rate limits.'
|
||||
)
|
||||
|
||||
return app
|
||||
+45
-3
@@ -115,7 +115,7 @@ def login():
|
||||
user.failed_login_count = (user.failed_login_count or 0) + 1
|
||||
if user.failed_login_count >= MAX_FAILED_LOGINS:
|
||||
from datetime import timedelta
|
||||
user.locked_until = datetime.utcnow() + timedelta(minutes=LOCKOUT_MINUTES)
|
||||
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES)
|
||||
AuditLog.log(
|
||||
user_id=user.id,
|
||||
action='auth.account_locked',
|
||||
@@ -139,7 +139,7 @@ def login():
|
||||
# Successful authentication — reset lockout state.
|
||||
user.failed_login_count = 0
|
||||
user.locked_until = None
|
||||
user.last_login = datetime.utcnow()
|
||||
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
AuditLog.log(
|
||||
user_id=user.id,
|
||||
@@ -455,17 +455,58 @@ def mfa_backup_codes_regenerate():
|
||||
@require_jwt
|
||||
def me():
|
||||
"""Return basic profile info for the authenticated user."""
|
||||
import json
|
||||
user = db.session.get(User, g.current_user_id)
|
||||
stored_codes = json.loads(user.mfa_backup_codes or '[]')
|
||||
return jsonify({
|
||||
'id': user.id,
|
||||
'email': user.email,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'last_login': user.last_login.isoformat() if user.last_login else None,
|
||||
'totp_enabled': user.totp_enabled,
|
||||
'backup_codes_remaining': len(stored_codes),
|
||||
'recovery_configured': bool(user.recovery_enc_salt),
|
||||
}), 200
|
||||
|
||||
|
||||
@auth_bp.route('/audit-log', methods=['GET'])
|
||||
@require_jwt
|
||||
@limiter.limit('30 per minute')
|
||||
def audit_log():
|
||||
"""
|
||||
Return the authenticated user's recent audit log entries.
|
||||
|
||||
Query params:
|
||||
limit — max entries to return (default 50, max 200)
|
||||
offset — pagination offset (default 0)
|
||||
|
||||
Sensitive field values are never logged — entries contain only action
|
||||
types, resource IDs, timestamps, and IP addresses.
|
||||
"""
|
||||
try:
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
offset = max(int(request.args.get('offset', 0)), 0)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'error': 'limit and offset must be integers'}), 400
|
||||
|
||||
entries = (
|
||||
AuditLog.query
|
||||
.filter_by(user_id=g.current_user_id)
|
||||
.order_by(AuditLog.created_at.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
.all()
|
||||
)
|
||||
total = AuditLog.query.filter_by(user_id=g.current_user_id).count()
|
||||
|
||||
return jsonify({
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
'entries': [e.to_dict() for e in entries],
|
||||
}), 200
|
||||
|
||||
|
||||
# ── Account management ────────────────────────────────────────────────────────
|
||||
|
||||
@auth_bp.route('/change-password', methods=['POST'])
|
||||
@@ -864,4 +905,5 @@ def recovery_items():
|
||||
{'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv}
|
||||
for item in items
|
||||
]
|
||||
}), 200
|
||||
}), 200
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from app import db
|
||||
@@ -210,7 +210,7 @@ def request_access(ea_id):
|
||||
return jsonify({'error': 'Not found or not in ready state'}), 404
|
||||
|
||||
ea.status = 'pending'
|
||||
ea.request_initiated_at = datetime.utcnow()
|
||||
ea.request_initiated_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
AuditLog.log(
|
||||
user_id=g.current_user_id,
|
||||
@@ -271,7 +271,7 @@ def get_emergency_vault(ea_id):
|
||||
return jsonify({'error': 'Not found'}), 404
|
||||
if not ea.wait_elapsed:
|
||||
if ea.request_initiated_at:
|
||||
elapsed_secs = (datetime.utcnow() - ea.request_initiated_at).total_seconds()
|
||||
elapsed_secs = (datetime.now(timezone.utc).replace(tzinfo=None) - ea.request_initiated_at).total_seconds()
|
||||
days_left = max(0, ea.wait_days - elapsed_secs / 86400)
|
||||
else:
|
||||
days_left = ea.wait_days
|
||||
|
||||
Reference in New Issue
Block a user