Keep login sessions persistent across browser restarts

This commit is contained in:
Peter Stockings
2026-10-22 23:07:46 +11:00
parent d660b86bb0
commit b8eb6d10f8
4 changed files with 45 additions and 2 deletions
+3
View File
@@ -130,6 +130,9 @@ The application uses CSRF protection, hashed passwords, login throttling, and
server-side authorization on every activity page and data endpoint. Email server-side authorization on every activity page and data endpoint. Email
verification/recovery and shared production rate-limit storage remain outstanding. verification/recovery and shared production rate-limit storage remain outstanding.
Deployment instructions and sync behavior are in docs/DEPLOYMENT.md. Deployment instructions and sync behavior are in docs/DEPLOYMENT.md.
Sign-ins persist across browser restarts and renew on use, with an effectively
unlimited application lifetime (100 years). Browsers may cap cookie retention;
signing out, clearing cookies, or changing the application secret ends access.
The local server binds only to loopback; production runs behind the Dokku HTTPS proxy. The local server binds only to loopback; production runs behind the Dokku HTTPS proxy.
Users will upload FIT files or connect Garmin by authenticating locally with Users will upload FIT files or connect Garmin by authenticating locally with
+10 -2
View File
@@ -5,7 +5,7 @@ from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import click import click
from flask import Flask, render_template, request from flask import Flask, render_template, request, session
from .extensions import csrf, db, limiter, login_manager, migrate from .extensions import csrf, db, limiter, login_manager, migrate
@@ -21,7 +21,9 @@ def create_app(config=None):
SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=production, SESSION_COOKIE_SECURE=production,
PERMANENT_SESSION_LIFETIME=timedelta(days=7), # Effectively unlimited application lifetime; browsers may cap cookie retention.
PERMANENT_SESSION_LIFETIME=timedelta(days=36500),
SESSION_REFRESH_EACH_REQUEST=True,
MAX_CONTENT_LENGTH=16 * 1024 * 1024, MAX_CONTENT_LENGTH=16 * 1024 * 1024,
LOCAL_SETUP=not production, LOCAL_SETUP=not production,
DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")), DATA_ROOT=os.environ.get("DATA_ROOT", str(ROOT / "data" / "garmin")),
@@ -98,6 +100,12 @@ def create_app(config=None):
def load_user(user_id): def load_user(user_id):
return db.session.get(User, int(user_id)) if user_id.isdigit() else None return db.session.get(User, int(user_id)) if user_id.isdigit() else None
@app.before_request
def persist_login_session():
# Also upgrade existing signed-in browser sessions on their next request.
if "_user_id" in session:
session.permanent = True
@app.template_filter("duration") @app.template_filter("duration")
def duration(value): def duration(value):
value = round(value or 0) value = round(value or 0)
+2
View File
@@ -120,6 +120,7 @@ def register_user(is_setup=False):
return "That email is already registered." return "That email is already registered."
session.clear() session.clear()
login_user(user) login_user(user)
session.permanent = True
flash( flash(
f"Welcome to Stride. {count} activities imported privately." f"Welcome to Stride. {count} activities imported privately."
if count if count
@@ -168,6 +169,7 @@ def login():
if user and valid and len(password) <= 128: if user and valid and len(password) <= 128:
session.clear() session.clear()
login_user(user) login_user(user)
session.permanent = True
return redirect(url_for("web.dashboard")) return redirect(url_for("web.dashboard"))
error = "Email or password was not recognised." error = "Email or password was not recognised."
return render_template("auth.html", mode="login", error=error) return render_template("auth.html", mode="login", error=error)
+30
View File
@@ -69,6 +69,36 @@ def login(client, name="runner"):
return response return response
def test_login_persists_and_logout_clears_cookie(app, monkeypatch):
import time
client = app.test_client()
response = login(client)
assert "Expires=" in response.headers["Set-Cookie"]
cookie = client.get_cookie(app.config["SESSION_COOKIE_NAME"])
restarted = app.test_client()
restarted.set_cookie(cookie.key, cookie.value)
now = time.time()
monkeypatch.setattr(time, "time", lambda: now + 400 * 86400)
response = restarted.get("/dashboard")
assert response.status_code == 200
assert "Expires=" in response.headers["Set-Cookie"]
assert restarted.post("/logout").status_code == 302
assert restarted.get_cookie(cookie.key) is None
assert restarted.get("/dashboard").status_code == 302
def test_existing_login_becomes_persistent(app):
client = app.test_client()
with client.session_transaction() as session:
session["_user_id"] = "1"
response = client.get("/dashboard")
assert response.status_code == 200
assert "Expires=" in response.headers["Set-Cookie"]
with client.session_transaction() as session:
assert session.permanent
def add_history(count, visibility="private"): def add_history(count, visibility="private"):
from datetime import timedelta from datetime import timedelta