diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..b379082 --- /dev/null +++ b/app/__init__.py @@ -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 \ No newline at end of file diff --git a/app/routes/auth.py b/app/routes/auth.py index 72e7291..de6853b 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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 \ No newline at end of file