diff --git a/package-lock.json b/package-lock.json index 9daa0e26b8de..ed3c917086af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41231,6 +41231,7 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@loopback/authentication": "^13.0.0", "@loopback/build": "^13.0.0", "@loopback/core": "^8.0.0", "@loopback/repository": "^9.0.0", diff --git a/packages/rest-crud/README.md b/packages/rest-crud/README.md index 50d47a545743..49087096c5e1 100644 --- a/packages/rest-crud/README.md +++ b/packages/rest-crud/README.md @@ -43,11 +43,13 @@ export class TryApplication extends BootMixin( Create a new file for the configuration, e.g. `src/model-endpoints/product.rest-config.ts` that defines the `model`, -`pattern`, `dataSource`, `basePath`, and `readonly` properties: +`pattern`, `dataSource`, `basePath`, `readonly` and optionaly `auth` with +authenticate method from @loopback/authentication and strategy properties: ```ts import {ModelCrudRestApiConfig} from '@loopback/rest-crud'; import {Product} from '../models'; +import {authenticate} from '@loopback/authentication'; module.exports = { model: Product, @@ -55,6 +57,18 @@ module.exports = { dataSource: 'db', basePath: '/products', readonly: false, + authenticate, + strategy: 'jwt', // if no strategy provided, the default would be jwt + auth: { + count: true, + get: true, + getById: true, + post: true, + patch: true, + patchById: true, + putById: true, + deleteById: true, + }, }; ``` diff --git a/packages/rest-crud/package.json b/packages/rest-crud/package.json index f9cd541228be..4b9b3b6f9381 100644 --- a/packages/rest-crud/package.json +++ b/packages/rest-crud/package.json @@ -42,6 +42,7 @@ "tslib": "^2.8.1" }, "devDependencies": { + "@loopback/authentication": "^13.0.0", "@loopback/build": "^13.0.0", "@loopback/core": "^8.0.0", "@loopback/repository": "^9.0.0", diff --git a/packages/rest-crud/src/__tests__/acceptance/authorization-model-crud-rest.acceptance.ts b/packages/rest-crud/src/__tests__/acceptance/authorization-model-crud-rest.acceptance.ts new file mode 100644 index 000000000000..a1e1fecd75d3 --- /dev/null +++ b/packages/rest-crud/src/__tests__/acceptance/authorization-model-crud-rest.acceptance.ts @@ -0,0 +1,170 @@ +// Copyright IBM Corp. and LoopBack contributors 2019,2020. All Rights Reserved. +// Node module: @loopback/rest-crud +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import { + defineCrudRepositoryClass, + Entity, + EntityCrudRepository, + juggler, + model, + property, +} from '@loopback/repository'; +import {HttpErrors, RestApplication, Request} from '@loopback/rest'; +import { + Client, + createRestAppClient, + givenHttpServerConfig, +} from '@loopback/testlab'; +import {defineCrudRestController} from '../..'; +import { + authenticate, + AuthenticationComponent, + AuthenticationStrategy, + registerAuthenticationStrategy, +} from '@loopback/authentication'; +import {UserProfile, securityId} from '@loopback/security'; + +// Define a simple mock strategy +class MockAuthenticationStrategy implements AuthenticationStrategy { + name = 'custom-strategy'; + + async authenticate(request: Request): Promise { + const authHeader = + request.headers.authorization || request.headers?.Authorization; + if (!authHeader) { + throw new HttpErrors.Unauthorized('Authorization header not found.'); + } + return { + [securityId]: '1', + email: 'testing@loopback.io', + name: 'Test User', + }; + } +} + +// In this test scenario, we create a customer to test authentication +// for rest-crud (model-endpoints) based apis. +// +// Please don't add any other scenarios to this test file, create a new file +// for each scenario instead. + +describe('CrudRestController for a simple Product model', () => { + @model() + class Customer extends Entity { + @property({id: true}) + id: number; + + @property({required: true}) + name: string; + + @property() + description?: string; + + constructor(data: Partial) { + super(data); + } + } + + let app: RestApplication; + let repo: EntityCrudRepository; + + let client: Client; + + const PATCH_DATA = {description: 'patched'}; + Object.freeze(PATCH_DATA); + + before(setupTestScenario); + after(stopTheApp); + + describe('authentication', () => { + it('fails to create a Customer without credentials', async () => { + await client.post('/customers').send({name: 'Jane Doe'}).expect(401); + }); + + it('fails to fetch all Customers without credentials', async () => { + await client.get('/customers').expect(401); + }); + + it('fails to fetch a single Customer without credentials', async () => { + await client.get('/customers/1').expect(401); + }); + + it('fails to fetch Customer count without credentials', async () => { + await client.get('/customers/count').expect(401); + }); + + it('fails to update Customers without credentials', async () => { + await client.patch('/customers').send(PATCH_DATA).expect(401); + }); + + it('fails to update a single Customer without credentials', async () => { + await client.patch('/customers/1').send(PATCH_DATA).expect(401); + }); + + it('fails to replace a single Customer without credentials', async () => { + await client.put('/customers/1').send(PATCH_DATA).expect(401); + }); + + it('fails to delete a single Customer without credentials', async () => { + await client.del('/customers/1').expect(401); + }); + + it('succeeds when credentaisl are provied', async () => { + await client + .get('/customers') + .set('Authorization', 'Bearer mock-token-value') + .expect(200); + }); + }); + + async function setupTestScenario() { + const CustomerCrudRestController = defineCrudRestController< + Customer, + typeof Customer.prototype.id, + 'id' + >(Customer, { + basePath: '/customers', + authenticate, + strategy: 'custom-strategy', + auth: { + post: true, + patch: true, + deleteById: true, + putById: true, + count: true, + get: true, + getById: true, + patchById: true, + }, + }); + + const db = new juggler.DataSource({connector: 'memory'}); + + const CustomerRepository = defineCrudRepositoryClass(Customer); + + repo = new CustomerRepository(db); + + class CustomerController extends CustomerCrudRestController { + constructor() { + super(repo); + } + } + + app = new RestApplication({rest: givenHttpServerConfig()}); + app.component(AuthenticationComponent); + registerAuthenticationStrategy(app, MockAuthenticationStrategy); + app + .bind('authentication.strategy.custom-strategy') + .toClass(MockAuthenticationStrategy); + app.controller(CustomerController); + + await app.start(); + client = createRestAppClient(app); + } + + async function stopTheApp() { + await app.stop(); + } +}); diff --git a/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts b/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts index 6168a10684c7..419096097c38 100644 --- a/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts +++ b/packages/rest-crud/src/__tests__/acceptance/default-model-crud-rest.acceptance.ts @@ -46,6 +46,7 @@ describe('CrudRestController for a simple Product model', () => { let app: RestApplication; let repo: EntityCrudRepository; + let client: Client; // sample data - call `seedData` to populate these items @@ -67,7 +68,6 @@ describe('CrudRestController for a simple Product model', () => { // FIXME: POST should return 201 // See https://github.com/loopbackio/loopback-next/issues/788 .expect(200); - const created = response.body; expect(created).to.containEql({name: 'A pen'}); expect(created).to.have.property('id').of.type('number'); diff --git a/packages/rest-crud/src/crud-rest.api-builder.ts b/packages/rest-crud/src/crud-rest.api-builder.ts index 85f9f3320088..cd5fdddd6fe3 100644 --- a/packages/rest-crud/src/crud-rest.api-builder.ts +++ b/packages/rest-crud/src/crud-rest.api-builder.ts @@ -35,6 +35,13 @@ export interface ModelCrudRestApiConfig extends ModelApiConfig { * If true, the generated controller only has find and count APIs */ readonly?: boolean; + // strategy for auth + strategy?: string; + /** + * passing authenticate method to enable auth for rest-crud based APIs + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authenticate?: ((strategy: string) => any) | undefined; } @injectable(asModelApiBuilder) diff --git a/packages/rest-crud/src/crud-rest.controller.ts b/packages/rest-crud/src/crud-rest.controller.ts index dcf134cd491f..60031f75c2bd 100644 --- a/packages/rest-crud/src/crud-rest.controller.ts +++ b/packages/rest-crud/src/crud-rest.controller.ts @@ -105,6 +105,23 @@ export interface CrudRestControllerOptions { * Whether to generate readonly APIs */ readonly?: boolean; + /** + * Whether to add authentication decorator or not + */ + auth?: { + count?: boolean; + get?: boolean; + getById?: boolean; + post?: boolean; + patch?: boolean; + patchById?: boolean; + putById?: boolean; + deleteById?: boolean; + }; + // autheticate method from @loopback/authentication extension + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authenticate?: ((strategy: string) => any) | undefined; + strategy?: string; } /** @@ -157,6 +174,11 @@ export function defineCrudRestController< public readonly repository: EntityCrudRepository, ) {} + @authenticatedMethod( + options.auth?.get, + options.authenticate, + options.strategy, + ) @get('/', { ...response.array(200, `Array of ${modelName} instances`, modelCtor, { includeRelations: true, @@ -174,6 +196,11 @@ export function defineCrudRestController< includeRelations: true, }), }) + @authenticatedMethod( + options.auth?.getById, + options.authenticate, + options.strategy, + ) async findById( @param(idPathParam) id: IdType, @param.query.object( @@ -188,6 +215,11 @@ export function defineCrudRestController< @get('/count', { ...response(200, `${modelName} count`, {schema: CountSchema}), }) + @authenticatedMethod( + options.auth?.count, + options.authenticate, + options.strategy, + ) async count( @param.where(modelCtor) where?: Where, @@ -207,6 +239,11 @@ export function defineCrudRestController< @post('/', { ...response.model(200, `${modelName} instance created`, modelCtor), }) + @authenticatedMethod( + options?.auth?.post, + options.authenticate, + options.strategy, + ) async create( @body(modelCtor, { title: `New${modelName}`, @@ -226,6 +263,11 @@ export function defineCrudRestController< schema: CountSchema, }), }) + @authenticatedMethod( + options?.auth?.patch, + options.authenticate, + options.strategy, + ) async updateAll( @body(modelCtor, {partial: true}) data: Partial, @param.where(modelCtor) @@ -244,6 +286,11 @@ export function defineCrudRestController< '204': {description: `${modelName} was updated`}, }, }) + @authenticatedMethod( + options?.auth?.patchById, + options.authenticate, + options.strategy, + ) async updateById( @param(idPathParam) id: IdType, @body(modelCtor, {partial: true}) data: Partial, @@ -261,6 +308,11 @@ export function defineCrudRestController< '204': {description: `${modelName} was updated`}, }, }) + @authenticatedMethod( + options.auth?.putById, + options.authenticate, + options.strategy, + ) async replaceById( @param(idPathParam) id: IdType, @body(modelCtor) data: T, @@ -273,6 +325,11 @@ export function defineCrudRestController< '204': {description: `${modelName} was deleted`}, }, }) + @authenticatedMethod( + options.auth?.deleteById, + options.authenticate, + options.strategy, + ) async deleteById(@param(idPathParam) id: IdType): Promise { await this.repository.deleteById(id); } @@ -363,3 +420,17 @@ namespace response { }); } } + +// Helper function to conditionally add @authenticate decorator +function authenticatedMethod( + applyAuth: boolean | undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authenticate: ((strategy: string) => any) | undefined, + strategy: string = 'jwt', +) { + if (authenticate && strategy && applyAuth) { + return authenticate(strategy); + } else { + return (target: Object, key: string, descriptor: PropertyDescriptor) => {}; + } +} diff --git a/packages/rest-crud/tsconfig.json b/packages/rest-crud/tsconfig.json index 0b455748217b..78615fc372a1 100644 --- a/packages/rest-crud/tsconfig.json +++ b/packages/rest-crud/tsconfig.json @@ -10,6 +10,9 @@ "src" ], "references": [ + { + "path": "../authentication/tsconfig.json" + }, { "path": "../core/tsconfig.json" },