Sep 15 - Fixed login session when check 'Remember for 30 days' box

This commit is contained in:
2026-09-15 12:11:32 -04:00
parent 23bd3a897b
commit 4786f34f4d
4 changed files with 47 additions and 12 deletions
+17 -2
View File
@@ -507,7 +507,13 @@ Exception: `/qr/<url>/checkin` is CSRF-exempt (public, unauthenticated).
### Session Security ### Session Security
- `session.clear()` before setting new keys on login (prevents session fixation) - `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 - `SESSION_COOKIE_SECURE=True` requires HTTPS — HTTP-only deployments must set `false` or login loops
### validate_session_security() — DO NOT USE in before_request ### 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 | | File | Fix |
|---|---| |---|---|
| 7 route files | `Model.query.get_or_404()``db.session.get()` + `abort(404)` (21 call sites) | | 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 | | `routes/attendance.py` | Split into 4 files sharing one blueprint |
| `requirements.txt` | `mysql-connector-python` removed | | `requirements.txt` | `mysql-connector-python` removed |
| `routes/attendance.py` | LIMIT 1001 + `records_truncated` flag + yellow banner | | `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 | | `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 | | — | 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 ## 21. Infrastructure & Deployment
+22 -9
View File
@@ -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 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 from dotenv import load_dotenv
import os import os
import time as _time import time as _time
@@ -229,15 +229,28 @@ def create_app() -> Flask:
@app.before_request @app.before_request
def adjust_session_lifetime(): def adjust_session_lifetime():
""" """
Dynamically set session lifetime based on the 'remember_me' flag stored Enforce the session lifetime chosen at login.
in the session. When the user chose 'Remember Me' at login, their
permanent session lives for 30 days; otherwise the default 10-hour app.permanent_session_lifetime is deliberately NOT changed here. It is
lifetime from Config.PERMANENT_SESSION_LIFETIME applies. 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'): if 'user_id' not in session or session.get('remember_me'):
app.permanent_session_lifetime = timedelta(days=30) return
else: login_epoch = session.get('login_epoch')
app.permanent_session_lifetime = timedelta(hours=10) 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 @app.before_request
def log_request_info(): def log_request_info():
+5 -1
View File
@@ -46,7 +46,11 @@ class Config:
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
# Session / cookies # 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 = ( SESSION_COOKIE_SECURE = (
os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true' os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
) )
+3
View File
@@ -139,6 +139,9 @@ def login():
session['role'] = user.role session['role'] = user.role
session['full_name'] = user.full_name session['full_name'] = user.full_name
session['login_time'] = datetime.now().isoformat() 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) # Create a secure session token (also clears failed attempts for this IP)
if sec_mgr: if sec_mgr: