mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: store rdp, vnc and telnet logins in a generic per-protocol table
This commit is contained in:
1 parent
e0c30a4e6f
commit
bba06f7ff6
107 files changed
+18750
-1224
No files matched your search
@@ -0,0 +1,178 @@
|
||||
import crypto from "node:crypto";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { TestSqliteDatabase } from "./test-support.js";
|
||||
import {
|
||||
HostProtocolAuthRepository,
|
||||
decryptProtocolLogin,
|
||||
protocolAuthRecordId,
|
||||
type HostProtocolLogin,
|
||||
} from "../../../database/repositories/host-protocol-auth-repository.js";
|
||||
import { FieldCrypto } from "../../../utils/field-crypto.js";
|
||||
|
||||
const ownerKey = crypto.randomBytes(32);
|
||||
const otherKey = crypto.randomBytes(32);
|
||||
|
||||
function login(overrides: Partial<HostProtocolLogin> = {}): HostProtocolLogin {
|
||||
return {
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
credentialId: null,
|
||||
username: "viewer",
|
||||
password: "spice-pass",
|
||||
fields: { display: "2" },
|
||||
secretFields: { ticket: "t-secret" },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("HostProtocolAuthRepository", () => {
|
||||
let adapter: TestSqliteDatabase | null = null;
|
||||
let writes = 0;
|
||||
|
||||
afterEach(async () => {
|
||||
await adapter?.close();
|
||||
adapter = null;
|
||||
});
|
||||
|
||||
async function createRepository(): Promise<HostProtocolAuthRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
await adapter.exec(`
|
||||
INSERT INTO users (id, username, password_hash) VALUES
|
||||
('owner-1', 'owner-1', 'hash'),
|
||||
('owner-2', 'owner-2', 'hash');
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(5, 'owner-1', 'cred-5', 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
|
||||
(42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 'password'),
|
||||
(43, 'owner-1', 'stage', '10.0.0.43', 22, 'root', 'password'),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 'password');
|
||||
`);
|
||||
writes = 0;
|
||||
return new HostProtocolAuthRepository(context, () => {
|
||||
writes++;
|
||||
});
|
||||
}
|
||||
|
||||
it("round-trips a login with its secrets encrypted at rest", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
|
||||
const row = await repository.findRow(42, "spice");
|
||||
expect(row).toMatchObject({
|
||||
hostId: 42,
|
||||
userId: "owner-1",
|
||||
protocol: "spice",
|
||||
authType: "direct",
|
||||
username: "viewer",
|
||||
fields: JSON.stringify({ display: "2" }),
|
||||
});
|
||||
expect(FieldCrypto.isEncrypted(row!.password!)).toBe(true);
|
||||
expect(row!.password).not.toContain("spice-pass");
|
||||
expect(row!.secretFields).not.toContain("t-secret");
|
||||
expect(await repository.find(42, "spice", ownerKey)).toEqual(login());
|
||||
expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
it("updates in place, one row per host and protocol", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
42,
|
||||
login({ username: "other", password: null, secretFields: {} }),
|
||||
ownerKey,
|
||||
);
|
||||
|
||||
expect(await repository.listRowsForHost(42)).toHaveLength(1);
|
||||
expect(await repository.find(42, "spice", ownerKey)).toMatchObject({
|
||||
username: "other",
|
||||
password: null,
|
||||
secretFields: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("opens nothing with another user's key", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
|
||||
expect(await repository.find(42, "spice", otherKey)).toMatchObject({
|
||||
username: "viewer",
|
||||
password: null,
|
||||
secretFields: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a tampered secret as missing", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
const row = (await repository.findRow(42, "spice"))!;
|
||||
const envelope = JSON.parse(row.password!);
|
||||
envelope.data = envelope.data.replace(/^./, (c: string) =>
|
||||
c === "0" ? "1" : "0",
|
||||
);
|
||||
|
||||
expect(
|
||||
decryptProtocolLogin(
|
||||
{ ...row, password: JSON.stringify(envelope) },
|
||||
ownerKey,
|
||||
).password,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("binds each secret to its host and protocol", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
const row = (await repository.findRow(42, "spice"))!;
|
||||
|
||||
expect(JSON.parse(row.password!).recordId).toBe(
|
||||
protocolAuthRecordId(42, "spice"),
|
||||
);
|
||||
expect(() =>
|
||||
FieldCrypto.decryptField(row.password!, ownerKey, "", "secretFields"),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("finds the owner's hosts whose login uses a credential", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
42,
|
||||
login({ authType: "credential", credentialId: 5, password: null }),
|
||||
ownerKey,
|
||||
);
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
43,
|
||||
login({ protocol: "x2go" }),
|
||||
ownerKey,
|
||||
);
|
||||
|
||||
expect(await repository.listHostIdsForCredential("owner-1", 5)).toEqual([
|
||||
42,
|
||||
]);
|
||||
expect(await repository.listHostIdsForCredential("owner-2", 5)).toEqual([]);
|
||||
expect(
|
||||
(await repository.listRowsForUser("owner-1"))
|
||||
.map((row) => row.hostId)
|
||||
.sort(),
|
||||
).toEqual([42, 43]);
|
||||
});
|
||||
|
||||
it("deletes one protocol's login", async () => {
|
||||
const repository = await createRepository();
|
||||
await repository.upsert("owner-1", 42, login(), ownerKey);
|
||||
await repository.upsert(
|
||||
"owner-1",
|
||||
42,
|
||||
login({ protocol: "x2go" }),
|
||||
ownerKey,
|
||||
);
|
||||
|
||||
expect(await repository.delete(42, "spice")).toBe(true);
|
||||
expect(await repository.delete(42, "spice")).toBe(false);
|
||||
expect(
|
||||
(await repository.listRowsForHost(42)).map((row) => row.protocol),
|
||||
).toEqual(["x2go"]);
|
||||
});
|
||||
});
|
||||
@@ -192,9 +192,6 @@ describe("HostResolutionRepository", () => {
|
||||
await expect(repository.findHostUpdateState(1)).resolves.toEqual({
|
||||
userId: "user-1",
|
||||
credentialId: 7,
|
||||
rdpCredentialId: null,
|
||||
vncCredentialId: null,
|
||||
telnetCredentialId: null,
|
||||
authType: "password",
|
||||
parentHostId: null,
|
||||
folder: null,
|
||||
|
||||
@@ -36,8 +36,8 @@ describe("RbacAccessRepository", () => {
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type) VALUES
|
||||
(44, 'admin', 'host-44', '10.0.0.45', 22, 'root', 'password');
|
||||
INSERT INTO ssh_data (
|
||||
id, user_id, name, ip, port, username, credential_id, rdp_credential_id, vnc_credential_id, telnet_credential_id, folder, tags, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 125, 126, 'servers', 'linux', 'password');
|
||||
id, user_id, name, ip, port, username, credential_id, folder, tags, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'servers', 'linux', 'password');
|
||||
INSERT INTO host_access (
|
||||
id, host_id, user_id, role_id, granted_by, permission_level, expires_at, created_at
|
||||
)
|
||||
@@ -138,9 +138,6 @@ describe("RbacAccessRepository", () => {
|
||||
{
|
||||
hostAccessId: 2,
|
||||
credentialId: 123,
|
||||
rdpCredentialId: 124,
|
||||
vncCredentialId: 125,
|
||||
telnetCredentialId: 126,
|
||||
hostId: 42,
|
||||
hostOwnerId: "owner-1",
|
||||
},
|
||||
|
||||
@@ -31,10 +31,13 @@ describe("SharedHostSecretsRepository", () => {
|
||||
INSERT INTO ssh_credentials (id, user_id, name, username, auth_type) VALUES
|
||||
(123, 'user-1', 'cred-123', 'root', 'password'),
|
||||
(124, 'user-1', 'cred-124', 'root', 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, rdp_credential_id, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 124, 'password'),
|
||||
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, NULL, 'password'),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, NULL, 'password');
|
||||
INSERT INTO ssh_data (id, user_id, name, ip, port, username, credential_id, auth_type)
|
||||
VALUES (42, 'owner-1', 'prod', '10.0.0.42', 22, 'root', 123, 'password'),
|
||||
(43, 'owner-1', 'staging', '10.0.0.43', 22, 'root', NULL, 'password'),
|
||||
(44, 'owner-2', 'other', '10.0.0.44', 22, 'root', 123, 'password');
|
||||
INSERT INTO host_protocol_auth (host_id, user_id, protocol, auth_type, credential_id)
|
||||
VALUES (42, 'owner-1', 'spice', 'credential', 124),
|
||||
(43, 'owner-1', 'spice', 'direct', NULL);
|
||||
INSERT INTO host_access (id, host_id, user_id, role_id, granted_by)
|
||||
VALUES
|
||||
(1, 42, 'user-1', NULL, 'owner-1'),
|
||||
|
||||
Reference in new issue
Block a user