From cf5b9c9bbd824f4edad6c3082bb99878f723e8a0 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Wed, 28 Mar 2018 11:22:06 -0700 Subject: [PATCH] feat: add authorization component --- packages/authorization/.npmrc | 1 + packages/authorization/LICENSE | 25 +++ packages/authorization/README.md | 56 +++++++ packages/authorization/docs.json | 12 ++ packages/authorization/index.d.ts | 6 + packages/authorization/index.js | 6 + packages/authorization/index.ts | 8 + packages/authorization/package.json | 47 ++++++ .../src/authorization-component.ts | 20 +++ .../authorization/src/decorators/authorize.ts | 152 ++++++++++++++++++ packages/authorization/src/index.ts | 13 ++ packages/authorization/src/keys.ts | 12 ++ .../src/providers/authorization-metadata.ts | 31 ++++ .../authorization/src/providers/authorize.ts | 58 +++++++ packages/authorization/src/types.ts | 49 ++++++ .../test/unit/authorize-decorator.test.ts | 126 +++++++++++++++ packages/authorization/tsconfig.build.json | 8 + 17 files changed, 630 insertions(+) create mode 100644 packages/authorization/.npmrc create mode 100644 packages/authorization/LICENSE create mode 100644 packages/authorization/README.md create mode 100644 packages/authorization/docs.json create mode 100644 packages/authorization/index.d.ts create mode 100644 packages/authorization/index.js create mode 100644 packages/authorization/index.ts create mode 100644 packages/authorization/package.json create mode 100644 packages/authorization/src/authorization-component.ts create mode 100644 packages/authorization/src/decorators/authorize.ts create mode 100644 packages/authorization/src/index.ts create mode 100644 packages/authorization/src/keys.ts create mode 100644 packages/authorization/src/providers/authorization-metadata.ts create mode 100644 packages/authorization/src/providers/authorize.ts create mode 100644 packages/authorization/src/types.ts create mode 100644 packages/authorization/test/unit/authorize-decorator.test.ts create mode 100644 packages/authorization/tsconfig.build.json diff --git a/packages/authorization/.npmrc b/packages/authorization/.npmrc new file mode 100644 index 000000000000..43c97e719a5a --- /dev/null +++ b/packages/authorization/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/authorization/LICENSE b/packages/authorization/LICENSE new file mode 100644 index 000000000000..af99ad048201 --- /dev/null +++ b/packages/authorization/LICENSE @@ -0,0 +1,25 @@ +Copyright (c) IBM Corp. 2018. All Rights Reserved. +Node module: @loopback/authorization +This project is licensed under the MIT License, full text below. + +-------- + +MIT license + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/packages/authorization/README.md b/packages/authorization/README.md new file mode 100644 index 000000000000..826d31b386d7 --- /dev/null +++ b/packages/authorization/README.md @@ -0,0 +1,56 @@ +# @loopback/authorization + +A LoopBack 4 component for authorization support. + +**This is a reference implementation showing how to implement an authorization +component, it is not production ready.** + +## Overview + +## Installation + +```shell +npm install --save @loopback/authorization +``` + +## Basic use + +Start by decorating your controller methods with `@authorize` to require the +request to be authorized. + +In this example, we make the user profile available via dependency injection +using a key available from `@loopback/authorization` package. + +```ts +import {inject} from '@loopback/context'; +import {authorize} from '@loopback/authorization'; +import {get} from '@loopback/rest'; + +export class MyController { + @authorize({allow: ['ADMIN']}) + @get('/number-of-views') + numOfViews(): number { + return 100; + } +} +``` + +## Related resources + +## Contributions + +- [Guidelines](https://github.com/strongloop/loopback-next/blob/master/docs/CONTRIBUTING.md) +- [Join the team](https://github.com/strongloop/loopback-next/issues/110) + +## Tests + +run `npm test` from the root folder. + +## Contributors + +See +[all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). + +## License + +MIT diff --git a/packages/authorization/docs.json b/packages/authorization/docs.json new file mode 100644 index 000000000000..0ce80ef8469d --- /dev/null +++ b/packages/authorization/docs.json @@ -0,0 +1,12 @@ +{ + "content": [ + "index.ts", + "src/index.ts", + "src/decorators/authorize.ts", + "src/providers/authorization-metadata.ts", + "src/providers/authorize.ts", + "src/authorization-component.ts", + "src/keys.ts" + ], + "codeSectionDepth": 4 +} diff --git a/packages/authorization/index.d.ts b/packages/authorization/index.d.ts new file mode 100644 index 000000000000..026b9d7c9ffc --- /dev/null +++ b/packages/authorization/index.d.ts @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './dist'; diff --git a/packages/authorization/index.js b/packages/authorization/index.js new file mode 100644 index 000000000000..28622def8c06 --- /dev/null +++ b/packages/authorization/index.js @@ -0,0 +1,6 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +module.exports = require('./dist'); diff --git a/packages/authorization/index.ts b/packages/authorization/index.ts new file mode 100644 index 000000000000..f81c156a7f3f --- /dev/null +++ b/packages/authorization/index.ts @@ -0,0 +1,8 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +// DO NOT EDIT THIS FILE +// Add any additional (re)exports to src/index.ts instead. +export * from './src'; diff --git a/packages/authorization/package.json b/packages/authorization/package.json new file mode 100644 index 000000000000..a59564092788 --- /dev/null +++ b/packages/authorization/package.json @@ -0,0 +1,47 @@ +{ + "name": "@loopback/authorization", + "version": "0.1.0", + "description": "A LoopBack component for authorization support.", + "engines": { + "node": ">=8" + }, + "scripts": { + "acceptance": "lb-mocha \"dist/test/acceptance/**/*.js\"", + "build": "lb-tsc es2017 --outDir dist", + "build:apidocs": "lb-apidocs", + "clean": "lb-clean loopback-authorization*.tgz dist package api-docs", + "integration": "lb-mocha \"dist/test/integration/**/*.js\"", + "prepublishOnly": "npm run build && npm run build:apidocs", + "pretest": "npm run build", + "test": "lb-mocha \"dist/test/unit/**/*.js\" \"dist/test/integration/**/*.js\" \"dist/test/acceptance/**/*.js\"", + "unit": "lb-mocha \"dist/test/unit/**/*.js\"", + "verify": "npm pack && tar xf loopback-authorization*.tgz && tree package && npm run clean" + }, + "author": "IBM", + "copyright.owner": "IBM Corp.", + "license": "MIT", + "dependencies": { + "@loopback/context": "^1.0.0", + "@loopback/core": "^1.0.0" + }, + "devDependencies": { + "@loopback/build": "^1.0.0", + "@loopback/testlab": "^1.0.0" + }, + "keywords": [ + "LoopBack", + "Authorization" + ], + "files": [ + "README.md", + "index.js", + "index.d.ts", + "dist/src", + "dist/index*", + "src" + ], + "repository": { + "type": "git", + "url": "https://github.com/strongloop/loopback-next.git" + } +} diff --git a/packages/authorization/src/authorization-component.ts b/packages/authorization/src/authorization-component.ts new file mode 100644 index 000000000000..4331ef48c590 --- /dev/null +++ b/packages/authorization/src/authorization-component.ts @@ -0,0 +1,20 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {AuthorizationBindings} from './keys'; +import {Component, ProviderMap} from '@loopback/core'; +import {AuthorizationProvider} from './providers/authorize'; +import {AuthMetadataProvider} from './providers/authorization-metadata'; + +export class AuthenticationComponent implements Component { + providers?: ProviderMap; + + constructor() { + this.providers = { + [AuthorizationBindings.AUTHORIZE_ACTION]: AuthorizationProvider, + [AuthorizationBindings.METADATA]: AuthMetadataProvider, + }; + } +} diff --git a/packages/authorization/src/decorators/authorize.ts b/packages/authorization/src/decorators/authorize.ts new file mode 100644 index 000000000000..9a2829b82ef0 --- /dev/null +++ b/packages/authorization/src/decorators/authorize.ts @@ -0,0 +1,152 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + MetadataInspector, + Constructor, + MethodDecoratorFactory, + MetadataMap, + BindingAddress, +} from '@loopback/context'; +import {AuthorizationBindings} from '../keys'; +import { + AuthorizationMetadata, + Voter, + EVERYONE, + ANONYMOUS, + AUTHENTICATED, + UNAUTHENTICATED, +} from '../types'; + +export class AuthorizeMethodDecoratorFactory extends MethodDecoratorFactory< + AuthorizationMetadata +> { + protected mergeWithOwn( + ownMetadata: MetadataMap, + target: Object, + methodName?: string, + // tslint:disable-next-line:no-any + methodDescriptor?: TypedPropertyDescriptor | number, + ) { + ownMetadata = ownMetadata || {}; + const methodMeta = ownMetadata[methodName!]; + methodMeta.allowedRoles = this.merge( + methodMeta.allowedRoles, + this.spec.allowedRoles, + ); + methodMeta.deniedRoles = this.merge( + methodMeta.deniedRoles, + this.spec.deniedRoles, + ); + methodMeta.scopes = this.merge(methodMeta.scopes, this.spec.scopes); + methodMeta.voters = this.merge(methodMeta.voters, this.spec.voters); + + return ownMetadata; + } + + private merge(src?: T[], target?: T[]): T[] { + const list: T[] = []; + const set = new Set(src || []); + if (target) { + for (const i of target) { + set.add(i); + } + } + for (const i of set.values()) list.push(i); + return list; + } +} +/** + * Mark a controller method as requiring authorized user. + * + * @param spec Authorization metadata + */ +export function authorize(spec: AuthorizationMetadata) { + return AuthorizeMethodDecoratorFactory.createDecorator( + AuthorizationBindings.METADATA, + spec, + ); +} + +export namespace authorize { + /** + * Shortcut to configure allowed roles + * @param roles + */ + export const allow = (...roles: string[]) => authorize({allowedRoles: roles}); + /** + * Shortcut to configure denied roles + * @param roles + */ + export const deny = (...roles: string[]) => authorize({deniedRoles: roles}); + /** + * Shortcut to specify access scopes + * @param scopes + */ + export const scope = (...scopes: string[]) => authorize({scopes}); + + /** + * Shortcut to configure voters + * @param voters + */ + export const vote = (...voters: (Voter | BindingAddress)[]) => + authorize({voters}); + + /** + * Allows all + */ + export const allowAll = () => allow(EVERYONE); + + /** + * Allow all but the given roles + * @param roles + */ + export const allowAllExcept = (...roles: string[]) => + authorize({ + deniedRoles: roles, + allowedRoles: [EVERYONE], + }); + + /** + * Deny all + */ + export const denyAll = () => deny(EVERYONE); + + /** + * Deny all but the given roles + * @param roles + */ + export const denyAllExcept = (...roles: string[]) => + authorize({ + allowedRoles: roles, + deniedRoles: [EVERYONE], + }); + + /** + * Allow authenticated users + */ + export const allowAuthenticated = () => allow(AUTHENTICATED); + /** + * Deny unauthenticated users + */ + export const denyUnauthenticated = () => deny(UNAUTHENTICATED); +} + +/** + * Fetch authorization metadata stored by `@authorize` decorator. + * + * @param controllerClass Target controller + * @param methodName Target method + */ +export function getAuthorizeMetadata( + controllerClass: Constructor<{}>, + methodName: string, +): AuthorizationMetadata | undefined { + return MetadataInspector.getMethodMetadata( + AuthorizationBindings.METADATA, + controllerClass.prototype, + methodName, + ); +} diff --git a/packages/authorization/src/index.ts b/packages/authorization/src/index.ts new file mode 100644 index 000000000000..ab2249b1d931 --- /dev/null +++ b/packages/authorization/src/index.ts @@ -0,0 +1,13 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export * from './types'; +export * from './authorization-component'; +export * from './decorators/authorize'; +export * from './keys'; + +// internals for tests +export * from './providers/authorization-metadata'; +export * from './providers/authorize'; diff --git a/packages/authorization/src/keys.ts b/packages/authorization/src/keys.ts new file mode 100644 index 000000000000..8ba282e0c3bf --- /dev/null +++ b/packages/authorization/src/keys.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +/** + * Binding keys used by this component. + */ +export namespace AuthorizationBindings { + export const AUTHORIZE_ACTION = 'authorization.actions.authorize'; + export const METADATA = 'authorization.operationMetadata'; +} diff --git a/packages/authorization/src/providers/authorization-metadata.ts b/packages/authorization/src/providers/authorization-metadata.ts new file mode 100644 index 000000000000..b7cb72a66fbd --- /dev/null +++ b/packages/authorization/src/providers/authorization-metadata.ts @@ -0,0 +1,31 @@ +// Copyright IBM Corp. 2017,2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {CoreBindings} from '@loopback/core'; +import {Constructor, Provider, inject} from '@loopback/context'; +import {getAuthorizeMetadata} from '../decorators/authorize'; +import {AuthorizationMetadata} from '../types'; + +/** + * @description Provides authorization metadata of a controller method + * @example `context.bind('authorization.meta') + * .toProvider(AuthMetadataProvider)` + */ +export class AuthMetadataProvider + implements Provider { + constructor( + @inject(CoreBindings.CONTROLLER_CLASS) + private readonly controllerClass: Constructor<{}>, + @inject(CoreBindings.CONTROLLER_METHOD_NAME) + private readonly methodName: string, + ) {} + + /** + * @returns AuthorizationMetadata + */ + value(): AuthorizationMetadata | undefined { + return getAuthorizeMetadata(this.controllerClass, this.methodName); + } +} diff --git a/packages/authorization/src/providers/authorize.ts b/packages/authorization/src/providers/authorize.ts new file mode 100644 index 000000000000..110be5d97c63 --- /dev/null +++ b/packages/authorization/src/providers/authorize.ts @@ -0,0 +1,58 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Provider} from '@loopback/context'; +import {AuthorizationMetadata} from '..'; + +export enum AuthorizationDecision { + ALLOW = 'Allow', + DENY = 'Deny', + AUDIT = 'Audit', +} + +export interface Principal { + name: string; + type: string; + // tslint:disable-next-line:no-any + [attribute: string]: any; +} + +export interface Role { + name: string; + type: string; + // tslint:disable-next-line:no-any + [attribute: string]: any; +} + +export interface SecurityContext { + principals: Principal[]; + roles: Role[]; +} + +export type AuthorizeFn = ( + caller: SecurityContext, + target: AuthorizationMetadata, +) => Promise; + +/** + * @description Provider of a function which authenticates + * @example `context.bind('authentication_key') + * .toProvider(AuthorizationProvider)` + */ +export class AuthorizationProvider implements Provider { + constructor() {} + + /** + * @returns authenticateFn + */ + value(): AuthorizeFn { + return async ( + securityContext: SecurityContext, + metadata: AuthorizationMetadata, + ) => { + return AuthorizationDecision.ALLOW; + }; + } +} diff --git a/packages/authorization/src/types.ts b/packages/authorization/src/types.ts new file mode 100644 index 000000000000..75a7265247ab --- /dev/null +++ b/packages/authorization/src/types.ts @@ -0,0 +1,49 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Context, BindingAddress} from '@loopback/context'; + +/** + * Voting decision for the authorization decision + */ +export enum VotingDecision { + ALLOW = 'ALLOW', + DENY = 'DENY', + ABSTAIN = 'ABSTAIN', +} + +/** + * A voter function + */ +export interface Voter { + (ctx: Context): Promise; +} + +export const EVERYONE = '$everyone'; +export const AUTHENTICATED = '$authenticated'; +export const UNAUTHENTICATED = '$unauthenticated'; +export const ANONYMOUS = '$anonymous'; + +/** + * Authorization metadata stored via Reflection API + */ +export interface AuthorizationMetadata { + /** + * Roles that are allowed access + */ + allowedRoles?: string[]; + /** + * Roles that are denied access + */ + deniedRoles?: string[]; + /** + * Define the access scopes + */ + scopes?: string[]; + /** + * Voters that help make the authorization decision + */ + voters?: (Voter | BindingAddress)[]; +} diff --git a/packages/authorization/test/unit/authorize-decorator.test.ts b/packages/authorization/test/unit/authorize-decorator.test.ts new file mode 100644 index 000000000000..2c7b3bd7c8d0 --- /dev/null +++ b/packages/authorization/test/unit/authorize-decorator.test.ts @@ -0,0 +1,126 @@ +// Copyright IBM Corp. 2018. All Rights Reserved. +// Node module: @loopback/authorization +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + authorize, + getAuthorizeMetadata, + EVERYONE, + AUTHENTICATED, + UNAUTHENTICATED, +} from '../..'; + +describe('Authentication', () => { + describe('@authorize decorator', () => { + it('can add authorize metadata to target method', () => { + class TestClass { + @authorize({allowedRoles: ['ADMIN'], scopes: ['secret.read']}) + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.eql({ + allowedRoles: ['ADMIN'], + scopes: ['secret.read'], + }); + }); + + it('can add allowAll to target method', () => { + class TestClass { + @authorize.allowAll() + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.eql({ + allowedRoles: [EVERYONE], + }); + }); + + it('can add allowAllExcept to target method', () => { + class TestClass { + @authorize.allowAllExcept('xyz') + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.eql({ + allowedRoles: [EVERYONE], + deniedRoles: ['xyz'], + }); + }); + + it('can add denyAll to target method', () => { + class TestClass { + @authorize.denyAll() + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.eql({ + deniedRoles: [EVERYONE], + }); + }); + + it('can add denyAllExcept to target method', () => { + class TestClass { + @authorize.denyAllExcept('xyz') + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.eql({ + allowedRoles: ['xyz'], + deniedRoles: [EVERYONE], + }); + }); + + it('can add allowAuthenticated to target method', () => { + class TestClass { + @authorize.allowAuthenticated() + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.eql({ + allowedRoles: [AUTHENTICATED], + }); + }); + + it('can add allowAuthenticated to target method', () => { + class TestClass { + @authorize.denyUnauthenticated() + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.eql({ + deniedRoles: [UNAUTHENTICATED], + }); + }); + + it('can stack decorators to target method', () => { + class TestClass { + @authorize.allow('a1', 'a2') + @authorize.deny('d1', 'd2') + @authorize({ + allowedRoles: ['a1', 'a3'], + deniedRoles: ['d3'], + }) + @authorize.scope('s1', 's2') + @authorize.vote('v1', 'v2') + getSecret() {} + } + + const metaData = getAuthorizeMetadata(TestClass, 'getSecret'); + expect(metaData).to.deepEqual({ + voters: ['v1', 'v2'], + allowedRoles: ['a1', 'a3', 'a2'], + deniedRoles: ['d3', 'd1', 'd2'], + scopes: ['s1', 's2'], + }); + }); + }); +}); diff --git a/packages/authorization/tsconfig.build.json b/packages/authorization/tsconfig.build.json new file mode 100644 index 000000000000..3ffcd508d23e --- /dev/null +++ b/packages/authorization/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json.schemastore.org/tsconfig", + "extends": "../build/config/tsconfig.common.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["index.ts", "src", "test"] +}