fix(auth): keep active sessions alive and add stay-signed-in (#1711)

This commit is contained in:
Anso
2026-07-28 07:36:50 -04:00
committed by GitHub
parent 2d88d9f8a8
commit 681ecc7047
20 changed files with 775 additions and 95 deletions
@@ -96,6 +96,7 @@ describe('deploy provenance trust boundary', () => {
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn(() => resolve()),
cookie: vi.fn(),
} as unknown as import('express').Response;
void Promise.resolve(authMiddleware(req, res, () => {
nextCalled = true;
@@ -0,0 +1,260 @@
/**
* Tests for the two pieces added to fix sessions expiring out from under
* active users: sliding-refresh (authMiddleware silently reissues a
* near-expiry session cookie) and "stay signed in" (a longer-lived session
* chosen at login, carried through MFA and password-change reissues).
*/
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import { OTP } from 'otplib';
import {
setupTestDb,
cleanupTestDb,
loginAsTestAdmin,
seedMfaUser,
TEST_USERNAME,
TEST_PASSWORD,
TEST_JWT_SECRET,
} from './helpers/setupTestDb';
// Match the server-side otplib configuration so test-generated OTPs are
// accepted by the verify path (see MfaService and __tests__/mfa.test.ts).
const authenticator = new OTP({ strategy: 'totp' });
const TOTP_PARAMS = { algorithm: 'sha1' as const, digits: 6, period: 30 };
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ app } = await import('../index'));
});
afterAll(() => cleanupTestDb(tmpDir));
/** All Set-Cookie entries in a response for one cookie name. */
function cookieEntries(setCookieHeader: string | string[] | undefined, name: string): string[] {
const cookies = Array.isArray(setCookieHeader) ? setCookieHeader : setCookieHeader ? [setCookieHeader] : [];
return cookies.filter((c) => c.startsWith(`${name}=`));
}
/** All sencho_token Set-Cookie entries in a response (should never be more than one). */
function sessionCookieEntries(setCookieHeader: string | string[] | undefined): string[] {
return cookieEntries(setCookieHeader, 'sencho_token');
}
/** Extract the sencho_token cookie's raw JWT value from a Set-Cookie header. */
function extractSessionToken(setCookieHeader: string | string[] | undefined): string | undefined {
const match = sessionCookieEntries(setCookieHeader)[0];
return match?.split(';')[0].split('=')[1];
}
describe('sliding session refresh', () => {
afterEach(() => {
DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '1');
});
it('refreshes a session nearing expiry', async () => {
const nearExpiryToken = jwt.sign({ username: TEST_USERNAME, remember: false }, TEST_JWT_SECRET, { expiresIn: '30s' });
const res = await request(app)
.get('/api/auth/check')
.set('Authorization', `Bearer ${nearExpiryToken}`);
expect(res.status).toBe(200);
const refreshed = extractSessionToken(res.headers['set-cookie']);
expect(refreshed).toBeDefined();
const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; remember?: boolean };
// Refreshed back to a full 24h session, not just extended by seconds.
expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(23 * 60 * 60 * 1000);
expect(decoded.remember).toBe(false);
});
it('does not refresh a session with plenty of life left', async () => {
const freshCookie = await loginAsTestAdmin(app);
const res = await request(app)
.get('/api/auth/check')
.set('Cookie', freshCookie);
expect(res.status).toBe(200);
expect(res.headers['set-cookie']).toBeUndefined();
});
it('reissues a "stay signed in" session back to a 30-day TTL, not 24h', async () => {
const nearExpiryRememberToken = jwt.sign({ username: TEST_USERNAME, remember: true }, TEST_JWT_SECRET, { expiresIn: '30s' });
const res = await request(app)
.get('/api/auth/check')
.set('Authorization', `Bearer ${nearExpiryRememberToken}`);
const refreshed = extractSessionToken(res.headers['set-cookie']);
expect(refreshed).toBeDefined();
const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; remember?: boolean };
expect(decoded.remember).toBe(true);
expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(29 * 24 * 60 * 60 * 1000);
});
it('does not refresh when session_sliding_refresh is disabled', async () => {
DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '0');
const nearExpiryToken = jwt.sign({ username: TEST_USERNAME, remember: false }, TEST_JWT_SECRET, { expiresIn: '30s' });
const res = await request(app)
.get('/api/auth/check')
.set('Authorization', `Bearer ${nearExpiryToken}`);
expect(res.status).toBe(200);
expect(res.headers['set-cookie']).toBeUndefined();
});
// The refresh sits after the token-version check in authMiddleware, which is
// what makes it safe: a stale token cannot ride the refresh back to life. If
// that ordering ever moved, these would be the tests to catch it.
it('rejects (does not refresh) a near-expiry token with a stale token_version', async () => {
const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!;
const staleToken = jwt.sign(
{ username: TEST_USERNAME, remember: false, tv: user.token_version - 1 },
TEST_JWT_SECRET,
{ expiresIn: '30s' },
);
const res = await request(app)
.get('/api/auth/check')
.set('Authorization', `Bearer ${staleToken}`);
expect(res.status).toBe(401);
expect(res.headers['set-cookie']).toBeUndefined();
});
it('refreshes a near-expiry token with a current token_version and carries it forward', async () => {
const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME)!;
const currentToken = jwt.sign(
{ username: TEST_USERNAME, remember: false, tv: user.token_version },
TEST_JWT_SECRET,
{ expiresIn: '30s' },
);
const res = await request(app)
.get('/api/auth/check')
.set('Authorization', `Bearer ${currentToken}`);
expect(res.status).toBe(200);
const refreshed = extractSessionToken(res.headers['set-cookie']);
expect(refreshed).toBeDefined();
const decoded = jwt.verify(refreshed!, TEST_JWT_SECRET) as { exp: number; tv?: number };
expect(decoded.tv).toBe(user.token_version);
expect(decoded.exp * 1000 - Date.now()).toBeGreaterThan(23 * 60 * 60 * 1000);
});
});
describe('"stay signed in" at login', () => {
it('issues a 30-day session when remember is true', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD, remember: true });
expect(res.status).toBe(200);
const cookieEntry = sessionCookieEntries(res.headers['set-cookie'])[0];
expect(cookieEntry).toBeDefined();
// The cookie's own Max-Age must match the JWT's exp, or a browser would
// drop the cookie before the token expires, defeating "stay signed in"
// even though the token itself looks correct.
expect(cookieEntry).toMatch(/Max-Age=2592000/);
const token = extractSessionToken(res.headers['set-cookie']);
expect(token).toBeDefined();
const decoded = jwt.verify(token!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
expect(decoded.remember).toBe(true);
expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2);
});
it('issues the standard 24h session when remember is omitted', async () => {
const res = await request(app)
.post('/api/auth/login')
.send({ username: TEST_USERNAME, password: TEST_PASSWORD });
const token = extractSessionToken(res.headers['set-cookie']);
const decoded = jwt.verify(token!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
expect(decoded.remember).toBe(false);
expect(decoded.exp - decoded.iat).toBeCloseTo(24 * 60 * 60, -2);
});
it('carries remember through an MFA challenge to the final session', async () => {
const { secret } = await seedMfaUser('mfa-remember-user', 'mfa-remember-pass');
const loginRes = await request(app)
.post('/api/auth/login')
.send({ username: 'mfa-remember-user', password: 'mfa-remember-pass', remember: true });
expect(loginRes.body.mfaRequired).toBe(true);
const pendingCookieHeader = cookieEntries(loginRes.headers['set-cookie'], 'sencho_mfa_pending')[0];
expect(pendingCookieHeader).toBeDefined();
const code = authenticator.generateSync({ secret, ...TOTP_PARAMS });
const mfaRes = await request(app)
.post('/api/auth/login/mfa')
.set('Cookie', pendingCookieHeader!)
.send({ code });
expect(mfaRes.status).toBe(200);
const finalToken = extractSessionToken(mfaRes.headers['set-cookie']);
expect(finalToken).toBeDefined();
const decoded = jwt.verify(finalToken!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
expect(decoded.remember).toBe(true);
expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2);
});
});
describe('reissueSessionAfterTokenBump preserves "stay signed in"', () => {
it('keeps a 30-day session after a password change', async () => {
const bcrypt = (await import('bcrypt')).default;
const passwordHash = await bcrypt.hash('bump-test-pass', 1);
DatabaseService.getInstance().addUser({ username: 'bump-test-user', password_hash: passwordHash, role: 'admin' });
const loginRes = await request(app)
.post('/api/auth/login')
.send({ username: 'bump-test-user', password: 'bump-test-pass', remember: true });
const rememberedCookie = extractSessionToken(loginRes.headers['set-cookie']);
const cookieHeader = sessionCookieEntries(loginRes.headers['set-cookie'])[0];
const changeRes = await request(app)
.put('/api/auth/password')
.set('Cookie', cookieHeader!)
.send({ oldPassword: 'bump-test-pass', newPassword: 'bump-test-pass-2' });
expect(changeRes.status).toBe(200);
// Exactly one sencho_token Set-Cookie, not one from the sliding refresh
// (pre-bump token_version) followed by a second from the post-bump
// reissue: a second, stale entry would leave any client that reads the
// first Set-Cookie signed out on its very next request.
expect(sessionCookieEntries(changeRes.headers['set-cookie'])).toHaveLength(1);
const reissued = extractSessionToken(changeRes.headers['set-cookie']);
expect(reissued).toBeDefined();
expect(reissued).not.toBe(rememberedCookie);
const decoded = jwt.verify(reissued!, TEST_JWT_SECRET) as { exp: number; iat: number; remember?: boolean };
expect(decoded.remember).toBe(true);
expect(decoded.exp - decoded.iat).toBeCloseTo(30 * 24 * 60 * 60, -2);
});
it('does not duplicate the session cookie when the password change lands inside the sliding-refresh window', async () => {
const bcrypt = (await import('bcrypt')).default;
const passwordHash = await bcrypt.hash('bump-window-pass', 1);
const db = DatabaseService.getInstance();
db.addUser({ username: 'bump-window-user', password_hash: passwordHash, role: 'admin' });
const user = db.getUserByUsername('bump-window-user')!;
// Hand-sign a near-expiry token so authMiddleware's sliding refresh fires
// on this very request, immediately before the route bumps token_version.
const nearExpiryToken = jwt.sign(
{ username: 'bump-window-user', remember: true, tv: user.token_version },
TEST_JWT_SECRET,
{ expiresIn: '30s' },
);
const changeRes = await request(app)
.put('/api/auth/password')
.set('Authorization', `Bearer ${nearExpiryToken}`)
.send({ oldPassword: 'bump-window-pass', newPassword: 'bump-window-pass-2' });
expect(changeRes.status).toBe(200);
const entries = sessionCookieEntries(changeRes.headers['set-cookie']);
expect(entries).toHaveLength(1);
const decoded = jwt.verify(extractSessionToken(changeRes.headers['set-cookie'])!, TEST_JWT_SECRET) as { tv?: number };
expect(decoded.tv).toBe(db.getUserByUsername('bump-window-user')!.token_version);
});
});
@@ -241,6 +241,47 @@ describe('prune_on_update (auto-prune after updates)', () => {
});
});
describe('session_sliding_refresh (keep active sessions alive)', () => {
it('defaults to ON in a freshly seeded database', () => {
expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('1');
});
it('is exposed through the settings GET projection', async () => {
const res = await request(app).get('/api/settings').set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.session_sliding_refresh).toBeDefined();
});
it('accepts a well-formed write and persists it', async () => {
const res = await request(app)
.post('/api/settings')
.set('Cookie', adminCookie)
.send({ key: 'session_sliding_refresh', value: '0' });
expect(res.status).toBe(200);
expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).toBe('0');
// Restore the seeded default so later suites observe the shipped behavior.
DatabaseService.getInstance().updateGlobalSetting('session_sliding_refresh', '1');
});
it('rejects a non-enum value (400) and does not write it', async () => {
const res = await request(app)
.post('/api/settings')
.set('Cookie', adminCookie)
.send({ key: 'session_sliding_refresh', value: 'banana' });
expect(res.status).toBe(400);
expect(res.body.error).toBe('Validation failed');
expect(DatabaseService.getInstance().getGlobalSettings().session_sliding_refresh).not.toBe('banana');
});
it('rejects a non-admin write with 403', async () => {
const res = await request(app)
.post('/api/settings')
.set('Cookie', viewerCookie)
.send({ key: 'session_sliding_refresh', value: '0' });
expect(res.status).toBe(403);
});
});
describe('prune_orphaned_scans (purge scans for deleted images/stacks)', () => {
it('defaults to ON in a freshly seeded database', () => {
expect(DatabaseService.getInstance().getGlobalSettings().prune_orphaned_scans).toBe('1');
+5
View File
@@ -23,6 +23,11 @@ export const MAX_ASSIGNMENTS = 1000;
// Session cookies
export const COOKIE_NAME = 'sencho_token';
export const SESSION_COOKIE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
export const REMEMBER_SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, "stay signed in"
// Sliding-refresh window: a user-session token with less than this much life
// left gets silently reissued with a fresh full TTL, so continued activity
// never runs into the hard expiry. See middleware/auth.ts::authMiddleware.
export const SESSION_REFRESH_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
export const MFA_PENDING_COOKIE_NAME = 'sencho_mfa_pending';
export const MFA_PENDING_SCOPE = 'mfa_pending';
export const MFA_PENDING_TTL_MS = 5 * 60 * 1000; // 5 minutes to complete the challenge
+7 -3
View File
@@ -1,15 +1,19 @@
import type { Request } from 'express';
import { SESSION_COOKIE_MAX_AGE_MS } from './constants';
/** True when the request arrived over HTTPS, either directly or via a trusted TLS-terminating proxy. */
export const isSecureRequest = (req: Request): boolean => {
return req.secure || req.headers['x-forwarded-proto'] === 'https';
};
/** Cookie options derived from the current request (secure flag follows the connection). */
/**
* Cookie options derived from the current request (secure flag follows the
* connection). Lifetime is deliberately not included: each caller sets its own
* `maxAge` (session cookies vary between the default and "stay signed in", the
* MFA-pending cookie is minutes long), so a shared default here would only ever
* be overridden or misread.
*/
export const getCookieOptions = (req: Request) => ({
httpOnly: true,
secure: isSecureRequest(req),
sameSite: 'strict' as const,
maxAge: SESSION_COOKIE_MAX_AGE_MS,
});
+67 -10
View File
@@ -17,6 +17,9 @@ import {
MFA_PENDING_COOKIE_NAME,
MFA_PENDING_SCOPE,
MFA_PENDING_TTL_MS,
SESSION_COOKIE_MAX_AGE_MS,
REMEMBER_SESSION_MAX_AGE_MS,
SESSION_REFRESH_THRESHOLD_MS,
} from '../helpers/constants';
import { getCookieOptions } from '../helpers/cookies';
import { looksLikeApiToken } from '../utils/apiTokenFormat';
@@ -79,7 +82,7 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean };
const decoded = jwt.verify(token, jwtSecret) as { username?: string; role?: string; scope?: string; tv?: number; user_id?: number; sso?: boolean; remember?: boolean; exp?: number };
if (isDebugEnabled()) console.log('[Auth:diag] Token type:', bearerToken ? 'bearer' : 'cookie', 'scope:', decoded.scope || 'user-session');
@@ -161,8 +164,11 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
}
// Token version check: rejects sessions after password change, role change, or admin reset.
// Pre-migration tokens (no tv claim) are accepted for backward compat and expire within 24h.
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
// A token without a tv claim is a pre-migration legacy token minted when
// token_version was 1. Default to 1 on decode so the token is rejected only
// when a security event (password change, MFA reset, role change, admin
// invalidation) has actually bumped the version since it was issued.
if (dbUser.token_version !== (decoded.tv ?? 1)) {
if (isDebugEnabled()) console.log('[Auth:diag] Token version mismatch for:', decoded.username, 'jwt:', decoded.tv, 'db:', dbUser.token_version);
console.log('[Auth] Session rejected: token version mismatch for:', decoded.username);
res.status(401).json({ error: 'Session invalidated. Please log in again.' });
@@ -173,6 +179,28 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
// Use the DB role (not the JWT role) so role changes take effect immediately
req.user = { username: dbUser.username, role: dbUser.role as UserRole, userId: dbUser.id };
const remember = decoded.remember === true;
req.sessionRemember = remember;
// Sliding refresh: a session nearing its expiry gets silently reissued with
// a fresh full TTL (matching whichever TTL, 24h or "stay signed in" 30d, the
// original login chose), so an actively-used tab never runs into the hard
// cutoff. Disabled via the session_sliding_refresh setting (default on) for
// admins who want a strict absolute session ceiling. This is a best-effort
// optimization on an already-authenticated request, so its own try/catch
// keeps a reissue failure from being reported as an invalid token.
if (settings.session_sliding_refresh !== '0' && typeof decoded.exp === 'number') {
const remainingMs = decoded.exp * 1000 - Date.now();
const shouldRefresh = remainingMs < SESSION_REFRESH_THRESHOLD_MS;
if (isDebugEnabled()) console.log('[Auth:diag] Sliding refresh check:', decoded.username, 'remainingMs:', remainingMs, 'refreshed:', shouldRefresh);
if (shouldRefresh) {
try {
issueSessionCookie(res, req, dbUser, jwtSecret, remember);
} catch (refreshErr) {
console.error('[Auth] Sliding session refresh failed for', dbUser.username, getErrorMessage(refreshErr, 'unknown'));
}
}
}
next();
} catch (err) {
@@ -182,19 +210,48 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response
}
};
/** Sign a session JWT and set it as an httpOnly cookie. */
/**
* Drop any already-queued `Set-Cookie` entry for `name` on this response
* before a caller appends a new one. `res.cookie()` appends rather than
* replaces, so a request path that issues the same cookie twice (the
* sliding refresh in `authMiddleware` followed by a token-bump reissue in
* the same response, e.g. a password change inside the refresh window)
* would otherwise send two `Set-Cookie` headers for one name: the first
* carrying an already-superseded `token_version`. Browsers apply the last
* one, but any other client taking the first would treat itself as
* signed out on its very next request. Deduping keeps exactly one, correct
* cookie in the response regardless of call order.
*/
function dropQueuedCookie(res: Response, name: string): void {
const existing = res.getHeader('Set-Cookie');
if (!existing) return;
const entries = Array.isArray(existing) ? existing : [String(existing)];
const filtered = entries.filter((entry) => !entry.startsWith(`${name}=`));
if (filtered.length !== entries.length) res.setHeader('Set-Cookie', filtered);
}
/**
* Sign a session JWT and set it as an httpOnly cookie. `remember` extends the
* session to `REMEMBER_SESSION_MAX_AGE_MS` (30 days, "stay signed in") instead
* of the default `SESSION_COOKIE_MAX_AGE_MS` (24h); the choice is carried in
* the token's `remember` claim so a later sliding refresh (authMiddleware) or
* post-token-bump reissue (reissueSessionAfterTokenBump) reapplies the same TTL.
*/
export function issueSessionCookie(
res: Response,
req: Request,
user: { username: string; role: string; token_version: number },
jwtSecret: string,
remember = false,
): void {
const maxAgeMs = remember ? REMEMBER_SESSION_MAX_AGE_MS : SESSION_COOKIE_MAX_AGE_MS;
const token = jwt.sign(
{ username: user.username, role: user.role, tv: user.token_version },
{ username: user.username, role: user.role, tv: user.token_version, remember },
jwtSecret,
{ expiresIn: '24h' },
{ expiresIn: Math.floor(maxAgeMs / 1000) },
);
res.cookie(COOKIE_NAME, token, getCookieOptions(req));
dropQueuedCookie(res, COOKIE_NAME);
res.cookie(COOKIE_NAME, token, { ...getCookieOptions(req), maxAge: maxAgeMs });
}
/**
@@ -209,10 +266,10 @@ export function issueMfaPendingCookie(
req: Request,
user: { id: number; username: string },
jwtSecret: string,
opts: { sso?: boolean } = {},
opts: { sso?: boolean; remember?: boolean } = {},
): void {
const token = jwt.sign(
{ scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true },
{ scope: MFA_PENDING_SCOPE, user_id: user.id, username: user.username, sso: opts.sso === true, remember: opts.remember === true },
jwtSecret,
{ expiresIn: Math.floor(MFA_PENDING_TTL_MS / 1000) },
);
@@ -239,6 +296,6 @@ export function reissueSessionAfterTokenBump(req: Request, res: Response, userId
const refreshed = db.getUserById(userId);
const settings = db.getGlobalSettings();
if (refreshed && settings.auth_jwt_secret) {
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret);
issueSessionCookie(res, req, refreshed, settings.auth_jwt_secret, req.sessionRemember === true);
}
}
+3 -2
View File
@@ -106,6 +106,7 @@ authRouter.post('/setup', authRateLimiter, async (req: Request, res: Response):
// Login endpoint
authRouter.post('/login', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
const { username, password } = req.body;
const remember = req.body.remember === true;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
@@ -131,13 +132,13 @@ authRouter.post('/login', authRateLimiter, async (req: Request, res: Response):
console.log('[MFA:diag] login: path=local user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'failedAttempts=', mfa?.failed_attempts ?? 0, 'lockedUntil=', mfa?.locked_until ?? null);
}
if (mfa?.enabled) {
issueMfaPendingCookie(res, req, user, jwtSecret);
issueMfaPendingCookie(res, req, user, jwtSecret, { remember });
console.log('[Auth] Login password OK, MFA challenge pending:', user.username);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, jwtSecret);
issueSessionCookie(res, req, user, jwtSecret, remember);
console.log('[Auth] Login successful:', user.username);
res.json({ success: true, message: 'Login successful' });
return;
+2 -2
View File
@@ -72,7 +72,7 @@ mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response
return;
}
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean };
let decoded: { scope?: string; user_id?: number; username?: string; sso?: boolean; remember?: boolean };
try {
decoded = jwt.verify(pendingCookie, jwtSecret) as typeof decoded;
} catch {
@@ -173,7 +173,7 @@ mfaRouter.post('/login/mfa', authRateLimiter, async (req: Request, res: Response
db.clearMfaFailures(decoded.user_id);
clearMfaPendingCookie(res, req);
issueSessionCookie(res, req, user, jwtSecret);
issueSessionCookie(res, req, user, jwtSecret, decoded.remember === true);
console.log('[Auth] MFA challenge cleared:', user.username);
if (isDebugEnabled()) console.log('[MFA:diag] login/mfa: success user=', user.username, 'durationMs=', Date.now() - startedAt);
res.json({ success: true });
+2
View File
@@ -36,6 +36,7 @@ const ALLOWED_SETTING_KEYS = new Set([
'auto_create_missing_external_networks',
'image_update_sidebar_indicators',
'notification_dispatch_retries',
'session_sliding_refresh',
]);
// Keys whose write requires a paid license, not just an admin role.
@@ -77,6 +78,7 @@ const SettingsPatchSchema = z.object({
});
}
}).transform((v) => String(parseNotificationDispatchRetries(v)!)),
session_sliding_refresh: z.enum(['0', '1']),
}).partial();
export const settingsRouter = Router();
+3 -2
View File
@@ -43,6 +43,7 @@ ssoRouter.get('/providers', (_req: Request, res: Response): void => {
ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Promise<void> => {
try {
const { username, password } = req.body;
const remember = req.body.remember === true;
if (!username || !password) {
res.status(400).json({ error: 'Username and password are required' });
return;
@@ -71,13 +72,13 @@ ssoRouter.post('/ldap', authRateLimiter, async (req: Request, res: Response): Pr
console.log('[MFA:diag] login: path=ldap user=', user.username, 'mfaEnabled=', !!mfa?.enabled, 'ssoEnforce=', mfa?.sso_enforce_mfa === 1);
}
if (mfa?.enabled && mfa.sso_enforce_mfa) {
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true });
issueMfaPendingCookie(res, req, user, settings.auth_jwt_secret, { sso: true, remember });
console.log(`[SSO] LDAP login password OK, MFA challenge pending: ${user.username}`);
res.json({ success: true, mfaRequired: true });
return;
}
issueSessionCookie(res, req, user, settings.auth_jwt_secret);
issueSessionCookie(res, req, user, settings.auth_jwt_secret, remember);
console.log(`[SSO] LDAP login successful: ${user.username}`);
res.json({ success: true, message: 'Login successful' });
} catch (error) {
+5
View File
@@ -2022,6 +2022,11 @@ export class DatabaseService {
stmt.run('notification_dispatch_retries', '0');
stmt.run('env_block_deploy_on_missing_required', '0');
stmt.run('auto_create_missing_external_networks', '0');
// Silently extend an actively-used session's cookie instead of hard
// expiring it. On by default (matches how most session-based web apps
// behave); admins who want a strict absolute session ceiling can turn
// it off in Settings > Users.
stmt.run('session_sliding_refresh', '1');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
+2
View File
@@ -19,6 +19,8 @@ declare global {
mfaPendingUserId?: number;
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
mfaPendingSso?: boolean;
/** True when the caller's current user-session cookie was issued with "stay signed in". Read by reissueSessionAfterTokenBump so a password/MFA change doesn't silently shorten a remembered session. */
sessionRemember?: boolean;
/** Cached remote-proxy target resolved by `remoteNodeProxy`'s outer gate so the http-proxy router/proxyReq callbacks do not re-resolve. */
proxyTarget?: { apiUrl: string; apiToken: string };
/** Trusted deploy provenance from machine auth or gateway overwrite. */