Skip to content

Commit

Permalink
Add auth update endpoint (#48)
Browse files Browse the repository at this point in the history
* 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
Mathieuka authored Dec 27, 2024
1 parent 472f39f commit 72956f3
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 4 deletions.
2 changes: 1 addition & 1 deletion scripts/seed-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ async function truncateTables (connection: Connection) {

async function seedUsers (connection: Connection) {
const usernames = ['basic', 'moderator', 'admin']
const hash = await scryptHash('password123$')
const hash = await scryptHash('Password123$')

// The goal here is to create a role hierarchy
// E.g. an admin should have all the roles
Expand Down
74 changes: 74 additions & 0 deletions src/routes/api/users/index.ts
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
14 changes: 14 additions & 0 deletions src/schemas/users.ts
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
})
2 changes: 1 addition & 1 deletion test/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ async function login (this: FastifyInstance, username: string) {
url: '/api/auth/login',
payload: {
username,
password: 'password123$'
password: 'Password123$'
}
})

Expand Down
4 changes: 2 additions & 2 deletions test/routes/api/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ test('Transaction should rollback on error', async (t) => {
url: '/api/auth/login',
payload: {
username: 'basic',
password: 'password123$'
password: 'Password123$'
}
})

Expand All @@ -39,7 +39,7 @@ test('POST /api/auth/login with valid credentials', async (t) => {
url: '/api/auth/login',
payload: {
username: 'basic',
password: 'password123$'
password: 'Password123$'
}
})

Expand Down
192 changes: 192 additions & 0 deletions test/routes/api/users/users.test.ts
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')
})
})

0 comments on commit 72956f3

Please sign in to comment.