-
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: create jwt auth service #33
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
import {BindingKey} from '@loopback/context'; | ||
import {JWTAuthenticationService} from './services/JWT.authentication.service'; | ||
import {HashPassword} from './services/hash.password.bcryptjs'; | ||
import {JWTStrategy} from './authentication-strategies/JWT.strategy'; | ||
|
||
// Discussion point for reviewers: | ||
// What would be the good naming conversion for bindings? | ||
export namespace JWTAuthenticationBindings { | ||
export const STRATEGY = BindingKey.create<JWTStrategy>( | ||
'authentication.strategies.jwt.strategy', | ||
); | ||
export const SECRET = BindingKey.create<string>('authentication.jwt.secret'); | ||
export const SERVICE = BindingKey.create<JWTAuthenticationService>( | ||
'services.authentication.jwt.service', | ||
); | ||
} | ||
|
||
export namespace OtherServicesBindings { | ||
export const HASH_PASSWORD = BindingKey.create<HashPassword>( | ||
'services.hash_password', | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
// 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 * as _ from 'lodash'; | ||
import {Credentials, UserRepository} from '../repositories/user.repository'; | ||
import {toJSON} from '@loopback/testlab'; | ||
import {promisify} from 'util'; | ||
import * as isemail from 'isemail'; | ||
import {HttpErrors} from '@loopback/rest'; | ||
import {UserProfile} from '@loopback/authentication'; | ||
import {compare} from 'bcryptjs'; | ||
import {repository} from '@loopback/repository'; | ||
import {inject} from '@loopback/core'; | ||
import {JWTAuthenticationBindings} from '../keys'; | ||
const jwt = require('jsonwebtoken'); | ||
const signAsync = promisify(jwt.sign); | ||
const verifyAsync = promisify(jwt.verify); | ||
|
||
/** | ||
* Constant for JWT secret string | ||
*/ | ||
export const JWT_SECRET = 'jwtsecret'; | ||
|
||
/** | ||
* A JWT authentication service that could be reused by | ||
* different clients. Usually it can be injected in the | ||
* controller constructor. | ||
* It provides services that handle the logics between the controller layer | ||
* and the repository layer. | ||
*/ | ||
export class JWTAuthenticationService { | ||
constructor( | ||
@repository(UserRepository) public userRepository: UserRepository, | ||
@inject(JWTAuthenticationBindings.SECRET) public jwt_secret: string, | ||
) {} | ||
|
||
/** | ||
* A function that retrieves the user with given credentials. Generates | ||
* JWT access token using user profile as payload if user found. | ||
* | ||
* Usually a request's corresponding controller function filters the credential | ||
* fields and invokes this function. | ||
* | ||
* @param credentials The user credentials including email and password. | ||
*/ | ||
async getAccessTokenForUser(credentials: Credentials): Promise<string> { | ||
const foundUser = await this.userRepository.findOne({ | ||
where: {email: credentials.email}, | ||
}); | ||
if (!foundUser) { | ||
throw new HttpErrors['NotFound']( | ||
`User with email ${credentials.email} not found.`, | ||
); | ||
} | ||
const passwordMatched = await compare( | ||
credentials.password, | ||
foundUser.password, | ||
); | ||
if (!passwordMatched) { | ||
throw new HttpErrors.Unauthorized('The credentials are not correct.'); | ||
} | ||
|
||
const currentUser = _.pick(toJSON(foundUser), ['id', 'email', 'firstName']); | ||
// Generate user token using JWT | ||
const token = await signAsync(currentUser, this.jwt_secret, { | ||
expiresIn: 300, | ||
}); | ||
|
||
return token; | ||
} | ||
|
||
/** | ||
* Decodes the user's information from a valid JWT access token. | ||
* Then generate a `UserProfile` instance as the returned user. | ||
* | ||
* @param token A JWT access token. | ||
*/ | ||
async decodeAccessToken(token: string): Promise<UserProfile> { | ||
const decoded = await verifyAsync(token, this.jwt_secret); | ||
let user = _.pick(decoded, ['id', 'email', 'firstName']); | ||
(user as UserProfile).name = user.firstName; | ||
delete user.firstName; | ||
return user; | ||
} | ||
} | ||
|
||
/** | ||
* To be removed in story | ||
* https://github.com/strongloop/loopback4-example-shopping/issues/39 | ||
* @param credentials | ||
*/ | ||
export function validateCredentials(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', | ||
); | ||
} | ||
} | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,19 @@ | ||
import {genSalt, hash} from 'bcryptjs'; | ||
|
||
/** | ||
* Service HashPassword using module 'bcryptjs'. | ||
* It takes in a plain password, generates a salt with given | ||
* round and returns the hashed password as a string | ||
*/ | ||
export type HashPassword = ( | ||
password: string, | ||
rounds: number, | ||
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. IMO, consumers of I am proposing the following API: export type HashPassword = (password: string) => Promise<string>; And the following usage: // in production
app.bind(OtherServicesBindings.HASH_PASSWORD)
.to(pwd => hasPassword(pwd, 10));
// in test
app.bind(OtherServicesBindings.HASH_PASSWORD)
.to(pwd => hasPassword(pwd, 4)); Having wrote that, I think we should go one step further. As I see it, the function for computing password hash is tightly coupled with the function for comparing user-provided password with the stored hash, and therefore both functions should be part of the same "service". interface PasswordHasher {
hash(password: string): Promise<string>;
compare(password: string): Promise<boolean>;
}
class BcryptHasher implements PasswordHasher {
constructor(
@inject('BcryptHasher.rounds')
private readonly rounds: number
) {}
// ...
}
app.bind('services.PasswordHasher').toClass(BcryptHasher);
// usage in production
app.bind('BcryptHasher.rounds').to(10);
// usage in tests
app.bind('BcryptHasher.rounds').to(4); Later in the future we should leverage loopbackio/loopback-next#2259 to control this configuration option. Feel free to leave this second part (service for hash+compare) for a follow-up pull request. 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.
Both good points 👍 Do you mind if I address both of them in the next PR as a refactor specific for password hashing? (within the same story #40) I would like to have this PR focus on the JWT related services first. Then improve the password hash in a second one. 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. Fair enough 👍 |
||
) => Promise<string>; | ||
// bind function to `services.bcryptjs.HashPassword` | ||
export async function hashPassword( | ||
password: string, | ||
rounds: number, | ||
): Promise<string> { | ||
const salt = await genSalt(rounds); | ||
return await hash(password, salt); | ||
} |
This file was deleted.
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.
It's not necessary to have
JWTAuthenticationServiceProvider
asJWTAuthenticationService
class can receive injections to create instances. You can simply do the following:And