Keep login sessions persistent across browser restarts
This commit is contained in:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user