Files
zoneminder/web/js/auth-helpers.js
T
Isaac ConnorandClaude Opus 5 da99928235 fix: revalidate on every resume rather than trusting a time window
The staleness window was unsound. calculateAuthHash() keys the hash to the
clock hour it was minted in and getAuthUser() accepts the last
ZM_AUTH_HASH_TTL hourly buckets, so a hash dies at the top of an hour rather
than at some age. generateAuthHash() then serves the cached one until it is
half a TTL old, so what arrives can already be nearly spent: on the defaults a
hash minted at 10:59 is still handed out at 11:58 and is refused at 12:00. A
client stamping that arrival as fresh for an hour skips the probe until 12:58
and restarts its streams on a dead hash - the exact failure this was written to
prevent. No fixed window is safe, because the remaining life of a hash we hold
can be anything down to zero, and AUTH_STALE_MS also ignored the configured
ZM_AUTH_HASH_TTL.

So drop AUTH_STALE_MS, authIsStale() and authFreshAt, and have whenAuthFresh()
revalidate. The one case that can still skip the probe is having no hash at all
- authentication off, or a relay form that does not use one - where there is
nothing that can expire and nothing a probe would report. revalidateAuth()
already shares one request between concurrent callers, so a resume that wakes
several of these still costs a single probe, and that is what the montage code
did unconditionally before any of this.

refreshTablesPendingVisibility() now returns as soon as it finds nothing was
deferred. It is bound on every classic page including the unauthenticated ones,
and the version before this ran the whole auth path on an empty queue, so
merely becoming visible could fire a probe with no work behind it.

Tests: two authIsStale cases removed with the function, two whenAuthFresh cases
added - a probe is sent and the callback held until it answers, and no probe is
sent when there is no hash. Reintroducing a fast path fails the first. Full JS
suite green, ESLint clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkQwahn9pi1y4wJe9BTxjM
2026-09-13 10:37:02 -04:00

251 lines
11 KiB
JavaScript

'use strict';
// Authentication helpers shared by skin.js, console.js and MonitorStream.js.
// Loaded as a plain browser script before skin.js.php and skin.js
// (web/skins/classic/includes/functions.php), so everything below is a global by
// the time any view script runs. The pure helpers and the ZMAuth type are also
// CommonJS-exported for node unit tests (tests/js/auth-helpers.test.js).
//
// The DOM-driven functions reference page globals defined elsewhere (thisUrl,
// currentView, zmAuth, $j, setNavBar, reloadWindow). Those only need to exist
// when the functions are *called* (on a user/visibility event), by which point
// skin.js and the view script have loaded, so the forward references are safe.
// They are never invoked at module load, so requiring this file under node
// (which lacks those globals) stays harmless.
// Decide what to do when an auth-bearing request fails.
// 'login' - the session/hash is dead, must re-authenticate
// 'retry' - transient (network/timeout/server) error, worth one silent retry
// 'ignore' - not auth related, leave it to the caller
function authFailureAction(httpStatus) {
if (httpStatus === 401 || httpStatus === 403) return 'login';
if (httpStatus === 0 || httpStatus === 408 || httpStatus >= 500) return 'retry';
return 'ignore';
}
// Build the login URL, preserving the current view so the user lands back where
// they were after re-authenticating.
function loginRedirectUrl(baseUrl, view) {
return baseUrl + '?view=login&postLoginQuery=' + encodeURIComponent('view=' + (view || 'console'));
}
// Join a query fragment onto a url with the right separator. A blank fragment
// leaves the url alone, so callers no longer need the `x ? '&'+x : ''` dance
// that produced dangling '?' when authentication is off.
function appendQuery(url, query) {
const out = url || '';
if (!query) return out;
return out + (out.indexOf('?') === -1 ? '?' : '&') + query;
}
// Replace a query parameter in place, or append it when absent. Works on whole
// urls and on bare query fragments such as auth_relay, hence the start anchor
// alongside [?&]; that anchor is also what keeps `auth=` from matching a
// parameter which merely ends in it.
function setUrlParam(url, name, value) {
const out = url || '';
const present = new RegExp('(^|[?&])' + name + '=[^&]*', 'i');
if (present.test(out)) return out.replace(present, '$1' + name + '=' + value);
return appendQuery(out, name + '=' + value);
}
// Pull the auth hash out of an auth_relay fragment. get_auth_relay() emits
// 'auth=<hash>&user=<name>' under ZM_AUTH_RELAY hashed, but
// 'username=...&password=...' under plain and 'username=...' under none,
// neither of which carries a hash. Returns '' when there is none.
function authHashFromRelay(relay) {
if (!relay) return '';
const match = String(relay).match(/(?:^|[?&])auth=(\w+)/i);
return match ? match[1] : '';
}
// Rewrite a stream <img> URL with a fresh auth hash and (optionally) a fresh
// connkey, used when reconnecting a broken zms stream. The old auth hash may
// have expired past AUTH_HASH_TTL and the zms process behind the old connkey has
// exited, so a clean reconnect needs both swapped. Pure (no DOM); prefer
// zmAuth.applyTo(), which feeds this the current hash and also handles the
// plain/none relay forms.
function rebuildStreamSrc(src, authHash, connKey) {
let out = src || '';
if (authHash) out = setUrlParam(out, 'auth', authHash);
if (connKey !== undefined && connKey !== null && connKey !== '') {
out = setUrlParam(out, 'connkey', connKey);
}
return out;
}
// The page's authentication state, and the single place it is stored.
//
// There used to be two globals: auth_relay (the query fragment every AJAX call
// is authenticated with) and auth_hash (the bare hash stamped into stream <img>
// URLs). Only auth_relay was refreshed unconditionally by the server -
// ajax/stream.php omitted `auth` from its reply whenever it matched the hash the
// request carried, and since that hash came from auth_relay, a drifted auth_hash
// could never be corrected. Reconnecting streams stamped the drifted global into
// their src and zms 403'd every request for hours afterwards.
//
// So the hash is not stored at all now; it is derived from the relay on demand.
// The two cannot disagree because there is only one value.
class ZMAuth {
constructor(relay) {
this.relay = relay || '';
}
// The bare auth hash, or '' under the plain/none relay forms.
get hash() {
return authHashFromRelay(this.relay);
}
// Absorb the authentication fields of any AJAX response. Returns true when
// the credential actually changed, so callers can repaint streams only when
// there is something to repaint. Responses carry auth_relay whenever
// authentication is on; the `auth`-only branch covers partial replies.
update(data) {
if (!data) return false;
if (data.auth_relay) {
if (data.auth_relay === this.relay) return false;
this.relay = data.auth_relay;
return true;
}
if (data.auth && data.auth !== this.hash) {
this.relay = setUrlParam(this.relay, 'auth', data.auth);
return true;
}
return false;
}
// Authenticate a URL. Safe to call when authentication is off (no-op).
appendTo(url) {
return appendQuery(url, this.relay);
}
// Point a stream URL at the current credential, optionally with a fresh
// connkey. A src that already carries auth= has just that parameter swapped
// so the rest of its stream options survive; one that carries none gets the
// whole relay, which is what brings user= (or username=/password=) along.
applyTo(src, connKey) {
// No src means there is no stream to point anywhere. Returning '' keeps
// callers that treat a blank src as "nothing to load" working; building
// '?auth=...' here would resolve against the current page and load the
// surrounding HTML as an image.
if (!src) return '';
let out = src;
if (this.relay) {
if (/(?:^|[?&])auth=\w*/i.test(out)) {
if (this.hash) out = setUrlParam(out, 'auth', this.hash);
} else {
out = this.appendTo(out);
}
}
return rebuildStreamSrc(out, '', connKey);
}
}
// Navigate to the login page. Guarded so repeated auth failures (e.g. every
// stream on a console) only trigger one navigation.
let authGoingToLogin = false;
function goToLogin() {
if (authGoingToLogin) return;
authGoingToLogin = true;
window.location.assign(loginRedirectUrl(thisUrl, currentView));
}
// How long a hash we are holding has left cannot be worked out on this side.
// calculateAuthHash() keys it to the clock hour it was minted in, and
// getAuthUser() accepts the last ZM_AUTH_HASH_TTL hourly buckets, so a hash is
// dead at the top of the hour ZM_AUTH_HASH_TTL after the one it was made in -
// a wall-clock deadline, not an age. generateAuthHash() then serves the cached
// one until it is half a TTL old, so what arrives can already be nearly spent:
// on the defaults, one minted at 10:59 is still handed out at 11:58 and is
// refused at 12:00. Any "trust it for N minutes" window is therefore wrong for
// some hash, which is why there isn't one here - see the note on whenAuthFresh.
// The probe carries no credential, deliberately. zm_authenticate_request()
// resolves the request against exactly one source: an auth= in the URL takes the
// ZM_AUTH_HASH_LOGINS branch (auth.php), and when getAuthUser() rejects it the
// chain has already been entered, so userFromSession() below it never runs and a
// live session cookie authenticates as nobody. Sending the very hash we suspect
// is dead is what would make the probe fail. Without it the session cookie is
// what answers, which is the question being asked: who am I, and what is my
// current hash?
function authProbeUrl(baseUrl) {
return baseUrl + '?view=request&request=status&entity=navBar';
}
// Perform a single silent auth probe against the lightweight navBar status
// endpoint. zmAuth is refreshed (via setNavBar) and the queued callbacks are
// then invoked so each view can repaint its streams with the fresh credential.
// Since the probe rides the session cookie, a rejection (401 or 403, both of
// which authFailureAction calls 'login') means the session itself is gone: go
// straight to login and drop the callbacks. Other failures still run them, since
// a transient blip is no reason to leave the page's streams stopped. Concurrent
// callers share the one request.
let authRevalidating = false;
const authPendingCallbacks = [];
function revalidateAuth(onValid) {
if (typeof onValid === 'function') authPendingCallbacks.push(onValid);
if (authRevalidating) return;
authRevalidating = true;
$j.getJSON(authProbeUrl(thisUrl))
.done(function(data) {
setNavBar(data);
})
.fail(function(jqxhr) {
if (authFailureAction(jqxhr.status) == 'login') goToLogin();
})
.always(function() {
authRevalidating = false;
const callbacks = authPendingCallbacks.splice(0, authPendingCallbacks.length);
if (authGoingToLogin) return;
for (let i = 0; i < callbacks.length; i++) callbacks[i]();
});
}
// Run cb against a credential the server has just confirmed. Use this anywhere
// a resume path - visibility, bfcache, idle timeout - kicks off authenticated
// requests after a gap, so nothing restarts a stream or a table poll on a hash
// that expired while the page was not listening.
//
// The one case that can skip the probe is having no hash at all: authentication
// off, or a relay form that does not use one. There is then nothing that can
// expire and nothing a probe would tell us. Every other case revalidates,
// because the remaining life of a hash we hold is not knowable here (above).
// Concurrent callers share the one request, so a resume that wakes several of
// these still costs a single probe.
function whenAuthFresh(cb) {
if (!zmAuth || !zmAuth.hash) {
cb();
return;
}
revalidateAuth(cb);
}
// When the tab becomes visible again after being hidden/slept, the baked-in auth
// hash on stream <img> elements may have expired. Re-validate auth FIRST so we
// either repaint with a fresh hash or redirect to login, instead of letting
// every stream fire a stale request that 403s. Wired up by skin.js for the
// authenticated views only.
function onAuthVisible() {
if (document.visibilityState !== 'visible') return;
revalidateAuth(function() {
// console repaints its thumbnails via the bootstrap-table reload; other
// views re-point their streams off the refreshed credential.
if (typeof reloadWindow === 'function') reloadWindow();
});
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
authFailureAction,
loginRedirectUrl,
appendQuery,
setUrlParam,
authHashFromRelay,
rebuildStreamSrc,
authProbeUrl,
revalidateAuth,
whenAuthFresh,
ZMAuth,
};
}