06/03 Optimize app
This commit is contained in:
+130
-4
@@ -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
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user