Skip to content

Commit

Permalink
feat: jwt auth
Browse files Browse the repository at this point in the history
Signed-off-by: jannyHou <[email protected]>
Co-authored-by: Nora <[email protected]>
Co-authored-by: Biniam <[email protected]>
Co-authored-by: Janny <[email protected]>
  • Loading branch information
3 people committed Jan 15, 2019
1 parent 6700975 commit f823a7c
Show file tree
Hide file tree
Showing 13 changed files with 2,339 additions and 1,678 deletions.
3,648 changes: 1,988 additions & 1,660 deletions package-lock.json

Large diffs are not rendered by default.

29 changes: 18 additions & 11 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.9",
"@loopback/boot": "^1.0.9",
"@loopback/context": "^1.4.1",
"@loopback/core": "^1.1.4",
"@loopback/openapi-v3": "^1.1.6",
"@loopback/repository": "^1.1.2",
"@loopback/rest": "^1.5.2",
"@loopback/service-proxy": "^1.0.6",
"@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 @@ -71,20 +75,23 @@
"@commitlint/cli": "^7.2.1",
"@commitlint/config-conventional": "^7.1.2",
"@commitlint/travis-cli": "^7.2.1",
"@loopback/build": "^1.0.1",
"@loopback/testlab": "^1.0.1",
"@loopback/build": "^1.2.0",
"@loopback/testlab": "^1.0.4",
"@loopback/tslint-config": "^2.0.0",
"@types/bcryptjs": "^2.4.2",
"@types/debug": "0.0.31",
"@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",
"husky": "^1.1.3",
"mocha": "^5.2.0",
"source-map-support": "^0.5.9"
"source-map-support": "^0.5.9",
"typescript": "3.2.2",
"tslint": "5.12.1"
},
"copyright.owner": "IBM Corp.",
"config": {
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 {AuthenticateActionProvider} 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(
AuthenticateActionProvider,
);
this.bind(AuthenticationBindings.STRATEGY).toProvider(
StrategyResolverProvider,
);

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

Expand Down
36 changes: 36 additions & 0 deletions src/authentication-strategies/JWT.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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';
import {AuthenticationStrategy} from './authentication.strategy';

export class JWTStrategy implements AuthenticationStrategy {
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
let token = request.query.token || request.headers['authorization'];
if (!token) throw new HttpErrors.Unauthorized('Token not found!');

if (token.startsWith('Bearer ')) {
token = token.slice(7, token.length);
}

try {
const decoded = await verifyAsync(token, SECRET);
let user = _.pick(decoded, ['id', 'email', 'firstName']);
(user as UserProfile).name = user.firstName;
delete user.firstName;
return user;
} catch (err) {
Object.assign(err, {
code: 'INVALID_ACCESS_TOKEN',
statusCode: 401,
});
throw err;
}
}
}
15 changes: 15 additions & 0 deletions src/authentication-strategies/authentication.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import {UserProfile} from '@loopback/authentication';
import {Request} from '@loopback/rest';

/**
* An interface describes the common authentication strategy.
*
* An authentication strategy is usually a class with an
* authenticate method that verifies a user's identity and
* returns the corresponding user profile.
*
* Please note this file should be moved to @loopback/authentication
*/
export interface AuthenticationStrategy {
authenticate(request: Request): Promise<UserProfile | undefined>;
}
72 changes: 71 additions & 1 deletion src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,34 @@ 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 '../repositories/user.repository';

const hashAsync = promisify(hash);

// TODO(jannyHou): This should be moved to @loopback/authentication
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 +84,30 @@ 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);
}

// TODO(@jannyHou): missing logout function.
// as a stateless authentication method, JWT doesn't actually
// have a logout operation. See article for details:
// https://medium.com/devgorilla/how-to-log-out-when-using-jwt-a8c7823e8a6

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

@post('/users/login', {
responses: {
'200': {
description: 'Token',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
token: {
type: 'string',
},
},
},
},
},
},
},
})
async login(
@requestBody() credentials: Credentials,
): Promise<{token: string}> {
let res = {token: ''};
res.token = await this.userRepository.login(credentials);
return res;
}
}
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 {AuthenticationStrategy} from '../authentication-strategies/authentication.strategy';

/**
* @description Provider of a function which authenticates
* @example `context.bind('authentication_key')
* .toProvider(AuthenticateActionProvider)`
*/
export class AuthenticateActionProvider 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<AuthenticationStrategy>,
@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';
28 changes: 28 additions & 0 deletions src/providers/strategy.resolver.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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;
// This should be extensible
if (name === 'jwt') {
return new JWTStrategy();
} else {
return Promise.reject(`The strategy ${name} is not available.`);
}
}
}
44 changes: 43 additions & 1 deletion src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ import {
import {User, Order} from '../models';
import {inject} from '@loopback/core';
import {OrderRepository} from './order.repository';
import * as isemail from 'isemail';
import {HttpErrors} from '@loopback/rest';
import _ = require('lodash');
import {promisify} from 'util';
import {toJSON} from '@loopback/testlab';
const jwt = require('jsonwebtoken');
const signAsync = promisify(jwt.sign);

export type Credentials = {
email: string;
password: string;
};

export class UserRepository extends DefaultCrudRepository<
User,
Expand All @@ -24,9 +36,39 @@ export class UserRepository extends DefaultCrudRepository<
@repository(OrderRepository) protected orderRepository: OrderRepository,
) {
super(User, datasource);
this.orders = this._createHasManyRepositoryFactoryFor(
this.orders = this.createHasManyRepositoryFactoryFor(
'orders',
async () => orderRepository,
);
}

async login(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',
);
}

const foundUser = await this.findOne({
where: {email: credentials.email, password: credentials.password},
});

if (!foundUser) {
throw new HttpErrors.Unauthorized('Wrong credentials!');
}

const currentUser = _.pick(toJSON(foundUser), ['id', 'email', 'firstName']);

// Generate user token using JWT
const token = await signAsync(currentUser, 'secretforjwt', {
expiresIn: 300,
});
return token;
}
}
Loading

0 comments on commit f823a7c

Please sign in to comment.