diff --git a/app/__init__.py b/app/__init__.py index 748cc8c..c2b5a54 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -2,7 +2,7 @@ import logging import os from flask import Flask from app.config import config -from app.extensions import db, login_manager, migrate, csrf +from app.extensions import db, login_manager, migrate, csrf, limiter from app.utils.formatters import format_currency, format_percent, format_large_number @@ -76,6 +76,7 @@ def create_app(config_name=None): login_manager.init_app(app) migrate.init_app(app, db) csrf.init_app(app) + limiter.init_app(app) from app.routes.auth import auth_bp from app.routes.dashboard import dashboard_bp diff --git a/app/extensions.py b/app/extensions.py index 37ab120..68e4b11 100644 --- a/app/extensions.py +++ b/app/extensions.py @@ -2,11 +2,14 @@ from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_migrate import Migrate from flask_wtf.csrf import CSRFProtect +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address db = SQLAlchemy() login_manager = LoginManager() migrate = Migrate() csrf = CSRFProtect() +limiter = Limiter(key_func=get_remote_address, storage_uri='memory://') login_manager.login_view = 'auth.login' login_manager.login_message = 'Please log in to access this page.' diff --git a/app/models/schwab_connection.py b/app/models/schwab_connection.py index 53c1cfb..416c93d 100644 --- a/app/models/schwab_connection.py +++ b/app/models/schwab_connection.py @@ -7,10 +7,11 @@ 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) - token_expires_at = db.Column(db.DateTime, nullable=False) # UTC - is_active = db.Column(db.Boolean, default=True) + access_token = db.Column(db.Text, nullable=False) + refresh_token = db.Column(db.Text, 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) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_synced_at = db.Column(db.DateTime, nullable=True) diff --git a/app/models/user.py b/app/models/user.py index 9e13822..97dfc24 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -16,6 +16,8 @@ class User(UserMixin, db.Model): currency = db.Column(db.String(10), default='USD') currency_symbol = db.Column(db.String(5), default='$') groq_model = db.Column(db.String(64), default='llama-3.3-70b-versatile') + totp_secret = db.Column(db.String(64), nullable=True) + totp_enabled = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_login = db.Column(db.DateTime, nullable=True) diff --git a/app/routes/auth.py b/app/routes/auth.py index 5b273d1..e00658e 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -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')) diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index d796150..5d64e62 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -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') diff --git a/app/services/schwab_service.py b/app/services/schwab_service.py index 92ad911..6303380 100644 --- a/app/services/schwab_service.py +++ b/app/services/schwab_service.py @@ -144,8 +144,11 @@ def refresh_tokens(connection): def _apply_token_data(connection, data): """Write token fields from token response onto connection model.""" - connection.access_token = data['access_token'] - connection.refresh_token = data.get('refresh_token', connection.refresh_token) + connection.access_token = data['access_token'] + if data.get('refresh_token'): + connection.refresh_token = data['refresh_token'] + # Schwab refresh tokens expire 7 days after issue + connection.refresh_token_expires_at = datetime.utcnow() + timedelta(days=7) expires_in = int(data.get('expires_in', 1800)) connection.token_expires_at = datetime.utcnow() + timedelta(seconds=expires_in - 60) diff --git a/app/templates/auth/totp_setup.html b/app/templates/auth/totp_setup.html new file mode 100644 index 0000000..b572055 --- /dev/null +++ b/app/templates/auth/totp_setup.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Enable Two-Factor Auth{% endblock %} +{% block page_title %}Enable Two-Factor Authentication{% endblock %} + +{% block content %} +
{{ secret }}
+ Enter the 6-digit code from your authenticator app.
++ 2FA is active. Each login requires a 6-digit code from your authenticator app. +
+ + {% else %} ++ Protect your account with a time-based one-time password (TOTP). + Use Google Authenticator, Authy, or any compatible app. +
+ + Enable 2FA + + {% endif %} +