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

Add auth update endpoint #48

Open
wants to merge 44 commits into
base: main
Choose a base branch
from

Conversation

Mathieuka
Copy link

@Mathieuka Mathieuka commented Oct 28, 2024

Description

Fixes #36

Key Changes

  • Allows authenticated users to change their passwords using a PUT request with schema validation.
  • Enforces a strong password policy requiring uppercase, lowercase, numeric, and special characters.
  • Rate Limiting: Limits password update attempts to 3 per minute to enhance security.
  • Includes tests for successful updates, validation errors, and rate limiting.

Checklist

@Mathieuka Mathieuka force-pushed the add-auth-update-endpoint branch 2 times, most recently from 5096495 to 659692e Compare October 28, 2024 21:58
src/routes/api/auth/index.ts Outdated Show resolved Hide resolved
src/routes/api/auth/index.ts Outdated Show resolved Hide resolved
Copy link
Contributor

@jean-michelet jean-michelet left a comment

Choose a reason for hiding this comment

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

Thanks for the PR!

Always add a link to the issue when you push a PR, e.g. Fixes #36
It allows people following the issue to be notified and readers of the issue to know that a PR has been pushed.

Your branch is out of date, you need to sync your fork with this repo, pull the changes and resolve conflicts.

/auth is related to authentication, it must be used for login, logout, reset password, etc.

Regarding the CRUD of user details, you need to create a new folder routes/api/users (the autoloader will automatically prefix all the routes defined in this folder with path /api/users).

I wouldn't mix password update with other user details update, as password update require specific logic and is more security sensible. Most of the time, updating user details only require payload validation.

src/routes/api/auth/index.ts Outdated Show resolved Hide resolved
src/schemas/auth.ts Outdated Show resolved Hide resolved
@Mathieuka Mathieuka force-pushed the add-auth-update-endpoint branch 3 times, most recently from f4045d0 to b87b39e Compare October 29, 2024 20:09
@Mathieuka Mathieuka marked this pull request as ready for review October 29, 2024 20:23
@Mathieuka Mathieuka marked this pull request as draft October 29, 2024 20:24
@Mathieuka
Copy link
Author

This is my first PR, I'm waiting for your review before committing to writing a test

Copy link
Contributor

@jean-michelet jean-michelet left a comment

Choose a reason for hiding this comment

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

You need to write tests that prove that the feature work as expected and reach 100% code coverage: https://en.wikipedia.org/wiki/Code_coverage

.where({ username })
.first()

if (user) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
if (user) {
if (!user) {

Prefer early return, if there is no user, the program can stop.

const username = request.session.user.username

try {
const user = await fastify.knex<Auth>('users')
Copy link
Contributor

Choose a reason for hiding this comment

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

All these operations should be wrapped in a transaction to avoid race conditions.

Comment on lines 3 to 5
export const UpdateCredentialsSchema = Type.Object({
currentPassword: Type.String(),
newPassword: Type.String()
Copy link
Contributor

Choose a reason for hiding this comment

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

I would force to use a strong password and check that the two values are different.

import { UpdateCredentialsSchema } from '../../../schemas/users.js'

const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
fastify.put(
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should use a rate limiter to mitigate brute force attack, there is an example here:

preHandler: fastify.rateLimit({

}

reply.status(401)
return { message: 'Invalid username or password.' }
Copy link
Contributor

Choose a reason for hiding this comment

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

This error message is wrong.

Copy link
Author

@Mathieuka Mathieuka Oct 30, 2024

Choose a reason for hiding this comment

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

This error message is wrong.

I think we should not return to the client if the user or password is wrong, it is not a good security design to prevent potential attackers from getting information about the part of the login information that is incorrect.

Or i have missed something

Copy link
Contributor

@jean-michelet jean-michelet Oct 30, 2024

Choose a reason for hiding this comment

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

I think we should not return to the client if the user or password is wrong

For a auth endpoint yes, for a setting endpoint, no. Because we know this is the user/admin that do this operation (unless hacked, this is why we have rate limiter). We must inform why the request for password update failed.

Regarding the line 52, if there is no user session, the error should be detected ahead of time in this hook:

if (!request.session.user) {

But you can still return: reply.unauthorized(), it will set status code to 401 automatically.

@jean-michelet
Copy link
Contributor

jean-michelet commented Oct 29, 2024

This is my first PR, I'm waiting for your review before committing to writing a test

Congrats for your first PR 👍
Keep in mind that writing tests does help you to produce robust and relevant code and then save code review time.

Comment on lines 3 to 14
export const UpdateCredentialsSchema = Type.Object({
currentPassword: Type.String(),
newPassword: Type.String()
})
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
export const UpdateCredentialsSchema = Type.Object({
currentPassword: Type.String(),
newPassword: Type.String()
})
const Password = Type.String({
// at least 1 upper case, 1 lower case, 1 numeric, 1 special characters with minimal 8 characters.
pattern: "^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$",
minLength: 8
})
export const UpdateCredentialsSchema = Type.Object({
currentPassword: Password,
newPassword: Password
})

Copy link
Author

@Mathieuka Mathieuka Oct 30, 2024

Choose a reason for hiding this comment

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

const Password = Type.String({
// at least 1 upper case, 1 lower case, 1 numeric, 1 special characters with minimal 8 characters.
pattern: "^(?=.?[A-Z])(?=.?[a-z])(?=.?[0-9])(?=.?[#?!@$%^&*-]).{8,}$",
minLength: 8
})

Thanks for the suggestion, also the proposed pattern is draft to give me an idea because that it only checks for the presence of at least one uppercase, one lowercase, one digit and one special char, but it does not allow for any additional characters beyond these, finally the pattern defined only match strings that are exactly 4 characters long

The following i think is more accurate

const atLeastOneUpperCasePattern = '(?=.*?[A-Z])'
const atLeastOneLowerCasePattern = '(?=.*?[a-z])'
const atLeastOneNumericPattern = '(?=.*?[0-9])'
const atLeastOneSpecialCharPattern = '(?=.*?[#?!@$%^&*-])'

const passwordPattern = `^${atLeastOneUpperCasePattern}${atLeastOneLowerCasePattern}${atLeastOneNumericPattern}${atLeastOneSpecialCharPattern}.*$`

Also, the minimum length rule is duplicated in both the regex pattern and the JSON Schema definition, i'll keep the rules from the schema.

May be we have specific reason to keep both ?

Copy link
Member

Choose a reason for hiding this comment

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

the pattern defined only match strings that are exactly 4 characters long

No, it matches any string that is 8 characters or longer. See the last part .{8,}.
image

the minimum length rule is duplicated in both the regex pattern and the JSON Schema definition, i'll keep the rules from the schema.

By looking at the pattern, it is very hard to understand how it works and does it limit the length.
The minLength is used here for ensure the string is at least 8 characters and faster understanding on it.
I don't knows which one take higher priority to check in ajv but more details is better.

Copy link
Author

Choose a reason for hiding this comment

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

"The minLength is used here for ensure the string is at least 8 characters and faster understanding on it."

True, thanks for your answer

})

assert.strictEqual(res.statusCode, 400)
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'body/newPassword must match pattern "^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$"' })
Copy link
Author

Choose a reason for hiding this comment

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

I should find a way to return a more explicit error message

Copy link
Contributor

Choose a reason for hiding this comment

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

Password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character (#?!@$%^&*-).

@jean-michelet
Copy link
Contributor

Code coverage is not reached, meaning some part of the code was not executed during test.

@Mathieuka Mathieuka force-pushed the add-auth-update-endpoint branch 3 times, most recently from 68c3bb2 to bac1131 Compare November 3, 2024 16:23
Comment on lines 16 to 23
errorResponseBuilder: function (_, context) {
return {
statusCode: 429,
error: 'Too Many Requests',
message: `You have reached the request limit. Please try again in ${Math.floor(context.ttl / 1000)} seconds.`,
date: new Date().toISOString(),
retryAfter: context.ttl
}
Copy link
Contributor

Choose a reason for hiding this comment

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

You leverage errorResponseBuilder but the message looks like the default one.
You can write a more explicit message regarding password update or you could just pass the needed configuration and let the plugin build the error for you.

Copy link
Author

Choose a reason for hiding this comment

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

No need to override the built in error message, I'll delete it

Comment on lines 3 to 8
const atLeastOneUpperCasePattern = '(?=.*?[A-Z])'
const atLeastOneLowerCasePattern = '(?=.*?[a-z])'
const atLeastOneNumericPattern = '(?=.*?[0-9])'
const atLeastOneSpecialCharPattern = '(?=.*?[#?!@$%^&*-])'

const passwordPattern = `^${atLeastOneUpperCasePattern}${atLeastOneLowerCasePattern}${atLeastOneNumericPattern}${atLeastOneSpecialCharPattern}.*$`
Copy link
Contributor

Choose a reason for hiding this comment

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

Not fundamentally opposed, but imo the initial regex is very simple to read, the concatenation is disturbing.

const atLeastOneSpecialCharPattern = '(?=.*?[#?!@$%^&*-])'

const passwordPattern = `^${atLeastOneUpperCasePattern}${atLeastOneLowerCasePattern}${atLeastOneNumericPattern}${atLeastOneSpecialCharPattern}.*$`
const Password = Type.String({
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const Password = Type.String({
const PasswordSchema = Type.String({


describe('User API', () => {
// Hashed value of `Password123$`
const Password123$ = 'ff57faf149a2bcab41bf7ecbbc8ce491.3ce6b34ea3edb3f0a09f811440885bfeda612832c04bfddc9d4b906019d97fa0'
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
const Password123$ = 'ff57faf149a2bcab41bf7ecbbc8ce491.3ce6b34ea3edb3f0a09f811440885bfeda612832c04bfddc9d4b906019d97fa0'
const hash = 'ff57faf149a2bcab41bf7ecbbc8ce491.3ce6b34ea3edb3f0a09f811440885bfeda612832c04bfddc9d4b906019d97fa0'

Can we just hash it with scryptHash function? If we change the algorithm implementation, this hash will become obsolete, this is not needed.

Copy link
Author

Choose a reason for hiding this comment

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

Good point

Comment on lines 50 to 52
if (!user) {
return reply.code(401).send({ message: 'User does not exist.' })
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we are 99.999% sure that the user exist, as we should have checked session before...
The only reason for that query to return null is if that request.session.user has been altered for some reason.

Personally, that's why I don't like the idea of working only with the decorator for the session, maybe we should create a getter that acts as a TypeScript guard:

// Request decorator
function getAuth (this: FastifyRequest): Auth {
  if (!this.session.user) {
    throw this.server.httpErrors.unauthorized()
  }

  return this.session.user
}

@climba03003 @melroy89 @Fdawgs
How do you handle that?

Copy link
Author

Choose a reason for hiding this comment

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

Isn't it possible for a user to be deleted between the time they authenticate and the time they try to change their password?

By themselves or an admin

99.999% -> ?%

Copy link
Contributor

@jean-michelet jean-michelet Nov 6, 2024

Choose a reason for hiding this comment

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

Isn't it possible for a user to be deleted between the time they authenticate and the time they try to change their password?

There is no user administration for now, but this is a valid point. I think we should be able to invalidate/update a session when we remove a user or change any property that is related to authentication like user_roles.
Maybe we can use Redis to store session with user id so we can write to it.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe we can use Redis to store session

I will create an issue about this.

Copy link
Member

Choose a reason for hiding this comment

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

How do you handle that?

I use constraint to derive the route handler before, but currently I handle it inside one handler.
The user information is retrieved way before the handler and I would use it as reference.
Something like requset.user if you have logged in.

Comment on lines 30 to 37
const users = ['random-user-0', 'random-user-1', 'random-user-2', 'random-user-3', 'random-user-4', 'random-user-5']

for (const user of users) {
await createUser(app, {
username: user,
password: Password123$
})
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't understand why you create all the users here, than recreate them individually in all the tests.

Copy link
Author

@Mathieuka Mathieuka Nov 6, 2024

Choose a reason for hiding this comment

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

This is an oversight, I will delete it and keep the preparation at the unit test level

assert.strictEqual(res.statusCode, 200)
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Password updated successfully' })

await deleteUser(app, 'random-user-0')
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do you delete the users at the end of each test?
Just clean all of them in after hook when you are done.

Copy link
Author

Choose a reason for hiding this comment

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

Tests should be run in isolation as much as possible, shared dependencies across preparation hooks are hell.

But if that's a style you prefer, I can use hooks

Copy link
Contributor

Choose a reason for hiding this comment

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

I told you that because you were creating all the users in the before hook, but I agree with you.

Comment on lines 105 to 115
const updatePassword = async () => {
return await updatePasswordWithLoginInjection(app, 'random-user-5', {
currentPassword: 'WrongPassword123$',
newPassword: 'Password123$'
})
}

await updatePassword()
await updatePassword()
await updatePassword()
const res = await updatePassword()
Copy link
Contributor

Choose a reason for hiding this comment

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

You can also do a simple for loop.

const passwordPattern = '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$'

const PasswordSchema = Type.String({
pattern: passwordPattern,
Copy link
Author

@Mathieuka Mathieuka Nov 6, 2024

Choose a reason for hiding this comment

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

@climba03003 @jean-michelet

Currently I don't think there is a way to customize the error message in case the payload doesn't match the pattern directly via the Type Builder Typebox.

I managed to do this via implementing fastify.setErrorHandler in the password update endpoint configuration, have you ever encountered this problem ?

Is it correct to manage the error message in a custom way at the endpoint ?

Copy link
Member

Choose a reason for hiding this comment

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

Is it correct to manage the error message in a custom way at the endpoint ?

You can using ajv-errors, so the message can be customize per schema.
https://www.npmjs.com/package/ajv-errors

Copy link
Author

@Mathieuka Mathieuka Nov 9, 2024

Choose a reason for hiding this comment

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

@climba03003

config:
locally Node v20.12.2
ajv-errors 3.0.0

I have installed and attempted to integrate ajv-errors with Fastify's AJV plugin, but it seems there is an issue when we add the errorMessage keyword in the TypeBox or AJV schema.

My research is based on the Fastify documentation

And I think an obsolete implementation example in the documentation, more than 3 years example

// Excerpt from the documentation
const fastify = Fastify({
  ajv: {
    customOptions: {
      jsonPointers: true,  // `jsonPointers` is not still part of `AjvOptions` in Fastify is replaced by `jsPropertySyntax` 
      allErrors: true
    },
    plugins: [
      require('ajv-errors')
    ]
  }
})

@jean-michelet If this is the case, I suggest creating an issue to update this part of documentation.

I have create a specific commit as an example of implementation a304da3

// Error thrown by the server
FastifyError [Error]: Failed building the validation schema for PUT: /api/user/update-password, due to error strict mode: unknown keyword: "errorMessage"

Copy link
Author

@Mathieuka Mathieuka Nov 16, 2024

Choose a reason for hiding this comment

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

@jean-michelet

Regarding the custom error message for AJV formatting, I haven't found a solution, and it seems that @climba03003 hasn't either ? as they approved the PR following my message #48 (comment)

As for SonarQube's warning about duplication, that's code I don't want to abstract and SonarQube doesn't know the difference

Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Member

Choose a reason for hiding this comment

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

We can review on the dashboard and claim it is fixed or safe.

Copy link
Contributor

Choose a reason for hiding this comment

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

@Eomm
Is it possible to give these rights to the Fastify members that collaborates on this repo?
Besides me, I am aware of @climba03003 and @Fdawgs.

Copy link
Member

Choose a reason for hiding this comment

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

I believe it can identify the Github account and give the correct permission?
Because I have updated the sonarcloud issue when I using the Github OAuth.

@Mathieuka Mathieuka marked this pull request as ready for review November 16, 2024 18:21
@jean-michelet
Copy link
Contributor

jean-michelet commented Nov 18, 2024

In RESTful architecture, resource naming conventions recommend using plural nouns for resource names.
Plz, change user for users, with s.

- Added a new schema `UpdateCredentialsSchema` for updating user credentials
- Change unauthorized response to return status code 401 and a message when user does not exist.
- Removed the errorResponseBuilder function from the user route configuration.
- Simplify the password pattern regex to require at least one uppercase, one lowercase, one numeric, and one special character
- Added rate limiting configuration to limit requests to 3 per minute.
- Rename `user` to `users` in file paths
- Update describe block from 'User API' to 'Users API'
- Update tags from 'User' to 'Users' in API endpoints definition
- Remove rate limiting configuration from the home route to simplify the code and improve performance.
- Updated loop condition from `< 3` to `< 4` to match new rate limit of 4 requests
Copy link

sonarcloud bot commented Nov 21, 2024

Quality Gate Failed Quality Gate failed

Failed conditions
15.2% Duplication on New Code (required ≤ 3%)

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Create endpoints to allow users to update their profile information and passwords.
3 participants