use resource guid in url closes #1517

This commit is contained in:
miloschwartz
2025-09-28 16:22:26 -07:00
parent 1a13694843
commit 8851156f23
9 changed files with 144 additions and 53 deletions

View File

@@ -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", {

View File

@@ -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", {

View File

@@ -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

View File

@@ -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",

View File

@@ -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<GetResourceAuthInfoResponse>(res, {
data: {
niceId: resource.niceId,
resourceGuid: resource.resourceGuid,
resourceId: resource.resourceId,
resourceName: resource.name,
password: password !== null,

View File

@@ -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`);

View File

@@ -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;
`);

View File

@@ -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 doesnt 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`);
}

View File

@@ -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<GetResourceAuthInfoResponse>
>(`/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 (
<div className="w-full max-w-md">
{/* @ts-ignore */}
<ResourceNotFound />
</div>
);
@@ -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<GetExchangeTokenResponse>
>(
`/resource/${params.resourceId}/get-exchange-token`,
`/resource/${authInfo.resourceId}/get-exchange-token`,
{},
await authCookieHeader()
);
@@ -132,7 +130,7 @@ export default async function ResourceAuthPage(props: {
<div className="w-full max-w-md">
<AccessToken
token={searchParams.token}
resourceId={params.resourceId}
resourceId={authInfo.resourceId}
/>
</div>
);