diff --git a/messages/en-US.json b/messages/en-US.json index 3b5fc9ea..c3ac72b9 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,5 +1,7 @@ { "setupCreate": "Create the organization, site, and resources", + "headerAuthCompatibilityInfo": "Enable this to force a 401 Unauthorized response when an authentication token is missing. This is required for browsers or specific HTTP libraries that do not send credentials without a server challenge.", + "headerAuthCompatibility": "Extended compatibility", "setupNewOrg": "New Organization", "setupCreateOrg": "Create Organization", "setupCreateResources": "Create Resources", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index c689a35a..9f74f601 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -456,6 +456,14 @@ export const resourceHeaderAuth = pgTable("resourceHeaderAuth", { headerAuthHash: varchar("headerAuthHash").notNull() }); +export const resourceHeaderAuthExtendedCompatibility = pgTable("resourceHeaderAuthExtendedCompatibility", { + headerAuthExtendedCompatibilityId: serial("headerAuthExtendedCompatibilityId").primaryKey(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + extendedCompatibilityIsActivated: boolean("extendedCompatibilityIsActivated").notNull().default(false), +}); + export const resourceAccessToken = pgTable("resourceAccessToken", { accessTokenId: varchar("accessTokenId").primaryKey(), orgId: varchar("orgId") @@ -856,6 +864,7 @@ export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; export type ResourceHeaderAuth = InferSelectModel; +export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel; export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 774c4e53..381185e5 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,6 @@ -import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db"; +import { + db, loginPage, LoginPage, loginPageOrg, Org, orgs, +} from "@server/db"; import { Resource, ResourcePassword, @@ -14,7 +16,9 @@ import { sessions, userOrgs, userResources, - users + users, + ResourceHeaderAuthExtendedCompatibility, + resourceHeaderAuthExtendedCompatibility } from "@server/db"; import { and, eq } from "drizzle-orm"; @@ -23,6 +27,7 @@ export type ResourceWithAuth = { pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null org: Org; }; @@ -52,7 +57,14 @@ export async function getResourceByDomain( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) - .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId) + ) + .innerJoin( + orgs, + eq(orgs.orgId, resources.orgId) + ) .where(eq(resources.fullDomain, domain)) .limit(1); @@ -65,6 +77,7 @@ export async function getResourceByDomain( pincode: result.resourcePincode, password: result.resourcePassword, headerAuth: result.resourceHeaderAuth, + headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility, org: result.orgs }; } diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 848289ee..e9f81b17 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -12,22 +12,22 @@ import { no } from "zod/v4/locales"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), baseDomain: text("baseDomain").notNull(), - configManaged: integer("configManaged", { mode: "boolean" }) + configManaged: integer("configManaged", {mode: "boolean"}) .notNull() .default(false), type: text("type"), // "ns", "cname", "wildcard" - verified: integer("verified", { mode: "boolean" }).notNull().default(false), - failed: integer("failed", { mode: "boolean" }).notNull().default(false), + verified: integer("verified", {mode: "boolean"}).notNull().default(false), + failed: integer("failed", {mode: "boolean"}).notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: text("certResolver"), - preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }) + preferWildcardCert: integer("preferWildcardCert", {mode: "boolean"}) }); export const dnsRecords = sqliteTable("dnsRecords", { - id: integer("id").primaryKey({ autoIncrement: true }), + id: integer("id").primaryKey({autoIncrement: true}), domainId: text("domainId") .notNull() - .references(() => domains.domainId, { onDelete: "cascade" }), + .references(() => domains.domainId, {onDelete: "cascade"}), recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" baseDomain: text("baseDomain"), @@ -41,7 +41,7 @@ export const orgs = sqliteTable("orgs", { subnet: text("subnet"), utilitySubnet: text("utilitySubnet"), // this is the subnet for utility addresses createdAt: text("createdAt"), - requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), + requireTwoFactor: integer("requireTwoFactor", {mode: "boolean"}), maxSessionLengthHours: integer("maxSessionLengthHours"), // hours passwordExpiryDays: integer("passwordExpiryDays"), // days settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year @@ -58,23 +58,23 @@ export const orgs = sqliteTable("orgs", { export const userDomains = sqliteTable("userDomains", { userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), domainId: text("domainId") .notNull() - .references(() => domains.domainId, { onDelete: "cascade" }) + .references(() => domains.domainId, {onDelete: "cascade"}) }); export const orgDomains = sqliteTable("orgDomains", { orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), + .references(() => orgs.orgId, {onDelete: "cascade"}), domainId: text("domainId") .notNull() - .references(() => domains.domainId, { onDelete: "cascade" }) + .references(() => domains.domainId, {onDelete: "cascade"}) }); export const sites = sqliteTable("sites", { - siteId: integer("siteId").primaryKey({ autoIncrement: true }), + siteId: integer("siteId").primaryKey({autoIncrement: true}), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -91,7 +91,7 @@ export const sites = sqliteTable("sites", { megabytesOut: integer("bytesOut").default(0), lastBandwidthUpdate: text("lastBandwidthUpdate"), type: text("type").notNull(), // "newt" or "wireguard" - online: integer("online", { mode: "boolean" }).notNull().default(false), + online: integer("online", {mode: "boolean"}).notNull().default(false), // exit node stuff that is how to connect to the site when it has a wg server address: text("address"), // this is the address of the wireguard interface in newt @@ -99,14 +99,14 @@ export const sites = sqliteTable("sites", { publicKey: text("publicKey"), // TODO: Fix typo in publicKey lastHolePunch: integer("lastHolePunch"), listenPort: integer("listenPort"), - dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) + dockerSocketEnabled: integer("dockerSocketEnabled", {mode: "boolean"}) .notNull() .default(true) }); export const resources = sqliteTable("resources", { - resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), - resourceGuid: text("resourceGuid", { length: 36 }) + resourceId: integer("resourceId").primaryKey({autoIncrement: true}), + resourceGuid: text("resourceGuid", {length: 36}) .unique() .notNull() .$defaultFn(() => randomUUID()), @@ -122,27 +122,27 @@ export const resources = sqliteTable("resources", { domainId: text("domainId").references(() => domains.domainId, { onDelete: "set null" }), - ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), - blockAccess: integer("blockAccess", { mode: "boolean" }) + ssl: integer("ssl", {mode: "boolean"}).notNull().default(false), + blockAccess: integer("blockAccess", {mode: "boolean"}) .notNull() .default(false), - sso: integer("sso", { mode: "boolean" }).notNull().default(true), - http: integer("http", { mode: "boolean" }).notNull().default(true), + sso: integer("sso", {mode: "boolean"}).notNull().default(true), + http: integer("http", {mode: "boolean"}).notNull().default(true), protocol: text("protocol").notNull(), proxyPort: integer("proxyPort"), - emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) + emailWhitelistEnabled: integer("emailWhitelistEnabled", {mode: "boolean"}) .notNull() .default(false), - applyRules: integer("applyRules", { mode: "boolean" }) + applyRules: integer("applyRules", {mode: "boolean"}) .notNull() .default(false), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), - stickySession: integer("stickySession", { mode: "boolean" }) + enabled: integer("enabled", {mode: "boolean"}).notNull().default(true), + stickySession: integer("stickySession", {mode: "boolean"}) .notNull() .default(false), tlsServerName: text("tlsServerName"), setHostHeader: text("setHostHeader"), - enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), + enableProxy: integer("enableProxy", {mode: "boolean"}).default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { onDelete: "set null" }), @@ -154,7 +154,7 @@ export const resources = sqliteTable("resources", { }); export const targets = sqliteTable("targets", { - targetId: integer("targetId").primaryKey({ autoIncrement: true }), + targetId: integer("targetId").primaryKey({autoIncrement: true}), resourceId: integer("resourceId") .references(() => resources.resourceId, { onDelete: "cascade" @@ -169,7 +169,7 @@ export const targets = sqliteTable("targets", { method: text("method"), port: integer("port").notNull(), internalPort: integer("internalPort"), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + enabled: integer("enabled", {mode: "boolean"}).notNull().default(true), path: text("path"), pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target @@ -183,8 +183,8 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { }), targetId: integer("targetId") .notNull() - .references(() => targets.targetId, { onDelete: "cascade" }), - hcEnabled: integer("hcEnabled", { mode: "boolean" }) + .references(() => targets.targetId, {onDelete: "cascade"}), + hcEnabled: integer("hcEnabled", {mode: "boolean"}) .notNull() .default(false), hcPath: text("hcPath"), @@ -206,7 +206,7 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { }); export const exitNodes = sqliteTable("exitNodes", { - exitNodeId: integer("exitNodeId").primaryKey({ autoIncrement: true }), + exitNodeId: integer("exitNodeId").primaryKey({autoIncrement: true}), name: text("name").notNull(), address: text("address").notNull(), // this is the address of the wireguard interface in gerbil endpoint: text("endpoint").notNull(), // this is how to reach gerbil externally - gets put into the wireguard config @@ -214,7 +214,7 @@ export const exitNodes = sqliteTable("exitNodes", { listenPort: integer("listenPort").notNull(), reachableAt: text("reachableAt"), // this is the internal address of the gerbil http server for command control maxConnections: integer("maxConnections"), - online: integer("online", { mode: "boolean" }).notNull().default(false), + online: integer("online", {mode: "boolean"}).notNull().default(false), lastPing: integer("lastPing"), type: text("type").default("gerbil"), // gerbil, remoteExitNode region: text("region") @@ -227,10 +227,10 @@ export const siteResources = sqliteTable("siteResources", { }), siteId: integer("siteId") .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }), + .references(() => sites.siteId, {onDelete: "cascade"}), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), + .references(() => orgs.orgId, {onDelete: "cascade"}), niceId: text("niceId").notNull(), name: text("name").notNull(), mode: text("mode").notNull(), // "host" | "cidr" | "port" @@ -283,20 +283,20 @@ export const users = sqliteTable("user", { onDelete: "cascade" }), passwordHash: text("passwordHash"), - twoFactorEnabled: integer("twoFactorEnabled", { mode: "boolean" }) + twoFactorEnabled: integer("twoFactorEnabled", {mode: "boolean"}) .notNull() .default(false), twoFactorSetupRequested: integer("twoFactorSetupRequested", { mode: "boolean" }).default(false), twoFactorSecret: text("twoFactorSecret"), - emailVerified: integer("emailVerified", { mode: "boolean" }) + emailVerified: integer("emailVerified", {mode: "boolean"}) .notNull() .default(false), dateCreated: text("dateCreated").notNull(), termsAcceptedTimestamp: text("termsAcceptedTimestamp"), termsVersion: text("termsVersion"), - serverAdmin: integer("serverAdmin", { mode: "boolean" }) + serverAdmin: integer("serverAdmin", {mode: "boolean"}) .notNull() .default(false), lastPasswordChange: integer("lastPasswordChange") @@ -330,7 +330,7 @@ export const webauthnChallenge = sqliteTable("webauthnChallenge", { export const setupTokens = sqliteTable("setupTokens", { tokenId: text("tokenId").primaryKey(), token: text("token").notNull(), - used: integer("used", { mode: "boolean" }).notNull().default(false), + used: integer("used", {mode: "boolean"}).notNull().default(false), dateCreated: text("dateCreated").notNull(), dateUsed: text("dateUsed") }); @@ -369,7 +369,7 @@ export const clients = sqliteTable("clients", { lastBandwidthUpdate: text("lastBandwidthUpdate"), lastPing: integer("lastPing"), type: text("type").notNull(), // "olm" - online: integer("online", { mode: "boolean" }).notNull().default(false), + online: integer("online", {mode: "boolean"}).notNull().default(false), // endpoint: text("endpoint"), lastHolePunch: integer("lastHolePunch") }); @@ -415,10 +415,10 @@ export const olms = sqliteTable("olms", { }); export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { - codeId: integer("id").primaryKey({ autoIncrement: true }), + codeId: integer("id").primaryKey({autoIncrement: true}), userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), codeHash: text("codeHash").notNull() }); @@ -426,7 +426,7 @@ export const sessions = sqliteTable("session", { sessionId: text("id").primaryKey(), userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), expiresAt: integer("expiresAt").notNull(), issuedAt: integer("issuedAt"), deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" }) @@ -438,7 +438,7 @@ export const newtSessions = sqliteTable("newtSession", { sessionId: text("id").primaryKey(), newtId: text("newtId") .notNull() - .references(() => newts.newtId, { onDelete: "cascade" }), + .references(() => newts.newtId, {onDelete: "cascade"}), expiresAt: integer("expiresAt").notNull() }); @@ -446,14 +446,14 @@ export const olmSessions = sqliteTable("clientSession", { sessionId: text("id").primaryKey(), olmId: text("olmId") .notNull() - .references(() => olms.olmId, { onDelete: "cascade" }), + .references(() => olms.olmId, {onDelete: "cascade"}), expiresAt: integer("expiresAt").notNull() }); export const userOrgs = sqliteTable("userOrgs", { userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -462,28 +462,28 @@ export const userOrgs = sqliteTable("userOrgs", { roleId: integer("roleId") .notNull() .references(() => roles.roleId), - isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), + isOwner: integer("isOwner", {mode: "boolean"}).notNull().default(false), autoProvisioned: integer("autoProvisioned", { mode: "boolean" }).default(false) }); export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { - codeId: integer("id").primaryKey({ autoIncrement: true }), + codeId: integer("id").primaryKey({autoIncrement: true}), userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), email: text("email").notNull(), code: text("code").notNull(), expiresAt: integer("expiresAt").notNull() }); export const passwordResetTokens = sqliteTable("passwordResetTokens", { - tokenId: integer("id").primaryKey({ autoIncrement: true }), + tokenId: integer("id").primaryKey({autoIncrement: true}), email: text("email").notNull(), userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), tokenHash: text("tokenHash").notNull(), expiresAt: integer("expiresAt").notNull() }); @@ -495,13 +495,13 @@ export const actions = sqliteTable("actions", { }); export const roles = sqliteTable("roles", { - roleId: integer("roleId").primaryKey({ autoIncrement: true }), + roleId: integer("roleId").primaryKey({autoIncrement: true}), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" }) .notNull(), - isAdmin: integer("isAdmin", { mode: "boolean" }), + isAdmin: integer("isAdmin", {mode: "boolean"}), name: text("name").notNull(), description: text("description") }); @@ -509,92 +509,92 @@ export const roles = sqliteTable("roles", { export const roleActions = sqliteTable("roleActions", { roleId: integer("roleId") .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), + .references(() => roles.roleId, {onDelete: "cascade"}), actionId: text("actionId") .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), + .references(() => actions.actionId, {onDelete: "cascade"}), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) + .references(() => orgs.orgId, {onDelete: "cascade"}) }); export const userActions = sqliteTable("userActions", { userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), actionId: text("actionId") .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), + .references(() => actions.actionId, {onDelete: "cascade"}), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) + .references(() => orgs.orgId, {onDelete: "cascade"}) }); export const roleSites = sqliteTable("roleSites", { roleId: integer("roleId") .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), + .references(() => roles.roleId, {onDelete: "cascade"}), siteId: integer("siteId") .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }) + .references(() => sites.siteId, {onDelete: "cascade"}) }); export const userSites = sqliteTable("userSites", { userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), siteId: integer("siteId") .notNull() - .references(() => sites.siteId, { onDelete: "cascade" }) + .references(() => sites.siteId, {onDelete: "cascade"}) }); export const userClients = sqliteTable("userClients", { userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), clientId: integer("clientId") .notNull() - .references(() => clients.clientId, { onDelete: "cascade" }) + .references(() => clients.clientId, {onDelete: "cascade"}) }); export const roleClients = sqliteTable("roleClients", { roleId: integer("roleId") .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), + .references(() => roles.roleId, {onDelete: "cascade"}), clientId: integer("clientId") .notNull() - .references(() => clients.clientId, { onDelete: "cascade" }) + .references(() => clients.clientId, {onDelete: "cascade"}) }); export const roleResources = sqliteTable("roleResources", { roleId: integer("roleId") .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), + .references(() => roles.roleId, {onDelete: "cascade"}), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, {onDelete: "cascade"}) }); export const userResources = sqliteTable("userResources", { userId: text("userId") .notNull() - .references(() => users.userId, { onDelete: "cascade" }), + .references(() => users.userId, {onDelete: "cascade"}), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, {onDelete: "cascade"}) }); export const userInvites = sqliteTable("userInvites", { inviteId: text("inviteId").primaryKey(), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), + .references(() => orgs.orgId, {onDelete: "cascade"}), email: text("email").notNull(), expiresAt: integer("expiresAt").notNull(), tokenHash: text("token").notNull(), roleId: integer("roleId") .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) + .references(() => roles.roleId, {onDelete: "cascade"}) }); export const resourcePincode = sqliteTable("resourcePincode", { @@ -603,7 +603,7 @@ export const resourcePincode = sqliteTable("resourcePincode", { }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, {onDelete: "cascade"}), pincodeHash: text("pincodeHash").notNull(), digitLength: integer("digitLength").notNull() }); @@ -614,7 +614,7 @@ export const resourcePassword = sqliteTable("resourcePassword", { }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, {onDelete: "cascade"}), passwordHash: text("passwordHash").notNull() }); @@ -624,18 +624,28 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", { }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, {onDelete: "cascade"}), headerAuthHash: text("headerAuthHash").notNull() }); +export const resourceHeaderAuthExtendedCompatibility = sqliteTable("resourceHeaderAuthExtendedCompatibility", { + headerAuthExtendedCompatibilityId: integer("headerAuthExtendedCompatibilityId").primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, {onDelete: "cascade"}), + extendedCompatibilityIsActivated: integer("extendedCompatibilityIsActivated", {mode: "boolean"}).notNull() +}); + export const resourceAccessToken = sqliteTable("resourceAccessToken", { accessTokenId: text("accessTokenId").primaryKey(), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), + .references(() => orgs.orgId, {onDelete: "cascade"}), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, {onDelete: "cascade"}), tokenHash: text("tokenHash").notNull(), sessionLength: integer("sessionLength").notNull(), expiresAt: integer("expiresAt"), @@ -648,13 +658,13 @@ export const resourceSessions = sqliteTable("resourceSessions", { sessionId: text("id").primaryKey(), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, {onDelete: "cascade"}), expiresAt: integer("expiresAt").notNull(), sessionLength: integer("sessionLength").notNull(), - doNotExtend: integer("doNotExtend", { mode: "boolean" }) + doNotExtend: integer("doNotExtend", {mode: "boolean"}) .notNull() .default(false), - isRequestToken: integer("isRequestToken", { mode: "boolean" }), + isRequestToken: integer("isRequestToken", {mode: "boolean"}), userSessionId: text("userSessionId").references(() => sessions.sessionId, { onDelete: "cascade" }), @@ -686,11 +696,11 @@ export const resourceSessions = sqliteTable("resourceSessions", { }); export const resourceWhitelist = sqliteTable("resourceWhitelist", { - whitelistId: integer("id").primaryKey({ autoIncrement: true }), + whitelistId: integer("id").primaryKey({autoIncrement: true}), email: text("email").notNull(), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }) + .references(() => resources.resourceId, {onDelete: "cascade"}) }); export const resourceOtp = sqliteTable("resourceOtp", { @@ -699,7 +709,7 @@ export const resourceOtp = sqliteTable("resourceOtp", { }), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), + .references(() => resources.resourceId, {onDelete: "cascade"}), email: text("email").notNull(), otpHash: text("otpHash").notNull(), expiresAt: integer("expiresAt").notNull() @@ -711,11 +721,11 @@ export const versionMigrations = sqliteTable("versionMigrations", { }); export const resourceRules = sqliteTable("resourceRules", { - ruleId: integer("ruleId").primaryKey({ autoIncrement: true }), + ruleId: integer("ruleId").primaryKey({autoIncrement: true}), resourceId: integer("resourceId") .notNull() - .references(() => resources.resourceId, { onDelete: "cascade" }), - enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + .references(() => resources.resourceId, {onDelete: "cascade"}), + enabled: integer("enabled", {mode: "boolean"}).notNull().default(true), priority: integer("priority").notNull(), action: text("action").notNull(), // ACCEPT, DROP, PASS match: text("match").notNull(), // CIDR, PATH, IP @@ -723,17 +733,17 @@ export const resourceRules = sqliteTable("resourceRules", { }); export const supporterKey = sqliteTable("supporterKey", { - keyId: integer("keyId").primaryKey({ autoIncrement: true }), + keyId: integer("keyId").primaryKey({autoIncrement: true}), key: text("key").notNull(), githubUsername: text("githubUsername").notNull(), phrase: text("phrase"), tier: text("tier"), - valid: integer("valid", { mode: "boolean" }).notNull().default(false) + valid: integer("valid", {mode: "boolean"}).notNull().default(false) }); // Identity Providers export const idp = sqliteTable("idp", { - idpId: integer("idpId").primaryKey({ autoIncrement: true }), + idpId: integer("idpId").primaryKey({autoIncrement: true}), name: text("name").notNull(), type: text("type").notNull(), defaultRoleMapping: text("defaultRoleMapping"), @@ -753,7 +763,7 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { variant: text("variant").notNull().default("oidc"), idpId: integer("idpId") .notNull() - .references(() => idp.idpId, { onDelete: "cascade" }), + .references(() => idp.idpId, {onDelete: "cascade"}), clientId: text("clientId").notNull(), clientSecret: text("clientSecret").notNull(), authUrl: text("authUrl").notNull(), @@ -781,22 +791,22 @@ export const apiKeys = sqliteTable("apiKeys", { apiKeyHash: text("apiKeyHash").notNull(), lastChars: text("lastChars").notNull(), createdAt: text("dateCreated").notNull(), - isRoot: integer("isRoot", { mode: "boolean" }).notNull().default(false) + isRoot: integer("isRoot", {mode: "boolean"}).notNull().default(false) }); export const apiKeyActions = sqliteTable("apiKeyActions", { apiKeyId: text("apiKeyId") .notNull() - .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), + .references(() => apiKeys.apiKeyId, {onDelete: "cascade"}), actionId: text("actionId") .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }) + .references(() => actions.actionId, {onDelete: "cascade"}) }); export const apiKeyOrg = sqliteTable("apiKeyOrg", { apiKeyId: text("apiKeyId") .notNull() - .references(() => apiKeys.apiKeyId, { onDelete: "cascade" }), + .references(() => apiKeys.apiKeyId, {onDelete: "cascade"}), orgId: text("orgId") .references(() => orgs.orgId, { onDelete: "cascade" @@ -807,10 +817,10 @@ export const apiKeyOrg = sqliteTable("apiKeyOrg", { export const idpOrg = sqliteTable("idpOrg", { idpId: integer("idpId") .notNull() - .references(() => idp.idpId, { onDelete: "cascade" }), + .references(() => idp.idpId, {onDelete: "cascade"}), orgId: text("orgId") .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }), + .references(() => orgs.orgId, {onDelete: "cascade"}), roleMapping: text("roleMapping"), orgMapping: text("orgMapping") }); @@ -828,19 +838,19 @@ export const blueprints = sqliteTable("blueprints", { name: text("name").notNull(), source: text("source").notNull(), createdAt: integer("createdAt").notNull(), - succeeded: integer("succeeded", { mode: "boolean" }).notNull(), + succeeded: integer("succeeded", {mode: "boolean"}).notNull(), contents: text("contents").notNull(), message: text("message") }); export const requestAuditLog = sqliteTable( "requestAuditLog", { - id: integer("id").primaryKey({ autoIncrement: true }), + id: integer("id").primaryKey({autoIncrement: true}), timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds orgId: text("orgId").references(() => orgs.orgId, { onDelete: "cascade" }), - action: integer("action", { mode: "boolean" }).notNull(), + action: integer("action", {mode: "boolean"}).notNull(), reason: integer("reason").notNull(), actorType: text("actorType"), actor: text("actor"), @@ -857,7 +867,7 @@ export const requestAuditLog = sqliteTable( host: text("host"), path: text("path"), method: text("method"), - tls: integer("tls", { mode: "boolean" }) + tls: integer("tls", {mode: "boolean"}) }, (table) => [ index("idx_requestAuditLog_timestamp").on(table.timestamp), @@ -913,6 +923,7 @@ export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; export type ResourceHeaderAuth = InferSelectModel; +export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel; export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 706fab12..6488dd32 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -2,7 +2,7 @@ import { domains, orgDomains, Resource, - resourceHeaderAuth, + resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, resourcePincode, resourceRules, resourceWhitelist, @@ -16,8 +16,8 @@ import { userResources, users } from "@server/db"; -import { resources, targets, sites } from "@server/db"; -import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm"; +import {resources, targets, sites} from "@server/db"; +import {eq, and, asc, or, ne, count, isNotNull} from "drizzle-orm"; import { Config, ConfigSchema, @@ -25,12 +25,12 @@ import { TargetData } from "./types"; import logger from "@server/logger"; -import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; -import { pickPort } from "@server/routers/target/helpers"; -import { resourcePassword } from "@server/db"; -import { hashPassword } from "@server/auth/password"; -import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; -import { get } from "http"; +import {createCertificate} from "#dynamic/routers/certificates/createCertificate"; +import {pickPort} from "@server/routers/target/helpers"; +import {resourcePassword} from "@server/db"; +import {hashPassword} from "@server/auth/password"; +import {isValidCIDR, isValidIP, isValidUrlGlobPattern} from "../validators"; +import {get} from "http"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -63,7 +63,7 @@ export async function updateProxyResources( if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({ siteId: sites.siteId }) + .select({siteId: sites.siteId}) .from(sites) .where( and( @@ -75,7 +75,7 @@ export async function updateProxyResources( } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx - .select({ siteId: sites.siteId }) + .select({siteId: sites.siteId}) .from(sites) .where( and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) @@ -93,7 +93,7 @@ export async function updateProxyResources( let internalPortToCreate; if (!targetData["internal-port"]) { - const { internalPort, targetIps } = await pickPort( + const {internalPort, targetIps} = await pickPort( site.siteId!, trx ); @@ -228,7 +228,7 @@ export async function updateProxyResources( tlsServerName: resourceData["tls-server-name"] || null, emailWhitelistEnabled: resourceData.auth?.[ "whitelist-users" - ] + ] ? resourceData.auth["whitelist-users"].length > 0 : false, headers: headers || null, @@ -287,21 +287,39 @@ export async function updateProxyResources( existingResource.resourceId ) ); + + await trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + existingResource.resourceId + ) + ); + if (resourceData.auth?.["basic-auth"]) { const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; - if (headerAuthUser && headerAuthPassword) { + const headerAuthExtendedCompatibility = + resourceData.auth?.["basic-auth"]?.extendedCompatibility; + if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` ).toString("base64") ); - await trx.insert(resourceHeaderAuth).values({ - resourceId: existingResource.resourceId, - headerAuthHash - }); + await Promise.all([ + trx.insert(resourceHeaderAuth).values({ + resourceId: existingResource.resourceId, + headerAuthHash + }), + trx.insert(resourceHeaderAuthExtendedCompatibility).values({ + resourceId: existingResource.resourceId, + extendedCompatibilityIsActivated: headerAuthExtendedCompatibility + }) + ]); } } @@ -362,7 +380,7 @@ export async function updateProxyResources( if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({ siteId: sites.siteId }) + .select({siteId: sites.siteId}) .from(sites) .where( and( @@ -374,7 +392,7 @@ export async function updateProxyResources( } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx - .select({ siteId: sites.siteId }) + .select({siteId: sites.siteId}) .from(sites) .where( and( @@ -419,7 +437,7 @@ export async function updateProxyResources( if (checkIfTargetChanged(existingTarget, updatedTarget)) { let internalPortToUpdate; if (!targetData["internal-port"]) { - const { internalPort, targetIps } = await pickPort( + const {internalPort, targetIps} = await pickPort( site.siteId!, trx ); @@ -656,18 +674,25 @@ export async function updateProxyResources( const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; + const headerAuthExtendedCompatibility = resourceData.auth?.["basic-auth"]?.extendedCompatibility; - if (headerAuthUser && headerAuthPassword) { + if (headerAuthUser && headerAuthPassword && headerAuthExtendedCompatibility !== null) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` ).toString("base64") ); - await trx.insert(resourceHeaderAuth).values({ - resourceId: newResource.resourceId, - headerAuthHash - }); + await Promise.all([ + trx.insert(resourceHeaderAuth).values({ + resourceId: newResource.resourceId, + headerAuthHash + }), + trx.insert(resourceHeaderAuthExtendedCompatibility).values({ + resourceId: newResource.resourceId, + extendedCompatibilityIsActivated: headerAuthExtendedCompatibility + }), + ]); } } @@ -1018,7 +1043,7 @@ async function getDomain( trx: Transaction ) { const [fullDomainExists] = await trx - .select({ resourceId: resources.resourceId }) + .select({resourceId: resources.resourceId}) .from(resources) .where( and( diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index df6d7bb0..4cde2fcd 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -53,12 +53,11 @@ export const AuthSchema = z.object({ // pincode has to have 6 digits pincode: z.number().min(100000).max(999999).optional(), password: z.string().min(1).optional(), - "basic-auth": z - .object({ - user: z.string().min(1), - password: z.string().min(1) - }) - .optional(), + "basic-auth": z.object({ + user: z.string().min(1), + password: z.string().min(1), + extendedCompatibility: z.boolean().default(false) + }).optional(), "sso-enabled": z.boolean().optional().default(false), "sso-roles": z .array(z.string()) diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 751a1a0c..a6a00645 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -36,8 +36,10 @@ import { LoginPage, resourceHeaderAuth, ResourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, + ResourceHeaderAuthExtendedCompatibility, orgs, - requestAuditLog + requestAuditLog, } from "@server/db"; import { resources, @@ -175,6 +177,7 @@ export type ResourceWithAuth = { pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; }; export type UserSessionWithUser = { @@ -498,6 +501,10 @@ hybridRouter.get( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId) + ) .where(eq(resources.fullDomain, domain)) .limit(1); @@ -530,7 +537,8 @@ hybridRouter.get( resource: result.resources, pincode: result.resourcePincode, password: result.resourcePassword, - headerAuth: result.resourceHeaderAuth + headerAuth: result.resourceHeaderAuth, + headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility }; return response(res, { diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 1343bdaa..80e3f419 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -10,7 +10,7 @@ Reasons: 100 - Allowed by Rule 101 - Allowed No Auth 102 - Valid Access Token -103 - Valid header auth +103 - Valid Header Auth (HTTP Basic Auth) 104 - Valid Pincode 105 - Valid Password 106 - Valid email diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0e3a3489..e209ee57 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -13,7 +13,7 @@ import { LoginPage, Org, Resource, - ResourceHeaderAuth, + ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility, ResourcePassword, ResourcePincode, ResourceRule, @@ -66,6 +66,7 @@ type BasicUserData = { export type VerifyUserResponse = { valid: boolean; + headerAuthChallenged?: boolean; redirectUrl?: string; userData?: BasicUserData; }; @@ -147,6 +148,7 @@ export async function verifyResourceSession( pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; } | undefined = cache.get(resourceCacheKey); @@ -176,7 +178,7 @@ export async function verifyResourceSession( cache.set(resourceCacheKey, resourceData, 5); } - const { resource, pincode, password, headerAuth } = resourceData; + const { resource, pincode, password, headerAuth, headerAuthExtendedCompatibility } = resourceData; if (!resource) { logger.debug(`Resource not found ${cleanHost}`); @@ -456,7 +458,8 @@ export async function verifyResourceSession( !sso && !pincode && !password && - !resource.emailWhitelistEnabled + !resource.emailWhitelistEnabled && + !headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated ) { logRequestAudit( { @@ -471,13 +474,15 @@ export async function verifyResourceSession( return notAllowed(res); } - } else if (headerAuth) { + } + else if (headerAuth) { // if there are no other auth methods we need to return unauthorized if nothing is provided if ( !sso && !pincode && !password && - !resource.emailWhitelistEnabled + !resource.emailWhitelistEnabled && + !headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated ) { logRequestAudit( { @@ -563,7 +568,7 @@ export async function verifyResourceSession( } if (resourceSession) { - // only run this check if not SSO sesion; SSO session length is checked later + // only run this check if not SSO session; SSO session length is checked later const accessPolicy = await enforceResourceSessionLength( resourceSession, resourceData.org @@ -707,6 +712,11 @@ export async function verifyResourceSession( } } + // If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge + if (headerAuthExtendedCompatibility && headerAuthExtendedCompatibility.extendedCompatibilityIsActivated && !clientHeaderAuth){ + return headerAuthChallenged(res, redirectPath, resource.orgId); + } + logger.debug("No more auth to check, resource not allowed"); if (config.getRawConfig().app.log_failed_attempts) { @@ -839,6 +849,46 @@ function allowed(res: Response, userData?: BasicUserData) { return response(res, data); } +async function headerAuthChallenged( + res: Response, + redirectPath?: string, + orgId?: string +) { + let loginPage: LoginPage | null = null; + if (orgId) { + const { tier } = await getOrgTierData(orgId); // returns null in oss + if (tier === TierId.STANDARD) { + loginPage = await getOrgLoginPage(orgId); + } + } + + let redirectUrl: string | undefined = undefined; + if (redirectPath) { + let endpoint: string; + + if (loginPage && loginPage.domainId && loginPage.fullDomain) { + const secure = config + .getRawConfig() + .app.dashboard_url?.startsWith("https"); + const method = secure ? "https" : "http"; + endpoint = `${method}://${loginPage.fullDomain}`; + } else { + endpoint = config.getRawConfig().app.dashboard_url!; + } + redirectUrl = `${endpoint}${redirectPath}`; + } + + const data = { + data: { headerAuthChallenged: true, valid: false, redirectUrl }, + success: true, + error: false, + message: "Access denied", + status: HttpCode.OK + }; + logger.debug(JSON.stringify(data)); + return response(res, data); +} + async function isUserAllowedToAccessResource( userSessionId: string, resource: Resource, diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 61479a4d..5602a909 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -1,19 +1,19 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; +import {Request, Response, NextFunction} from "express"; +import {z} from "zod"; import { db, - resourceHeaderAuth, + resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility, resourcePassword, resourcePincode, resources } from "@server/db"; -import { eq } from "drizzle-orm"; +import {eq} from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; +import {fromError} from "zod-validation-error"; import logger from "@server/logger"; -import { build } from "@server/build"; +import {build} from "@server/build"; const getResourceAuthInfoSchema = z.strictObject({ resourceGuid: z.string() @@ -27,6 +27,7 @@ export type GetResourceAuthInfoResponse = { password: boolean; pincode: boolean; headerAuth: boolean; + headerAuthExtendedCompatibility: boolean; sso: boolean; blockAccess: boolean; url: string; @@ -51,53 +52,68 @@ export async function getResourceAuthInfo( ); } - const { resourceGuid } = parsedParams.data; + const {resourceGuid} = parsedParams.data; const isGuidInteger = /^\d+$/.test(resourceGuid); const [result] = isGuidInteger && build === "saas" ? await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) - .leftJoin( - resourceHeaderAuth, - eq( - resourceHeaderAuth.resourceId, - resources.resourceId - ) - ) - .where(eq(resources.resourceId, Number(resourceGuid))) - .limit(1) + .leftJoin( + resourceHeaderAuth, + eq( + resourceHeaderAuth.resourceId, + resources.resourceId + ) + ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) + .where(eq(resources.resourceId, Number(resourceGuid))) + .limit(1) : await db - .select() - .from(resources) - .leftJoin( - resourcePincode, - eq(resourcePincode.resourceId, resources.resourceId) - ) - .leftJoin( - resourcePassword, - eq(resourcePassword.resourceId, resources.resourceId) - ) - .leftJoin( - resourceHeaderAuth, - eq( - resourceHeaderAuth.resourceId, - resources.resourceId - ) - ) - .where(eq(resources.resourceGuid, resourceGuid)) - .limit(1); + .select() + .from(resources) + .leftJoin( + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) + ) + .leftJoin( + resourcePassword, + eq(resourcePassword.resourceId, resources.resourceId) + ) + + .leftJoin( + resourceHeaderAuth, + eq( + resourceHeaderAuth.resourceId, + resources.resourceId + ) + ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) + .where(eq(resources.resourceGuid, resourceGuid)) + .limit(1); const resource = result?.resources; if (!resource) { @@ -109,6 +125,7 @@ export async function getResourceAuthInfo( const pincode = result?.resourcePincode; const password = result?.resourcePassword; const headerAuth = result?.resourceHeaderAuth; + const headerAuthExtendedCompatibility = result?.resourceHeaderAuthExtendedCompatibility; const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; @@ -121,6 +138,7 @@ export async function getResourceAuthInfo( password: password !== null, pincode: pincode !== null, headerAuth: headerAuth !== null, + headerAuthExtendedCompatibility: headerAuthExtendedCompatibility !== null, sso: resource.sso, blockAccess: resource.blockAccess, url, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 1c8f0864..da62eec3 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resourceHeaderAuth } from "@server/db"; +import {db, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility} from "@server/db"; import { resources, userResources, @@ -109,7 +109,7 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { domainId: resources.domainId, niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, - + headerAuthExtendedCompatibilityId: resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId, targetId: targets.targetId, targetIp: targets.ip, targetPort: targets.port, @@ -131,6 +131,10 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId) + ) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .leftJoin( targetHealthCheck, diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index b89179ae..6c866578 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -1,14 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db, resourceHeaderAuth } from "@server/db"; -import { eq } from "drizzle-orm"; +import {Request, Response, NextFunction} from "express"; +import {z} from "zod"; +import {db, resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility} from "@server/db"; +import {eq} from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; -import { response } from "@server/lib/response"; +import {fromError} from "zod-validation-error"; +import {response} from "@server/lib/response"; import logger from "@server/logger"; -import { hashPassword } from "@server/auth/password"; -import { OpenAPITags, registry } from "@server/openApi"; +import {hashPassword} from "@server/auth/password"; +import {OpenAPITags, registry} from "@server/openApi"; const setResourceAuthMethodsParamsSchema = z.object({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -16,7 +16,8 @@ const setResourceAuthMethodsParamsSchema = z.object({ const setResourceAuthMethodsBodySchema = z.strictObject({ user: z.string().min(4).max(100).nullable(), - password: z.string().min(4).max(100).nullable() + password: z.string().min(4).max(100).nullable(), + extendedCompatibility: z.boolean().nullable() }); registry.registerPath({ @@ -66,23 +67,29 @@ export async function setResourceHeaderAuth( ); } - const { resourceId } = parsedParams.data; - const { user, password } = parsedBody.data; + const {resourceId} = parsedParams.data; + const {user, password, extendedCompatibility} = parsedBody.data; await db.transaction(async (trx) => { await trx .delete(resourceHeaderAuth) .where(eq(resourceHeaderAuth.resourceId, resourceId)); + await trx.delete(resourceHeaderAuthExtendedCompatibility).where(eq(resourceHeaderAuthExtendedCompatibility.resourceId, resourceId)); - if (user && password) { - const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") - ); + if (user && password && extendedCompatibility !== null) { + const headerAuthHash = await hashPassword(Buffer.from(`${user}:${password}`).toString("base64")); - await trx - .insert(resourceHeaderAuth) - .values({ resourceId, headerAuthHash }); + await Promise.all([ + trx + .insert(resourceHeaderAuth) + .values({resourceId, headerAuthHash}), + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({resourceId, extendedCompatibilityIsActivated: extendedCompatibility}) + ]); } + + }); return response(res, { diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 37b09428..fa0784c4 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -397,7 +397,8 @@ export default function ResourceAuthenticationPage() { api.post(`/resource/${resource.resourceId}/header-auth`, { user: null, - password: null + password: null, + extendedCompatibility: null, }) .then(() => { toast({ diff --git a/src/components/SetResourceHeaderAuthForm.tsx b/src/components/SetResourceHeaderAuthForm.tsx index 12e02d25..6185d6f2 100644 --- a/src/components/SetResourceHeaderAuthForm.tsx +++ b/src/components/SetResourceHeaderAuthForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button } from "@app/components/ui/button"; +import {Button} from "@app/components/ui/button"; import { Form, FormControl, @@ -9,12 +9,12 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { toast } from "@app/hooks/useToast"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; +import {Input} from "@app/components/ui/input"; +import {toast} from "@app/hooks/useToast"; +import {zodResolver} from "@hookform/resolvers/zod"; +import {useEffect, useState} from "react"; +import {useForm} from "react-hook-form"; +import {z} from "zod"; import { Credenza, CredenzaBody, @@ -25,23 +25,27 @@ import { CredenzaHeader, CredenzaTitle } from "@app/components/Credenza"; -import { formatAxiosError } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { Resource } from "@server/db"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useTranslations } from "next-intl"; +import {formatAxiosError} from "@app/lib/api"; +import {AxiosResponse} from "axios"; +import {Resource} from "@server/db"; +import {createApiClient} from "@app/lib/api"; +import {useEnvContext} from "@app/hooks/useEnvContext"; +import {useTranslations} from "next-intl"; +import {SwitchInput} from "@/components/SwitchInput"; +import {InfoPopup} from "@/components/ui/info-popup"; const setHeaderAuthFormSchema = z.object({ user: z.string().min(4).max(100), - password: z.string().min(4).max(100) + password: z.string().min(4).max(100), + extendedCompatibility: z.boolean() }); type SetHeaderAuthFormValues = z.infer; const defaultValues: Partial = { user: "", - password: "" + password: "", + extendedCompatibility: false }; type SetHeaderAuthFormProps = { @@ -52,11 +56,11 @@ type SetHeaderAuthFormProps = { }; export default function SetResourceHeaderAuthForm({ - open, - setOpen, - resourceId, - onSetHeaderAuth -}: SetHeaderAuthFormProps) { + open, + setOpen, + resourceId, + onSetHeaderAuth + }: SetHeaderAuthFormProps) { const api = createApiClient(useEnvContext()); const t = useTranslations(); @@ -78,23 +82,11 @@ export default function SetResourceHeaderAuthForm({ async function onSubmit(data: SetHeaderAuthFormValues) { setLoading(true); - api.post>( - `/resource/${resourceId}/header-auth`, - { - user: data.user, - password: data.password - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorHeaderAuthSetup"), - description: formatAxiosError( - e, - t("resourceErrorHeaderAuthSetupDescription") - ) - }); - }) + api.post>(`/resource/${resourceId}/header-auth`, { + user: data.user, + password: data.password, + extendedCompatibility: data.extendedCompatibility + }) .then(() => { toast({ title: t("resourceHeaderAuthSetup"), @@ -105,6 +97,16 @@ export default function SetResourceHeaderAuthForm({ onSetHeaderAuth(); } }) + .catch((e) => { + toast({ + variant: "destructive", + title: t('resourceErrorHeaderAuthSetup'), + description: formatAxiosError( + e, + t('resourceErrorHeaderAuthSetupDescription') + ) + }); + }) .finally(() => setLoading(false)); } @@ -137,7 +139,7 @@ export default function SetResourceHeaderAuthForm({ ( + render={({field}) => ( {t("user")} @@ -147,14 +149,14 @@ export default function SetResourceHeaderAuthForm({ {...field} /> - + )} /> ( + render={({field}) => ( {t("password")} @@ -166,7 +168,25 @@ export default function SetResourceHeaderAuthForm({ {...field} /> - + + + )} + /> + ( + + + + + )} /> diff --git a/src/components/SetResourcePasswordForm.tsx b/src/components/SetResourcePasswordForm.tsx index 7b2345e4..d0720527 100644 --- a/src/components/SetResourcePasswordForm.tsx +++ b/src/components/SetResourcePasswordForm.tsx @@ -78,16 +78,6 @@ export default function SetResourcePasswordForm({ api.post>(`/resource/${resourceId}/password`, { password: data.password }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorPasswordSetup"), - description: formatAxiosError( - e, - t("resourceErrorPasswordSetupDescription") - ) - }); - }) .then(() => { toast({ title: t("resourcePasswordSetup"), @@ -98,6 +88,16 @@ export default function SetResourcePasswordForm({ onSetPassword(); } }) + .catch((e) => { + toast({ + variant: "destructive", + title: t('resourceErrorPasswordSetup'), + description: formatAxiosError( + e, + t('resourceErrorPasswordSetupDescription') + ) + }); + }) .finally(() => setLoading(false)); } diff --git a/src/components/SetResourcePincodeForm.tsx b/src/components/SetResourcePincodeForm.tsx index 82ef811a..caae42b9 100644 --- a/src/components/SetResourcePincodeForm.tsx +++ b/src/components/SetResourcePincodeForm.tsx @@ -84,16 +84,6 @@ export default function SetResourcePincodeForm({ api.post>(`/resource/${resourceId}/pincode`, { pincode: data.pincode }) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorPincodeSetup"), - description: formatAxiosError( - e, - t("resourceErrorPincodeSetupDescription") - ) - }); - }) .then(() => { toast({ title: t("resourcePincodeSetup"), @@ -104,6 +94,16 @@ export default function SetResourcePincodeForm({ onSetPincode(); } }) + .catch((e) => { + toast({ + variant: "destructive", + title: t('resourceErrorPincodeSetup'), + description: formatAxiosError( + e, + t('resourceErrorPincodeSetupDescription') + ) + }); + }) .finally(() => setLoading(false)); } diff --git a/src/components/SwitchInput.tsx b/src/components/SwitchInput.tsx index a2291c2e..e76b5277 100644 --- a/src/components/SwitchInput.tsx +++ b/src/components/SwitchInput.tsx @@ -1,11 +1,16 @@ import React from "react"; -import { Switch } from "./ui/switch"; -import { Label } from "./ui/label"; +import {Switch} from "./ui/switch"; +import {Label} from "./ui/label"; +import {Button} from "@/components/ui/button"; +import {Info} from "lucide-react"; +import {info} from "winston"; +import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"; interface SwitchComponentProps { id: string; label?: string; description?: string; + info?: string; checked?: boolean; defaultChecked?: boolean; disabled?: boolean; @@ -13,14 +18,26 @@ interface SwitchComponentProps { } export function SwitchInput({ - id, - label, - description, - disabled, - checked, - defaultChecked = false, - onCheckedChange -}: SwitchComponentProps) { + id, + label, + description, + info, + disabled, + checked, + defaultChecked = false, + onCheckedChange + }: SwitchComponentProps) { + const defaultTrigger = ( + + ); + return (
@@ -32,6 +49,18 @@ export function SwitchInput({ disabled={disabled} /> {label && } + {info && + + {defaultTrigger} + + + {info && ( +

+ {info} +

+ )} +
+
}
{description && (