-
-
Notifications
You must be signed in to change notification settings - Fork 17
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat(schemas): add UpdateCredentialsSchema - Added a new schema `UpdateCredentialsSchema` for updating user credentials * feat(api): add update credentials endpoint - Added new `/update` endpoint for updating user credentials - Added `UpdateCredentialsSchema` for request body validation - Updated logic to hash and update user password - Return success message when password is updated successfully * feat(schemas): add UpdateCredentialsSchema - Added new schema `UpdateCredentialsSchema` to handle user credentials updates in users.ts - Removed `UpdateCredentialsSchema` from auth.ts for separation of concerns and better organization of schemas. * refactor(auth): remove update endpoint * style(src): update users schema format - Update `UpdateCredentialsSchema` in `src/schemas/users.ts` to include `currentPassword` and `newPassword` properties * feat(api): add endpoint to update user password - Added a new endpoint '/update-password' to allow users to update their password * chore(routes): update tags in users route * fix(api): remove transaction to simplify the code * chore(api): rename user to singular * feat(api): add rate limiting to update-password route * refactor: Implement early return if user or password is falsy * fix(routes): prevent setting new password same as current password - If the passwords are the same, return a 400 status with a message indicating the issue * chore(schemas): update password validation pattern * fix(api): improve error handling - Return unauthorized status if user is not found * refactor: password validation logic * fix(auth): fix password case to match the password pattern * fix(schemas): update password pattern validation * feat: validate current password before allowing password update * feat(api): add test cases for user password update * test: newPassword should match the required pattern * test: refactor * test: should update the password successfully * feat: remove comment * fix(api): update unauthorized response - Change unauthorized response to return status code 401 and a message when user does not exist. * fix(user): update user.test.ts to include new test cases * test: isolate test by seeding users * test: add unit test to verify rate limiting for password update requests * refactor(test): refactor user creation loop * test: improve variable naming * fix(api): refactor user.test.ts * refactor: rename helper function for updating password injection * refactor: remove errorResponseBuilder from user route - Removed the errorResponseBuilder function from the user route configuration. * refactor: simplify password pattern regex - Simplify the password pattern regex to require at least one uppercase, one lowercase, one numeric, and one special character * refactor: rename Password to PasswordSchema * test: use scryptHash * test: create and delete user at the test level * test: add abstraction to rate limiting testing * chore: ajv-errors integration code example * feat: Remove ajv-errors and related implementation * feat(routes): add rate limiting to home route - Added rate limiting configuration to limit requests to 3 per minute. * refactor(api): remove redundant return statement * refactor(api): rename user to users - 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 * refactor(routes): remove rate limiting configuration - Remove rate limiting configuration from the home route to simplify the code and improve performance. * fix(test): update rate limit test to match new rate limit - Updated loop condition from `< 3` to `< 4` to match new rate limit of 4 requests * test: Add error handling test * feat: Add additional error responses to user API routes * test: Remove unnecessary await in user password update tests * feat: Remove HTTP sensitive status codes from user API schema * test: isolate rate limit test * test: refactor rate limit
- Loading branch information
Showing
6 changed files
with
284 additions
and
4 deletions.
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() | ||
} | ||
} | ||
) | ||
} | ||
|
||
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,192 @@ | ||
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 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 enforce rate limiting by returning a 429 status after exceeding 3 password update attempts within 1 minute', async () => { | ||
await createUser(app, { username: 'random-user-0', password: hash }) | ||
|
||
const loginResponse = await app.injectWithLogin('random-user-0', { | ||
method: 'POST', | ||
url: '/api/auth/login', | ||
payload: { | ||
username: 'random-user-0', | ||
password: 'Password123$' | ||
} | ||
}) | ||
|
||
app.config = { | ||
...app.config, | ||
COOKIE_SECRET: loginResponse.cookies[0].value | ||
} | ||
|
||
for (let i = 0; i < 3; i++) { | ||
const resInner = await app.inject({ | ||
method: 'PUT', | ||
url: '/api/users/update-password', | ||
payload: { | ||
currentPassword: 'Password1234$', | ||
newPassword: 'Password123$' | ||
}, | ||
cookies: { | ||
[app.config.COOKIE_NAME]: loginResponse.cookies[0].value | ||
} | ||
}) | ||
|
||
assert.strictEqual(resInner.statusCode, 401) | ||
} | ||
|
||
const res = await app.inject({ | ||
method: 'PUT', | ||
url: '/api/users/update-password', | ||
payload: { | ||
currentPassword: 'Password1234$', | ||
newPassword: 'Password123$' | ||
}, | ||
cookies: { | ||
[app.config.COOKIE_NAME]: loginResponse.cookies[0].value | ||
} | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 429) | ||
await deleteUser(app, 'random-user-0') | ||
}) | ||
|
||
it('Should update the password successfully', async () => { | ||
await createUser(app, { username: 'random-user-1', password: hash }) | ||
const res = await updatePasswordWithLoginInjection(app, 'random-user-1', { | ||
currentPassword: 'Password123$', | ||
newPassword: 'NewPassword123$' | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 200) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Password updated successfully' }) | ||
|
||
await deleteUser(app, 'random-user-1') | ||
}) | ||
|
||
it('Should return 400 if the new password is the same as current password', 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: 'New password cannot be the same as the current password.' }) | ||
|
||
await deleteUser(app, 'random-user-2') | ||
}) | ||
|
||
it('Should return 400 if the newPassword password not match the required pattern', async () => { | ||
await createUser(app, { username: 'random-user-3', password: hash }) | ||
const res = await updatePasswordWithLoginInjection(app, 'random-user-3', { | ||
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-3') | ||
}) | ||
|
||
it('Should return 401 the current password is incorrect', async () => { | ||
await createUser(app, { username: 'random-user-4', password: hash }) | ||
const res = await updatePasswordWithLoginInjection(app, 'random-user-4', { | ||
currentPassword: 'WrongPassword123$', | ||
newPassword: 'Password123$' | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 401) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Invalid current password.' }) | ||
|
||
await deleteUser(app, 'random-user-4') | ||
}) | ||
|
||
it('Should return 401 if user does not exist in the database', async () => { | ||
await createUser(app, { username: 'random-user-5', password: hash }) | ||
const loginResponse = await app.injectWithLogin('random-user-5', { | ||
method: 'POST', | ||
url: '/api/auth/login', | ||
payload: { | ||
username: 'random-user-5', | ||
password: 'Password123$' | ||
} | ||
}) | ||
|
||
assert.strictEqual(loginResponse.statusCode, 200) | ||
|
||
await deleteUser(app, 'random-user-5') | ||
console.log('%c LOG loginResponse.cookies', 'background: #222; color: #bada55', loginResponse.cookies) | ||
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-5') | ||
}) | ||
|
||
it('Should handle errors gracefully and return 500 Internal Server Error when an unexpected error occurs', async (t) => { | ||
const { mock: mockKnex } = t.mock.method(app, 'hash') | ||
mockKnex.mockImplementation(() => { | ||
throw new Error() | ||
}) | ||
|
||
await createUser(app, { username: 'random-user-6', password: hash }) | ||
|
||
const res = await updatePasswordWithLoginInjection(app, 'random-user-6', { | ||
currentPassword: 'Password123$', | ||
newPassword: 'NewPassword123$' | ||
}) | ||
|
||
assert.strictEqual(res.statusCode, 500) | ||
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Internal Server Error' }) | ||
|
||
await deleteUser(app, 'random-user-6') | ||
}) | ||
}) |