diff --git a/server/db/pg/schema.ts b/server/db/pg/schema.ts index 3cb5486b..5e34b033 100644 --- a/server/db/pg/schema.ts +++ b/server/db/pg/schema.ts @@ -9,6 +9,7 @@ import { text } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; +import { randomUUID } from "crypto"; export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), @@ -66,6 +67,10 @@ export const sites = pgTable("sites", { export const resources = pgTable("resources", { resourceId: serial("resourceId").primaryKey(), + resourceGuid: varchar("resourceGuid", { length: 36 }) + .unique() + .notNull() + .$defaultFn(() => randomUUID()), orgId: varchar("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -96,7 +101,7 @@ export const resources = pgTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "cascade" }), - headers: text("headers"), // comma-separated list of headers to add to the request + headers: text("headers") // comma-separated list of headers to add to the request }); export const targets = pgTable("targets", { @@ -117,7 +122,7 @@ export const targets = pgTable("targets", { internalPort: integer("internalPort"), enabled: boolean("enabled").notNull().default(true), path: text("path"), - pathMatchType: text("pathMatchType"), // exact, prefix, regex + pathMatchType: text("pathMatchType") // exact, prefix, regex }); export const exitNodes = pgTable("exitNodes", { @@ -135,7 +140,8 @@ export const exitNodes = pgTable("exitNodes", { region: varchar("region") }); -export const siteResources = pgTable("siteResources", { // this is for the clients +export const siteResources = pgTable("siteResources", { + // this is for the clients siteResourceId: serial("siteResourceId").primaryKey(), siteId: integer("siteId") .notNull() @@ -149,7 +155,7 @@ export const siteResources = pgTable("siteResources", { // this is for the clien proxyPort: integer("proxyPort").notNull(), destinationPort: integer("destinationPort").notNull(), destinationIp: varchar("destinationIp").notNull(), - enabled: boolean("enabled").notNull().default(true), + enabled: boolean("enabled").notNull().default(true) }); export const users = pgTable("user", { diff --git a/server/db/sqlite/schema.ts b/server/db/sqlite/schema.ts index 7362f28a..58827b00 100644 --- a/server/db/sqlite/schema.ts +++ b/server/db/sqlite/schema.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; @@ -72,6 +73,10 @@ export const sites = sqliteTable("sites", { export const resources = sqliteTable("resources", { resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), + resourceGuid: text("resourceGuid", { length: 36 }) + .unique() + .notNull() + .$defaultFn(() => randomUUID()), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -108,7 +113,7 @@ export const resources = sqliteTable("resources", { skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "cascade" }), - headers: text("headers"), // comma-separated list of headers to add to the request + headers: text("headers") // comma-separated list of headers to add to the request }); export const targets = sqliteTable("targets", { @@ -129,7 +134,7 @@ export const targets = sqliteTable("targets", { internalPort: integer("internalPort"), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), path: text("path"), - pathMatchType: text("pathMatchType"), // exact, prefix, regex + pathMatchType: text("pathMatchType") // exact, prefix, regex }); export const exitNodes = sqliteTable("exitNodes", { diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 4ab47ed6..c482a564 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -206,7 +206,7 @@ export async function verifyResourceSession( endpoint = config.getRawConfig().app.dashboard_url!; } const redirectUrl = `${endpoint}/auth/resource/${encodeURIComponent( - resource.resourceId + resource.resourceGuid )}?redirect=${encodeURIComponent(originalRequestURL)}`; // check for access token in headers diff --git a/server/routers/external.ts b/server/routers/external.ts index b851eda8..08b3c119 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -540,7 +540,7 @@ authenticated.post( ); authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey); -unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo); +unauthenticated.get("/resource/:resourceGuid/auth", resource.getResourceAuthInfo); // authenticated.get( // "/role/:roleId/resources", diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index c775564b..006495a7 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -15,16 +15,15 @@ import logger from "@server/logger"; const getResourceAuthInfoSchema = z .object({ - resourceId: z - .string() - .transform(Number) - .pipe(z.number().int().positive()) + resourceGuid: z.string() }) .strict(); export type GetResourceAuthInfoResponse = { resourceId: number; + resourceGuid: string; resourceName: string; + niceId: string; password: boolean; pincode: boolean; sso: boolean; @@ -51,7 +50,7 @@ export async function getResourceAuthInfo( ); } - const { resourceId } = parsedParams.data; + const { resourceGuid } = parsedParams.data; const [result] = await db .select() @@ -64,7 +63,7 @@ export async function getResourceAuthInfo( resourcePassword, eq(resourcePassword.resourceId, resources.resourceId) ) - .where(eq(resources.resourceId, resourceId)) + .where(eq(resources.resourceGuid, resourceGuid)) .limit(1); const resource = result?.resources; @@ -81,6 +80,8 @@ export async function getResourceAuthInfo( return response(res, { data: { + niceId: resource.niceId, + resourceGuid: resource.resourceGuid, resourceId: resource.resourceId, resourceName: resource.name, password: password !== null, diff --git a/server/setup/scriptsPg/1.10.4.ts b/server/setup/scriptsPg/1.10.4.ts index 77df637c..fdd1d0b5 100644 --- a/server/setup/scriptsPg/1.10.4.ts +++ b/server/setup/scriptsPg/1.10.4.ts @@ -1,6 +1,7 @@ import { db } from "@server/db/pg/driver"; import { sql } from "drizzle-orm"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; +import { randomUUID } from "crypto"; const version = "1.10.4"; @@ -10,34 +11,70 @@ export default async function migration() { try { await db.execute(sql`BEGIN`); - const webauthnCredentialsQuery = await db.execute(sql`SELECT "credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated" FROM "webauthnCredentials"`); + const webauthnCredentialsQuery = await db.execute( + sql`SELECT "credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated" FROM "webauthnCredentials"` + ); - const webauthnCredentials = webauthnCredentialsQuery.rows as { - credentialId: string; - publicKey: string; - userId: string; - signCount: number; - transports: string | null; - name: string | null; - lastUsed: string; + const webauthnCredentials = webauthnCredentialsQuery.rows as { + credentialId: string; + publicKey: string; + userId: string; + signCount: number; + transports: string | null; + name: string | null; + lastUsed: string; dateCreated: string; }[]; for (const webauthnCredential of webauthnCredentials) { - const newCredentialId = isoBase64URL.fromBuffer(new Uint8Array(Buffer.from(webauthnCredential.credentialId, 'base64'))); - const newPublicKey = isoBase64URL.fromBuffer(new Uint8Array(Buffer.from(webauthnCredential.publicKey, 'base64'))); - + const newCredentialId = isoBase64URL.fromBuffer( + new Uint8Array( + Buffer.from(webauthnCredential.credentialId, "base64") + ) + ); + const newPublicKey = isoBase64URL.fromBuffer( + new Uint8Array( + Buffer.from(webauthnCredential.publicKey, "base64") + ) + ); + // Delete the old record await db.execute(sql` - DELETE FROM "webauthnCredentials" + DELETE FROM "webauthnCredentials" WHERE "credentialId" = ${webauthnCredential.credentialId} `); - + // Insert the updated record with converted values await db.execute(sql` INSERT INTO "webauthnCredentials" ("credentialId", "publicKey", "userId", "signCount", "transports", "name", "lastUsed", "dateCreated") VALUES (${newCredentialId}, ${newPublicKey}, ${webauthnCredential.userId}, ${webauthnCredential.signCount}, ${webauthnCredential.transports}, ${webauthnCredential.name}, ${webauthnCredential.lastUsed}, ${webauthnCredential.dateCreated}) `); + + // 1. Add the column with placeholder so NOT NULL is satisfied + await db.execute(sql` + ALTER TABLE "resources" + ADD COLUMN IF NOT EXISTS "resourceGuid" varchar(36) NOT NULL DEFAULT 'PLACEHOLDER' + `); + + // 2. Fetch every row to backfill UUIDs + const rows = await db.execute( + sql`SELECT "resourceId" FROM "resources" WHERE "resourceGuid" = 'PLACEHOLDER'` + ); + const resources = rows.rows as { resourceId: number }[]; + + for (const r of resources) { + await db.execute(sql` + UPDATE "resources" + SET "resourceGuid" = ${randomUUID()} + WHERE "resourceId" = ${r.resourceId} + `); + } + + // 3. Add UNIQUE constraint now that values are filled + await db.execute(sql` + ALTER TABLE "resources" + ADD CONSTRAINT "resources_resourceGuid_unique" UNIQUE("resourceGuid") + `); } await db.execute(sql`COMMIT`); diff --git a/server/setup/scriptsPg/1.8.0.ts b/server/setup/scriptsPg/1.8.0.ts index 7c0b181b..f3b6c613 100644 --- a/server/setup/scriptsPg/1.8.0.ts +++ b/server/setup/scriptsPg/1.8.0.ts @@ -17,7 +17,7 @@ export default async function migration() { ALTER TABLE "sites" ADD COLUMN "remoteSubnets" text; ALTER TABLE "user" ADD COLUMN "termsAcceptedTimestamp" varchar; ALTER TABLE "user" ADD COLUMN "termsVersion" varchar; - + COMMIT; `); diff --git a/server/setup/scriptsSqlite/1.10.4.ts b/server/setup/scriptsSqlite/1.10.4.ts index cd00a65e..912c6fd5 100644 --- a/server/setup/scriptsSqlite/1.10.4.ts +++ b/server/setup/scriptsSqlite/1.10.4.ts @@ -2,6 +2,7 @@ import { APP_PATH } from "@server/lib/consts"; import Database from "better-sqlite3"; import path from "path"; import { isoBase64URL } from "@simplewebauthn/server/helpers"; +import { randomUUID } from "crypto"; const version = "1.10.4"; @@ -11,34 +12,77 @@ export default async function migration() { const location = path.join(APP_PATH, "db", "db.sqlite"); const db = new Database(location); - db.transaction(() => { - - const webauthnCredentials = db.prepare(`SELECT credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated FROM 'webauthnCredentials'`).all() as { - credentialId: string; publicKey: string; userId: string; signCount: number; transports: string | null; name: string | null; lastUsed: string; dateCreated: string; + db.transaction(() => { + const webauthnCredentials = db + .prepare( + `SELECT credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated FROM 'webauthnCredentials'` + ) + .all() as { + credentialId: string; + publicKey: string; + userId: string; + signCount: number; + transports: string | null; + name: string | null; + lastUsed: string; + dateCreated: string; }[]; for (const webauthnCredential of webauthnCredentials) { - const newCredentialId = isoBase64URL.fromBuffer(new Uint8Array(Buffer.from(webauthnCredential.credentialId, 'base64'))); - const newPublicKey = isoBase64URL.fromBuffer(new Uint8Array(Buffer.from(webauthnCredential.publicKey, 'base64'))); - + const newCredentialId = isoBase64URL.fromBuffer( + new Uint8Array( + Buffer.from(webauthnCredential.credentialId, "base64") + ) + ); + const newPublicKey = isoBase64URL.fromBuffer( + new Uint8Array( + Buffer.from(webauthnCredential.publicKey, "base64") + ) + ); + // Delete the old record - db.prepare(`DELETE FROM 'webauthnCredentials' WHERE 'credentialId' = ?`).run(webauthnCredential.credentialId); - + db.prepare( + `DELETE FROM 'webauthnCredentials' WHERE 'credentialId' = ?` + ).run(webauthnCredential.credentialId); + // Insert the updated record with converted values db.prepare( `INSERT INTO 'webauthnCredentials' (credentialId, publicKey, userId, signCount, transports, name, lastUsed, dateCreated) VALUES (?, ?, ?, ?, ?, ?, ?, ?)` ).run( - newCredentialId, - newPublicKey, - webauthnCredential.userId, - webauthnCredential.signCount, - webauthnCredential.transports, - webauthnCredential.name, - webauthnCredential.lastUsed, + newCredentialId, + newPublicKey, + webauthnCredential.userId, + webauthnCredential.signCount, + webauthnCredential.transports, + webauthnCredential.name, + webauthnCredential.lastUsed, webauthnCredential.dateCreated ); } - })(); + + // 1. Add the column (nullable or with placeholder) if it doesn’t exist yet + db.prepare( + `ALTER TABLE resources ADD COLUMN resourceGuid TEXT DEFAULT 'PLACEHOLDER';` + ).run(); + + db.prepare( + `CREATE UNIQUE INDEX resources_resourceGuid_unique ON resources ('resourceGuid');` + ).run(); + + // 2. Select all rows + const rows = db.prepare(`SELECT resourceId FROM resources`).all() as { + resourceId: number; + }[]; + + // 3. Prefill with random UUIDs + const updateStmt = db.prepare( + `UPDATE resources SET resourceGuid = ? WHERE resourceId = ?` + ); + + for (const row of rows) { + updateStmt.run(randomUUID(), row.resourceId); + } + })(); console.log(`${version} migration complete`); } diff --git a/src/app/auth/resource/[resourceId]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx similarity index 95% rename from src/app/auth/resource/[resourceId]/page.tsx rename to src/app/auth/resource/[resourceGuid]/page.tsx index 25580ee7..b221f44a 100644 --- a/src/app/auth/resource/[resourceId]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -20,7 +20,7 @@ import AutoLoginHandler from "@app/components/AutoLoginHandler"; export const dynamic = "force-dynamic"; export default async function ResourceAuthPage(props: { - params: Promise<{ resourceId: number }>; + params: Promise<{ resourceGuid: number }>; searchParams: Promise<{ redirect: string | undefined; token: string | undefined; @@ -37,7 +37,7 @@ export default async function ResourceAuthPage(props: { try { const res = await internal.get< AxiosResponse - >(`/resource/${params.resourceId}/auth`, authHeader); + >(`/resource/${params.resourceGuid}/auth`, authHeader); if (res && res.status === 200) { authInfo = res.data.data; @@ -48,10 +48,8 @@ export default async function ResourceAuthPage(props: { const user = await getUser({ skipCheckVerifyEmail: true }); if (!authInfo) { - // TODO: fix this return (
- {/* @ts-ignore */}
); @@ -86,7 +84,7 @@ export default async function ResourceAuthPage(props: { if (user && !user.emailVerified && env.flags.emailVerificationRequired) { redirect( - `/auth/verify-email?redirect=/auth/resource/${authInfo.resourceId}` + `/auth/verify-email?redirect=/auth/resource/${authInfo.resourceGuid}` ); } @@ -103,7 +101,7 @@ export default async function ResourceAuthPage(props: { const res = await priv.post< AxiosResponse >( - `/resource/${params.resourceId}/get-exchange-token`, + `/resource/${authInfo.resourceId}/get-exchange-token`, {}, await authCookieHeader() ); @@ -132,7 +130,7 @@ export default async function ResourceAuthPage(props: {
);