From bcf041e9db4625eae23f15f41d50f2bf193a98f4 Mon Sep 17 00:00:00 2001 From: dremond Date: Wed, 17 Apr 2019 14:09:20 -0400 Subject: [PATCH] feat: resolve authentication strategy registered via extension point Resolve authentication strategies registered via extension point --- .../docs/authentication-system.md | 189 +++++++ packages/authentication/package-lock.json | 114 ++++ packages/authentication/package.json | 3 +- .../basic-auth-extension.acceptance.ts | 315 +++++++++++ .../jwt-auth-extension.acceptance.ts | 531 ++++++++++++++++++ .../src/__tests__/fixtures/keys.ts | 26 + .../authentication-action.provider.ts | 59 ++ .../services/basic-auth-user-service.ts | 83 +++ .../fixtures/services/jwt-service.ts | 86 +++ .../fixtures/strategies/basic-strategy.ts | 78 +++ .../fixtures/strategies/jwt-strategy.ts | 50 ++ .../fixtures/users/user.repository.ts | 22 + .../src/__tests__/fixtures/users/user.ts | 12 + .../src/authentication.component.ts | 9 +- packages/authentication/src/keys.ts | 12 +- .../src/providers/auth-strategy.provider.ts | 56 ++ .../authentication/src/providers/index.ts | 1 + packages/authentication/src/types.ts | 10 + 18 files changed, 1649 insertions(+), 7 deletions(-) create mode 100644 packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts create mode 100644 packages/authentication/src/__tests__/acceptance/jwt-auth-extension.acceptance.ts create mode 100644 packages/authentication/src/__tests__/fixtures/keys.ts create mode 100644 packages/authentication/src/__tests__/fixtures/providers/authentication-action.provider.ts create mode 100644 packages/authentication/src/__tests__/fixtures/services/basic-auth-user-service.ts create mode 100644 packages/authentication/src/__tests__/fixtures/services/jwt-service.ts create mode 100644 packages/authentication/src/__tests__/fixtures/strategies/basic-strategy.ts create mode 100644 packages/authentication/src/__tests__/fixtures/strategies/jwt-strategy.ts create mode 100644 packages/authentication/src/__tests__/fixtures/users/user.repository.ts create mode 100644 packages/authentication/src/__tests__/fixtures/users/user.ts create mode 100644 packages/authentication/src/providers/auth-strategy.provider.ts diff --git a/packages/authentication/docs/authentication-system.md b/packages/authentication/docs/authentication-system.md index d9d1fd4eeeb8..bd5fee54e359 100644 --- a/packages/authentication/docs/authentication-system.md +++ b/packages/authentication/docs/authentication-system.md @@ -163,3 +163,192 @@ And the abstractions for: - return user - controller function: - process the injected user + +## Registering an authentication strategy via an extension point + +Authentication strategies register themselves to an authentication strategy +resolver using an +[ExtensionPoint/Extension Pattern](https://wiki.eclipse.org/FAQ_What_are_extensions_and_extension_points%3F) +as described in the +[Greeter extension example](https://github.com/strongloop/loopback-next/tree/master/examples/greeter-extension). + +The `AuthenticationStrategyProvider` class in +`src/providers/auth-strategy.provider.ts` declares an `extension point` named +`AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME` via the +`@extensionPoint` decorator. `AuthenticationStrategyProvider` is responsible for +returning an authentication strategy which has a `specific name` and has been +registered as an `extension` of the aforementioned `extension point` with the +aid of the `@extensions()` decorator. The binding scope is set to `transient` +because an authentication strategy `may` differ with each request. + +```ts +@extensionPoint( + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + {scope: BindingScope.TRANSIENT}, +) +export class AuthenticationStrategyProvider + implements Provider { + constructor( + @inject(AuthenticationBindings.METADATA) + private metadata: AuthenticationMetadata, + @extensions() + private authenticationStrategies: Getter, + ) {} + value(): ValueOrPromise { + if (!this.metadata) { + return; + } + const name = this.metadata.strategy; + + return this.findAuthenticationStrategy(name).then(function(strategy) { + if (strategy) { + return strategy; + } else { + // important not to throw a non-protocol-specific error here + throw new AuthenticationStrategyNotFoundError( + `The strategy '${name}' is not available.`, + ); + } + }); + } + + async findAuthenticationStrategy(name: string) { + const strategies = await this.authenticationStrategies(); + const matchingAuthStrategy = strategies.find(a => a.name === name); + return matchingAuthStrategy; + } +} +``` + +The `name` of the strategy is specified in the `authenticate` decorator that is +added to a controller method when authentication is desired for a specific +endpoint. + +```ts + class UserController { + constructor() {} + @get('/whoAmI') + @authenticate('basic') + whoAmI() + { + ... + } + } +``` + +An authentication strategy must implement the `AuthenticationStrategy` interface +defined in `src/types.ts`. + +```ts +export interface BasicAuthenticationStrategyCredentials { + email: string; + password: string; +} + +export class BasicAuthenticationStrategy implements AuthenticationStrategy { + name: string = 'basic'; + + constructor( + @inject(BasicAuthenticationStrategyBindings.USER_SERVICE) + private user_service: BasicAuthenticationUserService, + ) {} + + async authenticate(request: Request): Promise { + const credentials: BasicAuthenticationStrategyCredentials = this.extractCredentals( + request, + ); + const user = await this.user_service.verifyCredentials(credentials); + const userProfile = this.user_service.convertToUserProfile(user); + + return userProfile; + } +``` + +A custom sequence must be created to insert `AuthenticationBindings.AUTH_ACTION` +action. The `AuthenticateFn` function interface is implemented by the `value()` +function of `AuthenticateActionProvider` class in +`src/providers/authentication-action.provider.ts`. + +```ts +class SequenceIncludingAuthentication implements SequenceHandler { + constructor( + @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, + @inject(SequenceActions.PARSE_PARAMS) + protected parseParams: ParseParams, + @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, + @inject(SequenceActions.SEND) protected send: Send, + @inject(SequenceActions.REJECT) protected reject: Reject, + @inject(AuthenticationBindings.AUTH_ACTION) + protected authenticateRequest: AuthenticateFn, + ) {} + + async handle(context: RequestContext) { + try { + const {request, response} = context; + const route = this.findRoute(request); + const args = await this.parseParams(request, route); + + // + // The authentication action utilizes a strategy resolver to find + // an authentication strategy by name, and then it calls + // strategy.authenticate(request). + // + // The strategy resolver throws a non-http error if it cannot + // resolve the strategy. It is necessary to catch this error + // and rethrow it as in http error (in our REST application example) + // + // Errors thrown by the strategy implementations are http errors + // (in our REST application example). We simply rethrow them. + // + try { + //call authentication action + await this.authenticateRequest(request); + } catch (e) { + // strategy not found error + if (e instanceof AuthenticationStrategyNotFoundError) { + throw new HttpErrors.Unauthorized(e.message); + } //if + else { + // strategy error + throw e; + } //endif + } //catch + + // Authentication successful, proceed to invoke controller + const result = await this.invoke(route, args); + this.send(response, result); + } catch (error) { + this.reject(context, error); + return; + } + } +} +``` + +Then custom sequence must be bound to the application, and the authentication +strategy must be added as an extension of the extension point using the +`addExtension` function. + +```ts +export class MyApplication extends BootMixin( + ServiceMixin(RepositoryMixin(RestApplication)), +) { + constructor(options?: ApplicationConfig) { + super(options); + + this.component(AuthenticationComponent); + + this.sequence(SequenceIncludingAuthentication); + + addExtension( + this, + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + BasicAuthenticationStrategy, + { + namespace: + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + }, + ); + } +} +``` diff --git a/packages/authentication/package-lock.json b/packages/authentication/package-lock.json index c91354e6f719..8e96baf8fddf 100644 --- a/packages/authentication/package-lock.json +++ b/packages/authentication/package-lock.json @@ -91,6 +91,108 @@ "@types/mime": "*" } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=", + "dev": true + }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "jsonwebtoken": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", + "integrity": "sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==", + "dev": true, + "requires": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^5.6.0" + } + }, + "jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dev": true, + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dev": true, + "requires": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=", + "dev": true + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=", + "dev": true + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=", + "dev": true + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=", + "dev": true + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", + "dev": true + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", + "dev": true + }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, "passport": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/passport/-/passport-0.4.0.tgz", @@ -118,6 +220,18 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha1-HUCLP9t2kjuVQ9lvtMnf1TXZy10=" + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "semver": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.0.tgz", + "integrity": "sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA==", + "dev": true } } } diff --git a/packages/authentication/package.json b/packages/authentication/package.json index 3b6a11d2ff9b..68a6243245c4 100644 --- a/packages/authentication/package.json +++ b/packages/authentication/package.json @@ -36,7 +36,8 @@ "@types/node": "^10.11.2", "@types/passport": "^1.0.0", "@types/passport-http": "^0.3.6", - "passport-http": "^0.3.0" + "passport-http": "^0.3.0", + "jsonwebtoken": "^8.5.1" }, "keywords": [ "LoopBack", diff --git a/packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts b/packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts new file mode 100644 index 000000000000..b0689702086c --- /dev/null +++ b/packages/authentication/src/__tests__/acceptance/basic-auth-extension.acceptance.ts @@ -0,0 +1,315 @@ +// Copyright IBM Corp. 2019. 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 {inject} from '@loopback/context'; +import {addExtension, Application} from '@loopback/core'; +import {anOpenApiSpec} from '@loopback/openapi-spec-builder'; +import {api, get} from '@loopback/openapi-v3'; +import { + FindRoute, + HttpErrors, + InvokeMethod, + ParseParams, + Reject, + RequestContext, + RestBindings, + RestComponent, + RestServer, + Send, + SequenceHandler, +} from '@loopback/rest'; +import {Client, createClientForHandler} from '@loopback/testlab'; +import { + authenticate, + AuthenticateFn, + AuthenticationBindings, + AuthenticationComponent, + UserProfile, +} from '../..'; +import {AuthenticationStrategyNotFoundError} from '../../types'; +import {BasicAuthenticationStrategyBindings, USER_REPO} from '../fixtures/keys'; +import {AuthenticateActionProvider} from '../fixtures/providers/authentication-action.provider'; +import {BasicAuthenticationUserService} from '../fixtures/services/basic-auth-user-service'; +import {BasicAuthenticationStrategy} from '../fixtures/strategies/basic-strategy'; +import {UserRepository} from '../fixtures/users/user.repository'; +const SequenceActions = RestBindings.SequenceActions; + +describe('Basic Authentication', () => { + let app: Application; + let server: RestServer; + let users: UserRepository; + beforeEach(givenAServer); + beforeEach(givenControllerInApp); + beforeEach(givenAuthenticatedSequence); + beforeEach(givenProviders); + + it(`authenticates successfully for correct credentials for user 'jack'`, async () => { + const client = whenIMakeRequestTo(server); + const credential = + users.list['joe@example.com'].user.email + + ':' + + users.list['joe@example.com'].user.password; + const hash = Buffer.from(credential).toString('base64'); + await client + .get('/whoAmI') + .set('Authorization', 'Basic ' + hash) + .expect(users.list['joe@example.com'].user.email); + }); + + it(`authenticates successfully for correct credentials for user 'jill'`, async () => { + const client = whenIMakeRequestTo(server); + const credential = + users.list['jill@example.com'].user.email + + ':' + + users.list['jill@example.com'].user.password; + const hash = Buffer.from(credential).toString('base64'); + await client + .get('/whoAmI') + .set('Authorization', 'Basic ' + hash) + .expect(users.list['jill@example.com'].user.email); + }); + + it('returns error for missing Authorization header', async () => { + const client = whenIMakeRequestTo(server); + + //not passing in 'Authorization' header + await client.get('/whoAmI').expect(401); + }); + + it(`returns error for missing 'Basic ' portion of Authorization header value`, async () => { + const client = whenIMakeRequestTo(server); + const credential = + users.list['joe@example.com'].user.email + + ':' + + users.list['joe@example.com'].user.password; + const hash = Buffer.from(credential).toString('base64'); + await client + .get('/whoAmI') + .set('Authorization', 'NotB@s1c ' + hash) + .expect(401); + }); + + it(`returns error for missing ':' in decrypted Authorization header credentials value`, async () => { + const client = whenIMakeRequestTo(server); + const credential = + users.list['joe@example.com'].user.email + + '|' + + users.list['joe@example.com'].user.password; // substituting ':' with '|' + const hash = Buffer.from(credential).toString('base64'); + await client + .get('/whoAmI') + .set('Authorization', 'Basic ' + hash) + .expect(401); + }); + + it(`returns error for too many parts in decrypted Authorization header credentials value`, async () => { + const client = whenIMakeRequestTo(server); + const credential = + users.list['joe@example.com'].user.email + + ':' + + users.list['joe@example.com'].user.password + + ':' + + 'extraPart'; // three parts instead of two + const hash = Buffer.from(credential).toString('base64'); + await client + .get('/whoAmI') + .set('Authorization', 'Basic ' + hash) + .expect(401); + }); + + it('allows anonymous requests to methods with no decorator', async () => { + class InfoController { + @get('/status') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect(200, {running: true}); + }); + + async function givenAServer() { + app = new Application(); + app.component(AuthenticationComponent); + app.component(RestComponent); + server = await app.getServer(RestServer); + } + + function givenControllerInApp() { + const apispec = anOpenApiSpec() + .withOperation('get', '/whoAmI', { + 'x-operation-name': 'whoAmI', + responses: { + '200': { + description: '', + schema: { + type: 'string', + }, + }, + }, + }) + .build(); + + @api(apispec) + class MyController { + constructor( + @inject(AuthenticationBindings.CURRENT_USER) private user: UserProfile, + ) {} + + @authenticate('basic') + async whoAmI(): Promise { + if (this.user) { + if (this.user.email) return this.user.email; + else return 'user email is undefined'; + } //if + else return 'user is undefined'; + } + } + + app.controller(MyController); + } + + it('returns error for unknown authentication strategy', async () => { + class InfoController { + @get('/status') + @authenticate('doesnotexist') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect(401); + }); + + function givenAuthenticatedSequence() { + class MySequence implements SequenceHandler { + constructor( + @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, + @inject(SequenceActions.PARSE_PARAMS) + protected parseParams: ParseParams, + @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, + @inject(SequenceActions.SEND) protected send: Send, + @inject(SequenceActions.REJECT) protected reject: Reject, + @inject(AuthenticationBindings.AUTH_ACTION) + protected authenticateRequest: AuthenticateFn, + ) {} + + async handle(context: RequestContext) { + try { + const {request, response} = context; + const route = this.findRoute(request); + + // + // The authentication action utilizes a strategy resolver to find + // an authentication strategy by name, and then it calls + // strategy.authenticate(request). + // + // The strategy resolver throws a non-http error if it cannot + // resolve the strategy. It is necessary to catch this error + // and rethrow it as in http error (in our REST application example) + // + // Errors thrown by the strategy implementations are http errors + // (in our REST application example). We simply rethrow them. + // + try { + //call authentication action + await this.authenticateRequest(request); + } catch (e) { + // strategy not found error + if (e instanceof AuthenticationStrategyNotFoundError) { + throw new HttpErrors.Unauthorized(e.message); + } //if + else { + // strategy error + throw e; + } //endif + } //catch + + // Authentication successful, proceed to invoke controller + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + this.send(response, result); + } catch (error) { + this.reject(context, error); + return; + } + } + } + // bind user defined sequence + server.sequence(MySequence); + } + + function givenProviders() { + //bind the authentication action provider + server + .bind(AuthenticationBindings.AUTH_ACTION) + .toProvider(AuthenticateActionProvider); + + addExtension( + server, + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + BasicAuthenticationStrategy, + { + namespace: + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + }, + ); + + server + .bind(BasicAuthenticationStrategyBindings.USER_SERVICE) + .toClass(BasicAuthenticationUserService); + + users = new UserRepository({ + 'joe@example.com': { + user: { + id: '1', + firstname: 'joe', + surname: 'joeman', + email: 'joe@example.com', + password: 'joepa55w0rd', + }, + }, + 'jill@example.com': { + user: { + id: '2', + firstname: 'jill', + surname: 'jillman', + email: 'jill@example.com', + password: 'jillpa55w0rd', + }, + }, + 'jack@example.com': { + user: { + id: '3', + firstname: 'jack', + surname: 'jackman', + email: 'jack@example.com', + password: 'jackpa55w0rd', + }, + }, + 'janice@example.com': { + user: { + id: '4', + firstname: 'janice', + surname: 'janiceman', + email: 'janice@example.com', + password: 'janicepa55w0rd', + }, + }, + }); + + server.bind(USER_REPO).to(users); + } + + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); + } +}); diff --git a/packages/authentication/src/__tests__/acceptance/jwt-auth-extension.acceptance.ts b/packages/authentication/src/__tests__/acceptance/jwt-auth-extension.acceptance.ts new file mode 100644 index 000000000000..ffaa817ab23d --- /dev/null +++ b/packages/authentication/src/__tests__/acceptance/jwt-auth-extension.acceptance.ts @@ -0,0 +1,531 @@ +// Copyright IBM Corp. 2019. 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 {inject} from '@loopback/context'; +import {addExtension, Application} from '@loopback/core'; +import {get} from '@loopback/openapi-v3'; +import { + FindRoute, + HttpErrors, + InvokeMethod, + ParseParams, + Reject, + RequestContext, + RestBindings, + RestComponent, + RestServer, + Send, + SequenceHandler, +} from '@loopback/rest'; +import {Client, createClientForHandler, expect} from '@loopback/testlab'; +import { + authenticate, + AuthenticateFn, + AuthenticationBindings, + AuthenticationComponent, + UserProfile, +} from '../..'; +import {AuthenticationStrategyNotFoundError} from '../../types'; +import {JWTAuthenticationStrategyBindings, USER_REPO} from '../fixtures/keys'; +import {AuthenticateActionProvider} from '../fixtures/providers/authentication-action.provider'; +import {JWTService} from '../fixtures/services/jwt-service'; +import {JWTAuthenticationStrategy} from '../fixtures/strategies/jwt-strategy'; +import {UserRepository} from '../fixtures/users/user.repository'; +const SequenceActions = RestBindings.SequenceActions; + +describe('JWT Authentication', () => { + let app: Application; + let server: RestServer; + let test_users: UserRepository; + + beforeEach(givenAServer); + beforeEach(givenAuthenticatedSequence); + beforeEach(givenProviders); + + it('authenticates successfully with valid token', async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + ) {} + + @get('/login') + logIn() { + // + // ...Other code for verifying a valid user (e.g. basic or local strategy)... + // + + // Now with a valid userProfile, let's create a JSON web token + const joeUser = this.users.list['joe@example.com'].user; + + const joeUserProfile = { + id: joeUser.id, + email: joeUser.email, + name: `${joeUser.firstname} ${joeUser.surname}`, + }; + + return this.tokenService.generateToken(joeUserProfile).then(token => { + return token; + }); + } + + @get('/whoAmI') + @authenticate('jwt') + whoAmI( + @inject(AuthenticationBindings.CURRENT_USER) userProfile: UserProfile, + ) { + if (userProfile) { + if (userProfile.email) return userProfile.email; + else return 'userProfile email is undefined'; + } //if + else return 'userProfile is undefined'; + } + } + + app.controller(InfoController); + + const the_token: string = (await whenIMakeRequestTo(server) + .get('/login') + .expect(200)).text; + + expect(the_token !== null).to.equal(true); + expect(typeof the_token === 'string').to.equal(true); + + const email = (await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', 'Bearer ' + the_token) + .expect(200)).text; + + expect(email).to.equal(test_users.list['joe@example.com'].user.email); + }); + + it(`returns error for missing Authorization header`, async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + ) {} + + @get('/login') + logIn() { + // + // ...Other code for verifying a valid user (e.g. basic or local strategy)... + // + + // Now with a valid userProfile, let's create a JSON web token + const joeUser = this.users.list['joe@example.com'].user; + + const joeUserProfile = { + id: joeUser.id, + email: joeUser.email, + name: `${joeUser.firstname} ${joeUser.surname}`, + }; + + return this.tokenService.generateToken(joeUserProfile).then(token => { + return token; + }); + } + + @get('/whoAmI') + @authenticate('jwt') + whoAmI( + @inject(AuthenticationBindings.CURRENT_USER) userProfile: UserProfile, + ) { + if (userProfile) { + if (userProfile.email) return userProfile.email; + else return 'userProfile email is undefined'; + } //if + else return 'userProfile is undefined'; + } + } + + app.controller(InfoController); + + const the_token: string = (await whenIMakeRequestTo(server) + .get('/login') + .expect(200)).text; + + expect(the_token !== null).to.equal(true); + expect(typeof the_token === 'string').to.equal(true); + + //not passing in 'Authorization' header + await whenIMakeRequestTo(server) + .get('/whoAmI') + .expect(401); + }); + + it(`returns error for invalid 'Bearer ' portion of Authorization header value`, async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + ) {} + + @get('/login') + logIn() { + // + // ...Other code for verifying a valid user (e.g. basic or local strategy)... + // + + // Now with a valid userProfile, let's create a JSON web token + const joeUser = this.users.list['joe@example.com'].user; + + const joeUserProfile = { + id: joeUser.id, + email: joeUser.email, + name: `${joeUser.firstname} ${joeUser.surname}`, + }; + + return this.tokenService.generateToken(joeUserProfile).then(token => { + return token; + }); + } + + @get('/whoAmI') + @authenticate('jwt') + whoAmI( + @inject(AuthenticationBindings.CURRENT_USER) userProfile: UserProfile, + ) { + if (userProfile) { + if (userProfile.email) return userProfile.email; + else return 'userProfile email is undefined'; + } //if + else return 'userProfile is undefined'; + } + } + + app.controller(InfoController); + + const the_token: string = (await whenIMakeRequestTo(server) + .get('/login') + .expect(200)).text; + + expect(the_token !== null).to.equal(true); + expect(typeof the_token === 'string').to.equal(true); + + // not specifying an `Authorization` header value + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', 'NotB3ar3r ' + the_token) + .expect(401); + }); + + it('returns error due to expired token', async () => { + class InfoController { + constructor() {} + + @get('/whoAmI') + @authenticate('jwt') + whoAmI( + @inject(AuthenticationBindings.CURRENT_USER) userProfile: UserProfile, + ) { + if (userProfile) { + if (userProfile.email) return userProfile.email; + else return 'userProfile email is undefined'; + } //if + else return 'userProfile is undefined'; + } + } + + app.controller(InfoController); + + const expiredToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjEiLCJlbWFpbCI6ImpvZUBleGFtcGxlLmNvbSIsIm5hbWUiOiJqb2Ugam9lbWFuIiwiaWF0IjoxNTU1ODY3NDAzLCJleHAiOjE1NTU4Njc0NjN9.QKmO5qDC8Yg-aK3EedLRsXczL7VQDDnWtA-cpyqszqM'; + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', 'Bearer ' + expiredToken) + .expect(401); + }); + + it('returns error due to invalid token #1', async () => { + class InfoController { + constructor() {} + + @get('/whoAmI') + @authenticate('jwt') + whoAmI( + @inject(AuthenticationBindings.CURRENT_USER) userProfile: UserProfile, + ) { + if (userProfile) { + if (userProfile.email) return userProfile.email; + else return 'userProfile email is undefined'; + } //if + else return 'userProfile is undefined'; + } + } + + app.controller(InfoController); + + const invalidToken = 'aaa.bbb.ccc'; + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', 'Bearer ' + invalidToken) + .expect(401); + }); + + it('returns error due to invalid token #2', async () => { + class InfoController { + constructor() {} + + @get('/whoAmI') + @authenticate('jwt') + whoAmI( + @inject(AuthenticationBindings.CURRENT_USER) userProfile: UserProfile, + ) { + if (userProfile) { + if (userProfile.email) return userProfile.email; + else return 'userProfile email is undefined'; + } //if + else return 'userProfile is undefined'; + } + } + + app.controller(InfoController); + + const invalidToken = 'aaa.bbb.ccc.ddd'; + + await whenIMakeRequestTo(server) + .get('/whoAmI') + .set('Authorization', 'Bearer ' + invalidToken) + .expect(401); + }); + + it('create a json web token throws error for missing email', async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + ) {} + + @get('/createtoken') + createToken() { + const joeUser = this.users.list['joe@example.com'].user; + + const joeUserProfile = { + id: joeUser.id, + email: undefined, + name: `${joeUser.firstname} ${joeUser.surname}`, + }; + + return this.tokenService.generateToken(joeUserProfile).then(token => { + return {token}; + }); + } + } + + app.controller(InfoController); + + await whenIMakeRequestTo(server) + .get('/createtoken') + .expect(401); + }); + + it('create a json web token throws error for missing name', async () => { + class InfoController { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public tokenService: JWTService, + @inject(USER_REPO) + public users: UserRepository, + ) {} + + @get('/createtoken') + createToken() { + const joeUser = this.users.list['joe@example.com'].user; + + const joeUserProfile = { + id: joeUser.id, + email: joeUser.email, + name: undefined, + }; + + return this.tokenService.generateToken(joeUserProfile).then(token => { + return {token}; + }); + } + } + + app.controller(InfoController); + + await whenIMakeRequestTo(server) + .get('/createtoken') + .expect(401); + }); + + it('allows anonymous requests to methods with no decorator', async () => { + class InfoController { + @get('/status') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect(200, {running: true}); + }); + + it('returns error for unknown authentication strategy', async () => { + class InfoController { + @get('/status') + @authenticate('doesnotexist') + status() { + return {running: true}; + } + } + + app.controller(InfoController); + await whenIMakeRequestTo(server) + .get('/status') + .expect(401); + }); + + async function givenAServer() { + app = new Application(); + app.component(AuthenticationComponent); + app.component(RestComponent); + server = await app.getServer(RestServer); + } + + function givenAuthenticatedSequence() { + class MySequence implements SequenceHandler { + constructor( + @inject(SequenceActions.FIND_ROUTE) protected findRoute: FindRoute, + @inject(SequenceActions.PARSE_PARAMS) + protected parseParams: ParseParams, + @inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod, + @inject(SequenceActions.SEND) protected send: Send, + @inject(SequenceActions.REJECT) protected reject: Reject, + @inject(AuthenticationBindings.AUTH_ACTION) + protected authenticateRequest: AuthenticateFn, + ) {} + + async handle(context: RequestContext) { + try { + const {request, response} = context; + const route = this.findRoute(request); + + // + // The authentication action utilizes a strategy resolver to find + // an authentication strategy by name, and then it calls + // strategy.authenticate(request). + // + // The strategy resolver throws a non-http error if it cannot + // resolve the strategy. It is necessary to catch this error + // and rethrow it as in http error (in our REST application example) + // + // Errors thrown by the strategy implementations are http errors + // (in our REST application example). We simply rethrow them. + // + try { + //call authentication action + await this.authenticateRequest(request); + } catch (e) { + // strategy not found error + if (e instanceof AuthenticationStrategyNotFoundError) { + throw new HttpErrors.Unauthorized(e.message); + } //if + else { + // strategy error + throw e; + } //endif + } //catch + + // Authentication successful, proceed to invoke controller + const args = await this.parseParams(request, route); + const result = await this.invoke(route, args); + this.send(response, result); + } catch (error) { + this.reject(context, error); + return; + } + } + } + // bind user defined sequence + server.sequence(MySequence); + } + + function givenProviders() { + //bind the authentication action provider + server + .bind(AuthenticationBindings.AUTH_ACTION) + .toProvider(AuthenticateActionProvider); + + addExtension( + server, + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + JWTAuthenticationStrategy, + { + namespace: + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + }, + ); + + server + .bind(JWTAuthenticationStrategyBindings.TOKEN_SECRET) + .to('myjwts3cr3t'); + + server.bind(JWTAuthenticationStrategyBindings.TOKEN_EXPIRES_IN).to('60'); + + server + .bind(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + .toClass(JWTService); + + test_users = new UserRepository({ + 'joe@example.com': { + user: { + id: '1', + firstname: 'joe', + surname: 'joeman', + email: 'joe@example.com', + password: 'joepa55w0rd', + }, + }, + 'jill@example.com': { + user: { + id: '2', + firstname: 'jill', + surname: 'jillman', + email: 'jill@example.com', + password: 'jillpa55w0rd', + }, + }, + 'jack@example.com': { + user: { + id: '3', + firstname: 'jack', + surname: 'jackman', + email: 'jack@example.com', + password: 'jackpa55w0rd', + }, + }, + 'janice@example.com': { + user: { + id: '4', + firstname: 'janice', + surname: 'janiceman', + email: 'janice@example.com', + password: 'janicepa55w0rd', + }, + }, + }); + + server.bind(USER_REPO).to(test_users); + } + + function whenIMakeRequestTo(restServer: RestServer): Client { + return createClientForHandler(restServer.requestHandler); + } +}); diff --git a/packages/authentication/src/__tests__/fixtures/keys.ts b/packages/authentication/src/__tests__/fixtures/keys.ts new file mode 100644 index 000000000000..8a51fbd7ccaf --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/keys.ts @@ -0,0 +1,26 @@ +import {BindingKey} from '@loopback/context'; +import {BasicAuthenticationUserService} from './services/basic-auth-user-service'; +import {JWTService} from './services/jwt-service'; +import {UserRepository} from './users/user.repository'; + +export const USER_REPO = BindingKey.create( + 'authentication.user.repo', +); + +export namespace BasicAuthenticationStrategyBindings { + export const USER_SERVICE = BindingKey.create( + 'services.authentication.basic.user.service', + ); +} + +export namespace JWTAuthenticationStrategyBindings { + export const TOKEN_SECRET = BindingKey.create( + 'authentication.jwt.secret', + ); + export const TOKEN_EXPIRES_IN = BindingKey.create( + 'authentication.jwt.expires.in.seconds', + ); + export const TOKEN_SERVICE = BindingKey.create( + 'services.authentication.jwt.tokenservice', + ); +} diff --git a/packages/authentication/src/__tests__/fixtures/providers/authentication-action.provider.ts b/packages/authentication/src/__tests__/fixtures/providers/authentication-action.provider.ts new file mode 100644 index 000000000000..8c85668e5cb0 --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/providers/authentication-action.provider.ts @@ -0,0 +1,59 @@ +// Copyright IBM Corp. 2019. 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 {Getter, inject, Provider, Setter} from '@loopback/context'; +import {Request} from '@loopback/rest'; +import {AuthenticationBindings} from '../../../keys'; +import { + AuthenticateFn, + AuthenticationStrategy, + UserProfile, +} from '../../../types'; + +/** + * @description Provider of a function which authenticates + * @example `context.bind('authentication_key') + * .toProvider(AuthenticateActionProvider)` + */ +export class AuthenticateActionProvider implements Provider { + constructor( + // 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(AuthenticationBindings.STRATEGY) + readonly getStrategy: Getter, + @inject.setter(AuthenticationBindings.CURRENT_USER) + readonly setCurrentUser: Setter, + ) {} + + /** + * @returns authenticateFn + */ + value(): AuthenticateFn { + return request => this.action(request); + } + + /** + * The implementation of authenticate() sequence action. + * @param request The incoming request provided by the REST layer + */ + async action(request: Request): Promise { + const strategy = await this.getStrategy(); + if (!strategy) { + // The invoked operation does not require authentication. + return undefined; + } + if (!strategy.authenticate) { + throw new Error('invalid strategy parameter'); + } + const userProfile = await strategy.authenticate(request); + if (userProfile) this.setCurrentUser(userProfile); + return userProfile; + } +} diff --git a/packages/authentication/src/__tests__/fixtures/services/basic-auth-user-service.ts b/packages/authentication/src/__tests__/fixtures/services/basic-auth-user-service.ts new file mode 100644 index 000000000000..68710cd234ad --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/services/basic-auth-user-service.ts @@ -0,0 +1,83 @@ +// Copyright IBM Corp. 2019. 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 {inject} from '@loopback/context'; +import {HttpErrors} from '@loopback/rest'; +import {UserService} from '../../../services/user.service'; +import {UserProfile} from '../../../types'; +import {USER_REPO} from '../keys'; +import {BasicAuthenticationStrategyCredentials} from '../strategies/basic-strategy'; +import {User} from '../users/user'; +import {UserRepository} from '../users/user.repository'; + +export class BasicAuthenticationUserService + implements UserService { + constructor( + @inject(USER_REPO) + private userRepository: UserRepository, + ) {} + + async verifyCredentials( + credentials: BasicAuthenticationStrategyCredentials, + ): Promise { + if (!credentials) { + throw new HttpErrors.Unauthorized(`'credentials' is null`); + } + + if (!credentials.email) { + throw new HttpErrors.Unauthorized(`'credentials.email' is null`); + } + + if (!credentials.password) { + throw new HttpErrors.Unauthorized(`'credentials.password' is null`); + } + + const foundUser = this.userRepository.find( + credentials.email, + credentials.password, + ); + if (!foundUser) { + throw new HttpErrors['Unauthorized']( + `User with email ${credentials.email} not found.`, + ); + } + + if (credentials.password !== foundUser.password) { + throw new HttpErrors.Unauthorized('The password is not correct.'); + } + + return foundUser; + } + + convertToUserProfile(user: User): UserProfile { + if (!user) { + throw new HttpErrors.Unauthorized(`'user' is null`); + } + + if (!user.id) { + throw new HttpErrors.Unauthorized(`'user.id' is null`); + } + + if (!user.firstname) { + throw new HttpErrors.Unauthorized(`'user.firstname' is null`); + } + + if (!user.surname) { + throw new HttpErrors.Unauthorized(`'user.surname' is null`); + } + + if (!user.email) { + throw new HttpErrors.Unauthorized(`'user.email' is null`); + } + + const userProfile: UserProfile = { + id: user.id, + name: `${user.firstname} ${user.surname}`, + email: user.email, + }; + + return userProfile; + } +} diff --git a/packages/authentication/src/__tests__/fixtures/services/jwt-service.ts b/packages/authentication/src/__tests__/fixtures/services/jwt-service.ts new file mode 100644 index 000000000000..4d07ccc73053 --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/services/jwt-service.ts @@ -0,0 +1,86 @@ +// Copyright IBM Corp. 2019. 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 {inject} from '@loopback/context'; +import {HttpErrors} from '@loopback/rest'; +import {promisify} from 'util'; +import {TokenService} from '../../../services/token.service'; +import {UserProfile} from '../../../types'; +import {JWTAuthenticationStrategyBindings} from '../keys'; +const jwt = require('jsonwebtoken'); +const signAsync = promisify(jwt.sign); +const verifyAsync = promisify(jwt.verify); + +export class JWTService implements TokenService { + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SECRET) + private jwt_secret: string, + @inject(JWTAuthenticationStrategyBindings.TOKEN_EXPIRES_IN) + private jwt_expiresIn: string, + ) {} + + async verifyToken(token: string): Promise { + if (!token) { + throw new HttpErrors['Unauthorized']( + `Error verifying token : 'token' is null`, + ); + } //if + + let userProfile: UserProfile; + + try { + // decode user profile from token + userProfile = await verifyAsync(token, this.jwt_secret); + } catch (error) { + throw new HttpErrors['Unauthorized'](`Error verifying token : ${error}`); + } + + return userProfile; + } + + async generateToken(userProfile: UserProfile): Promise { + if (!userProfile) { + throw new HttpErrors['Unauthorized']( + 'Error generating token : userProfile is null', + ); + } //if + + if (!userProfile.id) { + throw new HttpErrors['Unauthorized']( + `Error generating token : userProfile 'id' is null`, + ); + } //if + + if (!userProfile.email) { + throw new HttpErrors['Unauthorized']( + `Error generating token : userProfile 'email' is null`, + ); + } //if + + if (!userProfile.name) { + throw new HttpErrors['Unauthorized']( + `Error generating token : userProfile 'name' is null`, + ); + } //if + + const userInfoForToken = { + id: userProfile.id, + email: userProfile.email, + name: userProfile.name, + }; + + // Generate a JSON Web Token + let token: string; + try { + token = await signAsync(userInfoForToken, this.jwt_secret, { + expiresIn: Number(this.jwt_expiresIn), + }); + } catch (error) { + throw new HttpErrors['Unauthorized'](`Error encoding token : ${error}`); + } + + return token; + } +} diff --git a/packages/authentication/src/__tests__/fixtures/strategies/basic-strategy.ts b/packages/authentication/src/__tests__/fixtures/strategies/basic-strategy.ts new file mode 100644 index 000000000000..b6f100bf3d2d --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/strategies/basic-strategy.ts @@ -0,0 +1,78 @@ +// Copyright IBM Corp. 2019. 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 {inject} from '@loopback/context'; +import {HttpErrors, Request} from '@loopback/rest'; +import {AuthenticationStrategy, UserProfile} from '../../../types'; +import {BasicAuthenticationStrategyBindings} from '../keys'; +import {BasicAuthenticationUserService} from '../services/basic-auth-user-service'; + +export interface BasicAuthenticationStrategyCredentials { + email: string; + password: string; +} + +export class BasicAuthenticationStrategy implements AuthenticationStrategy { + name: string = 'basic'; + + constructor( + @inject(BasicAuthenticationStrategyBindings.USER_SERVICE) + private user_service: BasicAuthenticationUserService, + ) {} + + async authenticate(request: Request): Promise { + const credentials: BasicAuthenticationStrategyCredentials = this.extractCredentals( + request, + ); + const user = await this.user_service.verifyCredentials(credentials); + const userProfile = this.user_service.convertToUserProfile(user); + + return userProfile; + } + + extractCredentals(request: Request): BasicAuthenticationStrategyCredentials { + if (!request.headers.authorization) { + //throw an error + throw new HttpErrors.Unauthorized(`Authorization header not found.`); + } //if + + // for example : Basic Z2l6bW9AZ21haWwuY29tOnBhc3N3b3Jk + let auth_header_value = request.headers.authorization; + + if (!auth_header_value.startsWith('Basic')) { + //throw an error + throw new HttpErrors.Unauthorized( + `Authorization header is not of type 'Basic'.`, + ); + } //if + + //split the string into 2 parts. We are interested in the base64 portion + let parts = auth_header_value.split(' '); + let encryptedCredentails = parts[1]; + + // decrypt the credentials. Should look like : 'user_email_value:user_password_value' + let decryptedCredentails = Buffer.from( + encryptedCredentails, + 'base64', + ).toString('utf8'); + + //split the string into 2 parts + let decryptedParts = decryptedCredentails.split(':'); + + if (decryptedParts.length !== 2) { + //throw an error + throw new HttpErrors.Unauthorized( + `Authorization header 'Basic' value does not contain two parts separated by ':'.`, + ); + } //if + + let creds: BasicAuthenticationStrategyCredentials = { + email: decryptedParts[0], + password: decryptedParts[1], + }; + + return creds; + } +} diff --git a/packages/authentication/src/__tests__/fixtures/strategies/jwt-strategy.ts b/packages/authentication/src/__tests__/fixtures/strategies/jwt-strategy.ts new file mode 100644 index 000000000000..608e85269efd --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/strategies/jwt-strategy.ts @@ -0,0 +1,50 @@ +// Copyright IBM Corp. 2019. 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 {inject} from '@loopback/context'; +import {HttpErrors, Request} from '@loopback/rest'; +import {AuthenticationStrategy, UserProfile} from '../../../types'; +import {JWTAuthenticationStrategyBindings} from '../keys'; +import {JWTService} from '../services/jwt-service'; + +export class JWTAuthenticationStrategy implements AuthenticationStrategy { + name: string = 'jwt'; + + constructor( + @inject(JWTAuthenticationStrategyBindings.TOKEN_SERVICE) + public token_service: JWTService, + ) {} + + async authenticate(request: Request): Promise { + const token: string = this.extractCredentals(request); + const userProfile: UserProfile = await this.token_service.verifyToken( + token, + ); + return userProfile; + } + + extractCredentals(request: Request): string { + if (!request.headers.authorization) { + //throw an error + throw new HttpErrors.Unauthorized(`Authorization header not found.`); + } //if + + // for example : Bearer xxx.yyy.zzz + let auth_header_value = request.headers.authorization; + + if (!auth_header_value.startsWith('Bearer')) { + //throw an error + throw new HttpErrors.Unauthorized( + `Authorization header is not of type 'Bearer'.`, + ); + } //if + + //split the string into 2 parts : 'Bearer ' and the `xxx.yyy.zzz` + let parts = auth_header_value.split(' '); + const token = parts[1]; + + return token; + } +} diff --git a/packages/authentication/src/__tests__/fixtures/users/user.repository.ts b/packages/authentication/src/__tests__/fixtures/users/user.repository.ts new file mode 100644 index 000000000000..30a0ffe760c2 --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/users/user.repository.ts @@ -0,0 +1,22 @@ +// Copyright IBM Corp. 2019. 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 {User} from './user'; + +export class UserRepository { + constructor(readonly list: {[key: string]: {user: User}}) {} + find(email: string, password: string): User | undefined { + const userList = this.list; + function search(key: string) { + return userList[key].user.email === email; + } + const found = Object.keys(userList).find(search); + if (found) { + return userList[found].user; + } //if + + return undefined; + } +} diff --git a/packages/authentication/src/__tests__/fixtures/users/user.ts b/packages/authentication/src/__tests__/fixtures/users/user.ts new file mode 100644 index 000000000000..74f25fc77bb7 --- /dev/null +++ b/packages/authentication/src/__tests__/fixtures/users/user.ts @@ -0,0 +1,12 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +export interface User { + id: string; + email: string; + password: string; + firstname?: string; + surname?: string; +} diff --git a/packages/authentication/src/authentication.component.ts b/packages/authentication/src/authentication.component.ts index be429cfe6d71..8c89a5664cd1 100644 --- a/packages/authentication/src/authentication.component.ts +++ b/packages/authentication/src/authentication.component.ts @@ -3,9 +3,13 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {AuthenticationBindings} from './keys'; import {Component, ProviderMap} from '@loopback/core'; -import {AuthenticateActionProvider, AuthMetadataProvider} from './providers'; +import {AuthenticationBindings} from './keys'; +import { + AuthenticateActionProvider, + AuthenticationStrategyProvider, + AuthMetadataProvider, +} from './providers'; export class AuthenticationComponent implements Component { providers?: ProviderMap; @@ -14,6 +18,7 @@ export class AuthenticationComponent implements Component { constructor() { this.providers = { [AuthenticationBindings.AUTH_ACTION.key]: AuthenticateActionProvider, + [AuthenticationBindings.STRATEGY.key]: AuthenticationStrategyProvider, [AuthenticationBindings.METADATA.key]: AuthMetadataProvider, }; } diff --git a/packages/authentication/src/keys.ts b/packages/authentication/src/keys.ts index b30c4131bc5d..62b81e3be714 100644 --- a/packages/authentication/src/keys.ts +++ b/packages/authentication/src/keys.ts @@ -3,11 +3,11 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {Strategy} from 'passport'; -import {AuthenticateFn, UserProfile} from './types'; -import {AuthenticationMetadata} from './decorators'; import {BindingKey} from '@loopback/context'; import {MetadataAccessor} from '@loopback/metadata'; +import {Strategy} from 'passport'; +import {AuthenticationMetadata} from './decorators'; +import {AuthenticateFn, UserProfile} from './types'; /** * Binding keys used by this component. @@ -87,18 +87,22 @@ export namespace AuthenticationBindings { /** * Key used to inject the user instance retrieved by the * authentication function - * + * ```ts * class MyController { * constructor( * @inject(AuthenticationBindings.CURRENT_USER) private user: UserProfile, * ) {} * * // ... routes that may need authentication + * ``` * } */ export const CURRENT_USER = BindingKey.create( 'authentication.currentUser', ); + + export const AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME = + 'authentication-strategy'; } /** diff --git a/packages/authentication/src/providers/auth-strategy.provider.ts b/packages/authentication/src/providers/auth-strategy.provider.ts new file mode 100644 index 000000000000..cc8d301a342d --- /dev/null +++ b/packages/authentication/src/providers/auth-strategy.provider.ts @@ -0,0 +1,56 @@ +// Copyright IBM Corp. 2019. 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 {BindingScope, Getter, inject} from '@loopback/context'; +import { + extensionPoint, + extensions, + Provider, + ValueOrPromise, +} from '@loopback/core'; +import {AuthenticationMetadata} from '../decorators/authenticate.decorator'; +import {AuthenticationBindings} from '../keys'; +import { + AuthenticationStrategy, + AuthenticationStrategyNotFoundError, +} from '../types'; + +//this needs to be transient, e.g. for request level context. +@extensionPoint( + AuthenticationBindings.AUTHENTICATION_STRATEGY_EXTENSION_POINT_NAME, + {scope: BindingScope.TRANSIENT}, +) +export class AuthenticationStrategyProvider + implements Provider { + constructor( + @inject(AuthenticationBindings.METADATA) + private metadata: AuthenticationMetadata, + @extensions() + private authenticationStrategies: Getter, + ) {} + value(): ValueOrPromise { + if (!this.metadata) { + return; + } + const name = this.metadata.strategy; + + return this.findAuthenticationStrategy(name).then(strategy => { + if (strategy) { + return strategy; + } else { + // important not to throw a non-protocol-specific error here + throw new AuthenticationStrategyNotFoundError( + `The strategy '${name}' is not available.`, + ); + } + }); + } + + async findAuthenticationStrategy(name: string) { + const strategies = await this.authenticationStrategies(); + const matchingAuthStrategy = strategies.find(a => a.name === name); + return matchingAuthStrategy; + } +} diff --git a/packages/authentication/src/providers/index.ts b/packages/authentication/src/providers/index.ts index 300623399844..5eae8666d143 100644 --- a/packages/authentication/src/providers/index.ts +++ b/packages/authentication/src/providers/index.ts @@ -4,4 +4,5 @@ // License text available at https://opensource.org/licenses/MIT export * from './auth-metadata.provider'; +export * from './auth-strategy.provider'; export * from './authentication.provider'; diff --git a/packages/authentication/src/types.ts b/packages/authentication/src/types.ts index 2ad358c6eb4c..8075166faa1b 100644 --- a/packages/authentication/src/types.ts +++ b/packages/authentication/src/types.ts @@ -51,3 +51,13 @@ export interface AuthenticationStrategy { */ authenticate(request: Request): Promise; } + +/** + * The error thrown when the authentication strategy resolver + * cannot find the specified authentication strategy by name. + */ +export class AuthenticationStrategyNotFoundError extends Error { + constructor(error: string) { + super(error); + } +}