|
|
|
@ -1,17 +1,26 @@ |
|
|
|
import { ALLOW_DURING_IMPERSONATION_KEY } from '@ghostfolio/api/decorators/allow-during-impersonation.decorator'; |
|
|
|
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|
|
|
import { REQUIRES_SCOPE_KEY } from '@ghostfolio/api/decorators/requires-scope.decorator'; |
|
|
|
|
|
|
|
import { |
|
|
|
DynamicModule, |
|
|
|
ForwardReference, |
|
|
|
RequestMethod, |
|
|
|
Type |
|
|
|
Type, |
|
|
|
VERSION_NEUTRAL |
|
|
|
} from '@nestjs/common'; |
|
|
|
import { |
|
|
|
GUARDS_METADATA, |
|
|
|
INTERCEPTORS_METADATA, |
|
|
|
METHOD_METADATA, |
|
|
|
MODULE_METADATA, |
|
|
|
PATH_METADATA |
|
|
|
PATH_METADATA, |
|
|
|
VERSION_METADATA |
|
|
|
} from '@nestjs/common/constants'; |
|
|
|
import { VersionValue } from '@nestjs/common/interfaces'; |
|
|
|
import { MetadataScanner } from '@nestjs/core'; |
|
|
|
import { AuthGuard } from '@nestjs/passport'; |
|
|
|
import { isUndefined, omitBy } from 'lodash-es'; |
|
|
|
|
|
|
|
import { AppModule } from './app.module'; |
|
|
|
|
|
|
|
@ -26,6 +35,13 @@ jest.mock('ai', () => { |
|
|
|
type ModuleDefinition = |
|
|
|
DynamicModule | ForwardReference<() => Type> | Promise<DynamicModule> | Type; |
|
|
|
|
|
|
|
/** |
|
|
|
* The strategies of the authentication which the routes apply via |
|
|
|
* AuthGuard(). The class of such a guard has a random name, hence the |
|
|
|
* snapshot of the routes names it by its strategy |
|
|
|
*/ |
|
|
|
const AUTHENTICATION_STRATEGIES = ['api-key', 'google', 'jwt', 'oidc']; |
|
|
|
|
|
|
|
/** |
|
|
|
* The routes of the controllers which answer a request without the |
|
|
|
* authentication of a user or of an API key, sorted by the request method and |
|
|
|
@ -98,6 +114,57 @@ async function getControllers( |
|
|
|
return [...new Set(controllers)]; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the guards or the interceptors which the decorators of the controller |
|
|
|
* and of the route handler set, in the order in which they run |
|
|
|
*/ |
|
|
|
function getEnhancers(key: string, controller: Type, routeHandler: object) { |
|
|
|
return [controller, routeHandler].flatMap((target) => { |
|
|
|
return (Reflect.getMetadata(key, target) ?? []) as Type[]; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the metadata of the route handler or, if it has none, of the |
|
|
|
* controller, as Reflector.getAllAndOverride() does |
|
|
|
*/ |
|
|
|
function getMetadataOfRoute<T>( |
|
|
|
key: string, |
|
|
|
controller: Type, |
|
|
|
routeHandler: object |
|
|
|
) { |
|
|
|
return (Reflect.getMetadata(key, routeHandler) ?? |
|
|
|
Reflect.getMetadata(key, controller)) as T | undefined; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the name of a guard or of an interceptor. The function mixin() |
|
|
|
* replaces the name of a class by a random one, hence the name is read from |
|
|
|
* the declaration of the class. A guard which extends AuthGuard() is named |
|
|
|
* with its strategy |
|
|
|
*/ |
|
|
|
function getName(enhancer: Type) { |
|
|
|
const authenticationStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { |
|
|
|
return AuthGuard(strategy) === enhancer; |
|
|
|
}); |
|
|
|
|
|
|
|
if (authenticationStrategy) { |
|
|
|
return `AuthGuard('${authenticationStrategy}')`; |
|
|
|
} |
|
|
|
|
|
|
|
const name = /^class\s+([\w$]+)/.exec(enhancer.toString())?.[1]; |
|
|
|
|
|
|
|
if (!name || name === 'MixinAuthGuard') { |
|
|
|
throw new Error('The name of a guard or of an interceptor is unknown'); |
|
|
|
} |
|
|
|
|
|
|
|
const parentStrategy = AUTHENTICATION_STRATEGIES.find((strategy) => { |
|
|
|
return Object.getPrototypeOf(enhancer) === AuthGuard(strategy); |
|
|
|
}); |
|
|
|
|
|
|
|
return parentStrategy ? `${name}('${parentStrategy}')` : name; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the paths which the decorator of a controller or of a route sets |
|
|
|
*/ |
|
|
|
@ -108,14 +175,10 @@ function getPaths(target: object) { |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the routes of the controller which apply neither AuthGuard('api-key') |
|
|
|
* nor AuthGuard('jwt'), each as the request method and the path |
|
|
|
* Gives the routes of the controller, each as the request method and the path |
|
|
|
* with its route handler |
|
|
|
*/ |
|
|
|
function getRoutesWithoutAuthentication(controller: Type) { |
|
|
|
const authenticationGuards: unknown[] = [ |
|
|
|
AuthGuard('api-key'), |
|
|
|
AuthGuard('jwt') |
|
|
|
]; |
|
|
|
function getRoutes(controller: Type) { |
|
|
|
const routeHandlersByName = controller.prototype as Record<string, object>; |
|
|
|
|
|
|
|
return new MetadataScanner() |
|
|
|
@ -131,19 +194,6 @@ function getRoutesWithoutAuthentication(controller: Type) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
|
|
|
|
const guards = [controller, routeHandler].flatMap((target) => { |
|
|
|
return (Reflect.getMetadata(GUARDS_METADATA, target) ?? |
|
|
|
[]) as unknown[]; |
|
|
|
}); |
|
|
|
|
|
|
|
const hasAuthentication = guards.some((guard) => { |
|
|
|
return authenticationGuards.includes(guard); |
|
|
|
}); |
|
|
|
|
|
|
|
if (hasAuthentication) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
|
|
|
|
return getPaths(controller).flatMap((controllerPath) => { |
|
|
|
return getPaths(routeHandler).map((routePath) => { |
|
|
|
const path = `${controllerPath}/${routePath}` |
|
|
|
@ -151,12 +201,110 @@ function getRoutesWithoutAuthentication(controller: Type) { |
|
|
|
.filter(Boolean) |
|
|
|
.join('/'); |
|
|
|
|
|
|
|
return `${RequestMethod[requestMethod]} /${path}`; |
|
|
|
return { |
|
|
|
routeHandler, |
|
|
|
route: `${RequestMethod[requestMethod]} /${path}` |
|
|
|
}; |
|
|
|
}); |
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the routes of the controller which apply neither AuthGuard('api-key') |
|
|
|
* nor AuthGuard('jwt'), each as the request method and the path |
|
|
|
*/ |
|
|
|
function getRoutesWithoutAuthentication(controller: Type) { |
|
|
|
const authenticationGuards: unknown[] = [ |
|
|
|
AuthGuard('api-key'), |
|
|
|
AuthGuard('jwt') |
|
|
|
]; |
|
|
|
|
|
|
|
return getRoutes(controller) |
|
|
|
.filter(({ routeHandler }) => { |
|
|
|
const hasAuthentication = getEnhancers( |
|
|
|
GUARDS_METADATA, |
|
|
|
controller, |
|
|
|
routeHandler |
|
|
|
).some((guard) => { |
|
|
|
return authenticationGuards.includes(guard); |
|
|
|
}); |
|
|
|
|
|
|
|
return !hasAuthentication; |
|
|
|
}) |
|
|
|
.map(({ route }) => { |
|
|
|
return route; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the routes of the controller, each as the request method, the path |
|
|
|
* and the version with the guards and the interceptors in the order in which |
|
|
|
* they run, the permission, the scopes and whether the route stays available |
|
|
|
* while an impersonation is active |
|
|
|
*/ |
|
|
|
function getRoutesWithSecurity(controller: Type) { |
|
|
|
return getRoutes(controller).map(({ route, routeHandler }) => { |
|
|
|
const security = omitBy( |
|
|
|
{ |
|
|
|
allowDuringImpersonation: getMetadataOfRoute<boolean>( |
|
|
|
ALLOW_DURING_IMPERSONATION_KEY, |
|
|
|
controller, |
|
|
|
routeHandler |
|
|
|
), |
|
|
|
guards: getEnhancers(GUARDS_METADATA, controller, routeHandler).map( |
|
|
|
(guard) => { |
|
|
|
return getName(guard); |
|
|
|
} |
|
|
|
), |
|
|
|
interceptors: getEnhancers( |
|
|
|
INTERCEPTORS_METADATA, |
|
|
|
controller, |
|
|
|
routeHandler |
|
|
|
).map((interceptor) => { |
|
|
|
return getName(interceptor); |
|
|
|
}), |
|
|
|
// HasPermissionGuard reads the permission of the route handler only
|
|
|
|
permission: Reflect.getMetadata(HAS_PERMISSION_KEY, routeHandler) as |
|
|
|
string | undefined, |
|
|
|
scopes: getMetadataOfRoute<string[]>( |
|
|
|
REQUIRES_SCOPE_KEY, |
|
|
|
controller, |
|
|
|
routeHandler |
|
|
|
) |
|
|
|
}, |
|
|
|
isUndefined |
|
|
|
); |
|
|
|
|
|
|
|
return [ |
|
|
|
`${route}${getVersionSuffix(controller, routeHandler)}`, |
|
|
|
security |
|
|
|
] as const; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* Gives the version of the route as a suffix of its key, which is empty for |
|
|
|
* the default version |
|
|
|
*/ |
|
|
|
function getVersionSuffix(controller: Type, routeHandler: object) { |
|
|
|
const version = getMetadataOfRoute<VersionValue>( |
|
|
|
VERSION_METADATA, |
|
|
|
controller, |
|
|
|
routeHandler |
|
|
|
); |
|
|
|
|
|
|
|
if (version === undefined) { |
|
|
|
return ''; |
|
|
|
} |
|
|
|
|
|
|
|
const versions = [version].flat().map((value) => { |
|
|
|
return value === VERSION_NEUTRAL ? 'neutral' : value; |
|
|
|
}); |
|
|
|
|
|
|
|
return ` (version ${versions.join(', ')})`; |
|
|
|
} |
|
|
|
|
|
|
|
describe('AppModule', () => { |
|
|
|
it('should require the authentication for each route which is not public', async () => { |
|
|
|
const controllers = await getControllers(AppModule); |
|
|
|
@ -169,4 +317,22 @@ describe('AppModule', () => { |
|
|
|
|
|
|
|
expect(routesWithoutAuthentication).toEqual(PUBLIC_ROUTES); |
|
|
|
}); |
|
|
|
|
|
|
|
// An intended change of a route is confirmed by an update of the snapshot
|
|
|
|
// (--updateSnapshot), hence each change shows in the review
|
|
|
|
it('should keep the security of each route', async () => { |
|
|
|
const controllers = await getControllers(AppModule); |
|
|
|
|
|
|
|
const routesWithSecurity = controllers.flatMap((controller) => { |
|
|
|
return getRoutesWithSecurity(controller); |
|
|
|
}); |
|
|
|
|
|
|
|
const routes = routesWithSecurity.map(([route]) => { |
|
|
|
return route; |
|
|
|
}); |
|
|
|
|
|
|
|
// The snapshot is an object, which would hide a route declared twice
|
|
|
|
expect(new Set(routes).size).toBe(routes.length); |
|
|
|
expect(Object.fromEntries(routesWithSecurity)).toMatchSnapshot(); |
|
|
|
}); |
|
|
|
}); |
|
|
|
|