fix(auth): persist login and skip redundant login page
This commit is contained in:
@@ -103,7 +103,11 @@ async function route(req, res) {
|
|||||||
clearLoginFailures(req);
|
clearLoginFailures(req);
|
||||||
return sendJsonWithHeaders(res, 200, loginHeaders(config), { ok: true });
|
return sendJsonWithHeaders(res, 200, loginHeaders(config), { ok: true });
|
||||||
}
|
}
|
||||||
if (url.pathname === '/login' || url.pathname === '/login.html' || url.pathname === '/styles.css' || url.pathname === '/login.js') return serveStatic(req, res);
|
if (url.pathname === '/login' || url.pathname === '/login.html') {
|
||||||
|
if (isAuthenticated(req, config)) return redirect(res, '/');
|
||||||
|
return serveStatic(req, res);
|
||||||
|
}
|
||||||
|
if (url.pathname === '/styles.css' || url.pathname === '/login.js') return serveStatic(req, res);
|
||||||
if (!isAuthenticated(req, config)) {
|
if (!isAuthenticated(req, config)) {
|
||||||
if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { ok: false, error: 'Authentication required.' });
|
if (url.pathname.startsWith('/api/')) return sendJson(res, 401, { ok: false, error: 'Authentication required.' });
|
||||||
return redirect(res, '/login');
|
return redirect(res, '/login');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'n
|
|||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
const COOKIE_NAME = 'kanban_session';
|
const COOKIE_NAME = 'kanban_session';
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
||||||
const attempts = new Map();
|
const attempts = new Map();
|
||||||
const LOGIN_WINDOW_MS = 5 * 60 * 1000;
|
const LOGIN_WINDOW_MS = 5 * 60 * 1000;
|
||||||
const MAX_LOGIN_FAILURES = 8;
|
const MAX_LOGIN_FAILURES = 8;
|
||||||
@@ -101,7 +102,7 @@ export function isAuthenticated(req, config) {
|
|||||||
export function loginHeaders(config) {
|
export function loginHeaders(config) {
|
||||||
const secure = config.authSecureCookie ? ' Secure;' : '';
|
const secure = config.authSecureCookie ? ' Secure;' : '';
|
||||||
return {
|
return {
|
||||||
'set-cookie': `${COOKIE_NAME}=${encodeURIComponent(config.auth.token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000;${secure}`
|
'set-cookie': `${COOKIE_NAME}=${encodeURIComponent(config.auth.token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_MAX_AGE_SECONDS};${secure}`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
67
test/auth.test.js
Normal file
67
test/auth.test.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
test('login persists authentication and skips the login page on later visits', async () => {
|
||||||
|
const testDirectory = mkdtempSync(join(tmpdir(), 'local-kanban-auth-'));
|
||||||
|
const token = 'test-only-auth-token';
|
||||||
|
process.env.KANBAN_CONFIG = join(testDirectory, 'config.json');
|
||||||
|
process.env.KANBAN_STATE = join(testDirectory, 'state.json');
|
||||||
|
process.env.KANBAN_AUTH_TOKEN = token;
|
||||||
|
process.env.KANBAN_AUTH_SECURE_COOKIE = '1';
|
||||||
|
process.env.KANBAN_REQUIRE_HTTPS = '0';
|
||||||
|
|
||||||
|
const { createKanbanServer } = await import(`../src/server.js?auth-test=${Date.now()}`);
|
||||||
|
const server = createKanbanServer();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const { port } = server.address();
|
||||||
|
const origin = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
const initialLoginPage = await fetch(`${origin}/login`, { redirect: 'manual' });
|
||||||
|
assert.equal(initialLoginPage.status, 200);
|
||||||
|
|
||||||
|
const rejectedLogin = await fetch(`${origin}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token: 'wrong-token' })
|
||||||
|
});
|
||||||
|
assert.equal(rejectedLogin.status, 401);
|
||||||
|
assert.equal(rejectedLogin.headers.get('set-cookie'), null);
|
||||||
|
|
||||||
|
const loginResponse = await fetch(`${origin}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token })
|
||||||
|
});
|
||||||
|
assert.equal(loginResponse.status, 200);
|
||||||
|
|
||||||
|
const setCookie = loginResponse.headers.get('set-cookie');
|
||||||
|
assert.match(setCookie, /^kanban_session=[^;]+;/);
|
||||||
|
const cookie = setCookie.split(';', 1)[0];
|
||||||
|
|
||||||
|
const laterLoginPage = await fetch(`${origin}/login`, {
|
||||||
|
headers: { cookie },
|
||||||
|
redirect: 'manual'
|
||||||
|
});
|
||||||
|
assert.equal(laterLoginPage.status, 302);
|
||||||
|
assert.equal(laterLoginPage.headers.get('location'), '/');
|
||||||
|
assert.match(setCookie, /Max-Age=31536000/);
|
||||||
|
assert.match(setCookie, /HttpOnly/);
|
||||||
|
assert.match(setCookie, /Secure/);
|
||||||
|
|
||||||
|
const logoutResponse = await fetch(`${origin}/api/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { cookie }
|
||||||
|
});
|
||||||
|
assert.equal(logoutResponse.status, 200);
|
||||||
|
assert.match(logoutResponse.headers.get('set-cookie'), /^kanban_session=;/);
|
||||||
|
assert.match(logoutResponse.headers.get('set-cookie'), /Max-Age=0/);
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
rmSync(testDirectory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user