Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3ce4a50
Resolve legacy photo and new indexed multi-photo attachments privately
jasrasr Oct 27, 2026
445bbc5
Add validated, revision-checked append-only mobile multi-photo uploads
jasrasr Oct 27, 2026
8dc3f6f
Add per-user multi-photo upload endpoint, editing picker and secure c…
jasrasr Oct 27, 2026
2629201
Add edit-time multi-picture uploads and authenticated workout image g…
jasrasr Oct 27, 2026
839315c
Support opt-in seven-day central session cookies without extending de…
jasrasr Oct 27, 2026
c573c2a
Wire seven-day remember-me choice into existing authenticated central…
jasrasr Oct 27, 2026
7c1c364
Keep 30-minute default and document optional seven-day persistent ses…
jasrasr Oct 27, 2026
be653ba
Validate configurable seven-day remember-me session limit
jasrasr Oct 27, 2026
db6ac8f
Offer explicit 7-day Stay signed in choice on central login form
jasrasr Oct 27, 2026
f4b0342
Style mobile account navigation, image picker and two-column private …
jasrasr Oct 27, 2026
0dd45ff
Test append-only multi-photo uploads against original records, owners…
jasrasr Oct 27, 2026
e0f7873
HTTP-test optional week-long remembered login and central sign-out re…
jasrasr Oct 27, 2026
74d34a6
Check multi-photo private gallery with legacy picture plus new attach…
jasrasr Oct 27, 2026
85fe3bb
Add photos and secure seven-day login integration checks to CI
jasrasr Oct 27, 2026
8c13e5c
Keep Edit click regression compatible with new picture picker and pre…
jasrasr Oct 27, 2026
7fad238
Document issue 135 mobile photo uploads, safe gallery, shared logout …
jasrasr Oct 27, 2026
e26d929
Log issue 135 safe multi-photo and central login improvements
jasrasr Oct 27, 2026
ac7bf2a
Document bounded opt-in remembered login and unchanged default sessio…
jasrasr Oct 27, 2026
f1b0a46
Test actual Edit photo uploader and mobile gallery without network or…
jasrasr Oct 27, 2026
c3b485b
Run mobile image-upload handler regression in Workout Tracker CI
jasrasr Oct 27, 2026
281fe63
Prevent PHP from downgrading remembered cookies and rotate expired se…
jasrasr Oct 27, 2026
0e007c9
Assert remembered login remains persistent but cannot slide its seven…
jasrasr Oct 27, 2026
c3f8410
Correct persistent-cookie follow-up assertion regex
jasrasr Oct 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/workout-tracker-validation.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# File Name: workout-tracker-validation.yml
# Author: Jason Lamb (with help from ChatGPT)
# Description: Validate PHP, JavaScript and preservation of Workout Tracker records.
# Revision: 1.5.0
# Revision: 1.6.1
# Created Date: 2026-10-22
# Modified Date: 2026-10-27
# Changelog: 1.5.0 - Validate seven-day and since-first attendance, weekday streaks, mobile card rendering. 1.4.0 - Run BPM parsing, input-validation and legacy-data isolation regression tests. 1.3.0 - Verify mobile detail-view DOM and private photo URLs. 1.2.0 - Test user-bound image access, backup ZIP, and read-only storage. 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests.
# Changelog: 1.6.1 - Execute multi-photo browser handler validation. 1.6.0 - Test multi-photo persistence and global remember-me session lifetime. 1.5.0 - Validate seven-day and since-first attendance, weekday streaks, mobile card rendering. 1.4.0 - Run BPM parsing, input-validation and legacy-data isolation regression tests. 1.3.0 - Verify mobile detail-view DOM and private photo URLs. 1.2.0 - Test user-bound image access, backup ZIP, and read-only storage. 1.1.0 - Validate optional measurements, legacy-safe set edits and new advanced UI tests. 1.0.1 - Run DOM-backed Edit button regression test to catch empty CSS class exceptions. 1.0.0 - Run parser and lossless edit regression tests on pull requests.
name: Workout Tracker validation
on:
pull_request:
Expand All @@ -31,6 +31,11 @@ jobs:
php -v
php -l workout-tracker/index.php
php -l workout-tracker/photo.php
php -l workout-tracker/photo-upload.php
php -l workout-tracker/tests/photo-upload.test.php
php -l 1-Framework/modules/Authentication/PasswordSession.php
php -l user-management/src/Auth.php
php -l user-management/index.php
php -l workout-tracker/backup.php
php -l workout-tracker/tests/photo-backup.test.php
php -l workout-tracker/tests/heart-rate.test.php
Expand All @@ -46,6 +51,7 @@ jobs:
php workout-tracker/tests/edit.test.php
php workout-tracker/tests/measurements.test.php
php workout-tracker/tests/photo-backup.test.php
php workout-tracker/tests/photo-upload.test.php
php workout-tracker/tests/heart-rate.test.php
- name: Verify JavaScript syntax and Edit button behavior
run: |
Expand All @@ -57,8 +63,12 @@ jobs:
node workout-tracker/tests/attendance-ui.test.cjs
node --check workout-tracker/tests/workout-detail.test.cjs
node workout-tracker/tests/workout-detail.test.cjs
node --check workout-tracker/tests/photo-ui.test.cjs
node workout-tracker/tests/photo-ui.test.cjs
node --check workout-tracker/advanced.js
node --check workout-tracker/tests/advanced.test.cjs
node workout-tracker/tests/advanced.test.cjs
node --check workout-tracker/tests/edit-button.test.cjs
node workout-tracker/tests/edit-button.test.cjs
- name: Verify long-lived login is opt-in and revocable
run: node --test user-management/tests/remember.test.cjs
74 changes: 65 additions & 9 deletions 1-Framework/modules/Authentication/PasswordSession.php
Original file line number Diff line number Diff line change
@@ -1,25 +1,78 @@
<?php
/* File Name: PasswordSession.php
Author: Jason Lamb (with help from ChatGPT)
Description: Shared PHP sessions with CSRF, revocation and optional bounded persistent login.
Revision: 1.1.1
Created Date: 2026-10-27
Modified Date: 2026-10-27
Changelog: 1.1.1 - Preserve fixed persistent-cookie expiry on every request and rotate expired IDs. 1.1.0 - Add explicitly opted-in 7-day cookies while retaining 30-minute default inactivity expiry.
*/
declare(strict_types=1);
namespace Jasr\Framework;

/** Extracted from box/lib/auth.php; removes project paths and global user records.
* Adds strict cookies, CSRF, session rotation, inactivity expiry, and caller-owned throttling.
*/
final class PasswordSession
{
public function __construct(string $name, string $path, bool $secure, private readonly int $timeout = 1800)
{
public function __construct(
private readonly string $name,
private readonly string $path,
private readonly bool $secure,
private readonly int $timeout = 1800,
private readonly int $rememberSeconds = 604800
) {
if (session_status() === PHP_SESSION_ACTIVE) throw new \RuntimeException('A session is already active.');
if ($timeout < 60 || $rememberSeconds < $timeout || $rememberSeconds > 30 * 86400) {
throw new \RuntimeException('Invalid identity session timeout.');
}
ini_set('session.use_strict_mode', '1');
// PHP's default 24-minute garbage collection would otherwise invalidate
// a remembered browser session before its advertised expiry.
$gc = max((int)ini_get('session.gc_maxlifetime'),$timeout,$rememberSeconds);
ini_set('session.gc_maxlifetime',(string)$gc);
session_name($name);
session_set_cookie_params(['httponly' => true, 'secure' => $secure, 'samesite' => 'Strict', 'path' => $path]);
session_set_cookie_params([
'httponly' => true,'secure' => $secure,'samesite' => 'Strict',
'path' => $path,'lifetime' => 0
]);
session_start();
if (isset($_SESSION['last']) && time() - $_SESSION['last'] > $timeout) $_SESSION = [];
$_SESSION['last'] = time();
$now = time();
$until = isset($_SESSION['remember_until']) && is_int($_SESSION['remember_until'])
? $_SESSION['remember_until'] : 0;
$idle = $until > 0 ? $rememberSeconds : $timeout;
if (($until > 0 && $until <= $now)
|| (isset($_SESSION['last']) && $now - (int)$_SESSION['last'] > $idle)) {
$_SESSION = [];
session_regenerate_id(true); // Never reuse an expired identity's session ID.
// Replace any persistent cookie with a new session-only anonymous ID.
$this->writeCookie(false);
} elseif ($until > $now) {
// session_start() can resend a session-only cookie on some hosts.
// Repeat the SAME fixed expiry, never sliding it forward.
$this->writeCookie(true);
}
$_SESSION['last'] = $now;
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
}

private function writeCookie(bool $remember): void
{
setcookie($this->name, session_id(), [
'expires'=>$remember ? (int)($_SESSION['remember_until'] ?? 0) : 0,
'path'=>$this->path,'secure'=>$this->secure,
'httponly'=>true,'samesite'=>'Strict'
]);
}

/** Only called by the central identity provider after successful password verification. */
public function enableRemember(): void
{
$_SESSION['remember_until'] = time() + $this->rememberSeconds;
$this->writeCookie(true);
}

public function csrf(): string { return $_SESSION['csrf']; }
public function validCsrf(mixed $token): bool { return is_string($token) && hash_equals($this->csrf(), $token); }
public function validCsrf(mixed $token): bool {
return is_string($token) && hash_equals($this->csrf(), $token);
}
public function loggedIn(): bool { return isset($_SESSION['user']); }
public function login(string $user, string $password, string $expectedUser, string $hash, callable $allow): bool
{
Expand All @@ -36,5 +89,8 @@ public function logout(): void
$_SESSION = [];
session_regenerate_id(true);
$_SESSION['csrf'] = bin2hex(random_bytes(32));
// Removing the persistent expiry on the new session cookie signs out
// this browser across all connected projects without stale remember flags.
$this->writeCookie(false);
}
}
12 changes: 11 additions & 1 deletion user-management/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,22 @@

Shared PHP/JSON accounts for projects in this repository. PHP 8.1+, no database or Composer dependencies. Uses the framework's `JsonStore` and `PasswordSession`; the framework exposes the configured provider through `SharedIdentity::connect()`.

## Optional remembered sign-in (2026-10-27)

The central login now includes **Stay signed in for 7 days on this browser**. This is deliberately unchecked by default. Standard sign-in still expires after **30 minutes idle** and uses a session cookie. Choosing the checkbox issues a persistent browser cookie with an absolute seven-day maximum; on every request, the server checks the remembered session's expiry and identity version. Session rotation, CSRF verification, strict/HttpOnly/secure cookies, password resets, disabled-account invalidation and central logout remain enforced across all registered projects.

The duration is configurable with `remember_seconds` in `config.local.php` (default 604800; limited to 30 days), while `timeout` remains 1800 seconds by default. Both are server settings; do not write secrets to this repository. **Existing sessions do not automatically become remembered.** Sign out and sign back in with the checkbox selected. The login checkbox is not recommended for shared devices. Hosts that purge PHP session data earlier than seven days require compatible server/session storage configuration. Test signed-in persistence and sign-out on the actual Hostinger installation.

Validation:

node --test user-management/tests/remember.test.cjs

## Features

- One same-host login/session for integrated projects.
- Central account creation, disable/enable, temporary password reset and password change.
- User, Admin and Super Admin account roles are separate from project scope. Any role can access multiple selected projects; all-project access is an explicit flag. Central directory administration is a separate permission.
- Server-side permission checks, CSRF tokens, secure cookies, session rotation, 30-minute idle expiry and persisted account/IP login limits.
- Server-side permission checks, CSRF tokens, secure cookies, session rotation, standard 30-minute idle expiry, **opt-in bounded seven-day remembered sessions**, and persisted account/IP login limits.
- Password/account/access changes revoke the affected user's sessions; required password changes block project access.
- Last active administrator protection. Users are disabled instead of deleted so project ownership remains stable.
- Runtime files stay in ignored storage; normal Git updates do not overwrite accounts.
Expand Down
6 changes: 6 additions & 0 deletions user-management/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ function jasr_users_config(): array
$local = is_file($file) ? require $file : [];
if (!is_array($local)) throw new RuntimeException('Invalid user management configuration.');
$config = array_replace($defaults, $local);
if (!is_int($config['timeout']) || $config['timeout'] < 60
|| !is_int($config['remember_seconds'])
|| $config['remember_seconds'] < $config['timeout']
|| $config['remember_seconds'] > 30 * 86400) {
throw new RuntimeException('Invalid identity session lifetime configuration.');
}
if (!is_bool($config['debug']) || !is_array($config['rate_limit_exempt_ips'])) {
throw new RuntimeException('Invalid development configuration.');
}
Expand Down
3 changes: 2 additions & 1 deletion user-management/config.local.example.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
'cookie_name' => 'JASR_IDENTITY',
'cookie_path' => '/',
'secure_cookie' => true, // HTTPS required. Set false ONLY for local HTTP tests.
'timeout' => 1800,
'timeout' => 1800, // Unchanged default: sign out after 30 minutes idle.
'remember_seconds' => 7 * 86400, // Only for an explicitly checked login: max 7 days.
// Development only. Keep false and empty on production hosting.
'debug' => false,
'rate_limit_exempt_ips' => [],
Expand Down
4 changes: 2 additions & 2 deletions user-management/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function field(array $input, string $key): string { return is_string($input[$key
if (field($_POST, 'password') !== field($_POST, 'confirm_password')) throw new InvalidArgumentException('Passwords do not match.');
$directory->setup(field($_POST, 'username'), field($_POST, 'name'), field($_POST, 'password'));
} elseif ($action === 'login') {
if (!$auth->login(field($_POST, 'username'), field($_POST, 'password'))) {
if (!$auth->login(field($_POST, 'username'), field($_POST, 'password'), field($_POST, 'remember') === 'yes')) {
http_response_code(401); throw new InvalidArgumentException('Sign-in failed. Check your credentials or wait 15 minutes if attempts were limited.');
}
} elseif ($action === 'logout') {
Expand Down Expand Up @@ -95,7 +95,7 @@ function roleFields(array $account): void
<?php form('setup'); ?><label>Setup key <input type="password" name="setup_key" autocomplete="off" required></label><label>Username <input name="username" autocomplete="username" required></label><label>Display name <input name="name" required></label><?php passwordFields(); ?><button>Create administrator</button></form>
<?php else: ?><p>Browser setup is disabled. Set a private setup key in <code>config.local.php</code> or run the CLI setup described in the README.</p><?php endif ?></section>
<?php elseif (!$user): ?>
<section class="compact"><h2>Sign in</h2><?php form('login'); ?><label>Username <input name="username" autocomplete="username" maxlength="64" required></label><label>Password <input type="password" name="password" autocomplete="current-password" maxlength="72" required></label><button>Sign in</button></form><p>Need a password reset? Ask your site administrator.</p></section>
<section class="compact"><h2>Sign in</h2><?php form('login'); ?><label>Username <input name="username" autocomplete="username" maxlength="64" required></label><label>Password <input type="password" name="password" autocomplete="current-password" maxlength="72" required></label><label class="check"><input type="checkbox" name="remember" value="yes"> Stay signed in for 7 days on this browser</label><p class="muted">Optional. Leave unchecked for the usual 30-minute inactivity timeout. Don't enable on a shared device.</p><button>Sign in</button></form><p>Need a password reset? Ask your site administrator.</p></section>
<?php else: ?>
<div class="account"><p>Signed in as <strong><?= e($user['name']) ?></strong></p><?php form('logout'); ?><button class="secondary">Sign out of all connected projects in this browser</button></form></div>
<?php if ($user['mustChangePassword']): ?><p class="notice">Change your temporary password before opening a project or managing users.</p><?php else: ?>
Expand Down
10 changes: 8 additions & 2 deletions user-management/src/Auth.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
use Jasr\Framework\PasswordSession;
use Jasr\Framework\Response;

/* File Name: Auth.php
Revision: 1.1.0
Modified Date: 2026-10-27
Changelog: 1.1.0 - Offer opt-in seven-day shared login without changing default expiry.
*/
final class Auth
{
public readonly Directory $directory;
Expand All @@ -16,7 +21,7 @@ public function __construct(private array $config)
throw new \RuntimeException('HTTPS is required.');
}
$this->directory = new Directory($config['data_path']);
$this->session = new PasswordSession($config['cookie_name'], $config['cookie_path'], $config['secure_cookie'], $config['timeout']);
$this->session = new PasswordSession($config['cookie_name'], $config['cookie_path'], $config['secure_cookie'], $config['timeout'], $config['remember_seconds']);
header('Cache-Control: no-store');
}
public function csrf(): string { return $this->session->csrf(); }
Expand All @@ -29,7 +34,7 @@ public function user(): ?array
if (!$user || !$user['active'] || $user['version'] !== ($_SESSION['identity_version'] ?? null)) return null;
return Directory::publicUser($user);
}
public function login(string $username, string $password): bool
public function login(string $username, string $password, bool $remember = false): bool
{
$username = strtolower(trim($username));
$remoteIp = (string) ($_SERVER['REMOTE_ADDR'] ?? 'cli');
Expand Down Expand Up @@ -59,6 +64,7 @@ public function login(string $username, string $password): bool
$this->session->logout(); // Rotate ID/token before establishing the shared identity.
$_SESSION['identity_id'] = $found['id'];
$_SESSION['identity_version'] = $found['version'];
if ($remember) $this->session->enableRemember();
// Do not clear the peer-IP counter: it also limits password spraying.
JsonStore::update($this->config['data_path'] . '/attempts.json', function (array $r) use ($keys): array {
unset($r[$keys[0]]);
Expand Down
Loading
Loading