05/02/2026 updated code for security 2c

This commit is contained in:
2026-05-02 18:58:31 -04:00
parent 413a0aaa05
commit 86dbd8ac8e
2 changed files with 266 additions and 42 deletions
+203
View File
@@ -0,0 +1,203 @@
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)
# ── Global JSON error handlers ─────────────────────────────────────────────
# All /api/* routes must return JSON even on unhandled exceptions.
# Without this, Flask returns an HTML 500 page which breaks JSON.parse()
# in the extension and surfaces as "Unexpected token '<'" to the user.
import logging as _logging
_api_log = _logging.getLogger(__name__)
@app.errorhandler(404)
def not_found(e):
if request.path.startswith('/api/'):
return jsonify({'error': 'Endpoint not found'}), 404
return render_template('auth/login.html'), 404
@app.errorhandler(405)
def method_not_allowed(e):
if request.path.startswith('/api/'):
return jsonify({'error': 'Method not allowed'}), 405
return render_template('auth/login.html'), 405
@app.errorhandler(Exception)
def handle_exception(e):
from werkzeug.exceptions import HTTPException
if isinstance(e, HTTPException):
if request.path.startswith('/api/'):
return jsonify({'error': e.description}), e.code
return e
# Unhandled exception — log it and return JSON for API routes.
_api_log.exception('[PassKeeper] Unhandled exception on %s %s', request.method, request.path)
if request.path.startswith('/api/'):
return jsonify({'error': 'An internal server error occurred. Please try again.'}), 500
return render_template('auth/login.html'), 500
# 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
+63 -42
View File
@@ -70,7 +70,8 @@ def register():
@auth_bp.route('/login', methods=['POST'])
@limiter.limit('10 per minute')
def login():
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from sqlalchemy.exc import OperationalError
# Number of consecutive failures before a temporary lockout is applied.
MAX_FAILED_LOGINS = 5
@@ -87,58 +88,79 @@ def login():
user = User.query.filter_by(email=email).first()
# Per-account lockout check — evaluated before password verification so the
# check itself doesn't leak whether the account exists via timing.
if user and user.locked_until:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user.locked_until > now:
remaining = int((user.locked_until - now).total_seconds() // 60) + 1
AuditLog.log(
user_id=user.id,
action='auth.login_blocked',
resource_type='user',
resource_id=user.id,
detail=f'Login blocked — account locked for {remaining} more minute(s)',
ip_address=_client_ip(),
)
db.session.commit()
return jsonify({
'error': f'Account temporarily locked. Try again in {remaining} minute(s).'
}), 429
else:
# Lockout has expired — reset the counter.
user.failed_login_count = 0
user.locked_until = None
# Per-account lockout check.
# Guarded with try/except so that a deployment where the migration has not
# yet been run (columns missing) degrades gracefully instead of returning
# an HTML 500 page that breaks JSON parsing in the extension.
try:
if user and user.locked_until:
now = datetime.now(timezone.utc).replace(tzinfo=None)
if user.locked_until > now:
remaining = int((user.locked_until - now).total_seconds() // 60) + 1
AuditLog.log(
user_id=user.id,
action='auth.login_blocked',
resource_type='user',
resource_id=user.id,
detail=f'Login blocked — account locked for {remaining} more minute(s)',
ip_address=_client_ip(),
)
db.session.commit()
return jsonify({
'error': f'Account temporarily locked. Try again in {remaining} minute(s).'
}), 429
else:
# Lockout has expired — reset the counter.
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
# Columns do not exist yet — migration pending. Skip lockout check.
db.session.rollback()
if not user or not verify_auth_token(auth_hash, user.master_hash):
if user:
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.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES)
AuditLog.log(
user_id=user.id,
action='auth.account_locked',
resource_type='user',
resource_id=user.id,
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
ip_address=_client_ip(),
)
else:
try:
user.failed_login_count = (user.failed_login_count or 0) + 1
if user.failed_login_count >= MAX_FAILED_LOGINS:
user.locked_until = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(minutes=LOCKOUT_MINUTES)
AuditLog.log(
user_id=user.id,
action='auth.account_locked',
resource_type='user',
resource_id=user.id,
detail=f'Account locked for {LOCKOUT_MINUTES} minutes after {user.failed_login_count} failed attempts',
ip_address=_client_ip(),
)
else:
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
ip_address=_client_ip(),
)
db.session.commit()
except OperationalError:
db.session.rollback()
AuditLog.log(
user_id=user.id,
action='auth.login_failed',
resource_type='user',
resource_id=user.id,
detail=f'Failed login attempt — invalid password ({user.failed_login_count}/{MAX_FAILED_LOGINS})',
detail='Failed login attempt — invalid password',
ip_address=_client_ip(),
)
db.session.commit()
db.session.commit()
return jsonify({'error': 'Invalid email or password'}), 401
# Successful authentication — reset lockout state.
user.failed_login_count = 0
user.locked_until = None
try:
user.failed_login_count = 0
user.locked_until = None
except OperationalError:
db.session.rollback()
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
AuditLog.log(
@@ -905,5 +927,4 @@ def recovery_items():
{'id': item.id, 'enc_data': item.enc_data, 'iv': item.iv}
for item in items
]
}), 200
}), 200