mirror of
https://github.com/fosrl/pangolin.git
synced 2026-01-29 06:10:47 +00:00
Adding HTTP Header Authentication
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from "./deleteResourceRule";
|
||||
export * from "./listResourceRules";
|
||||
export * from "./updateResourceRule";
|
||||
export * from "./getUserResources";
|
||||
export * from "./setResourceHeaderAuth";
|
||||
|
||||
101
server/routers/resource/setResourceHeaderAuth.ts
Normal file
101
server/routers/resource/setResourceHeaderAuth.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user