-
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
Conversation
08f6eb5
to
1f3473f
Compare
f3b0a60
to
aded6b6
Compare
src/controllers/user.controller.ts
Outdated
@@ -91,7 +93,7 @@ export class UserController { | |||
} | |||
|
|||
@post('/users/login') | |||
async login(@requestBody() user: User): Promise<User> { | |||
async login(@requestBody() user: User): Promise<string | undefined> { |
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.
We need a type filter to describe the credentials needed for login.
The filtered type would be
type Credentials = {
username: string,
// consider adding `Password` and `Email` as built-in loopback types
password: Password,
email: Email
}
Or
type Credentials = {
identity: string | Email
password: Password
}
} | ||
} | ||
|
||
return Promise.reject('Token not found!'); |
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.
When the client did not provide any token, perhaps because it did not know this URL requires authentication, the server should return 401 Unauthorized
response with WWW-Authenticate
header field.
In Passport, the strategy would call this.fail()
API, see e.g. how passport-jwt
uses it here: https://github.com/themikenicholson/passport-jwt/blob/f2044371bc9c85f22fbd30e8e58267a42dcd26ac/lib/strategy.js#L96
In @loopback/authentication
, the strategy adapter is implementing fails
as follows. I think this is not entirely correct implementation, e.g. I don't see any WWW-Authenticate
header field being set.
// add failure state handler to strategy instance
strategy.fail = function(challenge: string) {
reject(new HttpErrors.Unauthorized(challenge));
};
4e8fdea
to
e7ddbb8
Compare
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.
Great progress!
if (token) { | ||
try { | ||
const decoded = await verifyAsync(token, SECRET); | ||
return Promise.resolve(_.pick(decoded, ['id', 'email'])); |
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.
I think you should pick name
too, since that's a property described in UserProfileSchema below?
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 The picked properties are a little bit tricky.
- The type
UserProfile
only hasid
as required, which makes sense.email
andname
are optional properties. - The
User
model doesn't have aname
property but hasfirstName
andlastName
instead, we could compose aname
property by joining them, but given the fact thatname
is optional, is it necessary to compose one?
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.
This is tricky indeed.
I think we should make UserProfile
more flexible to allow the applications to choose what fields they want to include. That's probably out of scope of this spike though.
I would assume that UserProfile's name
property is meant either as a handle or a display name. If we have firstName and lastName properties in our domain model, then I propose to use firstName
as the profile name
.
@raymondfeng what do you think?
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.
I would assume that UserProfile's name property is meant either as a handle or a display name. If we have firstName and lastName properties in our domain model, then I propose to use firstName as the profile name.
I am good with doing it in this PR, and for the discussion about the flexibility, I created a new issue for it, let's continue the discussion there: loopbackio/loopback-next#2246
src/controllers/user.controller.ts
Outdated
// Check if user exists | ||
try { | ||
const foundUser = await this.userRepository.findOne({ | ||
where: {email: credentials.email, password: credentials.password}, |
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.
Uff uff, storing password in plain text is a huge security vulnerability. I recommend to use scrypt
algorithm and store the hash only. Note that scrypt
is built into Node.js from version 10, see https://nodejs.org/dist/latest-v10.x/docs/api/crypto.html#crypto_crypto_scrypt_password_salt_keylen_options_callback Since we are still supporting Node.js 8, we cannot use that built-in API and have to use a 3rd party module like https://www.npmjs.com/package/scrypt.
Historically (LoopBack 1.x/2x./3.x), we were using bcrypt
instead.
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 We changed the login API to return the token in a json payload.
I recommend to use scrypt algorithm and store the hash only. Note that scrypt is built into Node.js from version 10,
Hmm, we didn't include the password in the payload that's used to generate the token, so I think we don't need scrypt
here.
Or did you mean we shouldn't store the password directly in the database but the hash of it instead? That's probably out of the scope of this PR, but good point, and I can create another PR to fix it.
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.
Or did you mean we shouldn't store the password directly in the database but the hash of it instead?
Yes, I was talking about how to store the password in the database.
Storing the password plain-text is a huge security vulnerability. IMO, a reference implementation of authentication that stores passwords in plain-text is severely undermining our credibility. I would not trust any advice from somebody who is not aware of such basic security measures.
See also loopbackio/loopback-next#1996, where I proposed few more improvements to make passwords less likely to leak.
} | ||
// MongoDB returns an id object we need to convert to string | ||
// since the REST API returns a string for the id property. | ||
newUser.id = newUser.id.toString(); |
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.
Have you considered using toJSON
helper from @loopback/testlab
instead? See https://github.com/strongloop/loopback-next/tree/master/packages/testlab#toJSON
const me = _.pick(toJSON(newUser), ['id', 'email']));
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.
This comment does not seem to be addressed, PTAL.
(A detail not preventing this PR from being approved.)
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 Hmm we already switched to toJSON
, could you elaborate more about your expected implementation? thanks.
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.
Can we remove this line now? I believe that toJSON
will take care of converting id
from ObjectID to a string.
src/repositories/user.repository.ts
Outdated
async login(credentials: Credentials) { | ||
// Validate Email | ||
if (!isemail.validate(credentials.email)) { | ||
throw new HttpErrors.UnprocessableEntity('invalid email'); |
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.
Ah, I see there is a catch in what I proposed.
The Repository layer should not be coupled with HTTP/REST specifics. Instead of using HttpErrors.UnprocessableEntity
and signal the particular error case via statusCode
property, we should use just err.code
here and have this code translated to transport specific means elsewhere. For example, gRPC may want to use a different mechanism to signal malformed request payload.
You can find the current translation table in @loopback/rest
here:
// TODO(bajtos) Make this mapping configurable at RestServer level,
// allow apps and extensions to contribute additional mappings.
const codeToStatusCodeMap: {[key: string]: number} = {
ENTITY_NOT_FOUND: 404,
};
Unfortunately, we don't yet implemented API allowing extensions to contribute custom mappings. It's something we want to eventually provide, but there was no urgent need so far.
I am proposing the following approach here in this spike:
- Make sure that all authorization-related errors come with an error code, e.g.
UNPROCESSABLE_ENTITY
. - For this spike, set
statusCode
too (by calling one ofHttpErrors
factories) - Add a comment to remind us that this solution is only temporary, and we need to make
codeToStatusCodeMap
customizable by extensions. Create a user story for this task, it will become a part of this spike's outcome.
See also loopbackio/loopback-next@7a56bad, loopbackio/loopback-next#1658 and loopbackio/loopback-next#1469
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 found a really weird thing, the Error
interface in @types/node
doesn't have the code
property, it only has an optional property stack
. Which means by let err = new Error('Wrong Credentials!')
, you cannot give err
an error code. I could define a custom Error class that extend Error
but if having to do that, I would rather leveraging the HttpErrors
class from rest
to create the error instance.
Next I am going to:
- Create a PR in
@types
to add the NodeJs Error properties likecode
in itsError
interface. - Create a new story to discuss 1. how to contribute a custom mapping in the reject action. 2. how to provide the mapping in a component. 3. update this example repo or the authentication module to add the mapping for the unauthorized error.
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.
Create a PR in @types to add the NodeJs Error properties like code in its Error interface.
+1
Until that happens, you can use the following workaround:
const err = new HttpErrors.UnprocessableEntity('invalid email');
Object.assign(err, {code: 'INVALID_EMAIL'});
throw err;
Create a new story to discuss 1. how to contribute a custom mapping in the reject action. 2. how to provide the mapping in a component. 3. update this example repo or the authentication module to add the mapping for the unauthorized error.
Awesome 👍
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.
This is a warning sign that login
should be refactored out of the repo.
a3bc2e5
to
5b6defb
Compare
d6963b6
to
0001fe6
Compare
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.
Looks mostly good 👍
|
||
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 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.
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.
There will be a refactor PR in @loopback/authentication
coming soon, this change will definitely be part of it :)
f823a7c
to
543f54b
Compare
I understand your frustration. Let's take a look at #1997 description, emphasis is mine:
What I would like to see as the outcome of this pull request, is a reference implementation that we are happy to recommend to all LB4 users as the right way of dealing with authentication. Personally, I would prefer to revert the commit switching to passport-jwt:
I assumed that both Let's consider two situations. Situation 1
If our authentication layer was calling JWT implementation directly, bypassing any additional abstractions like passport-jwt, then this situation would never happen. Situation 2 A security vulnerability was found, for example in How long will it take until Anyhow, this is just me. What do other @strongloop/loopback-maintainers think, especially @raymondfeng? Are they happy with recommending the current version of this pull request as "The Right Way"? |
credentials: Credentials, | ||
): Promise<string> { | ||
const foundUser = await userRepository.findOne({ | ||
where: {email: credentials.email, password: credentials.password}, |
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.
Please see the discussion in #26 (comment) where I am explaining why storing passwords in plain-text is a security vulnerability.
To me, this is a blocker preventing landing of this pull request.
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.
On the second thought, I see that this problem was not introduced by your pull request, it was already there on master
, so maybe it's alright to leave the fix out of scope of this pull request.
I am proposing the following approach:
- Create a new issue in this repository to keep track of this problem and allow us to plan the work in one of the next milestones.
- Here in this pull request, add a prominent code comment to point out the problem and refer people to the GitHub issue for further discussion.
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 am ok to address this concern when waiting for team's agreement.(Or in a separate PR, it depends).
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 Given a look of loopbackio/loopback-next#1996 I take back my word :( Let's address it in a separate story.
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.
Actually, the scope of loopbackio/loopback-next#1996 is different. When I was creating that issue, I did not realize that we have a bigger problem in the current implementation!
As I see it, we have two changes to make:
- MUST HAVE: hash the passwords before they are stored in the database. I think we need to create a new issue for this?
- SHOULD HAVE: move credentials (password, salt?) from User model to a new model. This is covered by Refactoring: Customer credentials loopback-next#1996.
Please don't forget to add a comment to this place in code to warn users about not using this code as a reference implementation.
} | ||
// MongoDB returns an id object we need to convert to string | ||
// since the REST API returns a string for the id property. | ||
newUser.id = newUser.id.toString(); |
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.
This comment does not seem to be addressed, PTAL.
(A detail not preventing this PR from being approved.)
d5a59d7
to
e7b65f7
Compare
@bajtos I reverted to the original implementation, let's focus on one solution in this PR :) Some background of why I switched to |
ae23120
to
0d78333
Compare
Yes. In addition to controller and repository, there should be a few classes (services - probably need to find a good name here) that can be bound to |
Do we want to include the |
@hacksparrow Good catch, we should. Added them in the last commit. |
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.
Looks mostly good. I don't want to block further progress, feel free to address my comments in a follow-up pull request.
} | ||
|
||
try { | ||
const decoded = await verifyAsync(token, SECRET); |
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.
- 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.
credentials: Credentials, | ||
): Promise<string> { | ||
const foundUser = await userRepository.findOne({ | ||
where: {email: credentials.email, password: credentials.password}, |
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.
Actually, the scope of loopbackio/loopback-next#1996 is different. When I was creating that issue, I did not realize that we have a bigger problem in the current implementation!
As I see it, we have two changes to make:
- MUST HAVE: hash the passwords before they are stored in the database. I think we need to create a new issue for this?
- SHOULD HAVE: move credentials (password, salt?) from User model to a new model. This is covered by Refactoring: Customer credentials loopback-next#1996.
Please don't forget to add a comment to this place in code to warn users about not using this code as a reference implementation.
} | ||
// MongoDB returns an id object we need to convert to string | ||
// since the REST API returns a string for the id property. | ||
newUser.id = newUser.id.toString(); |
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.
Can we remove this line now? I believe that toJSON
will take care of converting id
from ObjectID to a string.
Oops I didn't continue addressing this comment due to switched to
I will create a new story to discuss it, I tried to store the hash using
while my understanding is we need to store the salt in database as well, which means we need to create a new property, and if that's the case I would like to do it in a new issue that explores the possibility of extracting credentials into a standalone model as you said. |
Signed-off-by: jannyHou <[email protected]> Co-authored-by: Nora <[email protected]> Co-authored-by: Biniam <[email protected]> Co-authored-by: Janny <[email protected]>
The follow-up PRs and stories:
|
I am going to fix the CI. It wouldn't block the review, the tests all pass on my local
connect to loopbackio/loopback-next#1997
This PR implements the JWT authentication strategy for verifying requests:
authenticate
method that retrieves the token from request and then restores the user profile by decoding the token.jwtStrategy.authenticate()
function.@authentication('jwt')