06/03 Optimize app

This commit is contained in:
2026-06-03 16:06:30 -04:00
parent 4af31a611e
commit ec1c0fc7c1
14 changed files with 447 additions and 12 deletions
+130 -4
View File
@@ -1,13 +1,21 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
import io
import base64
import logging
import pyotp
import qrcode
from flask import (Blueprint, render_template, redirect, url_for,
flash, request, session)
from flask_login import login_user, logout_user, login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Length
from app.models.user import User
from app.extensions import db
from app.extensions import db, limiter
from datetime import datetime
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
log = logging.getLogger(__name__)
class LoginForm(FlaskForm):
@@ -17,7 +25,10 @@ class LoginForm(FlaskForm):
submit = SubmitField('Sign In')
# ── Login / logout ────────────────────────────────────────────────────────────
@auth_bp.route('/login', methods=['GET', 'POST'])
@limiter.limit('10 per minute; 30 per hour')
def login():
if current_user.is_authenticated:
return redirect(url_for('dashboard.index'))
@@ -26,11 +37,25 @@ def login():
if form.validate_on_submit():
user = User.query.filter_by(username=form.username.data.strip()).first()
if user and user.check_password(form.password.data):
if user.totp_enabled:
# Park the user id in the session and redirect to TOTP verification
session['_totp_pending_id'] = user.id
session['_totp_remember'] = bool(form.remember_me.data)
session['_totp_next'] = request.args.get('next', '')
log.info('[auth] TOTP required for user %s', user.username)
return redirect(url_for('auth.totp_verify'))
login_user(user, remember=form.remember_me.data)
user.last_login = datetime.utcnow()
db.session.commit()
next_page = request.args.get('next')
return redirect(next_page or url_for('dashboard.index'))
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)
log.warning('[auth] failed login attempt for username=%r ip=%s',
form.username.data, request.remote_addr)
flash('Invalid username or password.', 'danger')
return render_template('auth/login.html', form=form)
@@ -42,3 +67,104 @@ def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
# ── TOTP: second-factor verification ─────────────────────────────────────────
@auth_bp.route('/totp/verify', methods=['GET', 'POST'])
@limiter.limit('10 per minute')
def totp_verify():
pending_id = session.get('_totp_pending_id')
if not pending_id:
return redirect(url_for('auth.login'))
user = db.session.get(User, pending_id)
if not user or not user.totp_enabled:
session.pop('_totp_pending_id', None)
return redirect(url_for('auth.login'))
error = None
if request.method == 'POST':
code = request.form.get('code', '').strip().replace(' ', '')
totp = pyotp.TOTP(user.totp_secret)
if totp.verify(code, valid_window=1):
session.pop('_totp_pending_id', None)
remember = session.pop('_totp_remember', False)
next_url = session.pop('_totp_next', '') or url_for('dashboard.index')
if not next_url.startswith('/'):
next_url = url_for('dashboard.index')
login_user(user, remember=remember)
user.last_login = datetime.utcnow()
db.session.commit()
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',
user.username, request.remote_addr)
error = 'Invalid code — please try again.'
return render_template('auth/totp_verify.html', error=error)
# ── TOTP: setup (enable) ──────────────────────────────────────────────────────
@auth_bp.route('/totp/setup', methods=['GET', 'POST'])
@login_required
def totp_setup():
user = current_user
if request.method == 'GET':
# Generate a new secret on every GET so the user always sees a fresh QR code
secret = pyotp.random_base32()
session['_totp_setup_secret'] = secret
totp = pyotp.TOTP(secret)
uri = totp.provisioning_uri(
name=user.username,
issuer_name='PFM — Personal Finance',
)
img = qrcode.make(uri)
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
qr_data = 'data:image/png;base64,' + base64.b64encode(buf.read()).decode()
return render_template('auth/totp_setup.html',
qr_data=qr_data, secret=secret, uri=uri)
# POST — user confirmed with their first code
secret = session.get('_totp_setup_secret')
code = request.form.get('code', '').strip().replace(' ', '')
if not secret:
flash('Setup session expired. Please try again.', 'warning')
return redirect(url_for('auth.totp_setup'))
totp = pyotp.TOTP(secret)
if totp.verify(code, valid_window=1):
user.totp_secret = secret
user.totp_enabled = True
db.session.commit()
session.pop('_totp_setup_secret', None)
log.info('[auth] TOTP enabled for user %s', user.username)
flash('Two-factor authentication enabled successfully.', 'success')
return redirect(url_for('settings.index'))
flash('Invalid code — the QR code was not scanned correctly. Please try again.', 'danger')
return redirect(url_for('auth.totp_setup'))
# ── TOTP: disable ─────────────────────────────────────────────────────────────
@auth_bp.route('/totp/disable', methods=['POST'])
@login_required
def totp_disable():
user = current_user
# Require password confirmation before disabling
password = request.form.get('password', '')
if not user.check_password(password):
flash('Incorrect password — 2FA not disabled.', 'danger')
return redirect(url_for('settings.index'))
user.totp_enabled = False
user.totp_secret = None
db.session.commit()
log.info('[auth] TOTP disabled for user %s', user.username)
flash('Two-factor authentication disabled.', 'info')
return redirect(url_for('settings.index'))
+15 -1
View File
@@ -42,6 +42,19 @@ def index():
period = request.args.get('period', 'this_month')
date_from, date_to, period_label = _parse_date_range(period)
# ── Schwab token expiry check ─────────────────────
from app.models.schwab_connection import SchwabConnection
from datetime import timedelta
schwab_warning = None
schwab_conn = SchwabConnection.query.filter_by(is_active=True).first()
if schwab_conn and schwab_conn.refresh_token_expires_at:
days_left = (schwab_conn.refresh_token_expires_at - datetime.utcnow()).days
if days_left <= 2:
schwab_warning = (
f'Schwab connection expires in {max(days_left, 0)} day(s). '
f'Reconnect now to keep syncing.'
)
# ── Summary cards ────────────────────────────────
total_income = db.session.query(
func.coalesce(func.sum(Transaction.amount), 0)
@@ -150,7 +163,8 @@ def index():
recent_txns=recent_txns,
fx=fx,
fx_history_data=fx_history_data,
ai_insight=ai_insight)
ai_insight=ai_insight,
schwab_warning=schwab_warning)
@dashboard_bp.route('/api/fx-history')