From eb85e70793e423fef25a5e073eb54a15e908cac7 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 3 Jun 2026 16:22:02 -0400 Subject: [PATCH] 06/03 Optimize app --- app/__init__.py | 29 +++++++ app/models/audit_log.py | 15 ++++ app/models/schwab_connection.py | 5 +- app/models/teller_enrollment.py | 3 +- app/routes/auth.py | 6 ++ app/routes/dashboard.py | 2 + app/routes/schwab.py | 3 + app/routes/settings.py | 18 ++++ app/templates/base.html | 9 +- app/templates/dashboard/index.html | 16 ++++ app/templates/investments/detail.html | 114 ++++++++++++++++++++++++++ app/templates/settings/audit.html | 83 +++++++++++++++++++ app/templates/settings/index.html | 13 +++ app/utils/audit.py | 25 ++++++ app/utils/crypto.py | 51 ++++++++++++ scripts/add_security_columns.py | 19 +++++ 16 files changed, 406 insertions(+), 5 deletions(-) create mode 100644 app/models/audit_log.py create mode 100644 app/templates/settings/audit.html create mode 100644 app/utils/audit.py create mode 100644 app/utils/crypto.py diff --git a/app/__init__.py b/app/__init__.py index c2b5a54..af04b56 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -120,6 +120,35 @@ def create_app(config_name=None): from app.models.teller_enrollment import TellerEnrollment, TellerAccount from app.models.schwab_connection import SchwabConnection, SchwabAccount + # ── Session idle timeout ────────────────────────────────────────────────── + from flask import session as _session, request as _request + from flask_login import current_user as _cu + from datetime import timedelta, datetime as _dt + import logging as _logging + _log = _logging.getLogger('app.auth') + SESSION_IDLE_MINUTES = app.config.get('SESSION_IDLE_MINUTES', 60) + + @app.before_request + def check_session_timeout(): + # Skip static files and unauthenticated sessions + if _request.endpoint and _request.endpoint.startswith('static'): + return + if not _cu.is_authenticated: + return + last = _session.get('_last_active') + now = _dt.utcnow().isoformat() + if last: + idle = (_dt.utcnow() - _dt.fromisoformat(last)).total_seconds() / 60 + if idle > SESSION_IDLE_MINUTES: + from flask_login import logout_user as _lu + _lu() + _session.clear() + from flask import redirect, url_for, flash + _log.info('[auth] session expired after %.0f min idle', idle) + flash('Your session expired due to inactivity. Please log in again.', 'warning') + return redirect(url_for('auth.login')) + _session['_last_active'] = now + @app.after_request def security_headers(response): response.headers['X-Frame-Options'] = 'DENY' diff --git a/app/models/audit_log.py b/app/models/audit_log.py new file mode 100644 index 0000000..0ee186f --- /dev/null +++ b/app/models/audit_log.py @@ -0,0 +1,15 @@ +from app.extensions import db +from datetime import datetime + + +class AuditLog(db.Model): + __tablename__ = 'audit_logs' + + id = db.Column(db.Integer, primary_key=True) + timestamp = db.Column(db.DateTime, default=datetime.utcnow, index=True) + action = db.Column(db.String(64), nullable=False, index=True) + description = db.Column(db.String(255), nullable=True) + ip_address = db.Column(db.String(45), nullable=True) # IPv4 or IPv6 + + def __repr__(self): + return f'' diff --git a/app/models/schwab_connection.py b/app/models/schwab_connection.py index 416c93d..15b7094 100644 --- a/app/models/schwab_connection.py +++ b/app/models/schwab_connection.py @@ -1,4 +1,5 @@ from app.extensions import db +from app.utils.crypto import EncryptedText from datetime import datetime @@ -7,8 +8,8 @@ class SchwabConnection(db.Model): __tablename__ = 'schwab_connections' id = db.Column(db.Integer, primary_key=True) - access_token = db.Column(db.Text, nullable=False) - refresh_token = db.Column(db.Text, nullable=False) + access_token = db.Column(EncryptedText, nullable=False) + refresh_token = db.Column(EncryptedText, nullable=False) token_expires_at = db.Column(db.DateTime, nullable=False) # access token expiry refresh_token_expires_at = db.Column(db.DateTime, nullable=True) # refresh token expiry (7 days) is_active = db.Column(db.Boolean, default=True) diff --git a/app/models/teller_enrollment.py b/app/models/teller_enrollment.py index ae66038..c5ad4fc 100644 --- a/app/models/teller_enrollment.py +++ b/app/models/teller_enrollment.py @@ -1,4 +1,5 @@ from app.extensions import db +from app.utils.crypto import EncryptedText from datetime import datetime @@ -11,7 +12,7 @@ class TellerEnrollment(db.Model): id = db.Column(db.Integer, primary_key=True) enrollment_id = db.Column(db.String(64), unique=True, nullable=False, index=True) - access_token = db.Column(db.String(128), nullable=False) + access_token = db.Column(EncryptedText, nullable=False) institution_name = db.Column(db.String(100), nullable=True) user_id = db.Column(db.String(64), nullable=True) # Teller user ID is_active = db.Column(db.Boolean, default=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index e00658e..cb39e63 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -12,6 +12,7 @@ from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired, Length from app.models.user import User from app.extensions import db, limiter +from app.utils.audit import audit from datetime import datetime auth_bp = Blueprint('auth', __name__, url_prefix='/auth') @@ -48,12 +49,14 @@ def login(): login_user(user, remember=form.remember_me.data) user.last_login = datetime.utcnow() db.session.commit() + audit('login_success', f'user={user.username}') log.info('[auth] user %s logged in', user.username) next_page = request.args.get('next', '') if not next_page.startswith('/'): next_page = url_for('dashboard.index') return redirect(next_page) + audit('login_failed', f'username={form.username.data!r}') log.warning('[auth] failed login attempt for username=%r ip=%s', form.username.data, request.remote_addr) flash('Invalid username or password.', 'danger') @@ -96,6 +99,7 @@ def totp_verify(): login_user(user, remember=remember) user.last_login = datetime.utcnow() db.session.commit() + audit('login_success_2fa', f'user={user.username}') log.info('[auth] TOTP verified for user %s', user.username) return redirect(next_url) log.warning('[auth] invalid TOTP code for user %s ip=%s', @@ -142,6 +146,7 @@ def totp_setup(): user.totp_enabled = True db.session.commit() session.pop('_totp_setup_secret', None) + audit('totp_enabled', f'user={user.username}') log.info('[auth] TOTP enabled for user %s', user.username) flash('Two-factor authentication enabled successfully.', 'success') return redirect(url_for('settings.index')) @@ -165,6 +170,7 @@ def totp_disable(): user.totp_enabled = False user.totp_secret = None db.session.commit() + audit('totp_disabled', f'user={user.username}') log.info('[auth] TOTP disabled for user %s', user.username) flash('Two-factor authentication disabled.', 'info') return redirect(url_for('settings.index')) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 5d64e62..2850ca9 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -73,6 +73,7 @@ def index(): ).scalar() net_cash_flow = float(total_income) - float(total_expense) + savings_rate = round(net_cash_flow / float(total_income) * 100, 1) if total_income else 0 # ── Accounts ───────────────────────────────────── accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() @@ -152,6 +153,7 @@ def index(): total_income=float(total_income), total_expense=float(total_expense), net_cash_flow=net_cash_flow, + savings_rate=savings_rate, accounts=accounts, total_assets=total_assets, total_liabilities=total_liabilities, diff --git a/app/routes/schwab.py b/app/routes/schwab.py index eb9775f..9f23ad3 100644 --- a/app/routes/schwab.py +++ b/app/routes/schwab.py @@ -17,6 +17,7 @@ from app.services.schwab_service import ( schwab_bp = Blueprint('schwab', __name__, url_prefix='/schwab') log = logging.getLogger(__name__) +from app.utils.audit import audit def _active_connection(): @@ -113,6 +114,7 @@ def callback(): )) db.session.commit() + audit('schwab_connected') flash('Schwab connected successfully. Map your accounts to get started.', 'success') return redirect(url_for('schwab.map_accounts')) @@ -314,5 +316,6 @@ def disconnect(): for sa in connection.accounts: sa.is_active = False db.session.commit() + audit('schwab_disconnected') flash('Disconnected from Schwab. Your imported transactions are kept.', 'info') return redirect(url_for('schwab.index')) diff --git a/app/routes/settings.py b/app/routes/settings.py index 01cfeee..61a6475 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -450,3 +450,21 @@ def view_receipt(filename): upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads') # Strip any path components to prevent directory traversal return send_from_directory(upload_dir, os.path.basename(filename)) + + +@settings_bp.route('/audit') +@login_required +def audit_log(): + from app.models.audit_log import AuditLog + page = request.args.get('page', 1, type=int) + action = request.args.get('action', '') + query = AuditLog.query.order_by(AuditLog.timestamp.desc()) + if action: + query = query.filter(AuditLog.action == action) + pagination = query.paginate(page=page, per_page=50, error_out=False) + actions = [r[0] for r in db.session.query(AuditLog.action).distinct().all()] + return render_template('settings/audit.html', + pagination=pagination, + logs=pagination.items, + actions=actions, + action=action) diff --git a/app/templates/base.html b/app/templates/base.html index ace526f..1cf7411 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -164,16 +164,20 @@ #main, #sidebar.collapsed ~ #main { margin-left: 0 !important; padding: 14px; } .pcard { padding: 14px; } .stat-card { padding: 14px 16px; } - .stat-card .stat-value { font-size: 20px; } + .stat-card .stat-value { font-size: 18px; } /* Tables: scroll horizontally on small screens */ .table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; } - .pfm-table { min-width: 580px; } + /* Remove h-100 height constraint on table wrappers so overflow-x works */ + .pcard.p-0.h-100 { height: auto !important; } + .pfm-table { min-width: 560px; } /* Hide low-priority columns */ .d-mob-none { display: none !important; } /* Topbar title: truncate so action buttons always fit */ .tb-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } /* Flash: stretch full width */ .flash-wrap { left: 8px; right: 8px; max-width: none; } + /* Teller/Schwab action rows: stack on very small cards */ + .pcard .d-flex.gap-2 { flex-wrap: wrap; } } /* Extra-small screens: hide button label text, keep icons */ @media (max-width: 575px) { @@ -181,6 +185,7 @@ .pcard { padding: 12px; } .btn-label { display: none; } .tb-right .btn { padding-left: 8px; padding-right: 8px; } + .stat-card .stat-value { font-size: 16px; } } .sb-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; } .sb-overlay.on { display: block; } diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html index 7eeedf3..9500640 100644 --- a/app/templates/dashboard/index.html +++ b/app/templates/dashboard/index.html @@ -73,6 +73,22 @@ +
+
+
+
+
Savings Rate
+
+ {% if savings_rate >= 0 %}+{% endif %}{{ savings_rate }}% +
+
+
+ +
+
+
of income saved this period
+
+
diff --git a/app/templates/investments/detail.html b/app/templates/investments/detail.html index b1a2b81..1cd6fe7 100644 --- a/app/templates/investments/detail.html +++ b/app/templates/investments/detail.html @@ -74,6 +74,29 @@
+ {% if inv.ticker %} +
+
+
+
+ Price History — {{ inv.ticker }} + +
+
+ {% for tf in ['1W','1M','3M','6M','1Y'] %} + + {% endfor %} +
+
+
Loading…
+
+ +
+
+
+ {% endif %} +
@@ -128,3 +151,94 @@ Back to Portfolio {% endblock %} + +{% block extra_css %} +.tf-btn { font-size:11px;font-weight:600;padding:3px 10px;border-radius:4px;border:1px solid var(--border);background:#fff;color:var(--muted);cursor:pointer;transition:all .15s; } +.tf-btn:hover { border-color:var(--accent);color:var(--accent); } +.tf-btn.active { background:var(--accent);color:#fff;border-color:var(--accent); } +.chg-badge { display:inline-flex;align-items:center;gap:3px;font-size:11px;font-weight:600;font-family:'DM Mono',monospace;padding:2px 7px;border-radius:4px; } +.chg-up { background:#d1fae5;color:#065f46; } +.chg-down { background:#fee2e2;color:#991b1b; } +.chg-flat { background:#f1f5f9;color:#64748b; } +{% endblock %} + +{% if inv.ticker %} +{% block extra_js %} + + +{% endblock %} +{% endif %} diff --git a/app/templates/settings/audit.html b/app/templates/settings/audit.html new file mode 100644 index 0000000..d4c1e2d --- /dev/null +++ b/app/templates/settings/audit.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} +{% block title %}Audit Log{% endblock %} +{% block page_title %}Audit Log{% endblock %} + +{% block content %} +
+
+ + + {% if action %}{% endif %} +
+
+ +
+ {% if logs %} + + + + + + + + + + + {% for entry in logs %} + + + + + + + {% endfor %} + +
TimeEventDetailsIP
+ {{ entry.timestamp.strftime('%b %d, %H:%M:%S') }} + + {% set colors = { + 'login_success':'#d1fae5|#065f46', + 'login_success_2fa':'#d1fae5|#065f46', + 'login_failed':'#fee2e2|#991b1b', + 'totp_enabled':'#dbeafe|#1e40af', + 'totp_disabled':'#fef3c7|#92400e', + 'schwab_connected':'#d1fae5|#065f46', + 'schwab_disconnected':'#fee2e2|#991b1b', + 'teller_connected':'#d1fae5|#065f46', + 'teller_disconnected':'#fee2e2|#991b1b', + 'password_changed':'#ede9fe|#5b21b6', + }.get(entry.action, '#f1f5f9|#475569').split('|') %} + + {{ entry.action | replace('_',' ') | title }} + + {{ entry.description or '—' }} + {{ entry.ip_address or '—' }} +
+ + {% if pagination.pages > 1 %} +
+ Page {{ pagination.page }} of {{ pagination.pages }} · {{ pagination.total }} events +
+ {% if pagination.has_prev %} + ← Prev + {% endif %} + {% if pagination.has_next %} + Next → + {% endif %} +
+
+ {% endif %} + + {% else %} +
+ +

No audit events recorded yet.

+
+ {% endif %} +
+{% endblock %} diff --git a/app/templates/settings/index.html b/app/templates/settings/index.html index 6f7b66f..1c71ef8 100644 --- a/app/templates/settings/index.html +++ b/app/templates/settings/index.html @@ -61,6 +61,19 @@
+ + +
diff --git a/app/utils/audit.py b/app/utils/audit.py new file mode 100644 index 0000000..5554def --- /dev/null +++ b/app/utils/audit.py @@ -0,0 +1,25 @@ +"""Thin wrapper for writing audit log entries.""" +import logging +from flask import request + +log = logging.getLogger('app.audit') + + +def audit(action: str, description: str = ''): + """ + Write one audit log entry. Safe to call from any request context; + silently swallows DB errors so it never breaks the main flow. + """ + try: + from app.extensions import db + from app.models.audit_log import AuditLog + entry = AuditLog( + action=action, + description=description[:255] if description else '', + ip_address=request.remote_addr, + ) + db.session.add(entry) + db.session.commit() + log.info('[audit] %s — %s (ip=%s)', action, description, request.remote_addr) + except Exception as exc: + log.warning('[audit] failed to write entry: %s', exc) diff --git a/app/utils/crypto.py b/app/utils/crypto.py new file mode 100644 index 0000000..78c334d --- /dev/null +++ b/app/utils/crypto.py @@ -0,0 +1,51 @@ +""" +Transparent at-rest encryption for sensitive DB columns (OAuth tokens). + +Uses a SQLAlchemy TypeDecorator so existing code needs zero changes — +values are encrypted on write and decrypted on read automatically. + +Key derivation: SHA-256(SECRET_KEY) → 32 bytes → Fernet-compatible base64 key. + +Migration: the decryptor falls back to returning the raw value when +decryption fails, so existing plaintext tokens keep working until +they are re-written (e.g. next token refresh or reconnect). +""" +import base64 +import hashlib +import logging + +from sqlalchemy import types + +log = logging.getLogger('app.crypto') + + +def _fernet(): + from flask import current_app + from cryptography.fernet import Fernet + secret = current_app.config['SECRET_KEY'] + key = base64.urlsafe_b64encode(hashlib.sha256(secret.encode()).digest()) + return Fernet(key) + + +class EncryptedText(types.TypeDecorator): + """Store text columns encrypted at rest using Fernet symmetric encryption.""" + impl = types.Text + cache_ok = True + + def process_bind_param(self, value, dialect): + if value is None: + return None + try: + return _fernet().encrypt(value.encode()).decode() + except Exception as exc: + log.warning('[crypto] encrypt failed: %s', exc) + return value # store plaintext rather than lose data + + def process_result_value(self, value, dialect): + if value is None: + return None + try: + return _fernet().decrypt(value.encode()).decode() + except Exception: + # Value is plaintext (pre-migration) — return as-is + return value diff --git a/scripts/add_security_columns.py b/scripts/add_security_columns.py index 3e4ec7d..668feaf 100644 --- a/scripts/add_security_columns.py +++ b/scripts/add_security_columns.py @@ -24,6 +24,20 @@ COLUMNS = [ "DATETIME NULL DEFAULT NULL"), ] +CREATE_TABLES = [ + """ + CREATE TABLE IF NOT EXISTS audit_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + timestamp DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + action VARCHAR(64) NOT NULL, + description VARCHAR(255), + ip_address VARCHAR(45), + INDEX ix_audit_logs_timestamp (timestamp), + INDEX ix_audit_logs_action (action) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ +] + with app.app_context(): with db.engine.connect() as conn: for table, column, definition in COLUMNS: @@ -42,4 +56,9 @@ with app.app_context(): conn.commit() print(f" Added {table}.{column}.") + for sql in CREATE_TABLES: + conn.execute(db.text(sql)) + conn.commit() + print(" Created audit_logs table (or already exists).") + print("Done.")