diff --git a/app/__init__.py b/app/__init__.py index 3e9c65d..be73026 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,6 +4,8 @@ from flask_login import LoginManager from flask_migrate import Migrate from flask_mail import Mail from flask_wtf.csrf import CSRFProtect +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address from config import config import os import logging @@ -14,6 +16,11 @@ login_manager = LoginManager() migrate = Migrate() mail = Mail() csrf = CSRFProtect() # initialized here; .init_app() called in create_app() +limiter = Limiter( + key_func = get_remote_address, + default_limits = [], # no global limit — applied per-route only + storage_uri = 'memory://', # in-process; swap for 'redis://...' in multi-worker setups +) def create_app(config_name='default'): @@ -25,6 +32,7 @@ def create_app(config_name='default'): migrate.init_app(app, db) mail.init_app(app) csrf.init_app(app) # enables CSRF protection for all web routes + limiter.init_app(app) # rate limiting — applied per-route via @limiter.limit() login_manager.login_view = 'auth.login' login_manager.login_message = 'Please log in to access this page.' diff --git a/app/api/auth.py b/app/api/auth.py index a976138..cbeff41 100644 --- a/app/api/auth.py +++ b/app/api/auth.py @@ -31,7 +31,7 @@ GET /api/v1/auth/me import logging from flask import Blueprint, request, g -from app import db +from app import db, limiter from app.models.user import User from app.models.api_token import RefreshToken, DeviceToken from app.api.errors import api_ok, api_error @@ -59,6 +59,7 @@ def _user_payload(user: User) -> dict: # ── Login ───────────────────────────────────────────────────────────────────── @bp.route('/auth/login', methods=['POST']) +@limiter.limit('10 per minute; 3 per second') def login(): """ Authenticate with username + password. @@ -116,6 +117,23 @@ def login(): ) db.session.commit() + # Passive cleanup — delete expired/revoked tokens for this user only + # so the table never accumulates dead rows without a cron dependency. + try: + from app.utils.time_utils import now_eastern + now = now_eastern() + RefreshToken.query.filter( + RefreshToken.user_id == user.id, + db.or_( + RefreshToken.expires_at < now, + RefreshToken.revoked == True, # noqa: E712 + ), + ).delete(synchronize_session=False) + db.session.commit() + except Exception as _cleanup_exc: + logger.warning('API LOGIN passive token cleanup failed: %s', _cleanup_exc) + db.session.rollback() + log_action(ACTION_LOGIN, 'User', user.id, user.username, f'source=mobile_api; device_id={device_id}') @@ -134,6 +152,7 @@ def login(): # ── Refresh ─────────────────────────────────────────────────────────────────── @bp.route('/auth/refresh', methods=['POST']) +@limiter.limit('30 per minute; 5 per second') def refresh(): """ Exchange a valid refresh token for a new access token. diff --git a/app/routes/auth.py b/app/routes/auth.py index b1b7f45..afdcf1a 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask_login import login_user, logout_user, login_required, current_user from urllib.parse import urlparse -from app import db +from app import db, limiter from app.models.user import User from app.utils.forms import LoginForm, UserForm, ProfileForm from app.utils.decorators import admin_required, supervisor_required @@ -30,6 +30,7 @@ def _safe_next(next_url: str | None) -> str: @bp.route('/login', methods=['GET', 'POST']) +@limiter.limit('20 per minute; 5 per second') def login(): if current_user.is_authenticated: return redirect(url_for('dashboard.index')) @@ -254,7 +255,7 @@ def toggle_active(user_id): f'account {action_label} by {current_user.username}', ) flash(f'User {user.username} has been {action_label}.', 'success') - return redirect(request.referrer or url_for('auth.list_users')) + return redirect(_safe_next(request.referrer) or url_for('auth.list_users')) # ── Notification Matrix ─────────────────────────────────────────────────────── diff --git a/app/routes/customers.py b/app/routes/customers.py index e425e2a..680da8a 100644 --- a/app/routes/customers.py +++ b/app/routes/customers.py @@ -13,6 +13,7 @@ Provides a single screen to: """ import logging +from urllib.parse import urlparse from flask import Blueprint, render_template, redirect, url_for, flash, request, abort from flask_login import login_required, current_user from app import db @@ -24,6 +25,17 @@ from app.utils.decorators import admin_required, supervisor_required from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.scope import get_customer_scope + +def _safe_referrer(fallback: str) -> str: + """Return request.referrer only if it is a safe relative URL, else fallback.""" + ref = request.referrer + if not ref: + return fallback + parsed = urlparse(ref) + if parsed.netloc or parsed.scheme: + return fallback + return ref + logger = logging.getLogger(__name__) bp = Blueprint('customers', __name__, url_prefix='/customers') @@ -88,12 +100,24 @@ def index(): # All active projects for the assignment modal projects = Project.query.filter_by(active=True).order_by(Project.name).all() + # ── Expired pending-setup invitations ───────────────────────────────── + # Surface customer accounts whose invitation token has expired but + # password_set is still False — they need a fresh invite to log in. + from app.utils.time_utils import now_eastern + expired_invitations = [ + c for c in customers + if not c.password_set + and c.set_password_token_expires is not None + and c.set_password_token_expires < now_eastern() + ] + return render_template( 'customers/index.html', - customers = customers, - assignment_map = assignment_map, - scope_map = scope_map, - projects = projects, + customers = customers, + assignment_map = assignment_map, + scope_map = scope_map, + projects = projects, + expired_invitations = expired_invitations, ) @@ -467,7 +491,7 @@ def toggle_active(customer_id): log_action(ACTION_UPDATE, 'User', customer.id, customer.username, f'account {label} via customer_mgmt by {current_user.username}') flash(f'Customer "{customer.username}" has been {label}.', 'success') - return redirect(request.referrer or url_for('customers.index')) + return redirect(_safe_referrer(url_for('customers.index'))) # ── AJAX: facilities for a project (used by add-assignment form) ────────────── diff --git a/app/routes/dashboard.py b/app/routes/dashboard.py index 49c662f..6d058b8 100644 --- a/app/routes/dashboard.py +++ b/app/routes/dashboard.py @@ -66,10 +66,10 @@ def index(): open_issues_q = open_issues_q.join( Area, Issue.area_id == Area.id ).filter(Area.facility_id.in_(customer_facility_ids)) - open_issues = open_issues_q.count() - # Severity breakdown for the open issues card + # Single query — derive count from the list to avoid hitting the DB twice open_issues_all = open_issues_q.all() + open_issues = len(open_issues_all) severity_breakdown = { 'critical': sum(1 for i in open_issues_all if i.severity == 'critical'), 'high': sum(1 for i in open_issues_all if i.severity == 'high'), diff --git a/app/routes/notifications.py b/app/routes/notifications.py index 0e8a8b4..dfb91f5 100644 --- a/app/routes/notifications.py +++ b/app/routes/notifications.py @@ -223,4 +223,46 @@ def check_sla(): sent = send_sla_alerts() logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent) - return jsonify({'ok': True, 'notifications_sent': sent}) \ No newline at end of file + return jsonify({'ok': True, 'notifications_sent': sent}) + + +# ── Expired token cleanup (called by cron) ──────────────────────────────────── + +@bp.route('/cleanup-tokens', methods=['POST']) +@csrf.exempt +def cleanup_tokens(): + """Purge expired and revoked refresh tokens from api_refresh_tokens. + + Safe to run frequently — only deletes rows where expires_at has passed + OR revoked=True. Keeps the table lean without touching live sessions. + + Recommended cron schedule — nightly is sufficient: + + 0 3 * * * curl -s -X POST https://yourdomain.com/notifications/cleanup-tokens \\ + -d "token=YOUR_DIGEST_SECRET" + """ + token = request.form.get('token') or request.args.get('token') + expected = current_app.config.get('DIGEST_SECRET') + + if not expected or token != expected: + logger.warning('TOKEN CLEANUP REJECTED | bad or missing token') + abort(403) + + from app.models.api_token import RefreshToken + from app.utils.time_utils import now_eastern + + now = now_eastern() + deleted = ( + RefreshToken.query + .filter( + db.or_( + RefreshToken.expires_at < now, + RefreshToken.revoked == True, # noqa: E712 + ) + ) + .delete(synchronize_session=False) + ) + db.session.commit() + + logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted) + return jsonify({'ok': True, 'deleted': deleted}) \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 81c8348..e2c728e 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -43,6 +43,19 @@ .notif-body { font-size: 0.78rem; color: #555; white-space: normal; } .notif-time { font-size: 0.7rem; color: #999; } .notif-empty { padding: 24px; text-align: center; color: #aaa; font-size: 0.85rem; } + + /* ── Active nav tab ── */ + .navbar-dark .navbar-nav .nav-link.active { + background-color: rgba(255, 255, 255, 0.18); + color: #ffffff !important; + border-radius: 6px; + font-weight: 600; + box-shadow: inset 0 -2px 0 rgba(255,255,255,0.6); + } + .navbar-dark .navbar-nav .nav-link:not(.active):hover { + background-color: rgba(255, 255, 255, 0.08); + border-radius: 6px; + }
diff --git a/app/templates/customers/index.html b/app/templates/customers/index.html index c357228..d7a38cf 100644 --- a/app/templates/customers/index.html +++ b/app/templates/customers/index.html @@ -18,6 +18,35 @@ {% if customers %} + +{% if expired_invitations %} +