From 3523ea719075b5e8d19e0aea9cff7f5aed86452d Mon Sep 17 00:00:00 2001 From: jannyHou Date: Fri, 25 Jan 2019 15:40:07 -0500 Subject: [PATCH] feat: create jwt auth service Signed-off-by: jannyHou --- src/application.ts | 18 +++ src/authentication-strategies/JWT.strategy.ts | 16 ++- src/controllers/user.controller.ts | 19 ++-- src/keys.ts | 22 ++++ src/providers/strategy.resolver.provider.ts | 5 +- src/services/JWT.authentication.service.ts | 106 ++++++++++++++++++ src/services/hash.password.bcryptjs.ts | 19 ++++ src/utils/user.authentication.ts | 81 ------------- test/acceptance/user.controller.acceptance.ts | 13 ++- test/unit/utils.authentication.unit.ts | 28 +++-- 10 files changed, 218 insertions(+), 109 deletions(-) create mode 100644 src/keys.ts create mode 100644 src/services/JWT.authentication.service.ts create mode 100644 src/services/hash.password.bcryptjs.ts delete mode 100644 src/utils/user.authentication.ts diff --git a/src/application.ts b/src/application.ts index f683d4d85..091a15b6c 100644 --- a/src/application.ts +++ b/src/application.ts @@ -14,8 +14,15 @@ import { AuthenticationBindings, AuthenticationComponent, } from '@loopback/authentication'; +import {JWTAuthenticationBindings, OtherServicesBindings} from './keys'; import {StrategyResolverProvider} from './providers/strategy.resolver.provider'; import {AuthenticateActionProvider} from './providers/custom.authentication.provider'; +import { + JWTAuthenticationService, + JWT_SECRET, +} from './services/JWT.authentication.service'; +import {hashPassword} from './services/hash.password.bcryptjs'; +import {JWTStrategy} from './authentication-strategies/JWT.strategy'; /** * Information from package.json @@ -38,6 +45,7 @@ export class ShoppingApplication extends BootMixin( // Bind package.json to the application context this.bind(PackageKey).to(pkg); + // Bind authentication component related elements this.component(AuthenticationComponent); this.bind(AuthenticationBindings.AUTH_ACTION).toProvider( AuthenticateActionProvider, @@ -46,6 +54,16 @@ export class ShoppingApplication extends BootMixin( StrategyResolverProvider, ); + // Bind JWT authentication strategy related elements + this.bind(JWTAuthenticationBindings.STRATEGY).toClass(JWTStrategy); + this.bind(JWTAuthenticationBindings.SECRET).to(JWT_SECRET); + this.bind(JWTAuthenticationBindings.SERVICE).toClass( + JWTAuthenticationService, + ); + + // Bind other services + this.bind(OtherServicesBindings.HASH_PASSWORD).to(hashPassword); + // Set up the custom sequence this.sequence(MySequence); diff --git a/src/authentication-strategies/JWT.strategy.ts b/src/authentication-strategies/JWT.strategy.ts index ea093859d..f84c71500 100644 --- a/src/authentication-strategies/JWT.strategy.ts +++ b/src/authentication-strategies/JWT.strategy.ts @@ -3,14 +3,20 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -// Consider turn it to a binding -const SECRET = 'secretforjwt'; +import {JWTAuthenticationBindings} from '../keys'; import {Request, HttpErrors} from '@loopback/rest'; import {UserProfile} from '@loopback/authentication'; import {AuthenticationStrategy} from './authentication.strategy'; -import {decodeAccessToken} from '../utils/user.authentication'; +import {inject} from '@loopback/core'; +import {JWTAuthenticationService} from '../services/JWT.authentication.service'; export class JWTStrategy implements AuthenticationStrategy { + constructor( + @inject(JWTAuthenticationBindings.SERVICE) + public jwt_authentication_service: JWTAuthenticationService, + @inject(JWTAuthenticationBindings.SECRET) + public jwt_secret: string, + ) {} async authenticate(request: Request): Promise { let token = request.query.access_token || request.headers['authorization']; if (!token) throw new HttpErrors.Unauthorized('No access token found!'); @@ -20,7 +26,9 @@ export class JWTStrategy implements AuthenticationStrategy { } try { - const user = await decodeAccessToken(token, SECRET); + const user = await this.jwt_authentication_service.decodeAccessToken( + token, + ); return user; } catch (err) { Object.assign(err, { diff --git a/src/controllers/user.controller.ts b/src/controllers/user.controller.ts index 5c7a32bac..1a169b99c 100644 --- a/src/controllers/user.controller.ts +++ b/src/controllers/user.controller.ts @@ -15,11 +15,10 @@ import { AuthenticationBindings, } from '@loopback/authentication'; import {Credentials} from '../repositories/user.repository'; -import { - validateCredentials, - getAccessTokenForUser, - hashPassword, -} from '../utils/user.authentication'; +import {HashPassword} from '../services/hash.password.bcryptjs'; +import {JWTAuthenticationService} from '../services/JWT.authentication.service'; +import {JWTAuthenticationBindings, OtherServicesBindings} from '../keys'; +import {validateCredentials} from '../services/JWT.authentication.service'; import * as _ from 'lodash'; // TODO(jannyHou): This should be moved to @loopback/authentication @@ -40,12 +39,16 @@ export class UserController { public recommender: RecommenderService, @inject.setter(AuthenticationBindings.CURRENT_USER) public setCurrentUser: Setter, + @inject(OtherServicesBindings.HASH_PASSWORD) + public hashPassword: HashPassword, + @inject(JWTAuthenticationBindings.SERVICE) + public jwt_authentication_service: JWTAuthenticationService, ) {} @post('/users') async create(@requestBody() user: User): Promise { validateCredentials(_.pick(user, ['email', 'password'])); - user.password = await hashPassword(user.password, 10); + user.password = await this.hashPassword(user.password, 10); // Save & Return Result const savedUser = await this.userRepository.create(user); @@ -143,7 +146,9 @@ export class UserController { @requestBody() credentials: Credentials, ): Promise<{token: string}> { validateCredentials(credentials); - const token = await getAccessTokenForUser(this.userRepository, credentials); + const token = await this.jwt_authentication_service.getAccessTokenForUser( + credentials, + ); return {token}; } } diff --git a/src/keys.ts b/src/keys.ts new file mode 100644 index 000000000..81eaf3fd8 --- /dev/null +++ b/src/keys.ts @@ -0,0 +1,22 @@ +import {BindingKey} from '@loopback/context'; +import {JWTAuthenticationService} from './services/JWT.authentication.service'; +import {HashPassword} from './services/hash.password.bcryptjs'; +import {JWTStrategy} from './authentication-strategies/JWT.strategy'; + +// Discussion point for reviewers: +// What would be the good naming conversion for bindings? +export namespace JWTAuthenticationBindings { + export const STRATEGY = BindingKey.create( + 'authentication.strategies.jwt.strategy', + ); + export const SECRET = BindingKey.create('authentication.jwt.secret'); + export const SERVICE = BindingKey.create( + 'services.authentication.jwt.service', + ); +} + +export namespace OtherServicesBindings { + export const HASH_PASSWORD = BindingKey.create( + 'services.hash_password', + ); +} diff --git a/src/providers/strategy.resolver.provider.ts b/src/providers/strategy.resolver.provider.ts index 4de77258e..35c60d9e3 100644 --- a/src/providers/strategy.resolver.provider.ts +++ b/src/providers/strategy.resolver.provider.ts @@ -10,11 +10,14 @@ import { AuthenticationMetadata, } from '@loopback/authentication'; import {JWTStrategy} from '../authentication-strategies/JWT.strategy'; +import {JWTAuthenticationBindings} from '../keys'; export class StrategyResolverProvider implements Provider { constructor( @inject(AuthenticationBindings.METADATA) private metadata: AuthenticationMetadata, + @inject(JWTAuthenticationBindings.STRATEGY) + private jwt_strategy: JWTStrategy, ) {} value(): ValueOrPromise { if (!this.metadata) { @@ -24,7 +27,7 @@ export class StrategyResolverProvider const name = this.metadata.strategy; // This should be extensible if (name === 'jwt') { - return new JWTStrategy(); + return this.jwt_strategy; } else { throw new Error(`The strategy ${name} is not available.`); } diff --git a/src/services/JWT.authentication.service.ts b/src/services/JWT.authentication.service.ts new file mode 100644 index 000000000..18219b4a2 --- /dev/null +++ b/src/services/JWT.authentication.service.ts @@ -0,0 +1,106 @@ +// Copyright IBM Corp. 2018, 2019. All Rights Reserved. +// Node module: @loopback4-example-shopping +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import * as _ from 'lodash'; +import {Credentials, UserRepository} from '../repositories/user.repository'; +import {toJSON} from '@loopback/testlab'; +import {promisify} from 'util'; +import * as isemail from 'isemail'; +import {HttpErrors} from '@loopback/rest'; +import {UserProfile} from '@loopback/authentication'; +import {compare} from 'bcryptjs'; +import {repository} from '@loopback/repository'; +import {inject} from '@loopback/core'; +import {JWTAuthenticationBindings} from '../keys'; +const jwt = require('jsonwebtoken'); +const signAsync = promisify(jwt.sign); +const verifyAsync = promisify(jwt.verify); + +/** + * Constant for JWT secret string + */ +export const JWT_SECRET = 'jwtsecret'; + +/** + * A JWT authentication service that could be reused by + * different clients. Usually it can be injected in the + * controller constructor. + * It provides services that handle the logics between the controller layer + * and the repository layer. + */ +export class JWTAuthenticationService { + constructor( + @repository(UserRepository) public userRepository: UserRepository, + @inject(JWTAuthenticationBindings.SECRET) public jwt_secret: string, + ) {} + + /** + * A function that retrieves the user with given credentials. Generates + * JWT access token using user profile as payload if user found. + * + * Usually a request's corresponding controller function filters the credential + * fields and invokes this function. + * + * @param credentials The user credentials including email and password. + */ + async getAccessTokenForUser(credentials: Credentials): Promise { + const foundUser = await this.userRepository.findOne({ + where: {email: credentials.email}, + }); + if (!foundUser) { + throw new HttpErrors['NotFound']( + `User with email ${credentials.email} not found.`, + ); + } + const passwordMatched = await compare( + credentials.password, + foundUser.password, + ); + if (!passwordMatched) { + throw new HttpErrors.Unauthorized('The credentials are not correct.'); + } + + const currentUser = _.pick(toJSON(foundUser), ['id', 'email', 'firstName']); + // Generate user token using JWT + const token = await signAsync(currentUser, this.jwt_secret, { + expiresIn: 300, + }); + + return token; + } + + /** + * Decodes the user's information from a valid JWT access token. + * Then generate a `UserProfile` instance as the returned user. + * + * @param token A JWT access token. + */ + async decodeAccessToken(token: string): Promise { + const decoded = await verifyAsync(token, this.jwt_secret); + let user = _.pick(decoded, ['id', 'email', 'firstName']); + (user as UserProfile).name = user.firstName; + delete user.firstName; + return user; + } +} + +/** + * To be removed in story + * https://github.com/strongloop/loopback4-example-shopping/issues/39 + * @param credentials + */ +export function validateCredentials(credentials: Credentials) { + // Validate Email + if (!isemail.validate(credentials.email)) { + throw new HttpErrors.UnprocessableEntity('invalid email'); + } + + // Validate Password Length + if (credentials.password.length < 8) { + throw new HttpErrors.UnprocessableEntity( + 'password must be minimum 8 characters', + ); + } +} diff --git a/src/services/hash.password.bcryptjs.ts b/src/services/hash.password.bcryptjs.ts new file mode 100644 index 000000000..cac696eb1 --- /dev/null +++ b/src/services/hash.password.bcryptjs.ts @@ -0,0 +1,19 @@ +import {genSalt, hash} from 'bcryptjs'; + +/** + * Service HashPassword using module 'bcryptjs'. + * It takes in a plain password, generates a salt with given + * round and returns the hashed password as a string + */ +export type HashPassword = ( + password: string, + rounds: number, +) => Promise; +// bind function to `services.bcryptjs.HashPassword` +export async function hashPassword( + password: string, + rounds: number, +): Promise { + const salt = await genSalt(rounds); + return await hash(password, salt); +} diff --git a/src/utils/user.authentication.ts b/src/utils/user.authentication.ts deleted file mode 100644 index 373e736b4..000000000 --- a/src/utils/user.authentication.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Copyright IBM Corp. 2018, 2019. All Rights Reserved. -// Node module: @loopback4-example-shopping -// This file is licensed under the MIT License. -// License text available at https://opensource.org/licenses/MIT - -import * as _ from 'lodash'; -import {Credentials, UserRepository} from '../repositories/user.repository'; -import {toJSON} from '@loopback/testlab'; -import {promisify} from 'util'; -import * as isemail from 'isemail'; -import {HttpErrors} from '@loopback/rest'; -import {UserProfile} from '@loopback/authentication'; -import {genSalt, compare, hash} from 'bcryptjs'; -const jwt = require('jsonwebtoken'); -const signAsync = promisify(jwt.sign); -const verifyAsync = promisify(jwt.verify); - -export async function hashPassword( - password: string, - rounds: number, -): Promise { - const salt = await genSalt(rounds); - return await hash(password, salt); -} - -export async function getAccessTokenForUser( - userRepository: UserRepository, - credentials: Credentials, -): Promise { - const foundUser = await userRepository.findOne({ - where: {email: credentials.email}, - }); - if (!foundUser) { - throw new HttpErrors['NotFound']( - `User with email ${credentials.email} not found.`, - ); - } - const passwordMatched = await compare( - credentials.password, - foundUser.password, - ); - if (!passwordMatched) { - throw new HttpErrors.Unauthorized('The credentials are not correct.'); - } - - const currentUser = _.pick(toJSON(foundUser), ['id', 'email', 'firstName']); - // Generate user token using JWT - const token = await signAsync(currentUser, 'secretforjwt', { - expiresIn: 300, - }); - - return token; -} - -export function validateCredentials(credentials: Credentials) { - // Validate Email - if (!isemail.validate(credentials.email)) { - throw new HttpErrors.UnprocessableEntity('invalid email'); - } - - // Validate Password Length - if (credentials.password.length < 8) { - throw new HttpErrors.UnprocessableEntity( - 'password must be minimum 8 characters', - ); - } -} - -// secret should be injected -// the refactor PR is in progress -// https://github.com/strongloop/loopback4-example-shopping/pull/33 -export async function decodeAccessToken( - token: string, - secret: string, -): Promise { - const decoded = await verifyAsync(token, secret); - let user = _.pick(decoded, ['id', 'email', 'firstName']); - (user as UserProfile).name = user.firstName; - delete user.firstName; - return user; -} diff --git a/test/acceptance/user.controller.acceptance.ts b/test/acceptance/user.controller.acceptance.ts index 90406bbfc..698d719d8 100644 --- a/test/acceptance/user.controller.acceptance.ts +++ b/test/acceptance/user.controller.acceptance.ts @@ -13,9 +13,10 @@ import {createRecommendationServer} from '../../recommender'; import {Server} from 'http'; import * as _ from 'lodash'; import { - getAccessTokenForUser, - hashPassword, -} from '../../src/utils/user.authentication'; + JWT_SECRET, + JWTAuthenticationService, +} from '../../src/services/JWT.authentication.service'; +import {hashPassword} from '../../src/services/hash.password.bcryptjs'; const recommendations = require('../../recommender/recommendations.json'); describe('UserController', () => { @@ -139,10 +140,12 @@ describe('UserController', () => { describe('authentication functions', () => { let plainPassword: string; + let jwt_auth_service: JWTAuthenticationService; before('create new user', async () => { plainPassword = user.password; user.password = await hashPassword(user.password, 4); + jwt_auth_service = new JWTAuthenticationService(userRepo, JWT_SECRET); }); it('login returns a valid token', async () => { @@ -170,7 +173,7 @@ describe('UserController', () => { it('/me returns the current user', async () => { const newUser = await userRepo.create(user); - const token = await getAccessTokenForUser(userRepo, { + const token = await jwt_auth_service.getAccessTokenForUser({ email: newUser.email, password: plainPassword, }); @@ -186,7 +189,7 @@ describe('UserController', () => { it('/me returns 401 when the token is not provided', async () => { const newUser = await userRepo.create(user); - await getAccessTokenForUser(userRepo, { + await jwt_auth_service.getAccessTokenForUser({ email: newUser.email, password: plainPassword, }); diff --git a/test/unit/utils.authentication.unit.ts b/test/unit/utils.authentication.unit.ts index a27048d1f..c3725f851 100644 --- a/test/unit/utils.authentication.unit.ts +++ b/test/unit/utils.authentication.unit.ts @@ -6,16 +6,15 @@ import {expect, toJSON} from '@loopback/testlab'; import {MongoDataSource} from '../../src/datasources'; import { - decodeAccessToken, - getAccessTokenForUser, - hashPassword, -} from '../../src/utils/user.authentication'; + JWT_SECRET, + JWTAuthenticationService, +} from '../../src/services/JWT.authentication.service'; +import {hashPassword} from '../../src/services/hash.password.bcryptjs'; import {UserRepository, OrderRepository} from '../../src/repositories'; import {User} from '../../src/models'; import * as _ from 'lodash'; import {JsonWebTokenError} from 'jsonwebtoken'; import {HttpErrors} from '@loopback/rest'; -const SECRET = 'secretforjwt'; describe('authentication utilities', () => { const mongodbDS = new MongoDataSource(); @@ -28,12 +27,14 @@ describe('authentication utilities', () => { surname: 'test', }; let newUser: User; + let jwt_service: JWTAuthenticationService; before(clearDatabase); before(createUser); + before(createService); it('getAccessTokenForUser creates valid jwt access token', async () => { - const token = await getAccessTokenForUser(userRepo, { + const token = await jwt_service.getAccessTokenForUser({ email: 'unittest@loopback.io', password: 'p4ssw0rd', }); @@ -45,7 +46,7 @@ describe('authentication utilities', () => { `User with email fake@loopback.io not found.`, ); return expect( - getAccessTokenForUser(userRepo, { + jwt_service.getAccessTokenForUser({ email: 'fake@loopback.io', password: 'fake', }), @@ -57,7 +58,7 @@ describe('authentication utilities', () => { 'The credentials are not correct.', ); return expect( - getAccessTokenForUser(userRepo, { + jwt_service.getAccessTokenForUser({ email: 'unittest@loopback.io', password: 'fake', }), @@ -65,19 +66,21 @@ describe('authentication utilities', () => { }); it('decodeAccessToken decodes valid access token', async () => { - const token = await getAccessTokenForUser(userRepo, { + const token = await jwt_service.getAccessTokenForUser({ email: 'unittest@loopback.io', password: 'p4ssw0rd', }); const expectedUser = getExpectedUser(newUser); - const currentUser = await decodeAccessToken(token, SECRET); + const currentUser = await jwt_service.decodeAccessToken(token); expect(currentUser).to.deepEqual(expectedUser); }); it('decodeAccessToken throws error for invalid accesstoken', async () => { const token = 'fake'; const error = new JsonWebTokenError('jwt malformed'); - return expect(decodeAccessToken(token, SECRET)).to.be.rejectedWith(error); + return expect(jwt_service.decodeAccessToken(token)).to.be.rejectedWith( + error, + ); }); async function createUser() { @@ -87,6 +90,9 @@ describe('authentication utilities', () => { async function clearDatabase() { await userRepo.deleteAll(); } + async function createService() { + jwt_service = new JWTAuthenticationService(userRepo, JWT_SECRET); + } }); function getExpectedUser(originalUser: User) {