mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
fix(auth): keep active sessions alive and add stay-signed-in (#1711)
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user