Fix: Extend Basic Auth compatibility with browsers

This commit is contained in:
Julien Breton
2025-12-01 01:18:09 +01:00
parent 02fbc279b5
commit 46ed27a218
31 changed files with 527 additions and 300 deletions

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Създайте своя организация, сайт и ресурси",
"setupNewOrg": "Нова организация",
"setupCreateOrg": "Създаване на организация",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Vytvořte si organizaci, lokalitu a služby",
"setupNewOrg": "Nová organizace",
"setupCreateOrg": "Vytvořit organizaci",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Erstelle eine Organisation, einen Standort und Ressourcen",
"setupNewOrg": "Neue Organisation",
"setupCreateOrg": "Organisation erstellen",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Create your organization, site, and resources",
"setupNewOrg": "New Organization",
"setupCreateOrg": "Create Organization",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Crea tu organización, sitio y recursos",
"setupNewOrg": "Nueva organización",
"setupCreateOrg": "Crear organización",

View File

@@ -1,4 +1,6 @@
{
"headerAuthCompatibilityInfo": "Activez cette option pour forcer une réponse 401 lorsqu'un jeton d'authentification est manquant. Cette option est nécessaire pour les navigateurs et certaines bibliothèques HTTP qui n'envoient pas d'informations d'identification sans challenge du serveur.",
"headerAuthCompatibility": "Compatibilité étendue",
"setupCreate": "Créez votre organisation, vos nœuds et vos ressources",
"setupNewOrg": "Nouvelle organisation",
"setupCreateOrg": "Créer une organisation",
@@ -1834,23 +1836,23 @@
"rewritePathDescription": "Réécrivez éventuellement le chemin avant de le transmettre à la cible.",
"continueToApplication": "Continuer vers l'application",
"checkingInvite": "Vérification de l'invitation",
"setResourceHeaderAuth": "Définir l\\'authentification d\\'en-tête de la ressource",
"resourceHeaderAuthRemove": "Supprimer l'authentification de l'en-tête",
"resourceHeaderAuthRemoveDescription": "Authentification de l'en-tête supprimée avec succès.",
"resourceErrorHeaderAuthRemove": "Échec de la suppression de l'authentification de l'en-tête",
"resourceErrorHeaderAuthRemoveDescription": "Impossible de supprimer l'authentification de l'en-tête de la ressource.",
"resourceHeaderAuthProtectionEnabled": "Authentification de l'en-tête activée",
"resourceHeaderAuthProtectionDisabled": "L'authentification de l'en-tête est désactivée",
"headerAuthRemove": "Supprimer l'authentification de l'en-tête",
"headerAuthAdd": "Ajouter l'authentification de l'en-tête",
"resourceErrorHeaderAuthSetup": "Impossible de définir l'authentification de l'en-tête",
"resourceErrorHeaderAuthSetupDescription": "Impossible de définir l'authentification de l'en-tête pour la ressource.",
"resourceHeaderAuthSetup": "Authentification de l'en-tête définie avec succès",
"resourceHeaderAuthSetupDescription": "L'authentification de l'en-tête a été définie avec succès.",
"resourceHeaderAuthSetupTitle": "Authentification de l'en-tête",
"resourceHeaderAuthSetupTitleDescription": "Définissez les identifiants d'authentification de base (nom d'utilisateur et mot de passe) pour protéger cette ressource avec l'authentification de l'en-tête HTTP. Accédez-y en utilisant le format https://username:password@resource.example.com",
"resourceHeaderAuthSubmit": "Authentification de l'en-tête",
"actionSetResourceHeaderAuth": "Authentification de l'en-tête",
"setResourceHeaderAuth": "Définir l\\'authentification via en-tête de la ressource",
"resourceHeaderAuthRemove": "Supprimer l'authentification via en-tête",
"resourceHeaderAuthRemoveDescription": "En-tête d'authentification supprimée avec succès.",
"resourceErrorHeaderAuthRemove": "Échec de la suppression de l'authentification via en-tête",
"resourceErrorHeaderAuthRemoveDescription": "Impossible de supprimer l'authentification via en-tête sur la ressource.",
"resourceHeaderAuthProtectionEnabled": "Authentification par en-tête Activée",
"resourceHeaderAuthProtectionDisabled": "Authentification par en-tête Désactivée",
"headerAuthRemove": "Supprimer l'authentification via en-tête",
"headerAuthAdd": "Ajouter une en-tête d'authentification",
"resourceErrorHeaderAuthSetup": "Impossible de définir l'authentification via en-tête",
"resourceErrorHeaderAuthSetupDescription": "Impossible de définir l'authentification via en-tête pour la ressource.",
"resourceHeaderAuthSetup": "Authentification via en-tête définie avec succès",
"resourceHeaderAuthSetupDescription": "L'authentification via en-tête a été définie avec succès.",
"resourceHeaderAuthSetupTitle": "Authentification via en-tête",
"resourceHeaderAuthSetupTitleDescription": "Définissez les identifiants d'authentification de base (nom d'utilisateur et mot de passe) pour protéger cette ressource avec l'authentification via en-tête HTTP. Accédez-y en utilisant le format https://username:password@resource.example.com",
"resourceHeaderAuthSubmit": "Activer la protection via en-tête",
"actionSetResourceHeaderAuth": "Authentification via en-tête",
"enterpriseEdition": "Édition Entreprise",
"unlicensed": "Sans licence",
"beta": "Bêta",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Crea la tua organizzazione, sito e risorse",
"setupNewOrg": "Nuova Organizzazione",
"setupCreateOrg": "Crea Organizzazione",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "조직, 사이트 및 리소스를 생성하십시오.",
"setupNewOrg": "새 조직",
"setupCreateOrg": "조직 생성",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Lag din organisasjon, område og dine ressurser",
"setupNewOrg": "Ny Organisasjon",
"setupCreateOrg": "Opprett organisasjon",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Maak uw organisatie, site en bronnen aan",
"setupNewOrg": "Nieuwe organisatie",
"setupCreateOrg": "Nieuwe organisatie aanmaken",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Utwórz swoją organizację, witrynę i zasoby",
"setupNewOrg": "Nowa organizacja",
"setupCreateOrg": "Utwórz organizację",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Crie sua organização, site e recursos",
"setupNewOrg": "Nova organização",
"setupCreateOrg": "Criar Organização",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Создайте свою организацию, сайт и ресурсы",
"setupNewOrg": "Новая организация",
"setupCreateOrg": "Создать организацию",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "Organizasyonunuzu, sitenizi ve kaynaklarınızı oluşturun",
"setupNewOrg": "Yeni Organizasyon",
"setupCreateOrg": "Organizasyon Oluştur",

View File

@@ -1,4 +1,6 @@
{
"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",
"setupCreate": "创建您的第一个组织、网站和资源",
"setupNewOrg": "新建组织",
"setupCreateOrg": "创建组织",

View File

@@ -419,6 +419,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")
@@ -781,6 +789,7 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;

View File

@@ -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,6 +57,10 @@ export async function getResourceByDomain(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId)
)
.innerJoin(
orgs,
eq(orgs.orgId, resources.orgId)
@@ -68,6 +77,7 @@ export async function getResourceByDomain(
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility,
org: result.orgs
};
}

View File

@@ -562,6 +562,16 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
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")
@@ -832,6 +842,7 @@ export type ResourceSession = InferSelectModel<typeof resourceSessions>;
export type ResourcePincode = InferSelectModel<typeof resourcePincode>;
export type ResourcePassword = InferSelectModel<typeof resourcePassword>;
export type ResourceHeaderAuth = InferSelectModel<typeof resourceHeaderAuth>;
export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<typeof resourceHeaderAuthExtendedCompatibility>;
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;

View File

@@ -2,7 +2,7 @@ import {
domains,
orgDomains,
Resource,
resourceHeaderAuth,
resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility,
resourcePincode,
resourceRules,
resourceWhitelist,
@@ -285,21 +285,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({
await Promise.all([
trx.insert(resourceHeaderAuth).values({
resourceId: existingResource.resourceId,
headerAuthHash
});
}),
trx.insert(resourceHeaderAuthExtendedCompatibility).values({
resourceId: existingResource.resourceId,
extendedCompatibilityIsActivated: headerAuthExtendedCompatibility
})
]);
}
}
@@ -646,18 +664,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({
await Promise.all([
trx.insert(resourceHeaderAuth).values({
resourceId: newResource.resourceId,
headerAuthHash
});
}),
trx.insert(resourceHeaderAuthExtendedCompatibility).values({
resourceId: newResource.resourceId,
extendedCompatibilityIsActivated: headerAuthExtendedCompatibility
}),
]);
}
}

View File

@@ -47,7 +47,8 @@ export const AuthSchema = z.object({
password: z.string().min(1).optional(),
"basic-auth": z.object({
user: z.string().min(1),
password: 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

View File

@@ -36,8 +36,10 @@ import {
LoginPage,
resourceHeaderAuth,
ResourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility,
ResourceHeaderAuthExtendedCompatibility,
orgs,
requestAuditLog
requestAuditLog,
} from "@server/db";
import {
resources,
@@ -188,6 +190,7 @@ export type ResourceWithAuth = {
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
};
export type UserSessionWithUser = {
@@ -511,6 +514,10 @@ hybridRouter.get(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(resourceHeaderAuthExtendedCompatibility.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, domain))
.limit(1);
@@ -543,7 +550,8 @@ hybridRouter.get(
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth
headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility: result.resourceHeaderAuthExtendedCompatibility
};
return response<ResourceWithAuth>(res, {

View File

@@ -9,7 +9,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

View File

@@ -13,7 +13,7 @@ import {
LoginPage,
Org,
Resource,
ResourceHeaderAuth,
ResourceHeaderAuth, ResourceHeaderAuthExtendedCompatibility,
ResourcePassword,
ResourcePincode,
ResourceRule,
@@ -65,6 +65,7 @@ type BasicUserData = {
export type VerifyUserResponse = {
valid: boolean;
headerAuthChallenged?: boolean;
redirectUrl?: string;
userData?: BasicUserData;
};
@@ -142,6 +143,7 @@ export async function verifyResourceSession(
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
org: Org;
}
| undefined = cache.get(resourceCacheKey);
@@ -171,7 +173,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}`);
@@ -450,7 +452,8 @@ export async function verifyResourceSession(
!sso &&
!pincode &&
!password &&
!resource.emailWhitelistEnabled
!resource.emailWhitelistEnabled &&
!headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated
) {
logRequestAudit(
{
@@ -465,13 +468,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(
{
@@ -557,7 +562,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
@@ -701,6 +706,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) {
@@ -833,6 +843,46 @@ function allowed(res: Response, userData?: BasicUserData) {
return response<VerifyUserResponse>(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<VerifyUserResponse>(res, data);
}
async function isUserAllowedToAccessResource(
userSessionId: string,
resource: Resource,

View File

@@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
import {z} from "zod";
import {
db,
resourceHeaderAuth,
resourceHeaderAuth, resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode,
resources
@@ -27,6 +27,7 @@ export type GetResourceAuthInfoResponse = {
password: boolean;
pincode: boolean;
headerAuth: boolean;
headerAuthExtendedCompatibility: boolean;
sso: boolean;
blockAccess: boolean;
url: string;
@@ -76,6 +77,13 @@ export async function getResourceAuthInfo(
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceId, Number(resourceGuid)))
.limit(1)
: await db
@@ -97,6 +105,13 @@ export async function getResourceAuthInfo(
resources.resourceId
)
)
.leftJoin(
resourceHeaderAuthExtendedCompatibility,
eq(
resourceHeaderAuthExtendedCompatibility.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceGuid, resourceGuid))
.limit(1);
@@ -110,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}`;
@@ -122,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,

View File

@@ -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,
@@ -111,7 +111,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,
@@ -133,6 +133,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,

View File

@@ -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 {eq} from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -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({
@@ -67,20 +68,28 @@ export async function setResourceHeaderAuth(
}
const {resourceId} = parsedParams.data;
const { user, password } = parsedBody.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) {
if (user && password && extendedCompatibility !== null) {
const headerAuthHash = await hashPassword(Buffer.from(`${user}:${password}`).toString("base64"));
await trx
await Promise.all([
trx
.insert(resourceHeaderAuth)
.values({ resourceId, headerAuthHash });
.values({resourceId, headerAuthHash}),
trx
.insert(resourceHeaderAuthExtendedCompatibility)
.values({resourceId, extendedCompatibilityIsActivated: extendedCompatibility})
]);
}
});
return response(res, {

View File

@@ -439,7 +439,8 @@ export default function ResourceAuthenticationPage() {
api.post(`/resource/${resource.resourceId}/header-auth`, {
user: null,
password: null
password: null,
extendedCompatibility: null,
})
.then(() => {
toast({

View File

@@ -31,17 +31,21 @@ 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<typeof setHeaderAuthFormSchema>;
const defaultValues: Partial<SetHeaderAuthFormValues> = {
user: "",
password: ""
password: "",
extendedCompatibility: false
};
type SetHeaderAuthFormProps = {
@@ -80,17 +84,8 @@ export default function SetResourceHeaderAuthForm({
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/header-auth`, {
user: data.user,
password: data.password
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorHeaderAuthSetup'),
description: formatAxiosError(
e,
t('resourceErrorHeaderAuthSetupDescription')
)
});
password: data.password,
extendedCompatibility: data.extendedCompatibility
})
.then(() => {
toast({
@@ -102,6 +97,16 @@ export default function SetResourceHeaderAuthForm({
onSetHeaderAuth();
}
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorHeaderAuthSetup'),
description: formatAxiosError(
e,
t('resourceErrorHeaderAuthSetupDescription')
)
});
})
.finally(() => setLoading(false));
}
@@ -163,6 +168,24 @@ export default function SetResourceHeaderAuthForm({
</FormItem>
)}
/>
<FormField
control={form.control}
name="extendedCompatibility"
render={({field}) => (
<FormItem>
<FormControl>
<SwitchInput
id="header-auth-compatibility-toggle"
label={t("headerAuthCompatibility")}
info={t('headerAuthCompatibilityInfo')}
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>

View File

@@ -77,16 +77,6 @@ export default function SetResourcePasswordForm({
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/password`, {
password: data.password
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPasswordSetup'),
description: formatAxiosError(
e,
t('resourceErrorPasswordSetupDescription')
)
});
})
.then(() => {
toast({
@@ -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));
}

View File

@@ -83,16 +83,6 @@ export default function SetResourcePincodeForm({
api.post<AxiosResponse<Resource>>(`/resource/${resourceId}/pincode`, {
pincode: data.pincode
})
.catch((e) => {
toast({
variant: "destructive",
title: t('resourceErrorPincodeSetup'),
description: formatAxiosError(
e,
t('resourceErrorPincodeSetupDescription')
)
});
})
.then(() => {
toast({
@@ -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));
}

View File

@@ -1,11 +1,16 @@
import React from "react";
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;
@@ -16,11 +21,23 @@ export function SwitchInput({
id,
label,
description,
info,
disabled,
checked,
defaultChecked = false,
onCheckedChange
}: SwitchComponentProps) {
const defaultTrigger = (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-full p-0"
>
<Info className="h-4 w-4"/>
<span className="sr-only">Show info</span>
</Button>
);
return (
<div>
<div className="flex items-center space-x-2 mb-2">
@@ -32,6 +49,18 @@ export function SwitchInput({
disabled={disabled}
/>
{label && <Label htmlFor={id}>{label}</Label>}
{info && <Popover>
<PopoverTrigger asChild>
{defaultTrigger}
</PopoverTrigger>
<PopoverContent className="w-80">
{info && (
<p className="text-sm text-muted-foreground">
{info}
</p>
)}
</PopoverContent>
</Popover>}
</div>
{description && (
<span className="text-muted-foreground text-sm">