-
Notifications
You must be signed in to change notification settings - Fork 208
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
feat: jwt auth #26
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
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); | ||
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; | ||
} | ||
} | ||
} |
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>; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 = { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There will be a refactor PR in |
||
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') | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can still log out by invalidating the jwt token. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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". There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
||
@get('/users/{userId}/recommend', { | ||
responses: { | ||
'200': { | ||
|
@@ -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}; | ||
} | ||
} |
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; | ||
} | ||
} |
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'; |
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.`); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
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.login
is implemented.I expect the same benefits:
There was a problem hiding this comment.
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 withpassport
module out-of-the-box.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@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.