added session revocation and settings menu for security measures
This commit is contained in:
@ -57,6 +57,7 @@ $routes = [
|
||||
'dev' => __DIR__ . '/../handlers/dev.php',
|
||||
'dev/import' => __DIR__ . '/../handlers/dev.php',
|
||||
'activity-log' => __DIR__ . '/../handlers/activity_log.php',
|
||||
'settings' => __DIR__ . '/../handlers/settings.php',
|
||||
];
|
||||
|
||||
try {
|
||||
|
||||
20
common.php
20
common.php
@ -223,6 +223,26 @@ function token_revoke_all_for_user(string $userID): int {
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/** Revoke every stored session (all users and devices). */
|
||||
function token_revoke_all_on_pdo(PDO $pdo): int
|
||||
{
|
||||
if (!qdb_table_exists($pdo, 'session')) {
|
||||
return 0;
|
||||
}
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn();
|
||||
$pdo->exec('DELETE FROM session');
|
||||
return $count;
|
||||
}
|
||||
|
||||
function token_revoke_all(): int
|
||||
{
|
||||
require_once __DIR__ . '/db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(true);
|
||||
$removed = token_revoke_all_on_pdo($pdo);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
return $removed;
|
||||
}
|
||||
|
||||
// --- Auth helpers for endpoints ---
|
||||
function get_bearer_token(): ?string {
|
||||
$auth = $_SERVER['HTTP_AUTHORIZATION'] ?? ($_SERVER['Authorization'] ?? '');
|
||||
|
||||
10
db_init.php
10
db_init.php
@ -109,6 +109,16 @@ function qdb_apply_migrations(PDO $pdo, string $schemaSql): bool {
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (!qdb_table_exists($pdo, 'system_setting')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE system_setting (
|
||||
settingKey TEXT NOT NULL PRIMARY KEY,
|
||||
settingValue TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
");
|
||||
$changed = true;
|
||||
}
|
||||
|
||||
if (qdb_table_exists($pdo, 'questionnaire_submission')) {
|
||||
require_once __DIR__ . '/lib/submissions.php';
|
||||
if (qdb_backfill_submissions_from_live($pdo)) {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/settings.php';
|
||||
require_once __DIR__ . '/../lib/login_rate_limit.php';
|
||||
|
||||
switch ($route) {
|
||||
|
||||
case 'auth/login':
|
||||
@ -15,6 +18,12 @@ case 'auth/login':
|
||||
json_error('MISSING_FIELDS', 'Username and password are required', 400);
|
||||
}
|
||||
|
||||
$securitySettings = qdb_settings_get();
|
||||
[$allowed, $retryAfter] = qdb_login_rate_limit_check($username, $securitySettings);
|
||||
if (!$allowed) {
|
||||
qdb_login_rate_limit_deny($retryAfter);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$stmt = $pdo->prepare(
|
||||
@ -26,9 +35,15 @@ case 'auth/login':
|
||||
|
||||
if (!$user || !password_verify($password, $user['passwordHash'])) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
$retryAfter = qdb_login_rate_limit_record_failure($username, $securitySettings);
|
||||
if ($retryAfter > 0) {
|
||||
qdb_login_rate_limit_deny($retryAfter);
|
||||
}
|
||||
json_error('INVALID_CREDENTIALS', 'Invalid username or password', 401);
|
||||
}
|
||||
|
||||
qdb_login_rate_limit_clear($username);
|
||||
|
||||
$assignedClients = [];
|
||||
if (($user['role'] ?? '') === 'coach' && ($user['entityID'] ?? '') !== '') {
|
||||
$cStmt = $pdo->prepare(
|
||||
@ -47,7 +62,7 @@ case 'auth/login':
|
||||
|
||||
if ((int)$user['mustChangePassword'] === 1) {
|
||||
$tempToken = bin2hex(random_bytes(32));
|
||||
token_add($tempToken, 10 * 60, [
|
||||
token_add($tempToken, qdb_session_ttl_seconds($securitySettings, true), [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
@ -62,7 +77,7 @@ case 'auth/login':
|
||||
}
|
||||
|
||||
$token = bin2hex(random_bytes(32));
|
||||
token_add($token, 30 * 24 * 60 * 60, [
|
||||
token_add($token, qdb_session_ttl_seconds($securitySettings, false), [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
@ -149,9 +164,11 @@ case 'auth/change-password':
|
||||
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
$securitySettings = qdb_settings_get();
|
||||
|
||||
// Issue a fresh session for this browser only
|
||||
$newToken = bin2hex(random_bytes(32));
|
||||
token_add($newToken, 30 * 24 * 60 * 60, [
|
||||
token_add($newToken, qdb_session_ttl_seconds($securitySettings, false), [
|
||||
'role' => $user['role'],
|
||||
'entityID' => $user['entityID'],
|
||||
'userID' => $user['userID'],
|
||||
|
||||
71
handlers/settings.php
Normal file
71
handlers/settings.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/../lib/settings.php';
|
||||
|
||||
$tokenRec = require_valid_token_web();
|
||||
require_role(['admin'], $tokenRec);
|
||||
|
||||
const QDB_REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
|
||||
|
||||
switch ($method) {
|
||||
|
||||
case 'GET':
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_read_or_fail();
|
||||
$settings = qdb_settings_get_on_pdo($pdo);
|
||||
$sessionCount = (int)$pdo->query('SELECT COUNT(*) FROM session')->fetchColumn();
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_success([
|
||||
'settings' => $settings,
|
||||
'activeSessions' => $sessionCount,
|
||||
'defaults' => qdb_settings_defaults(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Load settings', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PUT':
|
||||
case 'PATCH':
|
||||
$body = read_json_body();
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$current = qdb_settings_get_on_pdo($pdo);
|
||||
$merged = qdb_settings_validate_and_merge($body, $current);
|
||||
qdb_settings_save_on_pdo($pdo, $merged);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['settings' => $merged]);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
json_error('INVALID_FIELD', $e->getMessage(), 400);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Save settings', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'POST':
|
||||
$body = read_json_body();
|
||||
$action = trim((string)($body['action'] ?? ''));
|
||||
if ($action !== 'revokeAllSessions') {
|
||||
json_error('BAD_REQUEST', 'Unknown action', 400);
|
||||
}
|
||||
$confirm = trim((string)($body['confirmPhrase'] ?? ''));
|
||||
if ($confirm !== QDB_REVOKE_ALL_CONFIRM) {
|
||||
json_error(
|
||||
'CONFIRMATION_REQUIRED',
|
||||
'Type exactly "' . QDB_REVOKE_ALL_CONFIRM . '" to revoke every session',
|
||||
400
|
||||
);
|
||||
}
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
$removed = token_revoke_all_on_pdo($pdo);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
json_success(['revokedSessions' => $removed]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Revoke all sessions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
json_error('METHOD_NOT_ALLOWED', 'Method not allowed', 405);
|
||||
}
|
||||
@ -167,8 +167,67 @@ case 'PUT':
|
||||
case 'PATCH':
|
||||
$body = read_json_body();
|
||||
$targetUserID = trim($body['userID'] ?? '');
|
||||
$action = trim((string)($body['action'] ?? ''));
|
||||
$newPassword = (string)($body['password'] ?? $body['newPassword'] ?? '');
|
||||
|
||||
if ($action === 'revokeSessions') {
|
||||
if ($targetUserID === '') {
|
||||
json_error('MISSING_FIELDS', 'userID is required', 400);
|
||||
}
|
||||
if ($targetUserID === ($tokenRec['userID'] ?? '')) {
|
||||
json_error('FORBIDDEN', 'Use Admin settings to revoke your own other sessions, or sign out', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open_write_or_fail();
|
||||
|
||||
$row = $pdo->prepare('SELECT userID, username, role, entityID FROM users WHERE userID = :uid');
|
||||
$row->execute([':uid' => $targetUserID]);
|
||||
$target = $row->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$target) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('NOT_FOUND', 'User not found', 404);
|
||||
}
|
||||
|
||||
$targetRole = $target['role'] ?? '';
|
||||
if ($callerRole === 'admin') {
|
||||
if (!in_array($targetRole, ['admin', 'supervisor', 'coach'], true)) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Cannot revoke sessions for this user', 403);
|
||||
}
|
||||
} elseif ($callerRole === 'supervisor') {
|
||||
if ($targetRole !== 'coach') {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Supervisors may only revoke sessions for their coaches', 403);
|
||||
}
|
||||
$chk = $pdo->prepare(
|
||||
'SELECT coachID FROM coach WHERE coachID = :cid AND supervisorID = :svid'
|
||||
);
|
||||
$chk->execute([':cid' => $target['entityID'], ':svid' => $callerEntityID]);
|
||||
if (!$chk->fetch()) {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Coach is not under your supervision', 403);
|
||||
}
|
||||
} else {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
json_error('FORBIDDEN', 'Not allowed to revoke sessions', 403);
|
||||
}
|
||||
|
||||
$removed = token_revoke_all_for_user_on_pdo($pdo, $targetUserID);
|
||||
qdb_save($tmpDb, $lockFp);
|
||||
|
||||
json_success([
|
||||
'userID' => $targetUserID,
|
||||
'username' => $target['username'],
|
||||
'revokedSessions' => $removed,
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
qdb_handler_fail($e, 'Revoke user sessions', null, $tmpDb ?? null, $lockFp ?? null);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ($newPassword !== '') {
|
||||
if ($targetUserID === '') {
|
||||
json_error('MISSING_FIELDS', 'userID is required', 400);
|
||||
|
||||
176
lib/login_rate_limit.php
Normal file
176
lib/login_rate_limit.php
Normal file
@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
require_once __DIR__ . '/settings.php';
|
||||
|
||||
function qdb_login_rate_limit_path(): string
|
||||
{
|
||||
return dirname(QDB_PATH) . '/.login_rate_limit.json';
|
||||
}
|
||||
|
||||
function qdb_client_ip(): string
|
||||
{
|
||||
$raw = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '';
|
||||
if ($raw === '') {
|
||||
return '0.0.0.0';
|
||||
}
|
||||
if (str_contains($raw, ',')) {
|
||||
$raw = trim(explode(',', $raw)[0]);
|
||||
}
|
||||
return $raw;
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_key(string $username): string
|
||||
{
|
||||
$ip = qdb_client_ip();
|
||||
return hash('sha256', strtolower(trim($username)) . "\0" . $ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: bool, 1: int} allowed, retry_after_seconds
|
||||
*/
|
||||
function qdb_login_rate_limit_check(string $username, ?array $settings = null): array
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
$max = (int)$settings['login_max_attempts'];
|
||||
$window = (int)$settings['login_window_seconds'];
|
||||
$lockout = (int)$settings['login_lockout_seconds'];
|
||||
$now = time();
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return [true, 0];
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return [true, 0];
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$lockedUntil = (int)($entry['locked_until'] ?? 0);
|
||||
if ($lockedUntil > $now) {
|
||||
return [false, $lockedUntil - $now];
|
||||
}
|
||||
$attempts = array_values(array_filter(
|
||||
(array)($entry['attempts'] ?? []),
|
||||
static fn($t) => is_int($t) && $t > $now - $window
|
||||
));
|
||||
if (count($attempts) >= $max) {
|
||||
$entry['locked_until'] = $now + $lockout;
|
||||
$entry['attempts'] = $attempts;
|
||||
$data[$key] = $entry;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
return [false, $lockout];
|
||||
}
|
||||
return [true, 0];
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_record_failure(string $username, ?array $settings = null): int
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
$max = (int)$settings['login_max_attempts'];
|
||||
$window = (int)$settings['login_window_seconds'];
|
||||
$lockout = (int)$settings['login_lockout_seconds'];
|
||||
$now = time();
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return 0;
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
$entry = $data[$key] ?? ['attempts' => [], 'locked_until' => 0];
|
||||
$attempts = array_values(array_filter(
|
||||
(array)($entry['attempts'] ?? []),
|
||||
static fn($t) => is_int($t) && $t > $now - $window
|
||||
));
|
||||
$attempts[] = $now;
|
||||
$retry = 0;
|
||||
if (count($attempts) >= $max) {
|
||||
$entry['locked_until'] = $now + $lockout;
|
||||
$retry = $lockout;
|
||||
}
|
||||
$entry['attempts'] = $attempts;
|
||||
$data[$key] = $entry;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
return $retry;
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_clear(string $username): void
|
||||
{
|
||||
$key = qdb_login_rate_limit_key($username);
|
||||
$path = qdb_login_rate_limit_path();
|
||||
if (!is_file($path)) {
|
||||
return;
|
||||
}
|
||||
$fp = @fopen($path, 'c+');
|
||||
if ($fp === false) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
return;
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$data = is_string($raw) && $raw !== '' ? json_decode($raw, true) : [];
|
||||
if (!is_array($data) || !isset($data[$key])) {
|
||||
return;
|
||||
}
|
||||
unset($data[$key]);
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
fflush($fp);
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_login_rate_limit_deny(int $retryAfterSeconds): never
|
||||
{
|
||||
if ($retryAfterSeconds > 0) {
|
||||
header('Retry-After: ' . (string)$retryAfterSeconds);
|
||||
}
|
||||
json_error(
|
||||
'RATE_LIMITED',
|
||||
'Too many login attempts. Please wait before trying again.',
|
||||
429
|
||||
);
|
||||
}
|
||||
114
lib/settings.php
Normal file
114
lib/settings.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Persisted security settings (system_setting table in encrypted DB).
|
||||
*/
|
||||
|
||||
function qdb_settings_defaults(): array
|
||||
{
|
||||
return [
|
||||
'login_max_attempts' => 10,
|
||||
'login_window_seconds' => 900,
|
||||
'login_lockout_seconds' => 900,
|
||||
'session_ttl_seconds' => 30 * 24 * 60 * 60,
|
||||
'temp_session_ttl_seconds' => 10 * 60,
|
||||
];
|
||||
}
|
||||
|
||||
function qdb_settings_keys(): array
|
||||
{
|
||||
return array_keys(qdb_settings_defaults());
|
||||
}
|
||||
|
||||
function qdb_ensure_system_setting_table(PDO $pdo): void
|
||||
{
|
||||
if (!qdb_table_exists($pdo, 'system_setting')) {
|
||||
$pdo->exec("
|
||||
CREATE TABLE system_setting (
|
||||
settingKey TEXT NOT NULL PRIMARY KEY,
|
||||
settingValue TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
");
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_settings_get_on_pdo(PDO $pdo): array
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$out = qdb_settings_defaults();
|
||||
$rows = $pdo->query('SELECT settingKey, settingValue FROM system_setting')->fetchAll(PDO::FETCH_ASSOC);
|
||||
foreach ($rows as $row) {
|
||||
$key = (string)($row['settingKey'] ?? '');
|
||||
if ($key === '' || !array_key_exists($key, $out)) {
|
||||
continue;
|
||||
}
|
||||
$out[$key] = (int)$row['settingValue'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function qdb_settings_get(): array
|
||||
{
|
||||
require_once __DIR__ . '/../db_init.php';
|
||||
[$pdo, $tmpDb, $lockFp] = qdb_open(false);
|
||||
try {
|
||||
return qdb_settings_get_on_pdo($pdo);
|
||||
} finally {
|
||||
qdb_discard($tmpDb, $lockFp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $partial
|
||||
* @param array<string, int>|null $base existing settings; defaults used when null
|
||||
* @return array<string, int> saved settings
|
||||
*/
|
||||
function qdb_settings_validate_and_merge(array $partial, ?array $base = null): array
|
||||
{
|
||||
$merged = $base ?? qdb_settings_defaults();
|
||||
$limits = [
|
||||
'login_max_attempts' => [1, 100],
|
||||
'login_window_seconds' => [60, 86400],
|
||||
'login_lockout_seconds' => [60, 86400],
|
||||
'session_ttl_seconds' => [3600, 365 * 24 * 60 * 60],
|
||||
'temp_session_ttl_seconds' => [300, 24 * 60 * 60],
|
||||
];
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
if (!array_key_exists($key, $partial)) {
|
||||
continue;
|
||||
}
|
||||
$val = (int)$partial[$key];
|
||||
[$min, $max] = $limits[$key];
|
||||
if ($val < $min || $val > $max) {
|
||||
throw new InvalidArgumentException(
|
||||
"$key must be between $min and $max"
|
||||
);
|
||||
}
|
||||
$merged[$key] = $val;
|
||||
}
|
||||
return $merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $settings full merged settings to persist
|
||||
*/
|
||||
function qdb_settings_save_on_pdo(PDO $pdo, array $settings): void
|
||||
{
|
||||
qdb_ensure_system_setting_table($pdo);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO system_setting (settingKey, settingValue)
|
||||
VALUES (:k, :v)
|
||||
ON CONFLICT(settingKey) DO UPDATE SET settingValue = excluded.settingValue'
|
||||
);
|
||||
foreach (qdb_settings_keys() as $key) {
|
||||
$stmt->execute([':k' => $key, ':v' => (string)(int)($settings[$key] ?? 0)]);
|
||||
}
|
||||
}
|
||||
|
||||
function qdb_session_ttl_seconds(?array $settings = null, bool $temp = false): int
|
||||
{
|
||||
$settings ??= qdb_settings_get();
|
||||
return $temp
|
||||
? (int)$settings['temp_session_ttl_seconds']
|
||||
: (int)$settings['session_ttl_seconds'];
|
||||
}
|
||||
@ -328,6 +328,30 @@ code {
|
||||
.badge-activity-web_change { background: var(--badge-supervisor-bg); color: var(--badge-supervisor-fg); }
|
||||
.badge-activity-web_export { background: var(--badge-admin-bg); color: var(--badge-admin-fg); }
|
||||
.badge-activity-api_error { background: var(--badge-archived-bg); color: var(--badge-archived-fg); }
|
||||
.security-settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 14px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.security-settings-grid .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.revoke-confirm-input {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
}
|
||||
.user-actions-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.user-actions-cell .btn + .btn {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.activity-log-card .table-wrapper { max-height: 420px; overflow: auto; }
|
||||
.activity-log-card .filter-bar label {
|
||||
margin: 0;
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { apiGet, apiPost, apiDownloadFetch } from '../api.js';
|
||||
import { apiGet, apiPost, apiPut, apiDownloadFetch, redirectToLogin } from '../api.js';
|
||||
import { getRole, pageHeaderHTML, showToast } from '../app.js';
|
||||
import { navigate } from '../router.js';
|
||||
|
||||
const REVOKE_ALL_CONFIRM = 'REVOKE ALL SESSIONS';
|
||||
|
||||
const ACTIVITY_LABELS = {
|
||||
app_sync: 'App sync',
|
||||
app_change: 'App change',
|
||||
@ -20,6 +22,32 @@ export function devPage() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
app.innerHTML = `
|
||||
${pageHeaderHTML('Admin Settings')}
|
||||
<div class="card security-settings-card" style="max-width:720px;margin-bottom:16px">
|
||||
<h2 style="margin:0 0 8px;font-size:1.1rem">Security & sessions</h2>
|
||||
<p class="field-hint" style="margin:0 0 14px">
|
||||
Login rate limits apply per username and IP. Session length applies to new logins
|
||||
(website and mobile). Password-change tokens use the temporary session length.
|
||||
</p>
|
||||
<div id="securitySettingsForm" class="security-settings-form">
|
||||
<div class="spinner" style="margin:12px 0"></div>
|
||||
</div>
|
||||
<p id="securitySettingsMeta" class="field-hint" style="margin:12px 0 0"></p>
|
||||
<div class="security-revoke-all" style="margin-top:20px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<h3 style="margin:0 0 8px;font-size:1rem">Revoke all sessions</h3>
|
||||
<p class="field-hint callout-danger" style="margin:0 0 12px">
|
||||
<strong>Signs out everyone</strong> — all admins, supervisors, and coaches on every device.
|
||||
You will be logged out too and must sign in again. Use after a suspected compromise or policy change.
|
||||
</p>
|
||||
<label for="revokeAllConfirm" style="font-size:.85rem;font-weight:500;display:block;margin-bottom:6px">
|
||||
Type <code>${REVOKE_ALL_CONFIRM}</code> to confirm
|
||||
</label>
|
||||
<input type="text" id="revokeAllConfirm" class="revoke-confirm-input" autocomplete="off" spellcheck="false"
|
||||
placeholder="${REVOKE_ALL_CONFIRM}">
|
||||
<button type="button" class="btn btn-danger" id="revokeAllSessionsBtn" style="margin-top:10px" disabled>
|
||||
Revoke all sessions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" style="max-width:720px;margin-bottom:16px">
|
||||
<h2 style="margin:0 0 8px;font-size:1.1rem">Export all response data (ZIP)</h2>
|
||||
<p class="field-hint" style="margin:0 0 14px">
|
||||
@ -216,6 +244,7 @@ export function devPage() {
|
||||
|
||||
wireAdminZipExport();
|
||||
wireActivityLog();
|
||||
wireSecuritySettings();
|
||||
|
||||
document.getElementById('devImportBtn').addEventListener('click', async () => {
|
||||
if (!parsedFixture) return;
|
||||
@ -488,3 +517,122 @@ function wireAdminZipExport() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function wireSecuritySettings() {
|
||||
const formEl = document.getElementById('securitySettingsForm');
|
||||
const metaEl = document.getElementById('securitySettingsMeta');
|
||||
const revokeInput = document.getElementById('revokeAllConfirm');
|
||||
const revokeBtn = document.getElementById('revokeAllSessionsBtn');
|
||||
if (!formEl) return;
|
||||
|
||||
revokeInput?.addEventListener('input', () => {
|
||||
if (revokeBtn) {
|
||||
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
|
||||
}
|
||||
});
|
||||
|
||||
revokeBtn?.addEventListener('click', async () => {
|
||||
if (revokeInput.value.trim() !== REVOKE_ALL_CONFIRM) return;
|
||||
if (!confirm(
|
||||
'Revoke every active session?\n\n'
|
||||
+ 'All users (including you) will be signed out on website and mobile.'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
revokeBtn.disabled = true;
|
||||
try {
|
||||
const data = await apiPost('settings', {
|
||||
action: 'revokeAllSessions',
|
||||
confirmPhrase: REVOKE_ALL_CONFIRM,
|
||||
});
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
showToast(`Revoked ${data.revokedSessions ?? 0} session(s). Signing you out…`, 'success');
|
||||
revokeInput.value = '';
|
||||
setTimeout(() => redirectToLogin(), 800);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
revokeBtn.disabled = revokeInput.value.trim() !== REVOKE_ALL_CONFIRM;
|
||||
}
|
||||
});
|
||||
|
||||
loadSecuritySettings(formEl, metaEl);
|
||||
}
|
||||
|
||||
async function loadSecuritySettings(formEl, metaEl) {
|
||||
try {
|
||||
const data = await apiGet('settings');
|
||||
if (!data.success) throw new Error(data.error || 'Failed to load settings');
|
||||
const s = data.settings || {};
|
||||
const active = data.activeSessions ?? 0;
|
||||
if (metaEl) {
|
||||
metaEl.textContent = `${active} active session(s) in the database.`;
|
||||
}
|
||||
formEl.innerHTML = `
|
||||
<div class="security-settings-grid">
|
||||
<div class="form-group">
|
||||
<label for="setLoginMax">Max failed logins</label>
|
||||
<input type="number" id="setLoginMax" min="1" max="100" step="1"
|
||||
value="${escAttr(s.login_max_attempts)}">
|
||||
<span class="field-hint">Before lockout (per username + IP)</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setLoginWindow">Login attempt window (minutes)</label>
|
||||
<input type="number" id="setLoginWindow" min="1" max="1440" step="1"
|
||||
value="${escAttr(Math.round((s.login_window_seconds || 900) / 60))}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setLoginLockout">Lockout duration (minutes)</label>
|
||||
<input type="number" id="setLoginLockout" min="1" max="1440" step="1"
|
||||
value="${escAttr(Math.round((s.login_lockout_seconds || 900) / 60))}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setSessionDays">Session length (days)</label>
|
||||
<input type="number" id="setSessionDays" min="1" max="365" step="1"
|
||||
value="${escAttr(Math.max(1, Math.round((s.session_ttl_seconds || 2592000) / 86400)))}">
|
||||
<span class="field-hint">Website & app after normal login</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="setTempSession">Temporary session (minutes)</label>
|
||||
<input type="number" id="setTempSession" min="5" max="1440" step="1"
|
||||
value="${escAttr(Math.round((s.temp_session_ttl_seconds || 600) / 60))}">
|
||||
<span class="field-hint">Forced password change flow</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary" id="saveSecuritySettingsBtn">Save security settings</button>
|
||||
`;
|
||||
document.getElementById('saveSecuritySettingsBtn')?.addEventListener('click', () => {
|
||||
saveSecuritySettings(formEl, metaEl);
|
||||
});
|
||||
} catch (err) {
|
||||
formEl.innerHTML = `<p class="error-text">${escHtml(err.message)}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSecuritySettings(formEl, metaEl) {
|
||||
const btn = document.getElementById('saveSecuritySettingsBtn');
|
||||
const payload = {
|
||||
login_max_attempts: parseInt(document.getElementById('setLoginMax')?.value || '10', 10),
|
||||
login_window_seconds: parseInt(document.getElementById('setLoginWindow')?.value || '15', 10) * 60,
|
||||
login_lockout_seconds: parseInt(document.getElementById('setLoginLockout')?.value || '15', 10) * 60,
|
||||
session_ttl_seconds: parseInt(document.getElementById('setSessionDays')?.value || '30', 10) * 86400,
|
||||
temp_session_ttl_seconds: parseInt(document.getElementById('setTempSession')?.value || '10', 10) * 60,
|
||||
};
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const data = await apiPut('settings', payload);
|
||||
if (!data.success) throw new Error(data.error || 'Save failed');
|
||||
showToast('Security settings saved', 'success');
|
||||
await loadSecuritySettings(formEl, metaEl);
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function escAttr(v) {
|
||||
return String(v ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<');
|
||||
}
|
||||
|
||||
@ -83,7 +83,15 @@ export async function loginPage() {
|
||||
});
|
||||
const json = await res.json();
|
||||
if (!json.ok) {
|
||||
errEl.textContent = json.error?.message || 'Login failed';
|
||||
const code = json.error?.code || '';
|
||||
let msg = json.error?.message || 'Login failed';
|
||||
if (res.status === 429 || code === 'RATE_LIMITED') {
|
||||
const retry = res.headers.get('Retry-After');
|
||||
if (retry) {
|
||||
msg += ` Try again in about ${retry} seconds.`;
|
||||
}
|
||||
}
|
||||
errEl.textContent = msg;
|
||||
errEl.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -230,6 +230,9 @@ function updateRoleGroupTable(roleKey, myUsername) {
|
||||
role: btn.dataset.role || '',
|
||||
}));
|
||||
});
|
||||
tbody.querySelectorAll('.revoke-sessions-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => revokeUserSessions(btn.dataset.id, btn.dataset.name));
|
||||
});
|
||||
tbody.querySelectorAll('.coach-supervisor-select').forEach(sel => {
|
||||
sel.addEventListener('change', () => reassignCoachSupervisor(sel));
|
||||
});
|
||||
@ -276,6 +279,10 @@ function userRowHTML(u, myUsername) {
|
||||
(callerRole === 'admin' && (u.role === 'supervisor' || u.role === 'coach')) ||
|
||||
(callerRole === 'supervisor' && u.role === 'coach')
|
||||
);
|
||||
const canRevokeSessions = !isSelf && (
|
||||
callerRole === 'admin' ||
|
||||
(callerRole === 'supervisor' && u.role === 'coach')
|
||||
);
|
||||
|
||||
const actions = [];
|
||||
if (canResetPassword) {
|
||||
@ -283,6 +290,11 @@ function userRowHTML(u, myUsername) {
|
||||
`<button type="button" class="btn btn-sm reset-password-btn" data-id="${u.userID}" data-name="${esc(u.username)}" data-role="${esc(u.role)}">Reset password</button>`
|
||||
);
|
||||
}
|
||||
if (canRevokeSessions) {
|
||||
actions.push(
|
||||
`<button type="button" class="btn btn-sm revoke-sessions-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Sign out everywhere</button>`
|
||||
);
|
||||
}
|
||||
if (canDelete) {
|
||||
actions.push(
|
||||
`<button type="button" class="btn btn-sm btn-danger delete-user-btn" data-id="${u.userID}" data-name="${esc(u.username)}">Delete</button>`
|
||||
@ -330,6 +342,23 @@ async function reassignCoachSupervisor(selectEl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeUserSessions(userID, username) {
|
||||
if (!confirm(
|
||||
`Sign out "${username}" on all devices?\n\n`
|
||||
+ 'They must log in again on the website and mobile app.'
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = await apiPut('users.php', { action: 'revokeSessions', userID });
|
||||
if (!data.success) throw new Error(data.error || 'Failed');
|
||||
const n = data.revokedSessions ?? 0;
|
||||
showToast(`Signed out "${username}" (${n} session${n === 1 ? '' : 's'} revoked)`, 'success');
|
||||
} catch (e) {
|
||||
showToast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(userID, username) {
|
||||
if (!confirm(`Delete user "${username}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user