diff --git a/app.py b/app.py index 01778a8..cc96e49 100644 --- a/app.py +++ b/app.py @@ -82,6 +82,18 @@ def create_app(): response.headers["X-Frame-Options"] = "SAMEORIGIN" response.headers["X-Content-Type-Options"] = "nosniff" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " + "font-src 'self' https://fonts.gstatic.com; " + "img-src 'self' data:; " + "connect-src 'self'; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'self'" + ) return response # ── 413 handler: file too large ──────────────────────────────────────────── diff --git a/models.py b/models.py index dcc67e5..413b052 100644 --- a/models.py +++ b/models.py @@ -56,7 +56,7 @@ def log_action(user_id, action, entity=None, entity_id=None, detail=None): # ─── Authentication ──────────────────────────────────────────────────────────── -def authenticate(username: str, password: str): +def authenticate(username: str, password: str, ip_address: str = None): conn = None try: conn = get_connection() @@ -66,7 +66,7 @@ def authenticate(username: str, password: str): if not user or not _verify_password(password, user["password"]): logger.warning(f"Failed login attempt for username='{username}'.") cur.close() - record_failed_attempt(username) + record_failed_attempt(username, ip_address) return None if _needs_rehash(user["password"]): new_hash = _hash_password(password) diff --git a/routes/auth.py b/routes/auth.py index fb23990..3230557 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -35,7 +35,7 @@ def login(): error = f"Account locked. Try again in {mins}m {secs}s." logger.warning(f"Login blocked for '{username}' — still locked ({seconds_remaining}s remaining).") else: - user = authenticate(username, password) + user = authenticate(username, password, ip_address=ip) if user: session.clear() # prevent session fixation session.permanent = True diff --git a/routes/user_dashboard.py b/routes/user_dashboard.py index f806d7b..74361e8 100644 --- a/routes/user_dashboard.py +++ b/routes/user_dashboard.py @@ -9,7 +9,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, from models import ( get_today_checks, mark_website_checked, unmark_website_checked, update_check_note, get_user_active_shifts, get_website_credentials, - get_website_url, + get_website_url, log_action, ) from utils.decorators import login_required @@ -86,6 +86,9 @@ def view_credentials(website_id): """JSON endpoint: return decrypted credentials for a site.""" try: creds = get_website_credentials(website_id) + user_id = session["user"]["id"] + log_action(user_id, "VIEW_CREDENTIALS", "websites", website_id, + f"User viewed credentials for website {website_id}.") return jsonify([{ "label": c.get("label", ""), "username": c.get("username", ""), diff --git a/static/js/app.js b/static/js/app.js index 7d623c0..7ec0aa4 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -24,9 +24,9 @@ function closeModal(id) { } } -// Close modal when clicking the backdrop +// Close modal when clicking the backdrop (skips modals with data-no-backdrop-close) document.addEventListener('click', (e) => { - if (e.target.classList.contains('modal-overlay')) { + if (e.target.classList.contains('modal-overlay') && !e.target.hasAttribute('data-no-backdrop-close')) { e.target.classList.remove('open'); document.body.style.overflow = ''; } @@ -162,30 +162,60 @@ document.addEventListener('DOMContentLoaded', () => { /* ── Session timeout warning ───────────────────────────────── */ (function sessionWarning() { - const WARN_BEFORE_MS = 5 * 60 * 1000; // warn 5 min before expiry - const SESSION_MS = 30 * 60 * 1000; // match Flask SESSION_LIFETIME + const WARN_BEFORE_MS = 5 * 60 * 1000; + const SESSION_MS = 30 * 60 * 1000; - let warningTimer = null; - let expireTimer = null; + var warningTimer = null, expireTimer = null, countdownInterval = null; + + function isWarningShowing() { + var m = document.getElementById('modal-session-warning'); + return m && m.classList.contains('open'); + } + + function clearCountdown() { + if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } + } + + function startCountdown() { + var remaining = Math.round(WARN_BEFORE_MS / 1000); + var el = document.getElementById('session-countdown'); + clearCountdown(); + countdownInterval = setInterval(function() { + remaining--; + if (el) { + var m = Math.floor(remaining / 60), s = remaining % 60; + el.textContent = m + ':' + (s < 10 ? '0' : '') + s; + } + if (remaining <= 0) { clearCountdown(); window.location.href = '/login'; } + }, 1000); + } function resetTimers() { - clearTimeout(warningTimer); - clearTimeout(expireTimer); - warningTimer = setTimeout(() => { - if (confirm('Your session will expire in 5 minutes. Click OK to stay logged in.')) { - fetch('/ping', { credentials: 'same-origin' }).catch(() => {}); - resetTimers(); - } + clearTimeout(warningTimer); clearTimeout(expireTimer); clearCountdown(); + if (isWarningShowing()) closeModal('modal-session-warning'); + + warningTimer = setTimeout(function() { + openModal('modal-session-warning'); + startCountdown(); }, SESSION_MS - WARN_BEFORE_MS); - expireTimer = setTimeout(() => { - alert('Your session has expired. You will be redirected to login.'); - window.location.href = '/auth/login'; + expireTimer = setTimeout(function() { + window.location.href = '/login'; }, SESSION_MS); } - ['click', 'keydown', 'mousemove', 'scroll', 'touchstart'].forEach(evt => { - document.addEventListener(evt, () => resetTimers(), { passive: true }); + document.addEventListener('DOMContentLoaded', function() { + var btn = document.getElementById('btn-stay-logged-in'); + if (btn) btn.addEventListener('click', function() { + fetch('/ping', { credentials: 'same-origin' }).catch(function(){}); + resetTimers(); + }); + }); + + ['click', 'keydown', 'mousemove', 'scroll', 'touchstart'].forEach(function(evt) { + document.addEventListener(evt, function() { + if (!isWarningShowing()) resetTimers(); + }, { passive: true }); }); resetTimers(); diff --git a/templates/base.html b/templates/base.html index 3fb781f..faaf48d 100644 --- a/templates/base.html +++ b/templates/base.html @@ -69,6 +69,22 @@ {% block content %}{% endblock %} + +
+