Adding HTTP Header Authentication

This commit is contained in:
Owen
2025-10-06 10:14:02 -07:00
parent cb7c57fd03
commit 850e9a734a
13 changed files with 516 additions and 42 deletions

View File

@@ -61,6 +61,7 @@ export enum ActionsEnum {
getUser = "getUser",
setResourcePassword = "setResourcePassword",
setResourcePincode = "setResourcePincode",
setResourceHeaderAuth = "setResourceHeaderAuth",
setResourceWhitelist = "setResourceWhitelist",
getResourceWhitelist = "getResourceWhitelist",
generateAccessToken = "generateAccessToken",
@@ -194,7 +195,6 @@ export async function checkUserActionPermission(
return roleActionPermission.length > 0;
return false;
} catch (error) {
console.error("Error checking user action permission:", error);
throw createHttpError(

View File

@@ -380,6 +380,14 @@ export const resourcePassword = pgTable("resourcePassword", {
passwordHash: varchar("passwordHash").notNull()
});
export const resourceHeaderAuth = pgTable("resourceHeaderAuth", {
headerAuthId: serial("headerAuthId").primaryKey(),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
headerAuthHash: varchar("headerAuthHash").notNull()
});
export const resourceAccessToken = pgTable("resourceAccessToken", {
accessTokenId: varchar("accessTokenId").primaryKey(),
orgId: varchar("orgId")
@@ -689,6 +697,7 @@ export type UserOrg = InferSelectModel<typeof userOrgs>;
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 ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;

View File

@@ -6,6 +6,8 @@ import {
ResourceRule,
resourcePassword,
resourcePincode,
resourceHeaderAuth,
ResourceHeaderAuth,
resourceRules,
resources,
roleResources,
@@ -24,6 +26,7 @@ export type ResourceWithAuth = {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
};
export type UserSessionWithUser = {
@@ -72,6 +75,10 @@ export async function getResourceByDomain(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(resourceHeaderAuth.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, domain))
.limit(1);
@@ -82,7 +89,8 @@ export async function getResourceByDomain(
return {
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth
};
}

View File

@@ -513,6 +513,16 @@ export const resourcePassword = sqliteTable("resourcePassword", {
passwordHash: text("passwordHash").notNull()
});
export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthId: integer("headerAuthId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
headerAuthHash: text("headerAuthHash").notNull()
});
export const resourceAccessToken = sqliteTable("resourceAccessToken", {
accessTokenId: text("accessTokenId").primaryKey(),
orgId: text("orgId")
@@ -728,6 +738,7 @@ export type UserOrg = InferSelectModel<typeof userOrgs>;
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 ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;

View File

@@ -7,22 +7,21 @@ import {
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import {
getResourceByDomain,
getUserSessionWithUser,
getUserOrgRole,
getRoleResourceAccess,
getUserResourceAccess,
getResourceRules,
getOrgLoginPage
getRoleResourceAccess,
getUserOrgRole,
getUserResourceAccess,
getOrgLoginPage,
getUserSessionWithUser
} from "@server/db/queries/verifySessionQueries";
import {
LoginPage,
Resource,
ResourceAccessToken,
ResourceHeaderAuth,
ResourcePassword,
ResourcePincode,
ResourceRule,
sessions,
users
ResourceRule
} from "@server/db";
import config from "@server/lib/config";
import { isIpInCidr } from "@server/lib/ip";
@@ -37,6 +36,7 @@ import { fromError } from "zod-validation-error";
import { getCountryCodeForIp, remoteGetCountryCodeForIp } from "@server/lib/geoip";
import { getOrgTierData } from "@server/routers/private/billing";
import { TierId } from "@server/lib/private/billing/tiers";
import { verifyPassword } from "@server/auth/password";
// We'll see if this speeds anything up
const cache = new NodeCache({
@@ -101,25 +101,28 @@ export async function verifyResourceSession(
query
} = parsedBody.data;
// Extract HTTP Basic Auth credentials if present
const clientHeaderAuth = extractBasicAuth(headers);
const clientIp = requestIp
? (() => {
logger.debug("Request IP:", { requestIp });
if (requestIp.startsWith("[") && requestIp.includes("]")) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
logger.debug("Request IP:", { requestIp });
if (requestIp.startsWith("[") && requestIp.includes("]")) {
// if brackets are found, extract the IPv6 address from between the brackets
const ipv6Match = requestIp.match(/\[(.*?)\]/);
if (ipv6Match) {
return ipv6Match[1];
}
}
// ivp4
// split at last colon
const lastColonIndex = requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return requestIp.substring(0, lastColonIndex);
}
return requestIp;
})()
// ivp4
// split at last colon
const lastColonIndex = requestIp.lastIndexOf(":");
if (lastColonIndex !== -1) {
return requestIp.substring(0, lastColonIndex);
}
return requestIp;
})()
: undefined;
logger.debug("Client IP:", { clientIp });
@@ -134,10 +137,11 @@ export async function verifyResourceSession(
const resourceCacheKey = `resource:${cleanHost}`;
let resourceData:
| {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
}
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
}
| undefined = cache.get(resourceCacheKey);
if (!resourceData) {
@@ -152,7 +156,7 @@ export async function verifyResourceSession(
cache.set(resourceCacheKey, resourceData);
}
const { resource, pincode, password } = resourceData;
const { resource, pincode, password, headerAuth } = resourceData;
if (!resource) {
logger.debug(`Resource not found ${cleanHost}`);
@@ -209,21 +213,21 @@ export async function verifyResourceSession(
headers &&
headers[
config.getRawConfig().server.resource_access_token_headers.id
] &&
] &&
headers[
config.getRawConfig().server.resource_access_token_headers.token
]
]
) {
const accessTokenId =
headers[
config.getRawConfig().server.resource_access_token_headers
.id
];
];
const accessToken =
headers[
config.getRawConfig().server.resource_access_token_headers
.token
];
];
const { valid, error, tokenItem } = await verifyResourceAccessToken(
{
@@ -288,6 +292,18 @@ export async function verifyResourceSession(
}
}
// check for HTTP Basic Auth header
if (headerAuth && clientHeaderAuth) {
if(cache.get(clientHeaderAuth)) {
logger.debug("Resource allowed because header auth is valid (cached)");
return allowed(res);
}else if(await verifyPassword(clientHeaderAuth, headerAuth.headerAuthHash)){
cache.set(clientHeaderAuth, clientHeaderAuth);
logger.debug("Resource allowed because header auth is valid");
return allowed(res);
}
}
if (!sessions) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
@@ -800,3 +816,25 @@ async function isIpInGeoIP(ip: string, countryCode: string): Promise<boolean> {
return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase();
}
function extractBasicAuth(headers: Record<string, string> | undefined): string | undefined {
if (!headers || (!headers.authorization && !headers.Authorization)) {
return;
}
const authHeader = headers.authorization || headers.Authorization;
// Check if it's Basic Auth
if (!authHeader.startsWith("Basic ")) {
logger.debug("Authorization header is not Basic Auth");
return;
}
try {
// Extract the base64 encoded credentials
return authHeader.slice("Basic ".length);
} catch (error) {
logger.debug("Basic Auth: Failed to decode credentials", { error: error instanceof Error ? error.message : "Unknown error" });
}
}

View File

@@ -541,6 +541,13 @@ authenticated.post(
resource.setResourcePincode
);
authenticated.post(
`/resource/:resourceId/header-auth`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth
);
authenticated.post(
`/resource/:resourceId/whitelist`,
verifyResourceAccess,

View File

@@ -24,8 +24,7 @@ import {
verifyApiKeyIsRoot,
verifyApiKeyClientAccess,
verifyClientsEnabled,
verifyApiKeySiteResourceAccess,
verifyOrgAccess
verifyApiKeySiteResourceAccess
} from "@server/middlewares";
import HttpCode from "@server/types/HttpCode";
import { Router } from "express";
@@ -401,6 +400,13 @@ authenticated.post(
resource.setResourcePincode
);
authenticated.post(
`/resource/:resourceId/header-auth`,
verifyApiKeyResourceAccess,
verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth),
resource.setResourceHeaderAuth
);
authenticated.post(
`/resource/:resourceId/whitelist`,
verifyApiKeyResourceAccess,
@@ -660,4 +666,4 @@ authenticated.put(
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.applyBlueprint),
org.applyBlueprint
);
);

View File

@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourcePassword, resourcePincode, resources } from "@server/db";
import {
db,
resourceHeaderAuth,
resourcePassword,
resourcePincode,
resources
} from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -23,6 +28,7 @@ export type GetResourceAuthInfoResponse = {
niceId: string;
password: boolean;
pincode: boolean;
headerAuth: boolean;
sso: boolean;
blockAccess: boolean;
url: string;
@@ -64,6 +70,14 @@ export async function getResourceAuthInfo(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(
resourceHeaderAuth.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceId, Number(resourceGuid)))
.limit(1)
: await db
@@ -77,12 +91,21 @@ export async function getResourceAuthInfo(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.leftJoin(
resourceHeaderAuth,
eq(
resourceHeaderAuth.resourceId,
resources.resourceId
)
)
.where(eq(resources.resourceGuid, resourceGuid))
.limit(1);
const resource = result?.resources;
const pincode = result?.resourcePincode;
const password = result?.resourcePassword;
const headerAuth = result?.resourceHeaderAuth;
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
@@ -100,6 +123,7 @@ export async function getResourceAuthInfo(
resourceName: resource.name,
password: password !== null,
pincode: pincode !== null,
headerAuth: headerAuth !== null,
sso: resource.sso,
blockAccess: resource.blockAccess,
url,

View File

@@ -22,3 +22,4 @@ export * from "./deleteResourceRule";
export * from "./listResourceRules";
export * from "./updateResourceRule";
export * from "./getUserResources";
export * from "./setResourceHeaderAuth";

View File

@@ -0,0 +1,101 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, resourceHeaderAuth } 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";
import logger from "@server/logger";
import { hashPassword } from "@server/auth/password";
import { OpenAPITags, registry } from "@server/openApi";
const setResourceAuthMethodsParamsSchema = z.object({
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const setResourceAuthMethodsBodySchema = z
.object({
user: z.string().min(4).max(100).nullable(),
password: z.string().min(4).max(100).nullable()
})
.strict();
registry.registerPath({
method: "post",
path: "/resource/{resourceId}/header-auth",
description:
"Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.",
tags: [OpenAPITags.Resource],
request: {
params: setResourceAuthMethodsParamsSchema,
body: {
content: {
"application/json": {
schema: setResourceAuthMethodsBodySchema
}
}
}
},
responses: {}
});
export async function setResourceHeaderAuth(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = setResourceAuthMethodsParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = setResourceAuthMethodsBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { user, password } = parsedBody.data;
await db.transaction(async (trx) => {
await trx
.delete(resourceHeaderAuth)
.where(eq(resourceHeaderAuth.resourceId, resourceId));
if (user && password) {
const headerAuthHash = await hashPassword(Buffer.from(`${user}:${password}`).toString("base64"));
await trx
.insert(resourceHeaderAuth)
.values({ resourceId, headerAuthHash });
}
});
return response(res, {
data: {},
success: true,
error: false,
message: "Header Authentication set successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}