From ac3fd77abe386b88686d4d8b400b7a281df521d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miroslav=20Bajto=C5=A1?= Date: Fri, 4 Aug 2017 15:18:52 +0200 Subject: [PATCH] authentication: make it a proper component Export `AuthenticationComponent` that registers providers for `authenticate` action and current-route metadata. Simplify usage of `authenticate` action by shifting the complexity of deferring metadata resolution into the action itself (using the new `@inject.getter` decorator), so that custom Sequence implementations can inject this action the same way as other actions are injected. Improve README - fix markdown formatting, reword introductory texts, improve the usage example. Clean up the code in general - fix spelling mistakes, organize files into `decorators` and `providers` folders. --- packages/authentication/README.md | 152 ++++++++++++++++-- packages/authentication/package.json | 1 + packages/authentication/src/auth-component.ts | 22 +++ .../authenticate.ts} | 2 +- packages/authentication/src/index.ts | 11 +- packages/authentication/src/keys.ts | 4 +- .../auth-metadata.ts} | 5 +- .../authenticate.ts} | 20 ++- .../authentication/src/strategy-adapter.ts | 2 +- .../test/acceptance/basic-auth.ts | 56 +++---- .../test/unit/fixtures/mock-strategy.ts | 12 +- .../authentication/test/unit/provider.test.ts | 50 +++--- packages/core/src/component.ts | 10 +- packages/core/src/index.ts | 2 +- 14 files changed, 244 insertions(+), 105 deletions(-) create mode 100644 packages/authentication/src/auth-component.ts rename packages/authentication/src/{decorator.ts => decorators/authenticate.ts} (97%) rename packages/authentication/src/{metadata-provider.ts => providers/auth-metadata.ts} (90%) rename packages/authentication/src/{provider.ts => providers/authenticate.ts} (71%) diff --git a/packages/authentication/README.md b/packages/authentication/README.md index 54ccd337380e..ff52c4195dd5 100644 --- a/packages/authentication/README.md +++ b/packages/authentication/README.md @@ -2,45 +2,167 @@ A LoopBack component for authentication support. -# Overview - It demonstrates how to use LoopBack's user models and passport to interact with other authentication providers. - User can login using a passport.js strategy, which could include a third party provider. +**This is a reference implementation showing how to implement an authentication component, it is not production ready.* +## Overview -# Installation +The component demonstrates how to leverage Passport module and extension points +provided by LoopBack Next to implement authentication layer. + +## Installation ```shell npm install --save @loopback/authentication ``` -# Basic use +## Basic use + +Start by decorating your controller methods with `@authenticate` to require +the request to be authenticated. + +```ts +// controllers/my-controller.ts +import {UserProfile, authenticate} from '@loopback/authentication'; + +class MyController { + constructor(@inject('authentication.user') private user: UserProfile) {} + + @authenticate('BasicStrategy') + whoAmI() { + return this.user.id; + } +} +``` +Next, implement a Strategy provider to map strategy names specified +in `@authenticate` decorators into Passport Strategy instances. + +```ts +// providers/auth-strategy.ts +import { + inject, + Provider, + ValueOrPromise, +} from '@loopback/context'; +import { + BindingKeys, + AuthenticationMetadata, +} from '@loopback/authentication'; + +import {Strategy} from 'passport'; +import {BasicStrategy} from 'passport-http'; + +export class MyAuthStrategyProvider implements Provider { + constructor( + @inject(BindingKeys.Authentication.METADATA) + private metadata: AuthenticationMetadata, + ) {} + + value() : ValueOrPromise { + const name = this.metadata.strategy; + if (name === 'BasicStrategy') { + return new BasicStrategy(this.verify); + } else { + return Promise.reject(`The strategy ${name} is not available.`); + } + } + + verify(username: string, password: string, cb: Function) { + // find user by name & password + // call cb(null, false) when user not found + // call cb(null, userProfile) when user is authenticated + } +} +``` - ```ts - const strategy = new BasicStrategy(async (username, password) => { - return await findUser(username, password); - }; - getAuthenticatedUser(strategy, ParsedRequest); +In order to perform authentication, we need to implement a custom Sequence +invoking the authentication at the right time during the request handling. + +```ts +// sequence.ts +import { + FindRoute, + inject, + InvokeMethod, + ParsedRequest, + Reject + Send, + ServerResponse, + SequenceHandler, +} from '@loopback/core'; + +import { + AuthenticateFn, +} from '@loopback/authentication'; + +class MySequence implements SequenceHandler { + constructor( + @inject('sequence.actions.findRoute') protected findRoute: FindRoute, + @inject('sequence.actions.invokeMethod') protected invoke: InvokeMethod, + @inject('sequence.actions.send') protected send: Send, + @inject('sequence.actions.reject') protected reject: Reject, + @inject('authentication.actions.authenticate') + protected authenticateRequest: AuthenticateFn, + ) {} + + async handle(req: ParsedRequest, res: ServerResponse) { + try { + const route = this.findRoute(req); + + // This is the important line added to the default sequence implementation + const user = await this.authenticateRequest(req); + + const args = await parseOperationArgs(req, route); + const result = await this.invoke(route, args); + this.send(res, result); + } catch (err) { + this.reject(res, req, err); + } + } +} ``` +Finally, put it all together in your application object: + +```ts +import {Application} from '@loopback/core'; +import {AuthenticationComponent, BindingKeys} from '@loopback/authentication'; +import {MyAuthStrategyProvider} from './providers/auth-strategy'; +import {MyController} from './controllers/my-controller'; +import {MySequence} from './my-sequence'; + +class MyApp extends Application { + constructor() { + super({ + components: [AuthenticationComponent], + }); + + this.bind(BindingKeys.Authentication.STRATEGY) + .toProvider(MyPassportStrategyProvider); + this.sequence(MySequence); + + this.controller(MyController); + } +} +``` -# Related resources +## Related resources For more info about passport, see [passport.js](http://passportjs.org/). -# Contributions +## Contributions - [Guidelines](https://github.com/strongloop/loopback-next/wiki/Contributing#guidelines) - [Join the team](https://github.com/strongloop/loopback-next/issues/110) -# Tests +## Tests run `npm test` from the root folder. -# Contributors +## Contributors See [all contributors](https://github.com/strongloop/loopback-next/graphs/contributors). -# License +## License MIT diff --git a/packages/authentication/package.json b/packages/authentication/package.json index a20dc808e721..d20b77034ac6 100644 --- a/packages/authentication/package.json +++ b/packages/authentication/package.json @@ -23,6 +23,7 @@ "@loopback/context": "^4.0.0-alpha.9", "@loopback/core": "^4.0.0-alpha.9", "@types/passport": "^0.3.3", + "@types/passport-http": "^0.3.2", "passport": "^0.3.2", "passport-strategy": "^1.0.0" }, diff --git a/packages/authentication/src/auth-component.ts b/packages/authentication/src/auth-component.ts new file mode 100644 index 000000000000..5feeb3d14af3 --- /dev/null +++ b/packages/authentication/src/auth-component.ts @@ -0,0 +1,22 @@ +// Copyright IBM Corp. 2013,2017. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {BindingKeys} from './keys'; +import {Constructor} from '@loopback/context'; +import {Component, ProviderMap} from '@loopback/core'; +import {AuthenticationProvider} from './providers/authenticate'; +import {AuthMetadataProvider} from './providers/auth-metadata'; + +export class AuthenticationComponent implements Component { + providers?: ProviderMap; + + // TODO(bajtos) inject configuration + constructor() { + this.providers = { + [BindingKeys.Authentication.AUTH_ACTION]: AuthenticationProvider, + [BindingKeys.Authentication.METADATA]: AuthMetadataProvider, + }; + } +} diff --git a/packages/authentication/src/decorator.ts b/packages/authentication/src/decorators/authenticate.ts similarity index 97% rename from packages/authentication/src/decorator.ts rename to packages/authentication/src/decorators/authenticate.ts index 952c4944f506..89891a89ddeb 100644 --- a/packages/authentication/src/decorator.ts +++ b/packages/authentication/src/decorators/authenticate.ts @@ -4,7 +4,7 @@ // License text available at https://opensource.org/licenses/MIT import {Reflector, Constructor} from '@loopback/context'; -import {BindingKeys} from './keys'; +import {BindingKeys} from '../keys'; /** * Authentication metadata stored via Reflection API diff --git a/packages/authentication/src/index.ts b/packages/authentication/src/index.ts index a035c6686bf9..55d508172d17 100644 --- a/packages/authentication/src/index.ts +++ b/packages/authentication/src/index.ts @@ -3,8 +3,11 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export * from './decorator'; -export * from './strategy-adapter'; -export * from './metadata-provider'; -export * from './provider'; +export * from './auth-component'; +export * from './decorators/authenticate'; export * from './keys'; +export * from './strategy-adapter'; + +// internals for tests +export * from './providers/auth-metadata'; +export * from './providers/authenticate'; diff --git a/packages/authentication/src/keys.ts b/packages/authentication/src/keys.ts index 740ccd2399a5..a52f84c8cf94 100644 --- a/packages/authentication/src/keys.ts +++ b/packages/authentication/src/keys.ts @@ -9,7 +9,7 @@ export namespace BindingKeys { export namespace Authentication { export const STRATEGY = 'authentication.strategy'; - export const PROVIDER = 'authentication.provider'; - export const METADATA = 'authenticate'; + export const AUTH_ACTION = 'authentication.actions.authenticate'; + export const METADATA = 'authentication.operation-metadata'; } } diff --git a/packages/authentication/src/metadata-provider.ts b/packages/authentication/src/providers/auth-metadata.ts similarity index 90% rename from packages/authentication/src/metadata-provider.ts rename to packages/authentication/src/providers/auth-metadata.ts index e158dd4748c5..376b306b51d7 100644 --- a/packages/authentication/src/metadata-provider.ts +++ b/packages/authentication/src/providers/auth-metadata.ts @@ -5,7 +5,10 @@ import {BindingKeys} from '@loopback/core'; import {Constructor, Provider, inject} from '@loopback/context'; -import {AuthenticationMetadata, getAuthenticateMetadata} from './decorator'; +import { + AuthenticationMetadata, + getAuthenticateMetadata, +} from '../decorators/authenticate'; /** * @description Provides authentication metadata of a controller method diff --git a/packages/authentication/src/provider.ts b/packages/authentication/src/providers/authenticate.ts similarity index 71% rename from packages/authentication/src/provider.ts rename to packages/authentication/src/providers/authenticate.ts index c7d3e3357800..6d7475fff3c9 100644 --- a/packages/authentication/src/provider.ts +++ b/packages/authentication/src/providers/authenticate.ts @@ -7,8 +7,8 @@ import * as http from 'http'; import {HttpErrors, inject, ParsedRequest} from '@loopback/core'; import {Provider} from '@loopback/context'; import {Strategy} from 'passport'; -import {StrategyAdapter} from './strategy-adapter'; -import {BindingKeys} from './keys'; +import {StrategyAdapter} from '../strategy-adapter'; +import {BindingKeys} from '../keys'; /** * interface definition of a function which accepts a request @@ -35,15 +35,25 @@ export interface UserProfile { */ export class AuthenticationProvider implements Provider { constructor( - @inject(BindingKeys.Authentication.STRATEGY) readonly strategy: Strategy, + // The provider is instantiated for Sequence constructor, + // at which time we don't have information about the current + // route yet. This information is needed to determine + // what auth strategy should be used. + // To solve this, we are injecting a getter function that will + // defer resolution of the strategy until authenticate() action + // is executed. + @inject.getter(BindingKeys.Authentication.STRATEGY) + readonly getStrategy: () => Promise, ) {} /** * @returns authenticateFn */ value(): AuthenticateFn { - return async (request: ParsedRequest) => - await getAuthenticatedUser(this.strategy, request); + return async (request: ParsedRequest) => { + const strategy = await this.getStrategy(); + return await getAuthenticatedUser(strategy, request); + }; } } diff --git a/packages/authentication/src/strategy-adapter.ts b/packages/authentication/src/strategy-adapter.ts index d90fa5fa9287..5b58db14d3ea 100644 --- a/packages/authentication/src/strategy-adapter.ts +++ b/packages/authentication/src/strategy-adapter.ts @@ -5,7 +5,7 @@ import * as http from 'http'; import {HttpErrors, ParsedRequest} from '@loopback/core'; import {Strategy} from 'passport'; -import {UserProfile} from './provider'; +import {UserProfile} from './providers/authenticate'; /** * Shimmed Request to satisfy express requirements of passport strategies. diff --git a/packages/authentication/test/acceptance/basic-auth.ts b/packages/authentication/test/acceptance/basic-auth.ts index b95a19517af2..b0c841857b79 100644 --- a/packages/authentication/test/acceptance/basic-auth.ts +++ b/packages/authentication/test/acceptance/basic-auth.ts @@ -6,14 +6,9 @@ import { Application, api, - OpenApiSpec, - ParameterObject, - ServerRequest, ServerResponse, parseOperationArgs, - writeResultToResponse, ParsedRequest, - OperationArgs, FindRoute, InvokeMethod, GetFromContext, @@ -25,31 +20,29 @@ import { } from '@loopback/core'; import {expect, Client, createClientForApp} from '@loopback/testlab'; import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; -import {inject, +import { + inject, Provider, ValueOrPromise, - Context, - Injection, - BoundValue, } from '@loopback/context'; -import {authenticate, +import { + authenticate, UserProfile, BindingKeys, AuthenticateFn, - AuthenticationProvider, - AuthenticationMetadata, AuthMetadataProvider, + AuthenticationMetadata, + AuthenticationComponent, } from '../..'; import {Strategy} from 'passport'; import {HttpError} from 'http-errors'; - -const BasicStrategy = require('passport-http').BasicStrategy; +import {BasicStrategy} from 'passport-http'; describe('Basic Authentication', () => { let app: Application; let users: UserRepository; - beforeEach(givenUserRespository); + beforeEach(givenUserRepository); beforeEach(givenAnApplication); beforeEach(givenControllerInApp); beforeEach(givenAuthenticatedSequence); @@ -74,7 +67,7 @@ describe('Basic Authentication', () => { .expect(401); }); - function givenUserRespository() { + function givenUserRepository() { users = new UserRepository({ joe : {profile: {id: 'joe'}, password: '12345'}, Simpson: {profile: {id: 'sim123'}, password: 'alpha'}, @@ -84,8 +77,9 @@ describe('Basic Authentication', () => { } function givenAnApplication() { - app = new Application(); - app.bind('application.name').to('SequenceApp'); + app = new Application({ + components: [AuthenticationComponent], + }); } function givenControllerInApp() { @@ -112,26 +106,15 @@ describe('Basic Authentication', () => { app.controller(MyController); } - function deferredResolver( - ctx: Context, - injection: Injection, - ): BoundValue { - return async (...args: BoundValue[]) => { - const fn = await ctx.get(injection.bindingKey); - return await fn(...args); - }; - } - function givenAuthenticatedSequence() { class MySequence implements SequenceHandler { constructor( @inject('sequence.actions.findRoute') protected findRoute: FindRoute, - @inject('getFromContext') protected getFromContext: GetFromContext, @inject('sequence.actions.invokeMethod') protected invoke: InvokeMethod, @inject('sequence.actions.send') protected send: Send, @inject('sequence.actions.reject') protected reject: Reject, @inject('bindElement') protected bindElement: BindElement, - @inject('authentication.provider', {}, deferredResolver) + @inject('authentication.actions.authenticate') protected authenticateRequest: AuthenticateFn, ) {} @@ -157,7 +140,7 @@ describe('Basic Authentication', () => { } } // bind user defined sequence - app.bind('sequence').toClass(MySequence); + app.sequence(MySequence); } function givenProviders() { @@ -166,11 +149,12 @@ describe('Basic Authentication', () => { @inject(BindingKeys.Authentication.METADATA) private metadata: AuthenticationMetadata, ) {} - value() : Promise { - if (this.metadata.strategy === 'BasicStrategy') { + value() : ValueOrPromise { + const name = this.metadata.strategy; + if (name === 'BasicStrategy') { return new BasicStrategy(this.verify); } else { - return Promise.reject('configured strategy is not available'); + return Promise.reject(`The strategy ${name} is not available.`); } } // callback method for BasicStrategy @@ -180,12 +164,8 @@ describe('Basic Authentication', () => { }); } } - app.bind(BindingKeys.Authentication.METADATA) - .toProvider(AuthMetadataProvider); app.bind(BindingKeys.Authentication.STRATEGY) .toProvider(MyPassportStrategyProvider); - app.bind(BindingKeys.Authentication.PROVIDER) - .toProvider(AuthenticationProvider); } function whenIMakeRequestTo(application: Application): Client { diff --git a/packages/authentication/test/unit/fixtures/mock-strategy.ts b/packages/authentication/test/unit/fixtures/mock-strategy.ts index 1fd8e27d58bf..ba668b88084b 100644 --- a/packages/authentication/test/unit/fixtures/mock-strategy.ts +++ b/packages/authentication/test/unit/fixtures/mock-strategy.ts @@ -25,7 +25,7 @@ export class MockStrategy implements Strategy { } /** * @param req - * mock verfication function; usually passed in as constructor argument for + * mock verification function; usually passed in as constructor argument for * passport-strategy * * For the purpose of mock tests we have this here @@ -38,7 +38,7 @@ export class MockStrategy implements Strategy { req.headers.testState && req.headers.testState === 'fail' ) { - this.returnUnAuthourized({error: 'authorization failed'}); + this.returnUnauthorized({error: 'authorization failed'}); return; } else if ( req.headers && @@ -51,18 +51,18 @@ export class MockStrategy implements Strategy { process.nextTick(this.returnMockUser.bind(this)); } success(user: Object) { - throw new Error('should be overrided by adapter'); + throw new Error('should be overridden by adapter'); } fail(challenge: Object) { - throw new Error('should be overrided by adapter'); + throw new Error('should be overridden by adapter'); } error(error: string) { - throw new Error('should be overrided by adapter'); + throw new Error('should be overridden by adapter'); } returnMockUser() { this.success(this.mockUser); } - returnUnAuthourized(challenge: Object) { + returnUnauthorized(challenge: Object) { this.fail(challenge); } returnError(err: string) { diff --git a/packages/authentication/test/unit/provider.test.ts b/packages/authentication/test/unit/provider.test.ts index e1fa87f8f8b8..b5fd00165a00 100644 --- a/packages/authentication/test/unit/provider.test.ts +++ b/packages/authentication/test/unit/provider.test.ts @@ -16,29 +16,25 @@ import { import {MockStrategy} from './fixtures/mock-strategy'; describe('AuthenticationProvider', () => { - let provider: Provider; - let strategy: MockStrategy; - - const mockUser: UserProfile = {name: 'user-name', id: 'mock-id'}; - - beforeEach(givenAuthenticationProvider); - describe('constructor()', () => { it('instantiateClass injects authentication.strategy in the constructor', async () => { - const context: Context = new Context(); + const context = new Context(); + const strategy = new MockStrategy(); context.bind(BindingKeys.Authentication.STRATEGY).to(strategy); - provider = await instantiateClass>( - AuthenticationProvider, - context, - ); - const authenticationProvider: AuthenticationProvider = - provider as AuthenticationProvider; - expect(authenticationProvider.strategy).to.be.equal(strategy); + const provider = await instantiateClass(AuthenticationProvider, context); + expect(await provider.getStrategy()).to.be.equal(strategy); }); }); describe('value()', () => { + let provider: AuthenticationProvider; + let strategy: MockStrategy; + + const mockUser: UserProfile = {name: 'user-name', id: 'mock-id'}; + + beforeEach(givenAuthenticationProvider); + it('returns a function which authenticates a request and returns a user', async () => { const authenticate: AuthenticateFn = await Promise.resolve( @@ -55,11 +51,11 @@ describe('AuthenticationProvider', () => { const context: Context = new Context(); context.bind(BindingKeys.Authentication.STRATEGY).to(strategy); context - .bind(BindingKeys.Authentication.PROVIDER) + .bind(BindingKeys.Authentication.AUTH_ACTION) .toProvider(AuthenticationProvider); const request = {}; const authenticate = await context.get( - BindingKeys.Authentication.PROVIDER, + BindingKeys.Authentication.AUTH_ACTION, ); const user: UserProfile = await authenticate(request); expect(user).to.be.equal(mockUser); @@ -70,10 +66,10 @@ describe('AuthenticationProvider', () => { const context: Context = new Context(); context.bind(BindingKeys.Authentication.STRATEGY).to({}); context - .bind(BindingKeys.Authentication.PROVIDER) + .bind(BindingKeys.Authentication.AUTH_ACTION) .toProvider(AuthenticationProvider); const authenticate = await context.get( - BindingKeys.Authentication.PROVIDER, + BindingKeys.Authentication.AUTH_ACTION, ); const request = {}; let error; @@ -90,10 +86,10 @@ describe('AuthenticationProvider', () => { const context: Context = new Context(); context.bind(BindingKeys.Authentication.STRATEGY).to(strategy); context - .bind(BindingKeys.Authentication.PROVIDER) + .bind(BindingKeys.Authentication.AUTH_ACTION) .toProvider(AuthenticationProvider); const authenticate = await context.get( - BindingKeys.Authentication.PROVIDER, + BindingKeys.Authentication.AUTH_ACTION, ); const request = {}; request.headers = {testState: 'fail'}; @@ -106,11 +102,11 @@ describe('AuthenticationProvider', () => { expect(error).to.have.property('statusCode', 401); }); }); - }); - function givenAuthenticationProvider() { - strategy = new MockStrategy(); - strategy.setMockUser(mockUser); - provider = new AuthenticationProvider(strategy); - } + function givenAuthenticationProvider() { + strategy = new MockStrategy(); + strategy.setMockUser(mockUser); + provider = new AuthenticationProvider(() => Promise.resolve(strategy)); + } + }); }); diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index c37b608cdd0b..44adfb205dcb 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -3,16 +3,18 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Constructor, Provider} from '@loopback/context'; +import {Constructor, Provider, BoundValue} from '@loopback/context'; import {Application} from '.'; // tslint:disable:no-any +export interface ProviderMap { + [key: string]: Constructor>; +} + export interface Component { controllers?: Constructor[]; - providers?: { - [key: string]: Constructor>; - }; + providers?: ProviderMap; } export function mountComponent( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d8e6512ae2a9..6951c1443cb2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,7 +5,7 @@ // package dependencies export {Application} from './application'; -export {Component} from './component'; +export {Component, ProviderMap} from './component'; export * from './router/metadata'; export * from './sequence';