fix(auth): persist login and skip redundant login page

This commit is contained in:
2026-08-12 11:20:34 +08:00
parent 76b2ac69f8
commit 176119ca29
3 changed files with 74 additions and 2 deletions

View File

@@ -103,7 +103,11 @@ async function route(req, res) {
clearLoginFailures(req);
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 (url.pathname.startsWith('/api/')) return sendJson(res, 401, { ok: false, error: 'Authentication required.' });
return redirect(res, '/login');

View File

@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from 'n
import { dirname } from 'node:path';
const COOKIE_NAME = 'kanban_session';
const SESSION_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
const attempts = new Map();
const LOGIN_WINDOW_MS = 5 * 60 * 1000;
const MAX_LOGIN_FAILURES = 8;
@@ -101,7 +102,7 @@ export function isAuthenticated(req, config) {
export function loginHeaders(config) {
const secure = config.authSecureCookie ? ' Secure;' : '';
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
View 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 });
}
});