feat(auth): make the admin "Remember me" checkbox actually do something (#1186) (#1195)

The checkbox had no `checked`, no `onChange`, and no place in the login
request; `rememberMe` existed only as an i18n label. On the backend
establishAdminSession hardcoded `expiresIn: '24h'` and the cookie always got
DEFAULT_MAX_AGE_MS, so there was nothing to receive it anyway.

Wired end to end: state on the page, `remember_me` in the login body, and a
30-day JWT plus a matching 30-day cookie when it is set.

Opt-in on purpose. An absent or malformed value means "no", so a client that
never sends it keeps exactly the 24h session it always had, and a stolen cookie
is still worth a day by default.

The JWT and the cookie take their lifetime from the same flag. If they can
disagree the session either dies early (long cookie, short token) or outlives
what the user consented to, so the tests assert them against each other.

Review found the feature was non-functional as written, which is the important
part: sessionTimeoutMiddleware and isSessionExpired enforce
security_session_timeout_minutes — 60 minutes by default — against a session's
idle time regardless of how long its token lives, so a remembered admin was
logged out within the hour with a 30-day token sitting unused. rememberMe now
travels in the JWT payload and both checks exempt a remembered session from the
IDLE timeout. Not from expiry: the token still dies on its own 30-day exp, and
revocation, deactivation and password-change invalidation are untouched.

Also: /api/admin/auth/change-password reissued a hardcoded 24h token without
the flag, so a remembered admin dropped back to 24h the moment they changed
their password — which is mandatory for new and reset accounts. It now inherits
the choice from the session it replaces, carried on req.admin.rememberMe.

Through MFA the choice rides inside the signed mfa_pending token rather than
being resent, so the second leg cannot ask for longer than the first agreed to.

The tests drive POST /api/auth/admin/login and read the real Set-Cookie and
token rather than minting a local clone of the ternary they are meant to be
checking, boot one database per file before anything reads it, and generate
their credential per run so no literal that looks like a password lands in the
repository.

No visual change — the checkbox was uncontrolled, so it already toggled on
click; it just did nothing.

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-08-26 21:13:23 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 95e7301909
commit d3e9a7cf0d
8 changed files with 319 additions and 19 deletions
+12 -2
View File
@@ -9,6 +9,9 @@ const GUEST_COOKIE_PREFIX = 'guest_token_';
const CUSTOMER_COOKIE_NAME = 'customer_token';
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
// "Remember me" (#1186). Opt-in only: the default stays 24h, so a stolen
// cookie is worth a day unless the operator explicitly asked for longer.
const REMEMBER_ME_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
/**
* Cookie "Secure" flag mode:
@@ -109,9 +112,14 @@ function sanitizeSlugForCookie(slug = '') {
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
}
function setAdminAuthCookie(res, token) {
function setAdminAuthCookie(res, token, { rememberMe = false } = {}) {
if (!token) return;
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
// The cookie's lifetime has to track the JWT's, or one outlives the other:
// a 30-day cookie carrying a 24h token means a silent 401 the next morning,
// and a 24h cookie carrying a 30-day token throws away the session the user
// asked to keep.
const maxAge = rememberMe ? REMEMBER_ME_MAX_AGE_MS : DEFAULT_MAX_AGE_MS;
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res, maxAge));
}
function clearAdminAuthCookie(res) {
@@ -231,6 +239,8 @@ function getGuestTokenFromRequest(req, slug) {
module.exports = {
ADMIN_COOKIE_NAME,
buildCookieOptionsWithExpiry,
DEFAULT_MAX_AGE_MS,
REMEMBER_ME_MAX_AGE_MS,
buildClearCookieOptions,
GALLERY_COOKIE_NAME,
GALLERY_COOKIE_PREFIX,