Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: jwt auth #26

Merged
merged 1 commit into from
Jan 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,464 changes: 2,001 additions & 2,463 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
39 changes: 39 additions & 0 deletions src/authentication-strategies/JWT.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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

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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on our discussion around login, I feel this place should be changed in a similar way too.

  • JWTStrategy deals with REST-specific aspects only: how to extract the token from the request, how to bind UserProfile as request's current user.
  • Validation of the token and conversion into UserProfile should be handled by a new utility/service. This can be possibly the same class/file where login is implemented.

I expect the same benefits:

  • Other transports like gRPC and WebSocket can use the same utility/service to validate access tokens and convert them into user profiles.
  • The utility/service can be tested in isolation with no transport involved.

Copy link
Contributor Author

@jannyHou jannyHou Jan 18, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bajtos I removed the strategy written by us, replaced with the Strategy in passport-jwt, which is one of tens passport strategies that works with passport module out-of-the-box.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Based on our discussion around login, I feel this place should be changed in a similar way too.

  • JWTStrategy deals with REST-specific aspects only: how to extract the token from the request, how to bind UserProfile as request's current user.
  • Validation of the token and conversion into UserProfile should be handled by a new utility/service. This can be possibly the same class/file where login is implemented.

@jannyHou I feel this comment should be addressed before landing the patch. If it makes it easier for you, then I am ok to defer this work to a follow-up pull request, as long as the change is made as part of the actual user story this pull request belongs to.

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;
}
}
}
20 changes: 20 additions & 0 deletions src/authentication-strategies/authentication.strategy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// 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 {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 = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to find a way allowing applications to add additional properties to user profile.

I would also prefer to leverage @model and @property decorators together with the existing code converting LB4 models to JSON/OpenAPI schema, instead of maintaining the schema manually.

@model
class UserProfile extends Model {
  @property({type: 'string', required: true})
  id: string;

  @property()
  email: string;

  @property()
  name: string;
}

Feel free to leave such changes out of scope of this spike and create a new user story to follow up on this area.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There will be a refactor PR in @loopback/authentication coming soon, this change will definitely be part of it :)

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can still log out by invalidating the jwt token.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jannyHou we should have an endpoint which would invalidate the token for the user, technically "logging them out".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hacksparrow I didn't find any good approach to invalidate the token other than removing it from client side, which is covered in test case https://github.com/strongloop/loopback4-example-shopping/pull/26/files#diff-cd803ccaed549218efa0937d5edd4533R179.
Any suggestions?


@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;
}
}
7 changes: 7 additions & 0 deletions src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// 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

export * from './strategy.resolver.provider';
export * from './custom.authentication.provider';
32 changes: 32 additions & 0 deletions src/providers/strategy.resolver.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright IBM Corp. 2018. 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 {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