06/03 Optimize app

This commit is contained in:
2026-06-03 16:22:02 -04:00
parent ec1c0fc7c1
commit eb85e70793
16 changed files with 406 additions and 5 deletions
+29
View File
@@ -120,6 +120,35 @@ def create_app(config_name=None):
from app.models.teller_enrollment import TellerEnrollment, TellerAccount from app.models.teller_enrollment import TellerEnrollment, TellerAccount
from app.models.schwab_connection import SchwabConnection, SchwabAccount 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 @app.after_request
def security_headers(response): def security_headers(response):
response.headers['X-Frame-Options'] = 'DENY' response.headers['X-Frame-Options'] = 'DENY'
+15
View File
@@ -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'<AuditLog {self.action} @ {self.timestamp}>'
+3 -2
View File
@@ -1,4 +1,5 @@
from app.extensions import db from app.extensions import db
from app.utils.crypto import EncryptedText
from datetime import datetime from datetime import datetime
@@ -7,8 +8,8 @@ class SchwabConnection(db.Model):
__tablename__ = 'schwab_connections' __tablename__ = 'schwab_connections'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
access_token = db.Column(db.Text, nullable=False) access_token = db.Column(EncryptedText, nullable=False)
refresh_token = db.Column(db.Text, nullable=False) refresh_token = db.Column(EncryptedText, nullable=False)
token_expires_at = db.Column(db.DateTime, nullable=False) # access token expiry 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) refresh_token_expires_at = db.Column(db.DateTime, nullable=True) # refresh token expiry (7 days)
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
+2 -1
View File
@@ -1,4 +1,5 @@
from app.extensions import db from app.extensions import db
from app.utils.crypto import EncryptedText
from datetime import datetime from datetime import datetime
@@ -11,7 +12,7 @@ class TellerEnrollment(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
enrollment_id = db.Column(db.String(64), unique=True, nullable=False, index=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) institution_name = db.Column(db.String(100), nullable=True)
user_id = db.Column(db.String(64), nullable=True) # Teller user ID user_id = db.Column(db.String(64), nullable=True) # Teller user ID
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
+6
View File
@@ -12,6 +12,7 @@ from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length from wtforms.validators import DataRequired, Length
from app.models.user import User from app.models.user import User
from app.extensions import db, limiter from app.extensions import db, limiter
from app.utils.audit import audit
from datetime import datetime from datetime import datetime
auth_bp = Blueprint('auth', __name__, url_prefix='/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
@@ -48,12 +49,14 @@ def login():
login_user(user, remember=form.remember_me.data) login_user(user, remember=form.remember_me.data)
user.last_login = datetime.utcnow() user.last_login = datetime.utcnow()
db.session.commit() db.session.commit()
audit('login_success', f'user={user.username}')
log.info('[auth] user %s logged in', user.username) log.info('[auth] user %s logged in', user.username)
next_page = request.args.get('next', '') next_page = request.args.get('next', '')
if not next_page.startswith('/'): if not next_page.startswith('/'):
next_page = url_for('dashboard.index') next_page = url_for('dashboard.index')
return redirect(next_page) return redirect(next_page)
audit('login_failed', f'username={form.username.data!r}')
log.warning('[auth] failed login attempt for username=%r ip=%s', log.warning('[auth] failed login attempt for username=%r ip=%s',
form.username.data, request.remote_addr) form.username.data, request.remote_addr)
flash('Invalid username or password.', 'danger') flash('Invalid username or password.', 'danger')
@@ -96,6 +99,7 @@ def totp_verify():
login_user(user, remember=remember) login_user(user, remember=remember)
user.last_login = datetime.utcnow() user.last_login = datetime.utcnow()
db.session.commit() db.session.commit()
audit('login_success_2fa', f'user={user.username}')
log.info('[auth] TOTP verified for user %s', user.username) log.info('[auth] TOTP verified for user %s', user.username)
return redirect(next_url) return redirect(next_url)
log.warning('[auth] invalid TOTP code for user %s ip=%s', log.warning('[auth] invalid TOTP code for user %s ip=%s',
@@ -142,6 +146,7 @@ def totp_setup():
user.totp_enabled = True user.totp_enabled = True
db.session.commit() db.session.commit()
session.pop('_totp_setup_secret', None) session.pop('_totp_setup_secret', None)
audit('totp_enabled', f'user={user.username}')
log.info('[auth] TOTP enabled for user %s', user.username) log.info('[auth] TOTP enabled for user %s', user.username)
flash('Two-factor authentication enabled successfully.', 'success') flash('Two-factor authentication enabled successfully.', 'success')
return redirect(url_for('settings.index')) return redirect(url_for('settings.index'))
@@ -165,6 +170,7 @@ def totp_disable():
user.totp_enabled = False user.totp_enabled = False
user.totp_secret = None user.totp_secret = None
db.session.commit() db.session.commit()
audit('totp_disabled', f'user={user.username}')
log.info('[auth] TOTP disabled for user %s', user.username) log.info('[auth] TOTP disabled for user %s', user.username)
flash('Two-factor authentication disabled.', 'info') flash('Two-factor authentication disabled.', 'info')
return redirect(url_for('settings.index')) return redirect(url_for('settings.index'))
+2
View File
@@ -73,6 +73,7 @@ def index():
).scalar() ).scalar()
net_cash_flow = float(total_income) - float(total_expense) 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 ─────────────────────────────────────
accounts = Account.query.filter_by(is_active=True).order_by(Account.name).all() 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_income=float(total_income),
total_expense=float(total_expense), total_expense=float(total_expense),
net_cash_flow=net_cash_flow, net_cash_flow=net_cash_flow,
savings_rate=savings_rate,
accounts=accounts, accounts=accounts,
total_assets=total_assets, total_assets=total_assets,
total_liabilities=total_liabilities, total_liabilities=total_liabilities,
+3
View File
@@ -17,6 +17,7 @@ from app.services.schwab_service import (
schwab_bp = Blueprint('schwab', __name__, url_prefix='/schwab') schwab_bp = Blueprint('schwab', __name__, url_prefix='/schwab')
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from app.utils.audit import audit
def _active_connection(): def _active_connection():
@@ -113,6 +114,7 @@ def callback():
)) ))
db.session.commit() db.session.commit()
audit('schwab_connected')
flash('Schwab connected successfully. Map your accounts to get started.', 'success') flash('Schwab connected successfully. Map your accounts to get started.', 'success')
return redirect(url_for('schwab.map_accounts')) return redirect(url_for('schwab.map_accounts'))
@@ -314,5 +316,6 @@ def disconnect():
for sa in connection.accounts: for sa in connection.accounts:
sa.is_active = False sa.is_active = False
db.session.commit() db.session.commit()
audit('schwab_disconnected')
flash('Disconnected from Schwab. Your imported transactions are kept.', 'info') flash('Disconnected from Schwab. Your imported transactions are kept.', 'info')
return redirect(url_for('schwab.index')) return redirect(url_for('schwab.index'))
+18
View File
@@ -450,3 +450,21 @@ def view_receipt(filename):
upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads') upload_dir = current_app.config.get('UPLOAD_FOLDER', '/home/pfm/app/uploads')
# Strip any path components to prevent directory traversal # Strip any path components to prevent directory traversal
return send_from_directory(upload_dir, os.path.basename(filename)) 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)
+7 -2
View File
@@ -164,16 +164,20 @@
#main, #sidebar.collapsed ~ #main { margin-left: 0 !important; padding: 14px; } #main, #sidebar.collapsed ~ #main { margin-left: 0 !important; padding: 14px; }
.pcard { padding: 14px; } .pcard { padding: 14px; }
.stat-card { padding: 14px 16px; } .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 */ /* Tables: scroll horizontally on small screens */
.table-wrap, .pcard.p-0 { overflow-x: auto; -webkit-overflow-scrolling: touch; } .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 */ /* Hide low-priority columns */
.d-mob-none { display: none !important; } .d-mob-none { display: none !important; }
/* Topbar title: truncate so action buttons always fit */ /* Topbar title: truncate so action buttons always fit */
.tb-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .tb-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* Flash: stretch full width */ /* Flash: stretch full width */
.flash-wrap { left: 8px; right: 8px; max-width: none; } .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 */ /* Extra-small screens: hide button label text, keep icons */
@media (max-width: 575px) { @media (max-width: 575px) {
@@ -181,6 +185,7 @@
.pcard { padding: 12px; } .pcard { padding: 12px; }
.btn-label { display: none; } .btn-label { display: none; }
.tb-right .btn { padding-left: 8px; padding-right: 8px; } .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 { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 1039; }
.sb-overlay.on { display: block; } .sb-overlay.on { display: block; }
+16
View File
@@ -73,6 +73,22 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-6 col-xl-3">
<div class="stat-card">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="stat-label">Savings Rate</div>
<div class="stat-value {% if savings_rate >= 20 %}text-income{% elif savings_rate > 0 %}text-invest{% else %}text-expense{% endif %}">
{% if savings_rate >= 0 %}+{% endif %}{{ savings_rate }}%
</div>
</div>
<div class="stat-icon" style="background:{% if savings_rate >= 20 %}#d1fae5;color:#065f46{% elif savings_rate > 0 %}#dbeafe;color:#1e40af{% else %}#fee2e2;color:#991b1b{% endif %};">
<i class="bi bi-piggy-bank"></i>
</div>
</div>
<div style="font-size:10px;color:var(--muted);margin-top:4px;">of income saved this period</div>
</div>
</div>
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="stat-card"> <div class="stat-card">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
+114
View File
@@ -74,6 +74,29 @@
</div> </div>
</div> </div>
{% if inv.ticker %}
<div class="col-12">
<div class="pcard">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<span class="pcard-title mb-0">Price History — <span class="mono">{{ inv.ticker }}</span></span>
<span id="detail-chg" class="chg-badge chg-flat" style="display:none;"></span>
</div>
<div class="d-flex gap-1 flex-wrap">
{% for tf in ['1W','1M','3M','6M','1Y'] %}
<button class="tf-btn{% if tf == '1M' %} active{% endif %}"
onclick="loadDetailChart('{{ tf }}')" id="tf-{{ tf }}">{{ tf }}</button>
{% endfor %}
</div>
</div>
<div style="font-size:12px;color:var(--muted);margin-bottom:8px;" id="detail-info">Loading…</div>
<div style="position:relative;height:200px;">
<canvas id="detailCanvas"></canvas>
</div>
</div>
</div>
{% endif %}
<div class="col-12 col-md-7"> <div class="col-12 col-md-7">
<div class="pcard p-0 h-100"> <div class="pcard p-0 h-100">
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);"> <div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-bottom:1px solid var(--border);">
@@ -128,3 +151,94 @@
<a href="{{ url_for('investments.index') }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Portfolio</a> <a href="{{ url_for('investments.index') }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back to Portfolio</a>
{% endblock %} {% 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 %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
<script>
const sym = '{{ current_user.currency_symbol }}';
const ticker = '{{ inv.ticker }}';
let chartInst = null;
function fmtP(v) {
const n = parseFloat(v);
return sym + Math.abs(n).toLocaleString(undefined, {minimumFractionDigits:2, maximumFractionDigits:4});
}
function fmtChg(c, p) {
const s = c >= 0 ? '+' : '';
return `${s}${fmtP(c)} (${s}${parseFloat(p).toFixed(2)}%)`;
}
function chgCls(v) { return v > 0 ? 'chg-up' : v < 0 ? 'chg-down' : 'chg-flat'; }
function loadDetailChart(tf) {
// Update active button
document.querySelectorAll('.tf-btn').forEach(b =>
b.classList.toggle('active', b.id === 'tf-' + tf));
const infoEl = document.getElementById('detail-info');
infoEl.textContent = 'Loading…';
fetch(`{{ url_for('investments.api_price_history', ticker='__T__') }}`.replace('__T__', ticker) + '?tf=' + tf)
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => {
infoEl.innerHTML =
`Current: <strong>${fmtP(data.current)}</strong> &nbsp;·&nbsp; ` +
`${({'1W':'1 Week','1M':'1 Month','3M':'3 Months','6M':'6 Months','1Y':'1 Year'}[tf]||tf)}: ` +
`<strong class="${data.period_change >= 0 ? 'text-income' : 'text-expense'}">${fmtChg(data.period_change, data.period_change_pct)}</strong>`;
const chgEl = document.getElementById('detail-chg');
chgEl.className = 'chg-badge ' + chgCls(data.period_change);
chgEl.innerHTML = `<i class="bi bi-${data.period_change >= 0 ? 'arrow-up' : 'arrow-down'}-short"></i>${fmtChg(data.period_change, data.period_change_pct)}`;
chgEl.style.display = '';
const lineColor = data.period_change >= 0 ? '#10b981' : '#ef4444';
const ctx = document.getElementById('detailCanvas').getContext('2d');
if (chartInst) chartInst.destroy();
chartInst = new Chart(ctx, {
type: 'line',
data: {
labels: data.dates,
datasets: [{
data: data.closes,
borderColor: lineColor,
backgroundColor: lineColor + '18',
borderWidth: 2,
pointRadius: data.closes.length > 60 ? 0 : 2,
pointHoverRadius: 4,
tension: 0.3,
fill: true,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { display: false },
tooltip: { callbacks: { label: c => ' ' + fmtP(c.parsed.y) } }
},
scales: {
x: { grid: { display: false }, ticks: { font: { size: 10 }, maxTicksLimit: 8, maxRotation: 0 } },
y: { position: 'right', grid: { color: '#f1f5f9' },
ticks: { font: { size: 10 }, callback: v => sym + parseFloat(v).toLocaleString(undefined, {minimumFractionDigits: 2}) } }
}
}
});
})
.catch(() => { infoEl.textContent = 'Price data unavailable.'; });
}
// Load 1M chart on page load
loadDetailChart('1M');
</script>
{% endblock %}
{% endif %}
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% block title %}Audit Log{% endblock %}
{% block page_title %}Audit Log{% endblock %}
{% block content %}
<div class="pcard pcard-sm mb-3">
<form method="GET" class="d-flex gap-2 align-items-center flex-wrap">
<select name="action" class="form-select form-select-sm" style="max-width:220px;">
<option value="">All events</option>
{% for a in actions %}
<option value="{{ a }}" {% if a == action %}selected{% endif %}>{{ a | replace('_',' ') | title }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-outline-secondary"><i class="bi bi-filter me-1"></i>Filter</button>
{% if action %}<a href="{{ url_for('settings.audit_log') }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-x-lg"></i></a>{% endif %}
</form>
</div>
<div class="pcard p-0">
{% if logs %}
<table class="pfm-table">
<thead>
<tr>
<th style="padding-left:20px;">Time</th>
<th>Event</th>
<th class="d-mob-none">Details</th>
<th class="d-mob-none" style="padding-right:20px;">IP</th>
</tr>
</thead>
<tbody>
{% for entry in logs %}
<tr>
<td style="padding-left:20px;font-size:12px;color:var(--muted);white-space:nowrap;">
{{ entry.timestamp.strftime('%b %d, %H:%M:%S') }}
</td>
<td>
{% 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('|') %}
<span style="font-size:12px;font-weight:600;background:{{ colors[0] }};color:{{ colors[1] }};padding:2px 8px;border-radius:4px;">
{{ entry.action | replace('_',' ') | title }}
</span>
</td>
<td class="d-mob-none" style="font-size:12px;color:var(--muted);">{{ entry.description or '—' }}</td>
<td class="d-mob-none" style="padding-right:20px;font-size:12px;color:var(--muted);">
<span class="mono">{{ entry.ip_address or '—' }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if pagination.pages > 1 %}
<div class="d-flex justify-content-between align-items-center px-4 py-3" style="border-top:1px solid var(--border);font-size:12px;color:var(--muted);">
<span>Page {{ pagination.page }} of {{ pagination.pages }} · {{ pagination.total }} events</span>
<div class="d-flex gap-1">
{% if pagination.has_prev %}
<a href="{{ url_for('settings.audit_log', page=pagination.prev_num, action=action) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">← Prev</a>
{% endif %}
{% if pagination.has_next %}
<a href="{{ url_for('settings.audit_log', page=pagination.next_num, action=action) }}" class="btn btn-sm btn-outline-secondary" style="font-size:11px;">Next →</a>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="text-center py-5">
<i class="bi bi-shield-check text-muted" style="font-size:2.5rem;opacity:.4;"></i>
<p class="text-muted small mt-3 mb-0">No audit events recorded yet.</p>
</div>
{% endif %}
</div>
{% endblock %}
+13
View File
@@ -61,6 +61,19 @@
</div> </div>
</div> </div>
<!-- Audit Log nav card -->
<div class="row g-3 mt-0">
<div class="col-12 col-sm-6 col-lg-3">
<a href="{{ url_for('settings.audit_log') }}" class="text-decoration-none">
<div class="pcard text-center py-4" style="transition:all .15s;" onmouseover="this.style.borderColor='#ef4444'" onmouseout="this.style.borderColor='var(--border)'">
<i class="bi bi-clipboard-check" style="font-size:2rem;color:#ef4444;"></i>
<div style="font-size:14px;font-weight:600;margin-top:10px;">Audit Log</div>
<div style="font-size:12px;color:var(--muted);margin-top:4px;">Login, 2FA, and sync events</div>
</div>
</a>
</div>
</div>
<!-- Two-Factor Authentication --> <!-- Two-Factor Authentication -->
<div class="row g-3 mt-1"> <div class="row g-3 mt-1">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
+25
View File
@@ -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)
+51
View File
@@ -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
+19
View File
@@ -24,6 +24,20 @@ COLUMNS = [
"DATETIME NULL DEFAULT NULL"), "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 app.app_context():
with db.engine.connect() as conn: with db.engine.connect() as conn:
for table, column, definition in COLUMNS: for table, column, definition in COLUMNS:
@@ -42,4 +56,9 @@ with app.app_context():
conn.commit() conn.commit()
print(f" Added {table}.{column}.") 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.") print("Done.")