Skip to content

Commit

Permalink
Fix frequent Gatherling logouts by introducing the concept of db-held…
Browse files Browse the repository at this point in the history
… sessions

Despite a bunch of monkeying around with a cookie_lifetime setting and ini_set
calls, gatherling.com generally logs you out after 24 minutes of inactivity.

The root cause of this problem is abusing the PHP session and trying to make it
a thing that lives for 60 days instead of 24 minutes/until you close your
browser (the default).

The reason extending the session does not work out despite the messing around
with ini_set and so on is because most unix-based installs of PHP add a cron job
that checks every "active" php.ini on the sever for the session timeout,
declares the shortest time found the winner, and kills all older sessions
(making the files storing them empty).

The fix here is to let PHP sessions be PHP sessions. We no longer try to make
them live 60 days. Instead we cookie you with a remember_me cookie against which
we store your session details in the database. This cookie lives for 60 days, a
fact which is also enforced in the database. If either the cookie or the db row
go away then your session will no longer be persisted. We update expiry in both
places every time you complete a request in a shutdown function. So you'll stay
logged in forever as long as you visit every sixty days.

Because there are 150 mentions of "session" in the existing codebase I went for
an implementation that is agnostic about what is actually IN the session. We
just JSON encode whatever it is and put it in the db. Eventually perhaps all
session access will flow through Gatherling\Auth\Session and we can be a bit
less generic about it all.

We delete all expired sessions on every request from every user. If this ends up
being unnecessarily busy we can create a cron job or something similar.

Because it will PHP Fatal Error before it runs in web you have to run db-upgrade
from the commandline for this migration.

The remember_me cookie lives forever and any attempt to remove it will just
result in recreation. Even if it's just remembering you are an anonyous person
and your session is empty. If that ends up being unnecessarily busy we can
revisit.

We store expiry in the database not "last active" although either can be derived
from the other by adding/substrcacting 60 days.
  • Loading branch information
bakert committed Sep 8, 2024
1 parent a09b8ed commit 53b5e68
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 12 deletions.
74 changes: 74 additions & 0 deletions gatherling/Auth/Session.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

namespace Gatherling\Auth;

use Gatherling\Data\DB;

class Session
{
private static $LIFETIME = 60 * 60 * 24 * 60;

public static function start(): void
{
session_start();
self::init();
register_shutdown_function([self::class, 'save']);
}

private static function init(): void
{
if (!empty($_SESSION)) {
return;
}
if (!isset($_COOKIE['remember_me'])) {
return;
}
$token = $_COOKIE['remember_me'];
$_SESSION = self::load($token);
}

private static function load(string $token): array
{
$sql = '
SELECT
details
FROM
sessions
WHERE
token = :token
AND
expiry >= NOW()';
$args = ['token' => $token];
$details = DB::value($sql, $args);
return $details ? json_decode($details, true) : [];
}

private static function save(): void
{
if (isset($_COOKIE['remember_me'])) {
$token = $_COOKIE['remember_me'];
} else {
$token = bin2hex(random_bytes(32));
}
// Force to object so that we get '{}' instead of '[]' when empty
$details = json_encode((object) $_SESSION);
$expiry = time() + self::$LIFETIME;
$sql = '
INSERT INTO
sessions (token, details, expiry)
VALUES
(:token, :details, FROM_UNIXTIME(:expiry))
ON DUPLICATE KEY UPDATE
details = :details,
expiry = FROM_UNIXTIME(:expiry)';
$args = [
'token' => $token,
'details' => $details,
'expiry' => $expiry,
];
DB::execute($sql, $args);
setcookie('remember_me', $token, $expiry, '/');
$sql = 'DELETE FROM sessions WHERE expiry < NOW()';
DB::execute($sql);
}
}
6 changes: 6 additions & 0 deletions gatherling/Data/sql/migrations/55.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS sessions (
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(64) UNIQUE NOT NULL,
details TEXT NOT NULL,
expiry TIMESTAMP NOT NULL
);
3 changes: 0 additions & 3 deletions gatherling/config.php.docker
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ $CONFIG['style'] = "ChandraNeue";
# A description for the ical calendar which is accessible at calendar.php
$CONFIG['calendar_description'] = "a description for the events calendar";

# How long to store session cookies in seconds
$CONFIG['cookie_lifetime'] = 5184000;

# API Key for Brevo email sending (password reset)
$CONFIG['brevo_api_key'] = 'xkeysib-foobar-baz';

Expand Down
3 changes: 0 additions & 3 deletions gatherling/config.php.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ $CONFIG['style'] = "ChandraNeue";
# A description for the ical calendar which is accessible at calendar.php
$CONFIG['calendar_description'] = "a description for the events calendar";

# How long to store session cookies in seconds
$CONFIG['cookie_lifetime'] = 5184000;

# API Key for Brevo email sending (password reset)
$CONFIG['brevo_api_key'] = 'xkeysib-foobar-baz';

Expand Down
8 changes: 2 additions & 6 deletions gatherling/lib.php
Original file line number Diff line number Diff line change
@@ -1,21 +1,17 @@
<?php

use Gatherling\Auth\Session;
use Gatherling\Models\Database;
use Gatherling\Models\Format;
use Gatherling\Models\Player;

require_once 'bootstrap.php';
ob_start();
if (isset($CONFIG['cookie_lifetime'])) {
ini_set('session.gc_maxlifetime', $CONFIG['cookie_lifetime']);
ini_set('session.cookie_lifetime', $CONFIG['cookie_lifetime']);
session_set_cookie_params($CONFIG['cookie_lifetime']);
}
header('Strict-Transport-Security: max-age=63072000; includeSubDomains; preload');
header('Referrer-Policy: strict-origin-when-cross-origin');

if (php_sapi_name() !== 'cli' && session_status() !== PHP_SESSION_ACTIVE) {
session_start();
Session::start();
}

$HC = '#DDDDDD';
Expand Down

0 comments on commit 53b5e68

Please sign in to comment.