diff --git a/src/server.js b/src/server.js index c770999..6a5ce2d 100644 --- a/src/server.js +++ b/src/server.js @@ -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'); diff --git a/src/services/auth.js b/src/services/auth.js index 69bea6b..1b60064 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -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}` }; } diff --git a/test/auth.test.js b/test/auth.test.js new file mode 100644 index 0000000..ca55699 --- /dev/null +++ b/test/auth.test.js @@ -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 }); + } +});