|
| 1 | +<?php |
| 2 | +declare(strict_types=1); |
| 3 | + |
| 4 | +/** |
| 5 | + * Why the sign-in page says "Session expired". |
| 6 | + * |
| 7 | + * That message means the CSRF check failed, and the CSRF check fails when the |
| 8 | + * token stored in the session on one request is not there on the next. Which |
| 9 | + * is almost never about tokens: it is the session itself not surviving between |
| 10 | + * the page loading and the form being submitted. From the outside those look |
| 11 | + * identical, and there are four quite different reasons for it. |
| 12 | + * |
| 13 | + * So this does exactly what signing in does -- puts a token in the session, |
| 14 | + * renders it in a form, and checks it when the form comes back -- and reports |
| 15 | + * what actually happened, with the settings that decide it. |
| 16 | + * |
| 17 | + * It changes nothing and reads no credentials. It does print server paths and |
| 18 | + * cookie settings, which are not secrets but are nobody's business either, so |
| 19 | + * delete it once the panel works. It is the same bargain as install.php. |
| 20 | + */ |
| 21 | + |
| 22 | +require_once __DIR__ . '/src/csrf.php'; |
| 23 | + |
| 24 | +header('X-Robots-Tag: noindex, nofollow'); |
| 25 | +header('X-Frame-Options: DENY'); |
| 26 | +header('Content-Type: text/html; charset=utf-8'); |
| 27 | + |
| 28 | +$cookieName = 'nitesha_admin'; |
| 29 | +$sentCookie = isset($_COOKIE[$cookieName]); |
| 30 | + |
| 31 | +session_start_secure(); |
| 32 | + |
| 33 | +$posted = $_SERVER['REQUEST_METHOD'] === 'POST'; |
| 34 | +$expected = $_SESSION['check_token'] ?? ''; |
| 35 | +$given = (string) ($_POST['check_token'] ?? ''); |
| 36 | + |
| 37 | +// The one combination that silently eats every session: a cookie the browser |
| 38 | +// is told to keep only for HTTPS, handed out over HTTP. Worked out before the |
| 39 | +// verdict, because it outranks it -- browsers exempt localhost from the Secure |
| 40 | +// rule, so a test on a local copy can round-trip happily while the same |
| 41 | +// settings lose every session on a real domain. A page that reports success |
| 42 | +// and names a fault in the same breath is worse than one that reports neither. |
| 43 | +$cookieTrap = false; |
| 44 | + |
| 45 | +$verdict = null; |
| 46 | +if ($posted) { |
| 47 | + if ($expected === '') { |
| 48 | + $verdict = ['bad', 'The session was empty when the form came back.', |
| 49 | + 'The token was stored, the browser was told to keep a cookie, and on this ' |
| 50 | + . 'request there was no session to read it from. That is the same failure as ' |
| 51 | + . '"Session expired" on the sign-in page, so whatever is below is the cause.']; |
| 52 | + } elseif (hash_equals($expected, $given)) { |
| 53 | + $verdict = ['ok', 'The session survived the round trip.', |
| 54 | + 'Sessions are working, so "Session expired" on the sign-in page is not what ' |
| 55 | + . 'it says. Try signing in again -- if it still fails, the password is being ' |
| 56 | + . 'rejected rather than the session, and the panel would say so.']; |
| 57 | + } else { |
| 58 | + $verdict = ['bad', 'A session came back, but holding a different token.', |
| 59 | + 'Two sessions are in play -- usually the page and the form being on different ' |
| 60 | + . 'addresses, such as one with www and one without.']; |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +// A fresh token every time, so a reload is always a real test rather than a |
| 65 | +// replay of the last one. |
| 66 | +$_SESSION['check_token'] = bin2hex(random_bytes(16)); |
| 67 | + |
| 68 | +$params = session_get_cookie_params(); |
| 69 | +$savePath = session_save_path() ?: sys_get_temp_dir(); |
| 70 | + |
| 71 | +/** Did the visitor arrive over HTTPS, as opposed to what PHP was handed? */ |
| 72 | +$phpHttps = (($_SERVER['HTTPS'] ?? '') !== '') && $_SERVER['HTTPS'] !== 'off'; |
| 73 | +$forwarded = strtolower((string) ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')); |
| 74 | +$browserHttps = $phpHttps || $forwarded === 'https'; |
| 75 | + |
| 76 | +$cookieTrap = ((bool) $params['secure']) && !$browserHttps; |
| 77 | + |
| 78 | +if ($cookieTrap) { |
| 79 | + // Overrides whatever the round trip appeared to show. On a real domain |
| 80 | + // this configuration loses every session; if the test just passed, it |
| 81 | + // passed because of a local exemption and not because this works. |
| 82 | + $verdict = ['bad', 'The session cookie cannot come back over this connection.', |
| 83 | + 'The cookie is marked Secure and this page is not on HTTPS, so the browser ' |
| 84 | + . 'takes it and then refuses to send it again. That is what "Session expired" ' |
| 85 | + . 'is reporting.']; |
| 86 | +} |
| 87 | + |
| 88 | +$rows = [ |
| 89 | + ['The browser sent a session cookie', $sentCookie, |
| 90 | + $sentCookie ? 'Yes' : 'No — either this is the first visit, or the browser is refusing to keep it'], |
| 91 | + ['Session storage is writable', is_writable($savePath), |
| 92 | + $savePath . (is_writable($savePath) ? '' : ' — PHP cannot save sessions here, so nothing persists')], |
| 93 | + ['Cookie marked Secure', (bool) $params['secure'], |
| 94 | + $params['secure'] |
| 95 | + ? 'Yes — the browser will only send it over HTTPS' |
| 96 | + : 'No — it will be sent over plain HTTP too'], |
| 97 | + ['Visitor is on HTTPS', $browserHttps, |
| 98 | + $browserHttps ? 'Yes' : 'No'], |
| 99 | +]; |
| 100 | + |
| 101 | +$e = static fn (?string $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); |
| 102 | +?> |
| 103 | +<!doctype html> |
| 104 | +<html lang="en"> |
| 105 | +<head> |
| 106 | +<meta charset="utf-8"> |
| 107 | +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| 108 | +<title>Session check</title> |
| 109 | +<style> |
| 110 | + body{margin:0;padding:28px 16px;background:#FAF7F2;color:#1A2233; |
| 111 | + font:16px/1.55 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} |
| 112 | + main{max-width:680px;margin:0 auto} |
| 113 | + h1{font-size:1.35rem;margin:0 0 4px} |
| 114 | + .sub{color:#5C5348;margin:0 0 20px} |
| 115 | + .card{background:#fff;border:1px solid #E7E0D6;border-radius:14px;padding:18px;margin-bottom:14px} |
| 116 | + .verdict{font-weight:700;margin:0 0 6px} |
| 117 | + .ok{color:#2F6B4F}.bad{color:#B3261E} |
| 118 | + table{border-collapse:collapse;width:100%;margin-top:6px;font-size:.92rem} |
| 119 | + td{padding:7px 4px;border-bottom:1px solid #E7E0D6;vertical-align:top} |
| 120 | + td:first-child{width:46%} |
| 121 | + .mark{font-weight:800;width:22px} |
| 122 | + .note{color:#5C5348;font-size:.88rem} |
| 123 | + code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.82rem; |
| 124 | + background:#FAF7F2;padding:2px 5px;border-radius:5px;word-break:break-all} |
| 125 | + button{background:#E8A317;color:#1A2233;border:0;font:inherit;font-weight:700; |
| 126 | + padding:11px 20px;border-radius:10px;cursor:pointer} |
| 127 | + .trap{background:#FFF4E0;border:1px solid #E8A317;border-radius:10px;padding:12px;margin-top:12px} |
| 128 | +</style> |
| 129 | +</head> |
| 130 | +<body> |
| 131 | +<main> |
| 132 | + <h1>Session check</h1> |
| 133 | + <p class="sub">Does a session survive from one request to the next? That is all "Session expired" is about.</p> |
| 134 | + |
| 135 | +<?php if ($verdict !== null): ?> |
| 136 | + <div class="card"> |
| 137 | + <p class="verdict <?= $verdict[0] === 'ok' ? 'ok' : 'bad' ?>"><?= $e($verdict[1]) ?></p> |
| 138 | + <p class="note"><?= $e($verdict[2]) ?></p> |
| 139 | + </div> |
| 140 | +<?php endif; ?> |
| 141 | + |
| 142 | + <div class="card"> |
| 143 | + <form method="post"> |
| 144 | + <input type="hidden" name="check_token" value="<?= $e($_SESSION['check_token']) ?>"> |
| 145 | + <p style="margin:0 0 12px">Press this. It stores a token in the session and checks it comes back — exactly what signing in does.</p> |
| 146 | + <button type="submit">Run the test</button> |
| 147 | + </form> |
| 148 | + </div> |
| 149 | + |
| 150 | + <div class="card"> |
| 151 | + <table> |
| 152 | +<?php foreach ($rows as [$label, $ok, $detail]): ?> |
| 153 | + <tr> |
| 154 | + <td class="mark <?= $ok ? 'ok' : 'bad' ?>"><?= $ok ? '✓' : '×' ?></td> |
| 155 | + <td><?= $e($label) ?></td> |
| 156 | + <td class="note"><?= $e($detail) ?></td> |
| 157 | + </tr> |
| 158 | +<?php endforeach; ?> |
| 159 | + </table> |
| 160 | + |
| 161 | +<?php if ($cookieTrap): ?> |
| 162 | + <div class="trap"> |
| 163 | + <strong>This is the fault.</strong> The session cookie is marked Secure, but this page |
| 164 | + was not served over HTTPS — so the browser accepts the cookie and then refuses to send |
| 165 | + it back, and every session is lost the instant it is created. Reach the panel over |
| 166 | + <code>https://</code>, or set <code>'https_only' => false</code> in config.php. |
| 167 | + </div> |
| 168 | +<?php endif; ?> |
| 169 | + </div> |
| 170 | + |
| 171 | + <div class="card note"> |
| 172 | + <p style="margin:0 0 8px"><strong>What the server says:</strong></p> |
| 173 | + <p style="margin:0 0 4px">Session name: <code><?= $e(session_name()) ?></code>, |
| 174 | + path <code><?= $e($params['path']) ?></code>, |
| 175 | + SameSite <code><?= $e($params['samesite'] ?: 'not set') ?></code></p> |
| 176 | + <p style="margin:0 0 4px"><code>$_SERVER['HTTPS']</code>: <code><?= $e(($_SERVER['HTTPS'] ?? '') === '' ? 'not set' : $_SERVER['HTTPS']) ?></code></p> |
| 177 | + <p style="margin:0 0 4px"><code>X-Forwarded-Proto</code>: <code><?= $e($forwarded === '' ? 'not set' : $forwarded) ?></code></p> |
| 178 | + <p style="margin:0 0 4px"><code>https_only</code> in config.php: <code><?= config('https_only') ? 'true' : 'false' ?></code></p> |
| 179 | + <p style="margin:0">Address in use: <code><?= $e(($_SERVER['HTTP_HOST'] ?? '') . ($_SERVER['REQUEST_URI'] ?? '')) ?></code></p> |
| 180 | + </div> |
| 181 | + |
| 182 | + <p class="note">Delete this file once the panel works.</p> |
| 183 | +</main> |
| 184 | +</body> |
| 185 | +</html> |
0 commit comments