From 4786f34f4d4958e7d3042ee55e7b50f99d9e2ab9 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 15 Sep 2026 12:11:32 -0400 Subject: [PATCH] Sep 15 - Fixed login session when check 'Remember for 30 days' box --- Claude.md | 19 +++++++++++++++++-- app.py | 31 ++++++++++++++++++++++--------- config.py | 6 +++++- routes/auth.py | 3 +++ 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/Claude.md b/Claude.md index f27c3a4..cf56b5b 100644 --- a/Claude.md +++ b/Claude.md @@ -507,7 +507,13 @@ Exception: `/qr//checkin` is CSRF-exempt (public, unauthenticated). ### Session Security - `session.clear()` before setting new keys on login (prevents session fixation) -- `before_request` hook `adjust_session_lifetime()`: `remember_me` → 30 days, default → 10 hours +- **Remember Me** → permanent cookie, 30 days (`PERMANENT_SESSION_LIFETIME`), renewed on each request. + **Without it** → browser-session cookie; `before_request` hook `adjust_session_lifetime()` clears + the session 10 hours after `session['login_epoch']` (set in `auth.login`) +- **Never assign `app.permanent_session_lifetime` per request, and never lower + `PERMANENT_SESSION_LIFETIME` below 30 days.** Flask checks every session cookie's age against + it in `open_session()` *before* any `before_request` hook, and the value is shared by the whole + worker — setting it to 10 h for anonymous traffic (QR scans) logged Remember Me users out (Set 19) - `SESSION_COOKIE_SECURE=True` requires HTTPS — HTTP-only deployments must set `false` or login loops ### validate_session_security() — DO NOT USE in before_request @@ -1101,7 +1107,7 @@ exact-match test is what dropped every SP/PW/PT row the query had already return | File | Fix | |---|---| | 7 route files | `Model.query.get_or_404()` → `db.session.get()` + `abort(404)` (21 call sites) | -| `config.py` | `PERMANENT_SESSION_LIFETIME` → `timedelta(hours=10)` | +| `config.py` | `PERMANENT_SESSION_LIFETIME` → `timedelta(hours=10)` (later restored to 30 days — the 10-hour limit now lives in `adjust_session_lifetime()`, see Set 19) | | `routes/attendance.py` | Split into 4 files sharing one blueprint | | `requirements.txt` | `mysql-connector-python` removed | | `routes/attendance.py` | LIMIT 1001 + `records_truncated` flag + yellow banner | @@ -1190,6 +1196,15 @@ exact-match test is what dropped every SP/PW/PT row the query had already return | `templates/time_attendance_records.html` | Tooltip on the Export by Building button | | — | Replaces the manual "Copilot" procedure (delete PM rows + SP rows, then build a weekly table). Decisions: PM IDs removed from both sheets; SP hours excluded from weekly hours | +### Set 19 — Remember Me Logged Users Out Within 30 Days (Sept 15, 2026) +| File | Fix | +|---|---| +| `app.py` | `adjust_session_lifetime()` no longer assigns `app.permanent_session_lifetime` (10 h / 30 d per request). Flask validates the cookie's age against that worker-wide value in `open_session()` before hooks run, so any request without Remember Me — including the login POST itself and anonymous QR scans — made Remember Me cookies older than 10 h unreadable; under gevent it could also stamp a 10 h expiry on them. The hook now clears non-Remember-Me sessions 10 h after `login_epoch` instead | +| `routes/auth.py` | Login sets `session['login_epoch']` | +| `config.py` | Comment corrected: `PERMANENT_SESSION_LIFETIME` stays 30 days — it is the cookie age limit for every session | +| — | Verified by loading the real hook (before/after) into a Flask app with a controlled clock: Remember Me now survives 11 h, 25 days of daily use and 29 idle days, and expires after 31 idle days; non-Remember-Me still ends at 10 h | +| — | No forced re-login on deploy: valid sessions keep working; non-Remember-Me sessions from before the deploy (no `login_epoch`) get their 10 hours counted from their first request after it | + --- ## 21. Infrastructure & Deployment diff --git a/app.py b/app.py index 5611045..6cc809f 100644 --- a/app.py +++ b/app.py @@ -12,7 +12,7 @@ so that url_for() resolution is identical to the original monolithic app.py. """ from flask import Flask, render_template, request, redirect, url_for, flash, session, g -from datetime import datetime, timedelta +from datetime import datetime from dotenv import load_dotenv import os import time as _time @@ -229,15 +229,28 @@ def create_app() -> Flask: @app.before_request def adjust_session_lifetime(): """ - Dynamically set session lifetime based on the 'remember_me' flag stored - in the session. When the user chose 'Remember Me' at login, their - permanent session lives for 30 days; otherwise the default 10-hour - lifetime from Config.PERMANENT_SESSION_LIFETIME applies. + Enforce the session lifetime chosen at login. + + app.permanent_session_lifetime is deliberately NOT changed here. It is + shared by every request in the worker, and Flask checks the session + cookie's age against it in open_session() BEFORE this hook runs. Setting + it to 10 hours on requests without Remember Me (QR check-ins, the login + page) made any Remember Me cookie unused for 10 hours fail to load, and + under gevent a concurrent request could also stamp a 10-hour expiry on + a Remember Me cookie. It stays at Config.PERMANENT_SESSION_LIFETIME. + + - Remember Me: permanent cookie, 30 days, renewed on every request. + - Otherwise: browser-session cookie, and the login ends 10 hours after + sign-in (session['login_epoch'], set in auth.login). """ - if session.get('remember_me'): - app.permanent_session_lifetime = timedelta(days=30) - else: - app.permanent_session_lifetime = timedelta(hours=10) + if 'user_id' not in session or session.get('remember_me'): + return + login_epoch = session.get('login_epoch') + if login_epoch is None: + # Logged in before login_epoch existed: start the 10 hours now + session['login_epoch'] = _time.time() + elif _time.time() - login_epoch > 10 * 3600: + session.clear() @app.before_request def log_request_info(): diff --git a/config.py b/config.py index 42e04a6..eb7af30 100644 --- a/config.py +++ b/config.py @@ -46,7 +46,11 @@ class Config: # ------------------------------------------------------------------ # # Session / cookies # ------------------------------------------------------------------ # - PERMANENT_SESSION_LIFETIME = timedelta(days=30) # Reduced from 30 days — payroll data sensitivity + # Remember Me lifetime, and the maximum age Flask accepts for ANY session + # cookie. Logins without Remember Me end after 10 hours in app.py + # (adjust_session_lifetime) — do not lower this to get that, or Remember Me + # cookies stop loading. + PERMANENT_SESSION_LIFETIME = timedelta(days=30) SESSION_COOKIE_SECURE = ( os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true' ) diff --git a/routes/auth.py b/routes/auth.py index 9059d69..2f031e8 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -139,6 +139,9 @@ def login(): session['role'] = user.role session['full_name'] = user.full_name session['login_time'] = datetime.now().isoformat() + # Start of the 10-hour limit for logins without Remember Me + # (enforced in app.py adjust_session_lifetime) + session['login_epoch'] = datetime.now().timestamp() # Create a secure session token (also clears failed attempts for this IP) if sec_mgr: