mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: login policy hook for plugins and optional https redirect (#1584)
This commit is contained in:
1 parent
34a9c77114
commit
ad46f42ab1
53 files changed
+15250
-59
No files matched your search
@@ -8,6 +8,8 @@ const encryptionKey = crypto.randomBytes(32);
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hasUserDEK: vi.fn(() => false),
|
||||
adoptRecoveredDEK: vi.fn(async () => {}),
|
||||
revoke: vi.fn(async () => true),
|
||||
created: [] as Array<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({
|
||||
@@ -19,10 +21,27 @@ vi.mock("../../database/db/index.js", () => ({
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({ get: async () => null }),
|
||||
createCurrentSessionRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
id === "expired"
|
||||
? { id, expiresAt: new Date(Date.now() - 1000).toISOString() }
|
||||
: { id },
|
||||
findById: async (id: string) => {
|
||||
const minutesAgo = (n: number) =>
|
||||
new Date(Date.now() - n * 60_000).toISOString();
|
||||
if (id === "expired") {
|
||||
return { id, expiresAt: new Date(Date.now() - 1000).toISOString() };
|
||||
}
|
||||
if (id === "idle") {
|
||||
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(11) };
|
||||
}
|
||||
if (id === "active") {
|
||||
return { id, idleTimeout: 600, lastActiveAt: minutesAgo(5) };
|
||||
}
|
||||
if (id === "setup") return { id, restriction: "second-factor-setup" };
|
||||
return { id };
|
||||
},
|
||||
create: async (row: Record<string, unknown>) => {
|
||||
mocks.created.push(row);
|
||||
return row;
|
||||
},
|
||||
revoke: mocks.revoke,
|
||||
touch: async () => true,
|
||||
}),
|
||||
createCurrentUserRepository: () => ({}),
|
||||
createCurrentApiKeyRepository: () => ({}),
|
||||
@@ -54,7 +73,9 @@ vi.mock("../../utils/system-crypto.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const { AuthManager } = await import("../../utils/auth-manager.js");
|
||||
const { AuthManager, allowsSetupSession } =
|
||||
await import("../../utils/auth-manager.js");
|
||||
const { registerSecondFactor } = await import("../../auth/registry.js");
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
function makeLegacyDataKeyWrap(
|
||||
@@ -74,6 +95,8 @@ function makeLegacyDataKeyWrap(
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.created.length = 0;
|
||||
mocks.revoke.mockClear();
|
||||
mocks.hasUserDEK.mockReset().mockReturnValue(false);
|
||||
mocks.adoptRecoveredDEK.mockReset().mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -209,3 +232,97 @@ describe("AuthManager token handling", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("login policy on sessions", () => {
|
||||
const sign = (sessionId: string) =>
|
||||
jwt.sign({ userId: "user-1", sessionId }, jwtSecret, { expiresIn: "1h" });
|
||||
|
||||
it("caps the lifetime and stores the idle timeout and restriction", async () => {
|
||||
const token = await authManager.generateJWTToken("user-1", {
|
||||
rememberMe: true,
|
||||
deviceType: "web",
|
||||
deviceInfo: "Firefox",
|
||||
maxAgeSeconds: 600,
|
||||
idleTimeoutSeconds: 300,
|
||||
restriction: "second-factor-setup",
|
||||
});
|
||||
const payload = jwt.decode(token) as { iat: number; exp: number };
|
||||
expect(payload.exp - payload.iat).toBe(600);
|
||||
expect(mocks.created[0]).toMatchObject({
|
||||
idleTimeout: 300,
|
||||
restriction: "second-factor-setup",
|
||||
});
|
||||
const lifetime =
|
||||
new Date(mocks.created[0].expiresAt as string).getTime() -
|
||||
new Date(mocks.created[0].createdAt as string).getTime();
|
||||
expect(lifetime).toBe(600_000);
|
||||
});
|
||||
|
||||
it("ends a session idle past its timeout and keeps an active one", async () => {
|
||||
expect(await authManager.verifyJWTToken(sign("idle"))).toBeNull();
|
||||
expect(mocks.revoke).toHaveBeenCalledWith("idle");
|
||||
expect(await authManager.verifyJWTToken(sign("active"))).not.toBeNull();
|
||||
});
|
||||
|
||||
it("only lets a setup session reach setup routes", async () => {
|
||||
const middleware = authManager.createAuthMiddleware();
|
||||
const call = async (url: string) => {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
body: undefined as unknown,
|
||||
status(code: number) {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
},
|
||||
json(body: unknown) {
|
||||
res.body = body;
|
||||
return res;
|
||||
},
|
||||
clearCookie() {
|
||||
return res;
|
||||
},
|
||||
};
|
||||
const next = vi.fn();
|
||||
await middleware(
|
||||
{
|
||||
cookies: { jwt: sign("setup") },
|
||||
headers: {},
|
||||
originalUrl: url,
|
||||
url,
|
||||
} as never,
|
||||
res as never,
|
||||
next,
|
||||
);
|
||||
return { res, next };
|
||||
};
|
||||
|
||||
const blocked = await call("/host/db/host");
|
||||
expect(blocked.res.statusCode).toBe(403);
|
||||
expect(blocked.res.body).toMatchObject({
|
||||
code: "SECOND_FACTOR_SETUP_REQUIRED",
|
||||
});
|
||||
expect(blocked.next).not.toHaveBeenCalled();
|
||||
|
||||
const me = await call("/users/me");
|
||||
expect(me.next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows second factor plugins' routes and nothing else", () => {
|
||||
const dispose = registerSecondFactor({
|
||||
id: "key",
|
||||
pluginId: "keys",
|
||||
labelKey: "key",
|
||||
isEnrolled: async () => false,
|
||||
verify: async () => true,
|
||||
});
|
||||
try {
|
||||
expect(allowsSetupSession("/plugin-api/keys/register")).toBe(true);
|
||||
expect(allowsSetupSession("/plugin-api/other/thing")).toBe(false);
|
||||
expect(allowsSetupSession("/users/logout")).toBe(true);
|
||||
expect(allowsSetupSession("/users/me/token")).toBe(false);
|
||||
expect(allowsSetupSession("/credentials")).toBe(false);
|
||||
} finally {
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user