1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Introuduce auth header value and retrieval on renderer

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-12-01 15:10:54 -05:00
parent 6781b4657f
commit a5dab8549b
16 changed files with 314 additions and 137 deletions

View File

@ -0,0 +1,11 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { RequestChannel } from "../utils/channel/request-channel-listener-injection-token";
// This channel retreives the value needed to grant authentication to requests
export const lensAuthenticationChannel: RequestChannel<void, string> = {
id: "lens-authentication-channel",
};

View File

@ -0,0 +1,9 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* This is the header name that we use for request authentication
*/
export const lensAuthenticationHeader = "LENS-AUTHENTICATION";

View File

@ -0,0 +1,19 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { lensAuthenticationChannel } from "../../common/auth/channel";
import { getRequestChannelListenerInjectable } from "../utils/channel/channel-listeners/listener-tokens";
import authHeaderValueInjectable from "./auth-header-value.injectable";
const lensAuthenticationRequestListener = getRequestChannelListenerInjectable({
channel: lensAuthenticationChannel,
handler: (di) => {
const authHeaderValue = di.inject(authHeaderValueInjectable);
return () => authHeaderValue;
},
});
export default lensAuthenticationRequestListener;

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import * as uuid from "uuid";
const authHeaderValueInjectable = getInjectable({
id: "auth-header-value",
instantiate: () => uuid.v4(),
});
export default authHeaderValueInjectable;

View File

@ -5,7 +5,7 @@
import { getInjectable } from "@ogre-tools/injectable";
import { LensProxy } from "./lens-proxy";
import { kubeApiUpgradeRequest } from "./proxy-functions";
import routerInjectable from "../router/router.injectable";
import routeRequestInjectable from "../router/router.injectable";
import httpProxy from "http-proxy";
import clusterManagerInjectable from "../cluster/manager.injectable";
import shellApiRequestInjectable from "./proxy-functions/shell-api-request/shell-api-request.injectable";
@ -13,12 +13,13 @@ import lensProxyPortInjectable from "./lens-proxy-port.injectable";
import contentSecurityPolicyInjectable from "../../common/vars/content-security-policy.injectable";
import emitAppEventInjectable from "../../common/app-event-bus/emit-event.injectable";
import loggerInjectable from "../../common/logger.injectable";
import authHeaderValueInjectable from "./auth-header-value.injectable";
const lensProxyInjectable = getInjectable({
id: "lens-proxy",
instantiate: (di) => new LensProxy({
router: di.inject(routerInjectable),
routeRequest: di.inject(routeRequestInjectable),
proxy: httpProxy.createProxy(),
kubeApiUpgradeRequest,
shellApiRequest: di.inject(shellApiRequestInjectable),
@ -27,6 +28,7 @@ const lensProxyInjectable = getInjectable({
contentSecurityPolicy: di.inject(contentSecurityPolicyInjectable),
emitAppEvent: di.inject(emitAppEventInjectable),
logger: di.inject(loggerInjectable),
authHeaderValue: di.inject(authHeaderValueInjectable),
}),
});

View File

@ -7,7 +7,6 @@ import net from "net";
import http from "http";
import type httpProxy from "http-proxy";
import { apiPrefix, apiKubePrefix } from "../../common/vars";
import type { Router } from "../router/router";
import type { ClusterContextHandler } from "../context-handler/context-handler";
import type { Cluster } from "../../common/cluster/cluster";
import type { ProxyApiRequestArgs } from "./proxy-functions";
@ -16,6 +15,10 @@ import assert from "assert";
import type { SetRequired } from "type-fest";
import type { EmitAppEvent } from "../../common/app-event-bus/emit-event.injectable";
import type { Logger } from "../../common/logger";
import type { RouteRequest } from "../router/router.injectable";
import { lensAuthenticationHeader } from "../../common/vars/auth-header";
import { contentTypes } from "../router/router-content-types";
import { writeServerResponseFor } from "../router/write-server-response";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | undefined;
@ -26,11 +29,12 @@ interface Dependencies {
shellApiRequest: (args: ProxyApiRequestArgs) => void | Promise<void>;
kubeApiUpgradeRequest: (args: ProxyApiRequestArgs) => void | Promise<void>;
emitAppEvent: EmitAppEvent;
readonly router: Router;
routeRequest: RouteRequest;
readonly proxy: httpProxy;
readonly lensProxyPort: { set: (portNumber: number) => void };
readonly contentSecurityPolicy: string;
readonly logger: Logger;
readonly authHeaderValue: string;
}
const watchParam = "watch";
@ -61,9 +65,9 @@ const disallowedPorts = new Set([
]);
export class LensProxy {
protected proxyServer: http.Server;
protected readonly proxyServer: http.Server;
protected closed = false;
protected retryCounters = new Map<string, number>();
protected readonly retryCounters = new Map<string, number>();
constructor(private readonly dependencies: Dependencies) {
this.configureProxy(dependencies.proxy);
@ -75,6 +79,13 @@ export class LensProxy {
this.proxyServer
.on("upgrade", (req: ServerIncomingMessage, socket: net.Socket, head: Buffer) => {
const cluster = dependencies.getClusterForRequest(req);
const authHeader = req.headers[lensAuthenticationHeader.toLowerCase()];
if (authHeader !== this.dependencies.authHeaderValue) {
socket.destroy(new Error("Missing authorization"));
return;
}
if (!cluster) {
this.dependencies.logger.error(`[LENS-PROXY]: Could not find cluster for upgrade request from url=${req.url}`);
@ -210,13 +221,15 @@ export class LensProxy {
return proxy;
}
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise<httpProxy.ServerOptions | void> {
protected async getProxyTarget(req: http.IncomingMessage, contextHandler: ClusterContextHandler): Promise<httpProxy.ServerOptions | undefined> {
if (req.url?.startsWith(apiKubePrefix)) {
delete req.headers.authorization;
req.url = req.url.replace(apiKubePrefix, "");
return contextHandler.getApiTarget(isLongRunningRequest(req.url));
}
return undefined;
}
protected getRequestId(req: http.IncomingMessage): string {
@ -227,16 +240,28 @@ export class LensProxy {
protected async handleRequest(req: ServerIncomingMessage, res: http.ServerResponse) {
const cluster = this.dependencies.getClusterForRequest(req);
const writeServerResponse = writeServerResponseFor(res);
if (cluster) {
const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler);
if (proxyTarget) {
const authHeader = req.headers[lensAuthenticationHeader.toLowerCase()];
if (authHeader !== this.dependencies.authHeaderValue) {
writeServerResponse(contentTypes.txt.resultMapper({
statusCode: 401,
response: "Missing authorization",
}));
return;
}
return this.dependencies.proxy.web(req, res, proxyTarget);
}
}
res.setHeader("Content-Security-Policy", this.dependencies.contentSecurityPolicy);
await this.dependencies.router.route(cluster, req, res);
await this.dependencies.routeRequest(cluster, req, res);
}
}

View File

@ -5,73 +5,56 @@
import { getInjectable } from "@ogre-tools/injectable";
import type { ServerResponse } from "http";
import loggerInjectable from "../../common/logger.injectable";
import { object } from "../../common/utils";
import { lensAuthenticationHeader } from "../../common/vars/auth-header";
import authHeaderValueInjectable from "../lens-proxy/auth-header-value.injectable";
import type { LensApiRequest, Route } from "./route";
import { contentTypes } from "./router-content-types";
import { writeServerResponseFor } from "./write-server-response";
export type RouteHandler = (request: LensApiRequest<string>, response: ServerResponse) => Promise<void>;
export type CreateHandlerForRoute = (route: Route<unknown, string>) => RouteHandler;
interface LensServerResponse {
statusCode: number;
content: any;
headers: {
[name: string]: string;
};
}
const writeServerResponseFor = (serverResponse: ServerResponse) => ({
statusCode,
content,
headers,
}: LensServerResponse) => {
serverResponse.statusCode = statusCode;
for (const [name, value] of object.entries(headers)) {
serverResponse.setHeader(name, value);
}
if (content instanceof Buffer) {
serverResponse.write(content);
serverResponse.end();
} else if (content) {
serverResponse.end(content);
} else {
serverResponse.end();
}
};
const createHandlerForRouteInjectable = getInjectable({
id: "create-handler-for-route",
instantiate: (di): CreateHandlerForRoute => {
const logger = di.inject(loggerInjectable);
const authHeaderValue = di.inject(authHeaderValueInjectable);
return (route) => async (request, response) => {
const writeServerResponse = writeServerResponseFor(response);
if (route.requireAuthentication) {
const authHeader = request.getHeader(lensAuthenticationHeader);
if (authHeader !== authHeaderValue) {
writeServerResponse(contentTypes.txt.resultMapper({
statusCode: 401,
response: "Missing authorization",
}));
return;
}
}
try {
const result = await route.handler(request);
if (!result) {
const mappedResult = contentTypes.txt.resultMapper({
writeServerResponse(contentTypes.txt.resultMapper({
statusCode: 204,
response: undefined,
});
writeServerResponse(mappedResult);
}));
} else if (!result.proxy) {
const contentType = result.contentType || contentTypes.json;
writeServerResponse(contentType.resultMapper(result));
}
} catch(error) {
const mappedResult = contentTypes.txt.resultMapper({
logger.error(`[ROUTER]: route ${route.path}, called with ${request.path}, threw an error`, error);
writeServerResponse(contentTypes.txt.resultMapper({
statusCode: 500,
error: error ? String(error) : "unknown error",
});
logger.error(`[ROUTER]: route ${route.path}, called with ${request.path}, threw an error`, error);
writeServerResponse(mappedResult);
}));
}
};
},

View File

@ -35,6 +35,7 @@ export interface LensApiRequest<Path extends string> {
params: InferParamFromPath<Path>;
cluster: Cluster | undefined;
query: URLSearchParams;
getHeader: (key: string) => string | string[] | undefined;
raw: {
req: http.IncomingMessage;
res: http.ServerResponse;
@ -65,6 +66,7 @@ export interface RouteHandler<TResponse, Path extends string>{
export interface BaseRoutePaths<Path extends string> {
path: Path;
method: "get" | "post" | "put" | "patch" | "delete";
requireAuthentication?: boolean;
}
export interface PayloadValidator<Payload> {
@ -77,15 +79,20 @@ export interface ValidatorBaseRoutePaths<Path extends string, Payload> extends B
export interface Route<TResponse, Path extends string> extends BaseRoutePaths<Path> {
handler: RouteHandler<TResponse, Path>;
requireAuthentication: boolean;
}
export interface BindHandler<Path extends string> {
<TResponse>(handler: RouteHandler<TResponse, Path>): Route<TResponse, Path>;
}
export function route<Path extends string>(parts: BaseRoutePaths<Path>): BindHandler<Path> {
export function route<Path extends string>({
requireAuthentication = true,
...parts
}: BaseRoutePaths<Path>): BindHandler<Path> {
return (handler) => ({
...parts,
requireAuthentication,
handler,
});
}
@ -98,8 +105,12 @@ export interface BindClusterHandler<Path extends string> {
<TResponse>(handler: ClusterRouteHandler<TResponse, Path>): Route<TResponse, Path>;
}
export function clusterRoute<Path extends string>(parts: BaseRoutePaths<Path>): BindClusterHandler<Path> {
export function clusterRoute<Path extends string>({
requireAuthentication = true,
...parts
}: BaseRoutePaths<Path>): BindClusterHandler<Path> {
return (handler) => ({
requireAuthentication,
...parts,
handler: ({ cluster, ...rest }) => {
if (!cluster) {

View File

@ -2,12 +2,16 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Injectable, InjectionToken } from "@ogre-tools/injectable";
import type { DiContainerForInjection, Injectable, InjectionToken } from "@ogre-tools/injectable";
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import { Router } from "./router";
import parseRequestInjectable from "./parse-request.injectable";
import type { Route } from "./route";
import type { Route, LensApiRequest } from "./route";
import createHandlerForRouteInjectable from "./create-handler-for-route.injectable";
import Call from "@hapi/call";
import type http from "http";
import type { Cluster } from "../../common/cluster/cluster";
import type { ServerIncomingMessage } from "../lens-proxy/lens-proxy";
import type { RouteHandler } from "./create-handler-for-route.injectable";
export const routeInjectionToken = getInjectionToken<Route<unknown, string>>({
id: "route-injection-token",
@ -22,14 +26,75 @@ export function getRouteInjectable<T, Path extends string>(
});
}
const routerInjectable = getInjectable({
id: "router",
export type RouteRequest = (cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse) => Promise<boolean>;
instantiate: (di) => new Router({
parseRequest: di.inject(parseRequestInjectable),
routes: di.injectMany(routeInjectionToken),
createHandlerForRoute: di.inject(createHandlerForRouteInjectable),
}),
const createRouter = (di: DiContainerForInjection) => {
const routes = di.injectMany(routeInjectionToken);
const createHandlerForRoute = di.inject(createHandlerForRouteInjectable);
const router = new Call.Router<RouteHandler>();
for (const route of routes) {
router.add({ method: route.method, path: route.path }, createHandlerForRoute(route));
}
return router;
};
interface RouterRequestOpts {
req: http.IncomingMessage;
res: http.ServerResponse;
cluster: Cluster | undefined;
params: Partial<Record<string, string>>;
url: URL;
}
const getRequestWith = (di: DiContainerForInjection) => {
const parseRequest = di.inject(parseRequestInjectable);
return async (opts: RouterRequestOpts): Promise<LensApiRequest<string>> => {
const { req, res, url, cluster, params } = opts;
const { payload } = await parseRequest(req, null, {
parse: true,
output: "data",
});
return {
cluster,
path: url.pathname,
raw: {
req, res,
},
query: url.searchParams,
payload,
params,
getHeader: (key) => req.headers[key.toLowerCase()],
};
};
};
const routeRequestInjectable = getInjectable({
id: "route-request",
instantiate: (di): RouteRequest => {
const router = createRouter(di);
const getRequest = getRequestWith(di);
return async (cluster, req, res) => {
const url = new URL(req.url, "http://localhost");
const path = url.pathname;
const method = req.method.toLowerCase();
const matchingRoute = router.route(method, path);
if (matchingRoute instanceof Error) {
return false;
}
const request = await getRequest({ req, res, cluster, url, params: matchingRoute.params });
await matchingRoute.route(request, res);
return true;
};
},
});
export default routerInjectable;
export default routeRequestInjectable;

View File

@ -3,9 +3,9 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import routerInjectable, { routeInjectionToken } from "./router.injectable";
import type { RouteRequest } from "./router.injectable";
import routeRequestInjectable, { routeInjectionToken } from "./router.injectable";
import { getDiForUnitTesting } from "../getDiForUnitTesting";
import type { Router } from "./router";
import type { Cluster } from "../../common/cluster/cluster";
import { Request } from "mock-http";
import { getInjectable } from "@ogre-tools/injectable";
@ -24,7 +24,7 @@ import fsInjectable from "../../common/fs/fs.injectable";
import { runInAction } from "mobx";
describe("router", () => {
let router: Router;
let routeRequest: RouteRequest;
let routeHandlerMock: AsyncFnMock<() => any>;
beforeEach(async () => {
@ -51,6 +51,7 @@ describe("router", () => {
method: "get",
path: "/some-path",
handler: routeHandlerMock,
requireAuthentication: false,
} as Route<any, string>),
injectionToken: routeInjectionToken,
@ -60,7 +61,7 @@ describe("router", () => {
di.register(injectable);
});
router = di.inject(routerInjectable);
routeRequest = di.inject(routeRequestInjectable);
});
afterEach(() => {
@ -86,7 +87,7 @@ describe("router", () => {
clusterStub = {} as Cluster;
actualPromise = router.route(clusterStub, requestStub, responseStub);
actualPromise = routeRequest(clusterStub, requestStub, responseStub);
});
it("calls handler with the request", () => {

View File

@ -1,72 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import Call from "@hapi/call";
import type http from "http";
import type { Cluster } from "../../common/cluster/cluster";
import type { LensApiRequest, Route } from "./route";
import type { ServerIncomingMessage } from "../lens-proxy/lens-proxy";
import type { ParseRequest } from "./parse-request.injectable";
import type { CreateHandlerForRoute, RouteHandler } from "./create-handler-for-route.injectable";
export interface RouterRequestOpts {
req: http.IncomingMessage;
res: http.ServerResponse;
cluster: Cluster | undefined;
params: Partial<Record<string, string>>;
url: URL;
}
interface Dependencies {
parseRequest: ParseRequest;
createHandlerForRoute: CreateHandlerForRoute;
readonly routes: Route<unknown, string>[];
}
export class Router {
private readonly router = new Call.Router<RouteHandler>();
constructor(private readonly dependencies: Dependencies) {
for (const route of this.dependencies.routes) {
this.router.add({ method: route.method, path: route.path }, this.dependencies.createHandlerForRoute(route));
}
}
public async route(cluster: Cluster | undefined, req: ServerIncomingMessage, res: http.ServerResponse): Promise<boolean> {
const url = new URL(req.url, "http://localhost");
const path = url.pathname;
const method = req.method.toLowerCase();
const matchingRoute = this.router.route(method, path);
if (matchingRoute instanceof Error) {
return false;
}
const request = await this.getRequest({ req, res, cluster, url, params: matchingRoute.params });
await matchingRoute.route(request, res);
return true;
}
protected async getRequest(opts: RouterRequestOpts): Promise<LensApiRequest<string>> {
const { req, res, url, cluster, params } = opts;
const { payload } = await this.dependencies.parseRequest(req, null, {
parse: true,
output: "data",
});
return {
cluster,
path: url.pathname,
raw: {
req, res,
},
query: url.searchParams,
payload,
params,
};
}
}

View File

@ -0,0 +1,36 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { ServerResponse } from "http";
import { object } from "../../common/utils";
export interface LensServerResponse {
statusCode: number;
content: any;
headers: {
[name: string]: string;
};
}
export const writeServerResponseFor = (serverResponse: ServerResponse) => ({
statusCode,
content,
headers,
}: LensServerResponse) => {
serverResponse.statusCode = statusCode;
for (const [name, value] of object.entries(headers)) {
serverResponse.setHeader(name, value);
}
if (content instanceof Buffer) {
serverResponse.write(content);
serverResponse.end();
} else if (content) {
serverResponse.end(content);
} else {
serverResponse.end();
}
};

View File

@ -17,6 +17,7 @@ const staticFileRouteInjectable = getRouteInjectable({
return route({
method: "get",
path: `/{path*}`,
requireAuthentication: false,
})(
isDevelopment
? di.inject(devStaticFileRouteHandlerInjectable)

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
const authHeaderValueStateInjectable = getInjectable({
id: "auth-header-value-state",
instantiate: () => {
let state: string | undefined = undefined;
return {
get: () =>{
if (!state) {
throw new Error("Tried to get auth header value before it was initialized");
}
return state;
},
set: (newState: string) => {
if (state) {
throw new Error("Tried to overwrite existing state of auth header value");
}
state = newState;
},
};
},
});
export default authHeaderValueStateInjectable;

View File

@ -0,0 +1,13 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import authHeaderValueStateInjectable from "./auth-header-state.injectable";
const authHeaderValueInjectable = getInjectable({
id: "auth-header-value",
instantiate: (di) => di.inject(authHeaderValueStateInjectable).get(),
});
export default authHeaderValueInjectable;

View File

@ -0,0 +1,27 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import { lensAuthenticationChannel } from "../../common/auth/channel";
import { beforeFrameStartsInjectionToken } from "../before-frame-starts/before-frame-starts-injection-token";
import requestFromChannelInjectable from "../utils/channel/request-from-channel.injectable";
import authHeaderValueStateInjectable from "./auth-header-state.injectable";
const initAuthHeaderValueStateInjectable = getInjectable({
id: "init-auth-header-value-state",
instantiate: (di) => {
const state = di.inject(authHeaderValueStateInjectable);
const requestFromChannel = di.inject(requestFromChannelInjectable);
return {
id: "init-auth-header-value-state",
run: async () => {
state.set(await requestFromChannel(lensAuthenticationChannel));
},
};
},
injectionToken: beforeFrameStartsInjectionToken,
});
export default initAuthHeaderValueStateInjectable;