feat(sessions): implement user session management with session tracking and revocation
- Introduced new database schema for user sessions and session events. - Implemented session management page with filtering, sorting, and revocation capabilities. - Added backend services for creating, revoking, and expiring user sessions. - Implemented session cookie handling and security measures. - Created tests for session management functionalities and security features.
This commit is contained in:
1 parent
b731194014
commit
87fdbb493b
33 files changed
+2878
-1296
No files matched your search
@@ -28,6 +28,16 @@ test("redeploy endpoints require container write authorization", () => {
|
||||
assert.match(routes.slice(previewStart, previewStart + 180), /containerReadAccess/);
|
||||
});
|
||||
|
||||
test("internal deployment jobs retain browser session authentication", () => {
|
||||
const helperStart = routes.indexOf("function buildInternalJobHeaders");
|
||||
assert.ok(helperStart >= 0, "internal job header builder should exist");
|
||||
|
||||
const helper = routes.slice(helperStart, helperStart + 900);
|
||||
assert.match(helper, /"x-doktainer-request": "1"/);
|
||||
assert.match(helper, /const cookie = headers\.cookie/);
|
||||
assert.match(helper, /nextHeaders\.cookie = cookie/);
|
||||
});
|
||||
|
||||
test("redeploy restores the stored current revision without invoking source rebuild", () => {
|
||||
assert.match(redeployService, /redeployContainerCurrentRevision/);
|
||||
assert.match(redeployService, /getRollbackSnapshot\(input\)/);
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
enforceBrowserMutationProtection,
|
||||
} from "../../src/server/middleware/auth";
|
||||
import {
|
||||
canRevokeManagedSession,
|
||||
getSessionCookieName,
|
||||
hashSessionToken,
|
||||
normalizeSessionUserAgent,
|
||||
} from "../../src/server/services/auth-session.service";
|
||||
|
||||
function createReplyRecorder() {
|
||||
return {
|
||||
statusCode: 200,
|
||||
payload: undefined as unknown,
|
||||
status(code: number) {
|
||||
this.statusCode = code;
|
||||
return this;
|
||||
},
|
||||
send(payload: unknown) {
|
||||
this.payload = payload;
|
||||
return this;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("session tokens are stored as deterministic hashes", () => {
|
||||
const rawToken = "raw-session-token";
|
||||
const tokenHash = hashSessionToken(rawToken);
|
||||
|
||||
assert.equal(tokenHash.length, 64);
|
||||
assert.equal(tokenHash, hashSessionToken(rawToken));
|
||||
assert.notEqual(tokenHash, rawToken);
|
||||
});
|
||||
|
||||
test("production uses a host-bound session cookie name", () => {
|
||||
assert.equal(
|
||||
getSessionCookieName({ NODE_ENV: "production" } as NodeJS.ProcessEnv),
|
||||
"__Host-doktainer_session",
|
||||
);
|
||||
assert.equal(
|
||||
getSessionCookieName({ NODE_ENV: "development" } as NodeJS.ProcessEnv),
|
||||
"doktainer_session",
|
||||
);
|
||||
});
|
||||
|
||||
test("session user agents drop control characters and remain bounded", () => {
|
||||
const normalized = normalizeSessionUserAgent(`Browser\n${"x".repeat(600)}`);
|
||||
|
||||
assert.equal(normalized?.includes("\n"), false);
|
||||
assert.equal(normalized?.length, 512);
|
||||
});
|
||||
|
||||
test("browser mutation protection requires the application header", () => {
|
||||
const reply = createReplyRecorder();
|
||||
const allowed = enforceBrowserMutationProtection(
|
||||
{ method: "POST", headers: {} } as never,
|
||||
reply as never,
|
||||
);
|
||||
|
||||
assert.equal(allowed, false);
|
||||
assert.equal(reply.statusCode, 403);
|
||||
});
|
||||
|
||||
test("browser mutation protection rejects cross-site requests", () => {
|
||||
const reply = createReplyRecorder();
|
||||
const allowed = enforceBrowserMutationProtection(
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-doktainer-request": "1",
|
||||
"sec-fetch-site": "cross-site",
|
||||
},
|
||||
} as never,
|
||||
reply as never,
|
||||
);
|
||||
|
||||
assert.equal(allowed, false);
|
||||
assert.equal(reply.statusCode, 403);
|
||||
});
|
||||
|
||||
test("safe methods and verified same-origin mutations are accepted", () => {
|
||||
assert.equal(
|
||||
enforceBrowserMutationProtection(
|
||||
{ method: "GET", headers: {} } as never,
|
||||
createReplyRecorder() as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
enforceBrowserMutationProtection(
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"x-doktainer-request": "1",
|
||||
"sec-fetch-site": "same-origin",
|
||||
origin: "http://localhost:3000",
|
||||
},
|
||||
} as never,
|
||||
createReplyRecorder() as never,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("only owners and super admins can revoke a managed login session", () => {
|
||||
assert.equal(
|
||||
canRevokeManagedSession({
|
||||
actorUserId: "operator-1",
|
||||
actorRole: "OPERATOR",
|
||||
targetUserId: "user-2",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canRevokeManagedSession({
|
||||
actorUserId: "operator-1",
|
||||
actorRole: "OPERATOR",
|
||||
targetUserId: "operator-1",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
canRevokeManagedSession({
|
||||
actorUserId: "admin-1",
|
||||
actorRole: "SUPER_ADMIN",
|
||||
targetUserId: "user-2",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("session management is a dedicated protected page before Users and RBAC", () => {
|
||||
const navigation = readFileSync("src/lib/navigation.ts", "utf8");
|
||||
const permissions = readFileSync("src/lib/permissions.ts", "utf8");
|
||||
const dashboard = readFileSync("src/app/page.tsx", "utf8");
|
||||
const page = readFileSync("src/app/sessions/page.tsx", "utf8");
|
||||
const routes = readFileSync("src/server/routes/auth.ts", "utf8");
|
||||
|
||||
assert.ok(navigation.indexOf('href: "/sessions"') < navigation.indexOf('href: "/users"'));
|
||||
assert.match(permissions, /"\/sessions": "OPERATOR"/);
|
||||
assert.doesNotMatch(dashboard, /LoginSessionsPanel/);
|
||||
assert.match(page, /route="\/sessions"/);
|
||||
assert.match(page, /All statuses/);
|
||||
assert.match(page, /All users/);
|
||||
assert.match(routes, /app\.get\("\/sessions", \{ preHandler: \[requireRole\("OPERATOR"\)\]/);
|
||||
assert.match(routes, /"\/sessions\/:sessionId",\s+\{ preHandler: \[requireRole\("OPERATOR"\)\] \}/);
|
||||
assert.match(routes, /A browser login session is required/);
|
||||
});
|
||||
@@ -25,7 +25,7 @@ test("viewer role is blocked from write permissions", () => {
|
||||
|
||||
const allowed = enforceUserRolePermissions(
|
||||
{
|
||||
authMethod: "jwt",
|
||||
authMethod: "session",
|
||||
userRole: "VIEWER",
|
||||
} as never,
|
||||
reply as never,
|
||||
@@ -45,7 +45,7 @@ test("viewer role keeps read permissions", () => {
|
||||
|
||||
const allowed = enforceUserRolePermissions(
|
||||
{
|
||||
authMethod: "jwt",
|
||||
authMethod: "session",
|
||||
userRole: "VIEWER",
|
||||
} as never,
|
||||
reply as never,
|
||||
@@ -61,7 +61,7 @@ test("developer role keeps write permissions", () => {
|
||||
|
||||
const allowed = enforceUserRolePermissions(
|
||||
{
|
||||
authMethod: "jwt",
|
||||
authMethod: "session",
|
||||
userRole: "DEVELOPER",
|
||||
} as never,
|
||||
reply as never,
|
||||
|
||||
@@ -2,12 +2,15 @@ import assert from "node:assert/strict";
|
||||
import test, { afterEach, beforeEach } from "node:test";
|
||||
|
||||
import {
|
||||
getSensitiveStorageItem,
|
||||
removeSensitiveStorageItem,
|
||||
clearLegacyAuthStorage,
|
||||
sensitiveStorageKeys,
|
||||
setSensitiveStorageItem,
|
||||
} from "../src/lib/browser-storage.ts";
|
||||
import { auth, getToken } from "../src/lib/api.ts";
|
||||
import {
|
||||
auth,
|
||||
getUser,
|
||||
setUser,
|
||||
} from "../src/lib/api.ts";
|
||||
import {
|
||||
consumeGithubManifestState,
|
||||
encodeGithubManifestState,
|
||||
@@ -141,38 +144,33 @@ afterEach(() => {
|
||||
globalThis.window = originalWindow;
|
||||
});
|
||||
|
||||
test("reads sensitive token storage from localStorage", () => {
|
||||
window.localStorage.setItem(sensitiveStorageKeys.token, "legacy-token");
|
||||
test("removes legacy auth credentials without deleting organization preference", () => {
|
||||
window.localStorage.setItem("doktainer_token", "legacy-token");
|
||||
window.localStorage.setItem("doktainer_user", '{"id":"legacy"}');
|
||||
setSensitiveStorageItem(sensitiveStorageKeys.organization, "org-1");
|
||||
|
||||
clearLegacyAuthStorage();
|
||||
|
||||
assert.equal(window.localStorage.getItem("doktainer_token"), null);
|
||||
assert.equal(window.localStorage.getItem("doktainer_user"), null);
|
||||
assert.equal(
|
||||
getSensitiveStorageItem(sensitiveStorageKeys.token),
|
||||
"legacy-token",
|
||||
window.localStorage.getItem(sensitiveStorageKeys.organization),
|
||||
"org-1",
|
||||
);
|
||||
assert.equal(
|
||||
window.localStorage.getItem(sensitiveStorageKeys.token),
|
||||
"legacy-token",
|
||||
);
|
||||
});
|
||||
|
||||
test("writes and removes sensitive values in localStorage", () => {
|
||||
setSensitiveStorageItem(sensitiveStorageKeys.user, '{"id":"new"}');
|
||||
|
||||
assert.equal(
|
||||
window.localStorage.getItem(sensitiveStorageKeys.user),
|
||||
'{"id":"new"}',
|
||||
);
|
||||
|
||||
removeSensitiveStorageItem(sensitiveStorageKeys.user);
|
||||
|
||||
assert.equal(window.localStorage.getItem(sensitiveStorageKeys.user), null);
|
||||
});
|
||||
|
||||
test("clears stored session and redirects to login on authenticated 401", async () => {
|
||||
setSensitiveStorageItem(sensitiveStorageKeys.token, "stale-token");
|
||||
setSensitiveStorageItem(sensitiveStorageKeys.user, '{"id":"user-1"}');
|
||||
setUser({
|
||||
id: "user-1",
|
||||
name: "User",
|
||||
email: "[email protected]",
|
||||
role: "OPERATOR",
|
||||
});
|
||||
let requestInit: RequestInit | undefined;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
globalThis.fetch = async (_input, init) => {
|
||||
requestInit = init;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: "Unauthorized - session expired",
|
||||
@@ -182,11 +180,16 @@ test("clears stored session and redirects to login on authenticated 401", async
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
await assert.rejects(() => auth.me(), /Unauthorized - session expired/);
|
||||
await assert.rejects(() => auth.logout(), /Unauthorized - session expired/);
|
||||
|
||||
assert.equal(getToken(), null);
|
||||
assert.equal(getSensitiveStorageItem(sensitiveStorageKeys.user), null);
|
||||
assert.equal(getUser(), null);
|
||||
assert.equal(requestInit?.credentials, "include");
|
||||
assert.equal(
|
||||
(requestInit?.headers as Record<string, string>)["x-doktainer-request"],
|
||||
"1",
|
||||
);
|
||||
assert.equal(window.location.pathname, "/login");
|
||||
assert.equal(window.location.search, "?reason=session-expired");
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user