mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-04-07 19:34:17 +00:00
Consolidate API middlewares, move api-keys to device-api
Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
parent
5af146ec4e
commit
d08f25f0a3
@ -572,8 +572,6 @@ export const initialized = _.once(async () => {
|
||||
});
|
||||
|
||||
export const router = express.Router();
|
||||
router.use(express.urlencoded({ limit: '10mb', extended: true }));
|
||||
router.use(express.json({ limit: '10mb' }));
|
||||
|
||||
router.post('/v1/update', (req, res, next) => {
|
||||
eventTracker.track('Update notification');
|
||||
|
@ -61,8 +61,6 @@ const localModeManager = new LocalModeManager();
|
||||
|
||||
export const router = (() => {
|
||||
const $router = express.Router();
|
||||
$router.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
$router.use(express.json({ limit: '10mb' }));
|
||||
|
||||
createV1Api($router);
|
||||
createV2Api($router);
|
||||
|
@ -20,7 +20,7 @@ import {
|
||||
|
||||
import log from '../lib/supervisor-console';
|
||||
|
||||
import * as apiKeys from '../lib/api-keys';
|
||||
import * as apiKeys from '../device-api/api-keys';
|
||||
|
||||
export function camelCaseConfig(
|
||||
literalConfig: ConfigMap,
|
||||
|
@ -1,13 +1,13 @@
|
||||
import * as _ from 'lodash';
|
||||
import * as express from 'express';
|
||||
import * as memoizee from 'memoizee';
|
||||
import { TypedError } from 'typed-error';
|
||||
|
||||
import * as config from '../config';
|
||||
import * as db from '../db';
|
||||
|
||||
import { generateUniqueKey } from './register-device';
|
||||
import { generateUniqueKey } from '../lib/register-device';
|
||||
|
||||
export class KeyNotFoundError extends Error {}
|
||||
class KeyNotFoundError extends TypedError {}
|
||||
|
||||
/**
|
||||
* The schema for the `apiSecret` table in the database
|
||||
@ -20,7 +20,7 @@ interface DbApiSecret {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export type Scope = SerializableScope<ScopeTypeKey>;
|
||||
type Scope = SerializableScope<ScopeTypeKey>;
|
||||
type ScopeTypeKey = keyof ScopeTypes;
|
||||
type SerializableScope<T extends ScopeTypeKey> = {
|
||||
type: T;
|
||||
@ -59,13 +59,8 @@ const scopeChecks: ScopeCheckCollection = {
|
||||
resources.apps != null && resources.apps.includes(appId),
|
||||
};
|
||||
|
||||
export function serialiseScopes(scopes: Scope[]): string {
|
||||
return JSON.stringify(scopes);
|
||||
}
|
||||
|
||||
export function deserialiseScopes(json: string): Scope[] {
|
||||
return JSON.parse(json);
|
||||
}
|
||||
const serialiseScopes = (scopes: Scope[]): string => JSON.stringify(scopes);
|
||||
const deserialiseScopes = (json: string): Scope[] => JSON.parse(json);
|
||||
|
||||
export const isScoped = (
|
||||
resources: Partial<ScopedResources>,
|
||||
@ -99,95 +94,9 @@ export const initialized = _.once(async () => {
|
||||
await generateCloudKey();
|
||||
});
|
||||
|
||||
/**
|
||||
* This middleware will extract an API key used to make a call, and then expand it out to provide
|
||||
* access to the scopes it has. The `req` will be updated to include this `auth` data.
|
||||
*
|
||||
* E.g. `req.auth.scopes: []`
|
||||
*
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const authMiddleware: AuthorizedRequestHandler = async (
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
) => {
|
||||
// grab the API key used for the request
|
||||
const apiKey = getApiKeyFromRequest(req) ?? '';
|
||||
const isEqualScope = (a: Scope, b: Scope): boolean => _.isEqual(a, b);
|
||||
|
||||
// store the key in the request, and an empty scopes array to populate after resolving the key scopes
|
||||
req.auth = {
|
||||
apiKey,
|
||||
scopes: [],
|
||||
isScoped: (resources) => isScoped(resources, req.auth.scopes),
|
||||
};
|
||||
|
||||
try {
|
||||
const conf = await config.getMany(['localMode', 'unmanaged']);
|
||||
|
||||
// we only need to check the API key if managed and not in local mode
|
||||
const needsAuth = !conf.unmanaged && !conf.localMode;
|
||||
|
||||
// no need to authenticate, shortcut
|
||||
if (!needsAuth) {
|
||||
// Allow requests that do not need auth to be scoped for all applications
|
||||
req.auth.isScoped = () => true;
|
||||
return next();
|
||||
}
|
||||
|
||||
// if we have a key, find the scopes and add them to the request
|
||||
if (apiKey && apiKey !== '') {
|
||||
await initialized();
|
||||
const scopes = await getScopesForKey(apiKey);
|
||||
|
||||
if (scopes != null) {
|
||||
// keep the scopes for later incase they're desired
|
||||
req.auth.scopes.push(...scopes);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// we do not have a valid key...
|
||||
return res.sendStatus(401);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(503).send(`Unexpected error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
function isEqualScope(a: Scope, b: Scope): boolean {
|
||||
return _.isEqual(a, b);
|
||||
}
|
||||
|
||||
function getApiKeyFromRequest(req: express.Request): string | undefined {
|
||||
// Check query for key
|
||||
if (req.query.apikey) {
|
||||
if (typeof req.query.apikey !== 'string') {
|
||||
// We were passed something as an api key but it wasn't a string
|
||||
// so ignore it
|
||||
return;
|
||||
}
|
||||
return req.query.apikey;
|
||||
}
|
||||
|
||||
// Get Authorization header to search for key
|
||||
const authHeader = req.get('Authorization');
|
||||
|
||||
// Check header for key
|
||||
if (!authHeader) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check authHeader with various schemes
|
||||
const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i);
|
||||
|
||||
// Return key from match or undefined
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
export type GenerateKeyOptions = { force: boolean; scopes: Scope[] };
|
||||
type GenerateKeyOptions = { force: boolean; scopes: Scope[] };
|
||||
|
||||
export async function getScopesForKey(key: string): Promise<Scope[] | null> {
|
||||
const apiKey = await getApiKeyByKey(key);
|
@ -1,31 +1,14 @@
|
||||
import * as express from 'express';
|
||||
import * as _ from 'lodash';
|
||||
import * as morgan from 'morgan';
|
||||
|
||||
import * as middleware from './middleware';
|
||||
import * as apiKeys from './api-keys';
|
||||
import * as eventTracker from '../event-tracker';
|
||||
import * as deviceState from '../device-state';
|
||||
import blink = require('../lib/blink');
|
||||
import log from '../lib/supervisor-console';
|
||||
import * as apiKeys from '../lib/api-keys';
|
||||
import { UpdatesLockedError } from '../lib/errors';
|
||||
|
||||
import type { Server } from 'http';
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
const expressLogger = morgan(
|
||||
(tokens, req, res) =>
|
||||
[
|
||||
tokens.method(req, res),
|
||||
req.path,
|
||||
tokens.status(req, res),
|
||||
'-',
|
||||
tokens['response-time'](req, res),
|
||||
'ms',
|
||||
].join(' '),
|
||||
{
|
||||
stream: { write: (d) => log.api(d.toString().trimRight()) },
|
||||
},
|
||||
);
|
||||
|
||||
interface SupervisorAPIConstructOpts {
|
||||
routers: express.Router[];
|
||||
@ -48,7 +31,7 @@ export class SupervisorAPI {
|
||||
this.healthchecks = healthchecks;
|
||||
|
||||
this.api.disable('x-powered-by');
|
||||
this.api.use(expressLogger);
|
||||
this.api.use(middleware.logging);
|
||||
|
||||
this.api.get('/v1/healthy', async (_req, res) => {
|
||||
try {
|
||||
@ -66,7 +49,7 @@ export class SupervisorAPI {
|
||||
|
||||
this.api.get('/ping', (_req, res) => res.send('OK'));
|
||||
|
||||
this.api.use(apiKeys.authMiddleware);
|
||||
this.api.use(middleware.auth);
|
||||
|
||||
this.api.post('/v1/blink', (_req, res) => {
|
||||
eventTracker.track('Device blink');
|
||||
@ -102,44 +85,15 @@ export class SupervisorAPI {
|
||||
},
|
||||
);
|
||||
|
||||
this.api.use(express.urlencoded({ limit: '10mb', extended: true }));
|
||||
this.api.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// And assign all external routers
|
||||
for (const router of this.routers) {
|
||||
this.api.use(router);
|
||||
}
|
||||
|
||||
// Error handling.
|
||||
const messageFromError = (err?: Error | string | null): string => {
|
||||
let message = 'Unknown error';
|
||||
if (err != null) {
|
||||
if (_.isError(err) && err.message != null) {
|
||||
message = err.message;
|
||||
} else {
|
||||
message = err as string;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
this.api.use(
|
||||
(err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||
if (res.headersSent) {
|
||||
// Error happens while we are writing the response - default handler closes the connection.
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Return 423 Locked when locks as set
|
||||
const code = err instanceof UpdatesLockedError ? 423 : 503;
|
||||
if (code !== 423) {
|
||||
log.error(`Error on ${req.method} ${req.path}: `, err);
|
||||
}
|
||||
|
||||
res.status(code).send({
|
||||
status: 'failed',
|
||||
message: messageFromError(err),
|
||||
});
|
||||
},
|
||||
);
|
||||
this.api.use(middleware.errors);
|
||||
}
|
||||
|
||||
public async listen(port: number, apiTimeout: number): Promise<void> {
|
||||
|
84
src/device-api/middleware/auth.ts
Normal file
84
src/device-api/middleware/auth.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import * as apiKeys from '../api-keys';
|
||||
import * as config from '../../config';
|
||||
|
||||
import type { Request } from 'express';
|
||||
|
||||
/**
|
||||
* This middleware will extract an API key used to make a call, and then expand it out to provide
|
||||
* access to the scopes it has. The `req` will be updated to include this `auth` data.
|
||||
*
|
||||
* E.g. `req.auth.scopes: []`
|
||||
*
|
||||
* @param req
|
||||
* @param res
|
||||
* @param next
|
||||
*/
|
||||
export const auth: apiKeys.AuthorizedRequestHandler = async (
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
) => {
|
||||
// grab the API key used for the request
|
||||
const apiKey = getApiKeyFromRequest(req) ?? '';
|
||||
|
||||
// store the key in the request, and an empty scopes array to populate after resolving the key scopes
|
||||
req.auth = {
|
||||
apiKey,
|
||||
scopes: [],
|
||||
isScoped: (resources) => apiKeys.isScoped(resources, req.auth.scopes),
|
||||
};
|
||||
|
||||
try {
|
||||
const conf = await config.getMany(['localMode', 'unmanaged']);
|
||||
|
||||
// we only need to check the API key if managed and not in local mode
|
||||
const needsAuth = !conf.unmanaged && !conf.localMode;
|
||||
|
||||
// no need to authenticate, shortcut
|
||||
if (!needsAuth) {
|
||||
// Allow requests that do not need auth to be scoped for all applications
|
||||
req.auth.isScoped = () => true;
|
||||
return next();
|
||||
}
|
||||
|
||||
// if we have a key, find the scopes and add them to the request
|
||||
if (apiKey && apiKey !== '') {
|
||||
await apiKeys.initialized();
|
||||
const scopes = await apiKeys.getScopesForKey(apiKey);
|
||||
|
||||
if (scopes != null) {
|
||||
// keep the scopes for later incase they're desired
|
||||
req.auth.scopes.push(...scopes);
|
||||
return next();
|
||||
}
|
||||
}
|
||||
|
||||
// we do not have a valid key...
|
||||
return res.sendStatus(401);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(503).send(`Unexpected error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
function getApiKeyFromRequest(req: Request): string | undefined {
|
||||
const { apikey } = req.query;
|
||||
// Check query for key
|
||||
if (apikey && typeof apikey === 'string') {
|
||||
return apikey;
|
||||
}
|
||||
|
||||
// Get Authorization header to search for key
|
||||
const authHeader = req.get('Authorization');
|
||||
|
||||
// Check header for key
|
||||
if (!authHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Check authHeader with various schemes
|
||||
const match = authHeader.match(/^(?:ApiKey|Bearer) (\w+)$/i);
|
||||
|
||||
// Return key from match or undefined
|
||||
return match?.[1];
|
||||
}
|
42
src/device-api/middleware/errors.ts
Normal file
42
src/device-api/middleware/errors.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { UpdatesLockedError } from '../../lib/errors';
|
||||
import log from '../../lib/supervisor-console';
|
||||
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
|
||||
const messageFromError = (err?: Error | string | null): string => {
|
||||
let message = 'Unknown error';
|
||||
if (err != null) {
|
||||
if (_.isError(err) && err.message != null) {
|
||||
message = err.message;
|
||||
} else {
|
||||
message = err as string;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
};
|
||||
|
||||
export const errors = (
|
||||
err: Error,
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
if (res.headersSent) {
|
||||
// Error happens while we are writing the response - default handler closes the connection.
|
||||
next(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Return 423 Locked when locks as set
|
||||
const code = err instanceof UpdatesLockedError ? 423 : 503;
|
||||
if (code !== 423) {
|
||||
log.error(`Error on ${req.method} ${req.path}: `, err);
|
||||
}
|
||||
|
||||
res.status(code).send({
|
||||
status: 'failed',
|
||||
message: messageFromError(err),
|
||||
});
|
||||
};
|
3
src/device-api/middleware/index.ts
Normal file
3
src/device-api/middleware/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './logging';
|
||||
export * from './auth';
|
||||
export * from './errors';
|
19
src/device-api/middleware/logging.ts
Normal file
19
src/device-api/middleware/logging.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import * as morgan from 'morgan';
|
||||
import { Request } from 'express';
|
||||
|
||||
import log from '../../lib/supervisor-console';
|
||||
|
||||
export const logging = morgan(
|
||||
(tokens, req: Request, res) =>
|
||||
[
|
||||
tokens.method(req, res),
|
||||
req.path,
|
||||
tokens.status(req, res),
|
||||
'-',
|
||||
tokens['response-time'](req, res),
|
||||
'ms',
|
||||
].join(' '),
|
||||
{
|
||||
stream: { write: (d) => log.api(d.toString().trimEnd()) },
|
||||
},
|
||||
);
|
@ -9,7 +9,7 @@ import { doRestart, doPurge } from './common';
|
||||
import * as applicationManager from '../compose/application-manager';
|
||||
import { generateStep } from '../compose/composition-steps';
|
||||
import * as commitStore from '../compose/commit';
|
||||
import { AuthorizedRequest } from '../lib/api-keys';
|
||||
import { AuthorizedRequest } from './api-keys';
|
||||
import { getApp } from '../device-state/db-format';
|
||||
|
||||
export function createV1Api(router: express.Router) {
|
||||
|
@ -31,7 +31,7 @@ import supervisorVersion = require('../lib/supervisor-version');
|
||||
import { checkInt, checkTruthy } from '../lib/validation';
|
||||
import { isVPNActive } from '../network';
|
||||
import { doPurge, doRestart, safeStateClone } from './common';
|
||||
import { AuthorizedRequest } from '../lib/api-keys';
|
||||
import { AuthorizedRequest } from './api-keys';
|
||||
import { fromV2TargetState } from '../lib/legacy';
|
||||
|
||||
export function createV2Api(router: Router) {
|
||||
|
@ -45,7 +45,7 @@ import {
|
||||
AppState,
|
||||
} from './types';
|
||||
import * as dbFormat from './device-state/db-format';
|
||||
import * as apiKeys from './lib/api-keys';
|
||||
import * as apiKeys from './device-api/api-keys';
|
||||
import * as sysInfo from './lib/system-info';
|
||||
|
||||
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
|
||||
@ -65,8 +65,6 @@ function parseTargetState(state: unknown): TargetState {
|
||||
// device api stuff in ./device-api
|
||||
function createDeviceStateRouter() {
|
||||
router = express.Router();
|
||||
router.use(express.urlencoded({ limit: '10mb', extended: true }));
|
||||
router.use(express.json({ limit: '10mb' }));
|
||||
|
||||
const rebootOrShutdown = async (
|
||||
req: express.Request,
|
||||
|
@ -89,8 +89,6 @@ const formatCurrentAsState = (device) => ({
|
||||
|
||||
const createProxyvisorRouter = function (proxyvisor) {
|
||||
const router = express.Router();
|
||||
router.use(express.urlencoded({ limit: '10mb', extended: true }));
|
||||
router.use(express.json({ limit: '10mb' }));
|
||||
router.get('/v1/devices', async (_req, res) => {
|
||||
try {
|
||||
const fields = await db.models('dependentDevice').select();
|
||||
|
@ -3,7 +3,7 @@ import * as _ from 'lodash';
|
||||
import { expect } from 'chai';
|
||||
|
||||
import Service from '~/src/compose/service';
|
||||
import * as apiKeys from '~/lib/api-keys';
|
||||
import * as apiKeys from '~/src/device-api/api-keys';
|
||||
|
||||
describe('compose/service: integration tests', () => {
|
||||
describe('Feature labels', () => {
|
||||
|
@ -7,9 +7,9 @@ import * as apiBinder from '~/src/api-binder';
|
||||
import * as deviceState from '~/src/device-state';
|
||||
import Log from '~/lib/supervisor-console';
|
||||
import SupervisorAPI from '~/src/device-api';
|
||||
import * as apiKeys from '~/lib/api-keys';
|
||||
import * as apiKeys from '~/src/device-api/api-keys';
|
||||
import * as db from '~/src/db';
|
||||
import { cloudApiKey } from '~/lib/api-keys';
|
||||
import { cloudApiKey } from '~/src/device-api/api-keys';
|
||||
|
||||
const mockedOptions = {
|
||||
listenPort: 54321,
|
||||
|
@ -23,7 +23,7 @@ import * as logger from '~/src/logger';
|
||||
import SupervisorAPI from '~/src/device-api';
|
||||
import * as apiBinder from '~/src/api-binder';
|
||||
import * as deviceState from '~/src/device-state';
|
||||
import * as apiKeys from '~/lib/api-keys';
|
||||
import * as apiKeys from '~/src/device-api/api-keys';
|
||||
import * as dbus from '~/lib/dbus';
|
||||
import * as updateLock from '~/lib/update-lock';
|
||||
import * as TargetState from '~/src/device-state/target-state';
|
||||
|
@ -10,7 +10,7 @@ import * as deviceState from '~/src/device-state';
|
||||
import SupervisorAPI from '~/src/device-api';
|
||||
import * as serviceManager from '~/src/compose/service-manager';
|
||||
import * as images from '~/src/compose/images';
|
||||
import * as apiKeys from '~/lib/api-keys';
|
||||
import * as apiKeys from '~/src/device-api/api-keys';
|
||||
import * as config from '~/src/config';
|
||||
import * as updateLock from '~/lib/update-lock';
|
||||
import * as targetStateCache from '~/src/device-state/target-state-cache';
|
||||
|
@ -12,7 +12,7 @@ const api: express.Express & {
|
||||
} = express();
|
||||
|
||||
// tslint:disable-next-line
|
||||
api.use(require('body-parser').json());
|
||||
api.use(express.json());
|
||||
|
||||
api.balenaBackend = {
|
||||
currentId: 1,
|
||||
|
Loading…
x
Reference in New Issue
Block a user