Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
97 changes: 97 additions & 0 deletions tests/js/auth-helpers.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,5 +242,102 @@ test('applyTo only sets the connkey when authentication is off', () => {
'cgi-bin/nph-zms?monitor=26&connkey=99&mode=jpeg');
});

test('authIsStale trusts a credential the server just confirmed', () => {
const now = Date.now();
assert.strictEqual(ZM.authIsStale(now, now), false);
assert.strictEqual(ZM.authIsStale(now - 30 * 1000, now), false);
assert.strictEqual(ZM.authIsStale(now - ZM.AUTH_STALE_MS, now), false);
});
test('authIsStale flags a credential unheard of for longer than the rotation', () => {
const now = Date.now();
assert.strictEqual(ZM.authIsStale(now - ZM.AUTH_STALE_MS - 1, now), true);
assert.strictEqual(ZM.authIsStale(now - 8 * 60 * 60 * 1000, now), true);
});

// revalidateAuth() reaches for these as bare globals, the way the browser
// supplies them, so a fake jqXHR here is enough to drive it from node.
function fakeXhr() {
const cbs = {done: [], fail: [], always: []};
const xhr = {
done(fn) {
cbs.done.push(fn); return xhr;
},
fail(fn) {
cbs.fail.push(fn); return xhr;
},
always(fn) {
cbs.always.push(fn); return xhr;
},
resolve(data) {
cbs.done.forEach((f) => f(data)); cbs.always.forEach((f) => f());
},
reject(status) {
cbs.fail.forEach((f) => f({status: status})); cbs.always.forEach((f) => f());
},
};
return xhr;
}

let probeUrls = [];
let pendingXhr = null;
global.thisUrl = '/zm/index.php';
global.setNavBar = function() {};
// Present, and holding the very hash the probe must not send.
global.zmAuth = new ZM.ZMAuth('auth=deadbeef');
global.$j = {
getJSON: function(url) {
probeUrls.push(url);
pendingXhr = fakeXhr();
return pendingXhr;
},
};

console.log('revalidateAuth');
test('the probe carries no credential', () => {
// A stale hash in the URL takes the ZM_AUTH_HASH_LOGINS branch of
// zm_authenticate_request(), which never falls through to userFromSession(),
// so the session cookie would authenticate as nobody and the probe meant to
// renew the credential would be the one request guaranteed to fail.
assert.strictEqual(
ZM.authProbeUrl('/zm/index.php'),
'/zm/index.php?view=request&request=status&entity=navBar');
probeUrls = [];
ZM.revalidateAuth(function() {});
assert.strictEqual(probeUrls.length, 1);
assert.ok(probeUrls[0].indexOf('auth=') === -1, probeUrls[0]);
assert.ok(probeUrls[0].indexOf('password=') === -1, probeUrls[0]);
pendingXhr.resolve({});
});
test('concurrent callers share one request and all run', () => {
probeUrls = [];
let ran = 0;
ZM.revalidateAuth(() => ran++);
ZM.revalidateAuth(() => ran++);
assert.strictEqual(probeUrls.length, 1);
assert.strictEqual(ran, 0);
pendingXhr.resolve({});
assert.strictEqual(ran, 2);
});
test('a transient failure still runs the callbacks', () => {
// A network blip is no reason to leave the page's streams stopped.
let ran = 0;
ZM.revalidateAuth(() => ran++);
pendingXhr.reject(0);
assert.strictEqual(ran, 1);
});
// Last: goToLogin() latches for the life of the module.
test('a rejected session goes to login and drops the callbacks', () => {
let ran = 0;
let assigned = '';
global.window = {location: {assign: (u) => {
assigned = u;
}}};
global.currentView = 'watch';
ZM.revalidateAuth(() => ran++);
pendingXhr.reject(403);
assert.strictEqual(ran, 0);
assert.ok(assigned.indexOf('view=login') !== -1, assigned);
});

console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);
86 changes: 77 additions & 9 deletions web/js/auth-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,13 +103,19 @@ class ZMAuth {
update(data) {
if (!data) return false;
if (data.auth_relay) {
// Stamp even when it matches: the server just handed us this relay, so
// the credential is confirmed good whether or not it changed.
authFreshAt = Date.now();
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;
if (data.auth) {
authFreshAt = Date.now();
if (data.auth !== this.hash) {
this.relay = setUrlParam(this.relay, 'auth', data.auth);
return true;
}
}
return false;
}
Expand Down Expand Up @@ -150,28 +156,86 @@ function goToLogin() {
window.location.assign(loginRedirectUrl(thisUrl, currentView));
}

// How long a credential we have not heard about is trusted for. The server
// rotates the hash at half of AUTH_HASH_TTL (generateAuthHash()), so anything
// last confirmed longer ago than that is likely dead, and every request made off
// it 403s and fills the log with auth errors.
//
// Whatever stops the page from hearing about the credential is what makes it go
// stale, and there is more than one: a hidden tab has its timers throttled and
// a slept/frozen one has them stopped outright, while an idle montage that hit
// ZM_WEB_VIEWING_TIMEOUT stops its monitors - and their status polls - without
// ever going hidden. So track the age of the credential itself rather than the
// age of any one of those states.
const AUTH_STALE_MS = 60 * 60 * 1000;
// The relay was rendered into the page by PHP, so it is fresh as of load.
let authFreshAt = Date.now();

// Pure so it can be tested without faking the clock or the DOM.
function authIsStale(freshAt, now) {
return (now - freshAt) > AUTH_STALE_MS;
}

// 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. On success zmAuth is refreshed (via setNavBar) and onValid() is
// invoked so the view can repaint its streams with the fresh credential. A dead
// session (401) goes straight to login; transient errors are swallowed so we
// don't bounce the user on a blip.
// 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(zmAuth.appendTo(thisUrl + '?view=request&request=status&entity=navBar'))
$j.getJSON(authProbeUrl(thisUrl))
.done(function(data) {
// setNavBar feeds this through zmAuth.update(), which is what stamps
// the credential fresh. Stamp here too so authentication being off - no
// auth_relay in the reply, and no hash that can expire - doesn't leave
// every whenAuthFresh() caller revalidating once an hour forever.
authFreshAt = Date.now();
setNavBar(data);
if (typeof onValid === 'function') onValid();
})
.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 we have reason to trust. While the page has been
// hearing from the server the hash is still good and cb runs straight away;
// once it has gone quiet for AUTH_STALE_MS, cb is queued behind a revalidation
// so nothing restarts a stream or a table poll on an expired hash. Use this
// anywhere a resume path - visibility, bfcache, idle timeout - kicks off
// authenticated requests after a gap.
function whenAuthFresh(cb) {
if (!authIsStale(authFreshAt, Date.now())) {
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
Expand All @@ -194,6 +258,10 @@ if (typeof module !== 'undefined' && module.exports) {
setUrlParam,
authHashFromRelay,
rebuildStreamSrc,
authIsStale,
authProbeUrl,
revalidateAuth,
AUTH_STALE_MS,
ZMAuth,
};
}
16 changes: 13 additions & 3 deletions web/js/table-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,24 @@ function deferTableRequestWhileHidden(table) {
return true;
}

// Refresh every table whose request was skipped while hidden.
// Refresh every table whose request was skipped while hidden. After a long
// hide the auth hash we were holding has expired, so wait for a fresh one
// rather than have every deferred table 403 (auth-helpers.js). whenAuthFresh is
// absent under node and on the unauthenticated views; refresh directly there.
function refreshTablesPendingVisibility() {
if (document.visibilityState === 'hidden') return;
// Drain before refreshing: refresh() calls the ajax function synchronously,
// which would otherwise re-add the table while we are still iterating.
const tables = tablesPendingVisibility.splice(0, tablesPendingVisibility.length);
for (let i = 0; i < tables.length; i++) {
tables[i].bootstrapTable('refresh');
const refresh = function() {
for (let i = 0; i < tables.length; i++) {
tables[i].bootstrapTable('refresh');
}
};
if (typeof whenAuthFresh === 'function') {
whenAuthFresh(refresh);
} else {
refresh();
}
}

Expand Down
9 changes: 9 additions & 0 deletions web/skins/classic/js/skin.js
Original file line number Diff line number Diff line change
Expand Up @@ -959,13 +959,22 @@ function isJSON(str) {
}
}

// Cookies holding an absolute date range. They are shared between the events
// list and montage review (refs #4976), but do not age well: kept until 2038, a
// window picked months ago is restored on a bare page load and the view opens on
// a range with no events. Session scope keeps the sharing and lets a new session
// fall back to the last hour.
const sessionCookies = ['zmFilter_StartDateTime', 'zmFilter_EndDateTime'];

function setCookie(name, value, seconds) {
var newValue = (typeof value === 'string' || typeof value === 'boolean') ? value : JSON.stringify(value);
let expires = "";
if (seconds) {
const date = new Date();
date.setTime(date.getTime() + (seconds*1000));
expires = "; expires=" + date.toUTCString();
} else if (sessionCookies.includes(name)) {
expires = "";
} else {
// 2147483647 is 2^31 - 1 which is January of 2038 to avoid the 32bit integer overflow bug.
expires = "; max-age=2147483647";
Expand Down
19 changes: 5 additions & 14 deletions web/skins/classic/views/js/montage.js
Original file line number Diff line number Diff line change
Expand Up @@ -477,18 +477,6 @@ function startVisibleMonitors() {
}
}

function refreshAuthAndStartMonitors() {
$j.getJSON(zmAuth.appendTo(thisUrl + '?view=request&request=status&entity=navBar'))
.done(function(data) {
zmAuth.update(data);
startVisibleMonitors();
})
.fail(function() {
// Even if refresh fails, try to start with whatever auth we have
startVisibleMonitors();
});
}

function reloadWebSite(ndx) {
document.getElementById('imageFeed'+ndx).innerHTML = document.getElementById('imageFeed'+ndx).innerHTML;
}
Expand Down Expand Up @@ -701,7 +689,10 @@ function initPage() {
ayswModal = insertModalHtml('AYSWModal', data.html);
ayswModal.on('hidden.bs.modal', function() {
idleTimeoutTriggered = false;
refreshAuthAndStartMonitors();
// The modal may have sat here for hours with the monitors -
// and their status polls - stopped, so the auth hash baked
// into their srcs can be dead (auth-helpers.js).
whenAuthFresh(startVisibleMonitors);
});
Comment on lines 689 to 696

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right — the description predated 52400cf, which folded montage.js in and replaced the hidden-timestamp approach (authHiddenTooLong(hiddenAt, now)) with credential-age tracking (authIsStale(freshAt, now)). Description rewritten to match what actually shipped, including montage.js losing its bespoke refresh helper and its duplicate navBar probe.

Your suppressed note about 401 vs 403 was the useful one. Chasing it turned up a real bug rather than a comment slip: the probe was going out as zmAuth.appendTo(...), carrying the very hash it existed to replace. zm_authenticate_request() resolves a request against exactly one source — a non-empty auth= in the URL enters the ZM_AUTH_HASH_LOGINS branch (on by default), and when getAuthUser() rejects it the chain has already been taken, so the userFromSession() arm below it never runs and a live session cookie authenticates as nobody.

So past ZM_AUTH_HASH_TTL — a tab hidden more than two hours on the defaults, exactly the case this PR is for — the probe was the one request guaranteed to fail, and that failure reads as login. The user got bounced to the login page with a perfectly good session: worse than the 403s in the log this set out to remove.

0ac31d9 sends the probe bare, which also makes the failure handling mean what its comment claimed — a rejection now really is a dead session rather than a dead hash, and the comment says 401 and 403 as you noted. Four new revalidateAuth tests cover it; reverting the probe to the credentialed form fails two of them.

ayswModal.modal('show');
})
Expand Down Expand Up @@ -1099,7 +1090,7 @@ document.onvisibilitychange = () => {
if (!idleTimeoutTriggered) {
// Refresh auth hash before restarting streams, since browsers throttle
// timers for hidden tabs and the auth hash may have gone stale.
refreshAuthAndStartMonitors();
whenAuthFresh(startVisibleMonitors);
} // end if not AYSW
}
};
Expand Down
5 changes: 3 additions & 2 deletions web/skins/classic/views/js/montagereview.js
Original file line number Diff line number Diff line change
Expand Up @@ -1348,8 +1348,9 @@ function loadEventData(e) {
}
data[name] = val;
const cookie = el.attr('data-cookie');
// Persist (no expiry) so the shared filter/date range does not silently
// expire after an hour and desync from the other views. refs #4976
// Persist so the shared filter does not silently expire after an hour
// and desync from the other views. setCookie scopes the date-range
// cookies to the session; the rest are kept. refs #4976
if (cookie) setCookie(cookie, val);
} // end if name
} // end if val
Expand Down
27 changes: 16 additions & 11 deletions web/skins/classic/views/js/watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -1550,17 +1550,22 @@ function stopPage() {
function startPage() {
// Always clear it because the return to visibility might happen before timeout
TimerHideShow = clearTimeout(TimerHideShow);
if (monitorStream && prevStateStarted == 'played' && !idleTimeoutTriggered) {
prevStateStarted = null;
onPlay(); //Set the correct state of the player buttons.
monitorStream.isActive = true;
monitorStream.start(monitorStream.currentChannelStream);
monitorsSetScale(monitorId);
//} else if (prevStateStarted != 'paused') {
} else if (monitorStream && monitorStream.element && ((monitorStream.zmsState == 'paused') || (monitorStream.element.video && monitorStream.element.video.paused) || monitorStream.element.paused)) {
prevStateStarted = null;
}
if (prevStateCycle) cycleStart();
// The stream src still carries the auth hash from before we were hidden. If
// we were away long enough for it to expire, get a fresh one before starting
// anything, otherwise zms 403s the reconnect (auth-helpers.js).
whenAuthFresh(function() {
if (monitorStream && prevStateStarted == 'played' && !idleTimeoutTriggered) {
prevStateStarted = null;
onPlay(); //Set the correct state of the player buttons.
monitorStream.isActive = true;
monitorStream.start(monitorStream.currentChannelStream);
monitorsSetScale(monitorId);
//} else if (prevStateStarted != 'paused') {
} else if (monitorStream && monitorStream.element && ((monitorStream.zmsState == 'paused') || (monitorStream.element.video && monitorStream.element.video.paused) || monitorStream.element.paused)) {
prevStateStarted = null;
}
if (prevStateCycle) cycleStart();
});
}

function setButtonStateWatch(element_id, btnClass) {
Expand Down
Loading