diff --git a/app/__init__.py b/app/__init__.py index b379082..af76c2c 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -110,31 +110,32 @@ def create_app(config_name: str = 'development') -> Flask: # 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 + from flask import request as _request, jsonify as _jsonify _api_log = _logging.getLogger(__name__) @app.errorhandler(404) def not_found(e): - if request.path.startswith('/api/'): - return jsonify({'error': 'Endpoint not found'}), 404 + 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 + 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 + 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 + _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 diff --git a/app/routes/__init__.py b/app/routes/__init__.py index e61b80c..e69de29 100644 --- a/app/routes/__init__.py +++ b/app/routes/__init__.py @@ -1,171 +0,0 @@ -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 \ No newline at end of file diff --git a/app/services/auth_service.py b/app/services/auth_service.py index 1218780..752b6d9 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -129,7 +129,7 @@ def blacklist_token(token: str, token_type: str) -> None: if not jti: return exp = payload.get('exp') - expires_at = datetime.fromtimestamp(exp) if exp else datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=7, tz=timezone.utc).replace(tzinfo=None) + expires_at = datetime.fromtimestamp(exp, tz=timezone.utc).replace(tzinfo=None) if exp else datetime.now(timezone.utc).replace(tzinfo=None) + timedelta(days=7) from app.models.token_blacklist import TokenBlacklist from app import db # Avoid duplicate if already blacklisted @@ -279,4 +279,4 @@ def verify_and_consume_backup_code(hashed_codes: list[str], candidate: str) -> t return False, hashed_codes remaining = [h for i, h in enumerate(hashed_codes) if i != matched_index] - return True, remaining + return True, remaining \ No newline at end of file