-
-
Notifications
You must be signed in to change notification settings - Fork 15
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
Mathieuka
wants to merge
44
commits into
fastify:main
Choose a base branch
from
Mathieuka:add-auth-update-endpoint
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
44 commits
Select commit
Hold shift + click to select a range
278ce7d
feat(schemas): add UpdateCredentialsSchema
Mathieuka dacae2b
feat(api): add update credentials endpoint
Mathieuka 926db83
feat(schemas): add UpdateCredentialsSchema
Mathieuka 571fa57
refactor(auth): remove update endpoint
Mathieuka c407db6
style(src): update users schema format
Mathieuka ac8f341
feat(api): add endpoint to update user password
Mathieuka 2f98620
chore(routes): update tags in users route
Mathieuka 58fbfb5
fix(api): remove transaction to simplify the code
Mathieuka 0b68ee0
chore(api): rename user to singular
Mathieuka 1dbd8f3
feat(api): add rate limiting to update-password route
Mathieuka ba0e63e
refactor: Implement early return if user or password is falsy
Mathieuka 90ec6b7
fix(routes): prevent setting new password same as current password
Mathieuka 4fc8904
chore(schemas): update password validation pattern
Mathieuka 8919cf6
fix(api): improve error handling
Mathieuka fc7ee3d
refactor: password validation logic
Mathieuka a584645
fix(auth): fix password case to match the password pattern
Mathieuka b1bc64d
fix(schemas): update password pattern validation
Mathieuka 9a6e66a
feat: validate current password before allowing password update
Mathieuka 3af77a7
feat(api): add test cases for user password update
Mathieuka 41d5a78
test: newPassword should match the required pattern
Mathieuka afe2f5a
test: refactor
Mathieuka 50bf928
test: should update the password successfully
Mathieuka b719403
feat: remove comment
Mathieuka d55b31c
fix(api): update unauthorized response
Mathieuka 3ebe20f
fix(user): update user.test.ts to include new test cases
Mathieuka 1ef8c8a
test: isolate test by seeding users
Mathieuka e4cabd6
test: add unit test to verify rate limiting for password update requests
Mathieuka cb017ea
refactor(test): refactor user creation loop
Mathieuka 0e68537
test: improve variable naming
Mathieuka 259bbf7
fix(api): refactor user.test.ts
Mathieuka 27ae3e2
refactor: rename helper function for updating password injection
Mathieuka dec5bc3
refactor: remove errorResponseBuilder from user route
Mathieuka 213e4b8
refactor: simplify password pattern regex
Mathieuka 486488a
refactor: rename Password to PasswordSchema
Mathieuka 29cc091
test: use scryptHash
Mathieuka 5ca3969
test: create and delete user at the test level
Mathieuka ffe5522
test: add abstraction to rate limiting testing
Mathieuka 07fd4c2
chore: ajv-errors integration code example
Mathieuka f10522b
feat: Remove ajv-errors and related implementation
Mathieuka 57da679
feat(routes): add rate limiting to home route
Mathieuka 56d775b
refactor(api): remove redundant return statement
Mathieuka 9326a67
refactor(api): rename user to users
Mathieuka 5b456a6
refactor(routes): remove rate limiting configuration
Mathieuka 6059bae
fix(test): update rate limit test to match new rate limit
Mathieuka File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import { | ||
FastifyPluginAsyncTypebox, | ||
Type | ||
} from '@fastify/type-provider-typebox' | ||
import { Auth } from '../../../schemas/auth.js' | ||
import { UpdateCredentialsSchema } from '../../../schemas/users.js' | ||
|
||
const plugin: FastifyPluginAsyncTypebox = async (fastify) => { | ||
fastify.put( | ||
'/update-password', | ||
{ | ||
config: { | ||
rateLimit: { | ||
max: 3, | ||
timeWindow: '1 minute' | ||
} | ||
}, | ||
schema: { | ||
body: UpdateCredentialsSchema, | ||
response: { | ||
200: Type.Object({ | ||
message: Type.String() | ||
}), | ||
401: Type.Object({ | ||
message: Type.String() | ||
}) | ||
}, | ||
tags: ['Users'] | ||
} | ||
}, | ||
async function (request, reply) { | ||
const { newPassword, currentPassword } = request.body | ||
const username = request.session.user.username | ||
|
||
try { | ||
const user = await fastify.knex<Auth>('users') | ||
.select('username', 'password') | ||
.where({ username }) | ||
.first() | ||
|
||
if (!user) { | ||
return reply.code(401).send({ message: 'User does not exist.' }) | ||
} | ||
|
||
const isPasswordValid = await fastify.compare( | ||
currentPassword, | ||
user.password | ||
) | ||
|
||
if (!isPasswordValid) { | ||
return reply.code(401).send({ message: 'Invalid current password.' }) | ||
} | ||
|
||
if (newPassword === currentPassword) { | ||
reply.status(400) | ||
return { message: 'New password cannot be the same as the current password.' } | ||
} | ||
|
||
const hashedPassword = await fastify.hash(newPassword) | ||
await fastify.knex('users') | ||
.update({ | ||
password: hashedPassword | ||
}) | ||
.where({ username }) | ||
|
||
return { message: 'Password updated successfully' } | ||
} catch (error) { | ||
reply.internalServerError('An error occurred while updating the password.') | ||
} | ||
} | ||
) | ||
} | ||
|
||
export default plugin |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
import { Type } from '@sinclair/typebox' | ||
|
||
const passwordPattern = '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$' | ||
|
||
const PasswordSchema = Type.String({ | ||
pattern: passwordPattern, | ||
minLength: 8 | ||
|
||
}) | ||
|
||
export const UpdateCredentialsSchema = Type.Object({ | ||
currentPassword: PasswordSchema, | ||
newPassword: PasswordSchema | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
import { it, describe, beforeEach, afterEach } from 'node:test' | ||
import assert from 'node:assert' | ||
import { build } from '../../../helper.js' | ||
import { FastifyInstance } from 'fastify' | ||
import { scryptHash } from '../../../../src/plugins/custom/scrypt.js' | ||
|
||
async function createUser (app: FastifyInstance, userData: Partial<{ username: string; password: string }>) { | ||
const [id] = await app.knex('users').insert(userData) | ||
return id | ||
} | ||
|
||
async function deleteUser (app: FastifyInstance, username: string) { | ||
await app.knex('users').delete().where({ username }) | ||
} | ||
|
||
async function updatePasswordWithLoginInjection (app: FastifyInstance, username: string, payload: { currentPassword: string; newPassword: string }) { | ||
return await app.injectWithLogin(username, { | ||
method: 'PUT', | ||
url: '/api/users/update-password', | ||
payload | ||
}) | ||
} | ||
|
||
describe('Users API', async () => { | ||
const hash = await scryptHash('Password123$') | ||
let app: FastifyInstance | ||
|
||
beforeEach(async () => { | ||
app = await build() | ||
}) | ||
|
||
afterEach(async () => { | ||
await app.close() | ||
}) | ||
|
||
it('Should update the password successfully', async () => { | ||
await createUser(app, { username: 'random-user-0', password: hash }) | ||
const res = await updatePasswordWithLoginInjection(app, 'random-user-0', { | ||
currentPassword: 'Password123$', | ||
newPassword: 'NewPassword123$' | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 200) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Password updated successfully' }) | ||
|
||
await deleteUser(app, 'random-user-0') | ||
}) | ||
|
||
it('Should return 400 if the new password is the same as current password', async () => { | ||
await createUser(app, { username: 'random-user-1', password: hash }) | ||
const res = await updatePasswordWithLoginInjection(app, 'random-user-1', { | ||
currentPassword: 'Password123$', | ||
newPassword: 'Password123$' | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 400) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'New password cannot be the same as the current password.' }) | ||
|
||
await deleteUser(app, 'random-user-1') | ||
}) | ||
|
||
it('Should return 400 if the newPassword password not match the required pattern', async () => { | ||
await createUser(app, { username: 'random-user-2', password: hash }) | ||
const res = await updatePasswordWithLoginInjection(app, 'random-user-2', { | ||
currentPassword: 'Password123$', | ||
newPassword: 'password123$' | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 400) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'body/newPassword must match pattern "^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$"' }) | ||
|
||
await deleteUser(app, 'random-user-2') | ||
}) | ||
|
||
it('Should return 401 the current password is incorrect', async () => { | ||
await createUser(app, { username: 'random-user-3', password: hash }) | ||
const res = await updatePasswordWithLoginInjection(app, 'random-user-3', { | ||
currentPassword: 'WrongPassword123$', | ||
newPassword: 'Password123$' | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 401) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Invalid current password.' }) | ||
|
||
await deleteUser(app, 'random-user-3') | ||
}) | ||
|
||
it('Should return 401 if user does not exist in the database', async () => { | ||
await createUser(app, { username: 'random-user-4', password: hash }) | ||
const loginResponse = await app.injectWithLogin('random-user-4', { | ||
method: 'POST', | ||
url: '/api/auth/login', | ||
payload: { | ||
username: 'random-user-4', | ||
password: 'Password123$' | ||
} | ||
}) | ||
|
||
assert.strictEqual(loginResponse.statusCode, 200) | ||
|
||
await deleteUser(app, 'random-user-4') | ||
|
||
app.config = { | ||
...app.config, | ||
COOKIE_SECRET: loginResponse.cookies[0].value | ||
} | ||
|
||
const res = await app.inject({ | ||
method: 'PUT', | ||
url: '/api/users/update-password', | ||
payload: { | ||
currentPassword: 'Password123$', | ||
newPassword: 'NewPassword123$' | ||
}, | ||
cookies: { | ||
[app.config.COOKIE_NAME]: loginResponse.cookies[0].value | ||
} | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 401) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'User does not exist.' }) | ||
await deleteUser(app, 'random-user-4') | ||
}) | ||
|
||
it('Should enforce rate limiting by returning a 429 status after exceeding 3 password update attempts within 1 minute', async () => { | ||
const updatePassword = async () => { | ||
return await updatePasswordWithLoginInjection(app, 'random-user-5', { | ||
currentPassword: 'WrongPassword123$', | ||
newPassword: 'Password123$' | ||
}) | ||
} | ||
|
||
const performMultiplePasswordUpdates = async () => { | ||
for (let i = 0; i < 3; i++) { | ||
await updatePassword() | ||
} | ||
} | ||
|
||
await createUser(app, { username: 'random-user-5', password: hash }) | ||
|
||
await performMultiplePasswordUpdates() | ||
|
||
const res = await updatePassword() | ||
|
||
assert.strictEqual(res.statusCode, 429) | ||
assert.equal(res.payload, '{"message":"Rate limit exceeded, retry in 1 minute"}') | ||
|
||
await deleteUser(app, 'random-user-5') | ||
}) | ||
}) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
@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 ?
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.
You can using
ajv-errors
, so the message can be customize per schema.https://www.npmjs.com/package/ajv-errors
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.
@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 theerrorMessage
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
@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
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.
@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
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.
Is there a way to comment the lines with false positives to avoid warnings?
https://sonarcloud.io/project/security_hotspots?id=fastify_demo&pullRequest=48&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true
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 can review on the dashboard and claim it is fixed or safe.
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.
@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.
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 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.