Skip to content

Commit 72956f3

Browse files
authored
Add auth update endpoint (#48)
* 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
1 parent 472f39f commit 72956f3

File tree

6 files changed

+284
-4
lines changed

6 files changed

+284
-4
lines changed

scripts/seed-database.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ async function truncateTables (connection: Connection) {
4848

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

5353
// The goal here is to create a role hierarchy
5454
// E.g. an admin should have all the roles

src/routes/api/users/index.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {
2+
FastifyPluginAsyncTypebox,
3+
Type
4+
} from '@fastify/type-provider-typebox'
5+
import { Auth } from '../../../schemas/auth.js'
6+
import { UpdateCredentialsSchema } from '../../../schemas/users.js'
7+
8+
const plugin: FastifyPluginAsyncTypebox = async (fastify) => {
9+
fastify.put(
10+
'/update-password',
11+
{
12+
config: {
13+
rateLimit: {
14+
max: 3,
15+
timeWindow: '1 minute'
16+
}
17+
},
18+
schema: {
19+
body: UpdateCredentialsSchema,
20+
response: {
21+
200: Type.Object({
22+
message: Type.String()
23+
}),
24+
401: Type.Object({
25+
message: Type.String()
26+
})
27+
},
28+
tags: ['Users']
29+
}
30+
},
31+
async function (request, reply) {
32+
const { newPassword, currentPassword } = request.body
33+
const username = request.session.user.username
34+
35+
try {
36+
const user = await fastify.knex<Auth>('users')
37+
.select('username', 'password')
38+
.where({ username })
39+
.first()
40+
41+
if (!user) {
42+
return reply.code(401).send({ message: 'User does not exist.' })
43+
}
44+
45+
const isPasswordValid = await fastify.compare(
46+
currentPassword,
47+
user.password
48+
)
49+
50+
if (!isPasswordValid) {
51+
return reply.code(401).send({ message: 'Invalid current password.' })
52+
}
53+
54+
if (newPassword === currentPassword) {
55+
reply.status(400)
56+
return { message: 'New password cannot be the same as the current password.' }
57+
}
58+
59+
const hashedPassword = await fastify.hash(newPassword)
60+
await fastify.knex('users')
61+
.update({
62+
password: hashedPassword
63+
})
64+
.where({ username })
65+
66+
return { message: 'Password updated successfully' }
67+
} catch (error) {
68+
reply.internalServerError()
69+
}
70+
}
71+
)
72+
}
73+
74+
export default plugin

src/schemas/users.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Type } from '@sinclair/typebox'
2+
3+
const passwordPattern = '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$'
4+
5+
const PasswordSchema = Type.String({
6+
pattern: passwordPattern,
7+
minLength: 8
8+
9+
})
10+
11+
export const UpdateCredentialsSchema = Type.Object({
12+
currentPassword: PasswordSchema,
13+
newPassword: PasswordSchema
14+
})

test/helper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ async function login (this: FastifyInstance, username: string) {
2727
url: '/api/auth/login',
2828
payload: {
2929
username,
30-
password: 'password123$'
30+
password: 'Password123$'
3131
}
3232
})
3333

test/routes/api/auth/auth.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ test('Transaction should rollback on error', async (t) => {
1717
url: '/api/auth/login',
1818
payload: {
1919
username: 'basic',
20-
password: 'password123$'
20+
password: 'Password123$'
2121
}
2222
})
2323

@@ -39,7 +39,7 @@ test('POST /api/auth/login with valid credentials', async (t) => {
3939
url: '/api/auth/login',
4040
payload: {
4141
username: 'basic',
42-
password: 'password123$'
42+
password: 'Password123$'
4343
}
4444
})
4545

test/routes/api/users/users.test.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { it, describe, beforeEach, afterEach } from 'node:test'
2+
import assert from 'node:assert'
3+
import { build } from '../../../helper.js'
4+
import { FastifyInstance } from 'fastify'
5+
import { scryptHash } from '../../../../src/plugins/custom/scrypt.js'
6+
7+
async function createUser (app: FastifyInstance, userData: Partial<{ username: string; password: string }>) {
8+
const [id] = await app.knex('users').insert(userData)
9+
return id
10+
}
11+
12+
async function deleteUser (app: FastifyInstance, username: string) {
13+
await app.knex('users').delete().where({ username })
14+
}
15+
16+
async function updatePasswordWithLoginInjection (app: FastifyInstance, username: string, payload: { currentPassword: string; newPassword: string }) {
17+
return app.injectWithLogin(username, {
18+
method: 'PUT',
19+
url: '/api/users/update-password',
20+
payload
21+
})
22+
}
23+
24+
describe('Users API', async () => {
25+
const hash = await scryptHash('Password123$')
26+
let app: FastifyInstance
27+
28+
beforeEach(async () => {
29+
app = await build()
30+
})
31+
32+
afterEach(async () => {
33+
await app.close()
34+
})
35+
36+
it('Should enforce rate limiting by returning a 429 status after exceeding 3 password update attempts within 1 minute', async () => {
37+
await createUser(app, { username: 'random-user-0', password: hash })
38+
39+
const loginResponse = await app.injectWithLogin('random-user-0', {
40+
method: 'POST',
41+
url: '/api/auth/login',
42+
payload: {
43+
username: 'random-user-0',
44+
password: 'Password123$'
45+
}
46+
})
47+
48+
app.config = {
49+
...app.config,
50+
COOKIE_SECRET: loginResponse.cookies[0].value
51+
}
52+
53+
for (let i = 0; i < 3; i++) {
54+
const resInner = await app.inject({
55+
method: 'PUT',
56+
url: '/api/users/update-password',
57+
payload: {
58+
currentPassword: 'Password1234$',
59+
newPassword: 'Password123$'
60+
},
61+
cookies: {
62+
[app.config.COOKIE_NAME]: loginResponse.cookies[0].value
63+
}
64+
})
65+
66+
assert.strictEqual(resInner.statusCode, 401)
67+
}
68+
69+
const res = await app.inject({
70+
method: 'PUT',
71+
url: '/api/users/update-password',
72+
payload: {
73+
currentPassword: 'Password1234$',
74+
newPassword: 'Password123$'
75+
},
76+
cookies: {
77+
[app.config.COOKIE_NAME]: loginResponse.cookies[0].value
78+
}
79+
})
80+
81+
assert.strictEqual(res.statusCode, 429)
82+
await deleteUser(app, 'random-user-0')
83+
})
84+
85+
it('Should update the password successfully', async () => {
86+
await createUser(app, { username: 'random-user-1', password: hash })
87+
const res = await updatePasswordWithLoginInjection(app, 'random-user-1', {
88+
currentPassword: 'Password123$',
89+
newPassword: 'NewPassword123$'
90+
})
91+
92+
assert.strictEqual(res.statusCode, 200)
93+
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Password updated successfully' })
94+
95+
await deleteUser(app, 'random-user-1')
96+
})
97+
98+
it('Should return 400 if the new password is the same as current password', async () => {
99+
await createUser(app, { username: 'random-user-2', password: hash })
100+
const res = await updatePasswordWithLoginInjection(app, 'random-user-2', {
101+
currentPassword: 'Password123$',
102+
newPassword: 'Password123$'
103+
})
104+
105+
assert.strictEqual(res.statusCode, 400)
106+
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'New password cannot be the same as the current password.' })
107+
108+
await deleteUser(app, 'random-user-2')
109+
})
110+
111+
it('Should return 400 if the newPassword password not match the required pattern', async () => {
112+
await createUser(app, { username: 'random-user-3', password: hash })
113+
const res = await updatePasswordWithLoginInjection(app, 'random-user-3', {
114+
currentPassword: 'Password123$',
115+
newPassword: 'password123$'
116+
})
117+
118+
assert.strictEqual(res.statusCode, 400)
119+
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'body/newPassword must match pattern "^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$"' })
120+
121+
await deleteUser(app, 'random-user-3')
122+
})
123+
124+
it('Should return 401 the current password is incorrect', async () => {
125+
await createUser(app, { username: 'random-user-4', password: hash })
126+
const res = await updatePasswordWithLoginInjection(app, 'random-user-4', {
127+
currentPassword: 'WrongPassword123$',
128+
newPassword: 'Password123$'
129+
})
130+
131+
assert.strictEqual(res.statusCode, 401)
132+
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Invalid current password.' })
133+
134+
await deleteUser(app, 'random-user-4')
135+
})
136+
137+
it('Should return 401 if user does not exist in the database', async () => {
138+
await createUser(app, { username: 'random-user-5', password: hash })
139+
const loginResponse = await app.injectWithLogin('random-user-5', {
140+
method: 'POST',
141+
url: '/api/auth/login',
142+
payload: {
143+
username: 'random-user-5',
144+
password: 'Password123$'
145+
}
146+
})
147+
148+
assert.strictEqual(loginResponse.statusCode, 200)
149+
150+
await deleteUser(app, 'random-user-5')
151+
console.log('%c LOG loginResponse.cookies', 'background: #222; color: #bada55', loginResponse.cookies)
152+
app.config = {
153+
...app.config,
154+
COOKIE_SECRET: loginResponse.cookies[0].value
155+
}
156+
157+
const res = await app.inject({
158+
method: 'PUT',
159+
url: '/api/users/update-password',
160+
payload: {
161+
currentPassword: 'Password123$',
162+
newPassword: 'NewPassword123$'
163+
},
164+
cookies: {
165+
[app.config.COOKIE_NAME]: loginResponse.cookies[0].value
166+
}
167+
})
168+
169+
assert.strictEqual(res.statusCode, 401)
170+
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'User does not exist.' })
171+
await deleteUser(app, 'random-user-5')
172+
})
173+
174+
it('Should handle errors gracefully and return 500 Internal Server Error when an unexpected error occurs', async (t) => {
175+
const { mock: mockKnex } = t.mock.method(app, 'hash')
176+
mockKnex.mockImplementation(() => {
177+
throw new Error()
178+
})
179+
180+
await createUser(app, { username: 'random-user-6', password: hash })
181+
182+
const res = await updatePasswordWithLoginInjection(app, 'random-user-6', {
183+
currentPassword: 'Password123$',
184+
newPassword: 'NewPassword123$'
185+
})
186+
187+
assert.strictEqual(res.statusCode, 500)
188+
assert.deepStrictEqual(JSON.parse(res.payload), { message: 'Internal Server Error' })
189+
190+
await deleteUser(app, 'random-user-6')
191+
})
192+
})

0 commit comments

Comments
 (0)