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 21, 2019
1 parent 6700975 commit 6907c71
Show file tree
Hide file tree
Showing 14 changed files with 1,321 additions and 655 deletions.
1,600 changes: 964 additions & 636 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
34 changes: 34 additions & 0 deletions src/authentication-strategies/JWT.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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> {
let token = request.query.access_token || request.headers['authorization'];
if (!token) throw new HttpErrors.Unauthorized('No access token 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>;
}
78 changes: 76 additions & 2 deletions src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,40 @@ import {User, Product} from '../models';
import {UserRepository} from '../repositories';
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';
import {
validateCredentials,
getAccessTokenForUser,
} from '../utils/user.authentication';
import * as isemail from 'isemail';

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 +88,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 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 +134,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}> {
validateCredentials(credentials);
const token = await getAccessTokenForUser(this.userRepository, credentials);
return {token};
}
}
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';
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';
export class StrategyResolverProvider
implements Provider<JWTStrategy | undefined> {
constructor(
@inject(AuthenticationBindings.METADATA)
private metadata: AuthenticationMetadata,
) {}
value(): ValueOrPromise<JWTStrategy | undefined> {
if (!this.metadata) {
return;
}

const name = this.metadata.strategy;
// This should be extensible
if (name === 'jwt') {
return new JWTStrategy();
} else {
throw new Error(`The strategy ${name} is not available.`);
}
}
}
6 changes: 5 additions & 1 deletion src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
import {User, Order} from '../models';
import {inject} from '@loopback/core';
import {OrderRepository} from './order.repository';
export type Credentials = {
email: string;
password: string;
};

export class UserRepository extends DefaultCrudRepository<
User,
Expand All @@ -24,7 +28,7 @@ export class UserRepository extends DefaultCrudRepository<
@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
Loading

0 comments on commit 6907c71

Please sign in to comment.