Skip to content

Commit

Permalink
feat: jwt auth
Browse files Browse the repository at this point in the history
Signed-off-by: Nora <[email protected]>
Co-authored-by: Janny <[email protected]>
Co-authored-by: Biniam <[email protected]>
  • Loading branch information
jannyHou and b-admike committed Jan 8, 2019
1 parent 6700975 commit 4e8fdea
Show file tree
Hide file tree
Showing 14 changed files with 354 additions and 6,861 deletions.
6,847 changes: 0 additions & 6,847 deletions package-lock.json

This file was deleted.

20 changes: 12 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,21 @@
"src"
],
"dependencies": {
"@loopback/boot": "^1.0.3",
"@loopback/context": "^1.0.1",
"@loopback/core": "^1.0.1",
"@loopback/openapi-v3": "^1.1.0",
"@loopback/repository": "^1.0.3",
"@loopback/rest": "^1.2.0",
"@loopback/service-proxy": "^1.0.1",
"@loopback/authentication": "^1.0.8",
"@loopback/boot": "^1.0.8",
"@loopback/context": "^1.4.0",
"@loopback/core": "^1.1.3",
"@loopback/openapi-v3": "^1.1.5",
"@loopback/repository": "^1.1.1",
"@loopback/rest": "^1.5.1",
"@loopback/service-proxy": "^1.0.5",
"@types/jsonwebtoken": "^8.3.0",
"@types/passport": "^1.0.0",
"bcryptjs": "^2.4.3",
"debug": "^4.1.0",
"express": "^4.16.4",
"isemail": "^3.2.0",
"jsonwebtoken": "^8.4.0",
"lodash": "^4.17.11",
"loopback-connector-kv-redis": "^3.0.0",
"loopback-connector-mongodb": "^3.9.2",
Expand All @@ -78,7 +82,7 @@
"@types/express": "^4.16.0",
"@types/lodash": "^4.14.118",
"@types/mocha": "^5.0.0",
"@types/node": "^10.12.3",
"@types/node": "^10.12.12",
"commitizen": "^3.0.4",
"concurrently": "^4.0.1",
"cz-conventional-changelog": "^2.1.0",
Expand Down
14 changes: 14 additions & 0 deletions src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {RestApplication} from '@loopback/rest';
import {ServiceMixin} from '@loopback/service-proxy';
import {MySequence} from './sequence';
import * as path from 'path';
import {
AuthenticationBindings,
AuthenticationComponent,
} from '@loopback/authentication';
import {StrategyResolverProvider} from './providers/strategy.resolver.provider';
import {MyAuthenticateActionProvider} from './providers/custom.authentication.provider';

/**
* Information from package.json
Expand All @@ -32,6 +38,14 @@ export class ShoppingApplication extends BootMixin(
// Bind package.json to the application context
this.bind(PackageKey).to(pkg);

this.component(AuthenticationComponent);
this.bind(AuthenticationBindings.AUTH_ACTION).toProvider(
MyAuthenticateActionProvider,
);
this.bind(AuthenticationBindings.STRATEGY).toProvider(
StrategyResolverProvider,
);

// Set up the custom sequence
this.sequence(MySequence);

Expand Down
30 changes: 30 additions & 0 deletions src/authentication-strategies/JWT.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const jwt = require('jsonwebtoken');
import {promisify} from 'util';
const verifyAsync = promisify(jwt.verify);
// Consider turn it to a binding
const SECRET = 'secretforjwt';
import {Request, HttpErrors} from '@loopback/rest';
import {UserProfile} from '@loopback/authentication';
import * as _ from 'lodash';

export class JWTStrategy {
// tslint:disable-next-line:no-any
async authenticate(request: Request): Promise<UserProfile | undefined> {
// there is a discussion regarding how to retrieve the token,
// see comment https://github.com/strongloop/loopback-next/issues/1997#issuecomment-451054806
const token = request.query.token || request.headers['authorization'];
if (token) {
try {
const decoded = await verifyAsync(token, SECRET);
return Promise.resolve(_.pick(decoded, ['id', 'email']));
} catch (err) {
if (err)
return Promise.reject(
new HttpErrors.Unauthorized('Could not decode the JWT token!'),
);
}
} else {
return Promise.reject(new HttpErrors.Unauthorized('Token not found!'));
}
}
}
127 changes: 126 additions & 1 deletion src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,36 @@ import {hash} from 'bcryptjs';
import {promisify} from 'util';
import * as isemail from 'isemail';
import {RecommenderService} from '../services/recommender.service';
import {inject} from '@loopback/core';
import {inject, Setter} from '@loopback/core';
import {
authenticate,
UserProfile,
AuthenticationBindings,
} from '@loopback/authentication';
import {Credentials} from '../types';
import * as _ from 'lodash';
const jwt = require('jsonwebtoken');
const signAsync = promisify(jwt.sign);

const hashAsync = promisify(hash);

const UserProfileSchema = {
type: 'object',
required: ['id'],
properties: {
id: {type: 'string'},
email: {type: 'string'},
name: {type: 'string'},
},
};

export class UserController {
constructor(
@repository(UserRepository) public userRepository: UserRepository,
@inject('services.RecommenderService')
public recommender: RecommenderService,
@inject.setter(AuthenticationBindings.CURRENT_USER)
public setCurrentUser: Setter<UserProfile>,
) {}

@post('/users')
Expand Down Expand Up @@ -65,6 +86,58 @@ export class UserController {
});
}

@get('/users/me', {
responses: {
'200': {
description: 'The current user profile',
content: {
'application/json': {
schema: UserProfileSchema,
},
},
},
},
})
@authenticate('jwt')
async printCurrentUser(
@inject('authentication.currentUser') currentUser: UserProfile,
): Promise<UserProfile> {
return Promise.resolve(currentUser);
}

// @post('users/logout', {
// responses: {
// '200': {
// description: 'Logging out successfully',
// content: {
// 'application/json': {
// schema: {
// type: 'object',
// properties: {
// success: {type: 'boolean'},
// },
// },
// },
// },
// },
// },
// })
// @authenticate('jwt')
// async logout(
// @inject('authentication.currentUser', {optional: true})
// currentUser: UserProfile,
// ): Promise<{success: boolean}> {
// if (currentUser) {
// const AnonymousUser = {
// id: 'ANONYMOUS',
// };
// this.setCurrentUser(AnonymousUser);
// return Promise.resolve({success: true});
// }

// return Promise.reject(new Error('No user logged in'));
// }

@get('/users/{userId}/recommend', {
responses: {
'200': {
Expand All @@ -87,4 +160,56 @@ export class UserController {
): Promise<Product[]> {
return this.recommender.getProductRecommendations(userId);
}

@post('/users/login', {
responses: {
'200': {
description: 'Token',
content: {
'text/plain': {
schema: {
type: 'string',
},
},
},
},
},
})
async login(
@requestBody() credentials: Credentials,
): Promise<String | undefined> {
// 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',
);
}

// Check if user exists
try {
const foundUser = await this.userRepository.findOne({
where: {email: credentials.email, password: credentials.password},
});
let token = undefined;
if (foundUser) {
const currentUser = _.pick(foundUser.toJSON(), ['id', 'email']);
// Generate user token using JWT
token = await signAsync(currentUser, 'secretforjwt', {
expiresIn: 300,
});
} else {
return Promise.reject(
new HttpErrors.Unauthorized('Wrong credentials!'),
);
}
return Promise.resolve(token);
} catch (err) {
return Promise.reject(new Error('Wrong Credentials!'));
}
}
}
56 changes: 56 additions & 0 deletions src/providers/custom.authentication.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright IBM Corp. 2017,2018. 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, Provider, Setter, inject} from '@loopback/context';
import {Request} from '@loopback/rest';
import {AuthenticationBindings} from '@loopback/authentication';
import {AuthenticateFn, UserProfile} from '@loopback/authentication';
import {JWTStrategy} from '../authentication-strategies/JWT.strategy';

/**
* @description Provider of a function which authenticates
* @example `context.bind('authentication_key')
* .toProvider(AuthenticateActionProvider)`
*/
export class MyAuthenticateActionProvider implements Provider<AuthenticateFn> {
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<JWTStrategy>,
@inject.setter(AuthenticationBindings.CURRENT_USER)
readonly setCurrentUser: Setter<UserProfile>,
) {}

/**
* @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<UserProfile | undefined> {
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 user = await strategy.authenticate(request);
if (user) this.setCurrentUser(user);
return user;
}
}
2 changes: 2 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './strategy.resolver.provider';
export * from './custom.authentication.provider';
27 changes: 27 additions & 0 deletions src/providers/strategy.resolver.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {Provider, ValueOrPromise} from '@loopback/core';
import {inject} from '@loopback/context';
import {
AuthenticationBindings,
AuthenticationMetadata,
} from '@loopback/authentication';
import {JWTStrategy} from '../authentication-strategies/JWT.strategy';
// import {JWTStrategy} from '@loopback/authentication';
export class StrategyResolverProvider
implements Provider<JWTStrategy | undefined> {
constructor(
@inject(AuthenticationBindings.METADATA)
private metadata: AuthenticationMetadata,
) {}
value(): ValueOrPromise<JWTStrategy | undefined> {
if (!this.metadata) {
return Promise.resolve(undefined);
}

const name = this.metadata.strategy;
if (name === 'jwt') {
return new JWTStrategy();
} else {
return Promise.reject(`The strategy ${name} is not available.`);
}
}
}
3 changes: 2 additions & 1 deletion src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ export class UserRepository extends DefaultCrudRepository<
typeof User.prototype.id
> {
public orders: HasManyRepositoryFactory<Order, typeof User.prototype.id>;
// From @jannyhou: should the relation here be hasOne or hasMany?

constructor(
@inject('datasources.mongo') protected datasource: juggler.DataSource,
@repository(OrderRepository) protected orderRepository: OrderRepository,
) {
super(User, datasource);
this.orders = this._createHasManyRepositoryFactoryFor(
this.orders = this.createHasManyRepositoryFactoryFor(
'orders',
async () => orderRepository,
);
Expand Down
4 changes: 4 additions & 0 deletions src/sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
Send,
SequenceHandler,
} from '@loopback/rest';
import {AuthenticationBindings, AuthenticateFn} from '@loopback/authentication';

const SequenceActions = RestBindings.SequenceActions;

Expand All @@ -24,12 +25,15 @@ export class MySequence implements SequenceHandler {
@inject(SequenceActions.INVOKE_METHOD) protected invoke: InvokeMethod,
@inject(SequenceActions.SEND) public send: Send,
@inject(SequenceActions.REJECT) public reject: Reject,
@inject(AuthenticationBindings.AUTH_ACTION)
protected authenticateRequest: AuthenticateFn,
) {}

async handle(context: RequestContext) {
try {
const {request, response} = context;
const route = this.findRoute(request);
await this.authenticateRequest(request);
const args = await this.parseParams(request, route);
const result = await this.invoke(route, args);
this.send(response, result);
Expand Down
4 changes: 4 additions & 0 deletions src/types/custom-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Credentials = {
email: string;
password: string;
};
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './custom-types';
5 changes: 3 additions & 2 deletions test/acceptance/user-order.controller.acceptance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import {setupApplication} from './helper';
describe('UserOrderController acceptance tests', () => {
let app: ShoppingApplication;
let client: supertest.SuperTest<supertest.Test>;
const orderRepo = new OrderRepository(new MongoDataSource());
const userRepo = new UserRepository(new MongoDataSource(), orderRepo);
const mongodbDS = new MongoDataSource();
const orderRepo = new OrderRepository(mongodbDS);
const userRepo = new UserRepository(mongodbDS, orderRepo);

before('setupApplication', async () => {
({app, client} = await setupApplication());
Expand Down
Loading

0 comments on commit 4e8fdea

Please sign in to comment.