Skip to content

Commit

Permalink
Add backend validation when updating user and update documentation (#98)
Browse files Browse the repository at this point in the history
* Update user service README

* Add documentation for new routes to update username, email and password
* Modify examples to conform to format requirements
* Add new section detailing input format requirements
* Add input validation segment to relevant routes

* Add input validation for user service when updating user

* Fix linting

* Rename required_errors to requiredErrors

* Update user service README

* Shift endpoint input validation to be under body section
  • Loading branch information
McNaBry authored Nov 13, 2024
1 parent deb6415 commit fdd794d
Show file tree
Hide file tree
Showing 3 changed files with 189 additions and 29 deletions.
152 changes: 146 additions & 6 deletions services/user/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,12 @@

- Body
- Required: `username` (string), `email` (string), `password` (string)

- This endpoint validates whether the [username](#password), [email](#email) and [password](#password) supplied are of the correct format.
```json
{
"username": "SampleUserName",
"email": "[email protected]",
"password": "SecurePassword"
"password": "SecurePassword@3219"
}
```

Expand Down Expand Up @@ -135,12 +135,13 @@

- Body
- At least one of the following fields is required: `username` (string), `email` (string), `password` (string)
- This endpoint validates whether the [username](#password), [email](#email) or [password](#password) supplied are of the correct format.

```json
{
"username": "SampleUserName",
"email": "[email protected]",
"password": "SecurePassword"
"password": "SecurePassword@8"
}
```

Expand All @@ -157,13 +158,97 @@
| Response Code | Explanation |
|-----------------------------|---------------------------------------------------------|
| 200 (OK) | User updated successfully, updated user data returned |
| 400 (Bad Request) | Missing fields |
| 400 (Bad Request) | Missing/invalid fields |
| 401 (Unauthorized) | Access denied due to missing/invalid/expired JWT |
| 403 (Forbidden) | Access denied for non-admin users updating others' data |
| 404 (Not Found) | User with the specified ID not found |
| 409 (Conflict) | Duplicate username or email encountered |
| 500 (Internal Server Error) | Database or server error |

### Update Username and Email

- This endpoint allows updating a user's username and email using the user's ID given the correct password.

- HTTP Method: `PATCH`

- Endpoint: http://localhost:8082/api/user/users/username-email/{userId}

- Parameters
- Required: `userId` path parameter

- Body
- All of the following fields are required: `username` (string), `email` (string), `password` (string)
- This endpoint validates whether the [username](#username) and [email](#email) supplied are of the correct format.

```json
{
"username": "SampleUserName",
"email": "[email protected]",
"password": "SecurePassword@3219"
}
```

- Headers
- Required: `Authorization: Bearer <JWT_ACCESS_TOKEN>`
- Auth Rules:

- Admin users: Cannot update any user's data as it requires the user's password to authorize the request. Admin users should update user details via http://localhost:8082/api/user/users/{userId} instead.

- Non-admin users: Can only update their own data. The server checks if the user ID in the request URL matches the ID of the user associated with the JWT token. If it matches and the user supplies their own password correctly, the server updates the user's own data.

- Responses:

| Response Code | Explanation |
|-----------------------------|-----------------------------------------------------------------------------|
| 200 (OK) | User updated successfully, updated user data returned |
| 400 (Bad Request) | Missing/invalid fields |
| 401 (Unauthorized) | Access denied due to missing/invalid/expired JWT or wrong password |
| 403 (Forbidden) | Access denied for non-admin users updating others' data |
| 404 (Not Found) | User with the specified ID not found |
| 409 (Conflict) | Duplicate username or email encountered |
| 500 (Internal Server Error) | Database or server error |

### Update Password

- This endpoint allows updating a user's password given the correct old/original password.

- HTTP Method: `PATCH`

- Endpoint: http://localhost:8082/api/user/users/password/{userId}

- Parameters
- Required: `userId` path parameter

- Body
- All of the following fields are required: `oldPassword` (string), `newPassword` (string)
- This endpoint validates whether the [new password](#password) supplied is of the correct format.

```json
{
"oldPassword": "SecurePassword",
"newPassword": "SecurePassword@3219",
}
```

- Headers
- Required: `Authorization: Bearer <JWT_ACCESS_TOKEN>`
- Auth Rules:

- Admin users: Cannot update any user's data as it requires the user's password to authorize the request. Admin users should update user details via http://localhost:8082/api/user/users/{userId} instead.

- Non-admin users: Can only update their own data. The server checks if the user ID in the request URL matches the ID of the user associated with the JWT token. If it matches and the user supplies their own password correctly, the server updates the user's password.

- Responses:

| Response Code | Explanation |
|-----------------------------|-----------------------------------------------------------------------------|
| 200 (OK) | User updated successfully, updated user data returned |
| 400 (Bad Request) | Missing/invalid fields |
| 401 (Unauthorized) | Access denied due to missing/invalid/expired JWT or wrong original password |
| 403 (Forbidden) | Access denied for non-admin users updating others' data |
| 404 (Not Found) | User with the specified ID not found |
| 500 (Internal Server Error) | Database or server error |

### Update User Privilege

- This endpoint allows updating a user’s privilege, i.e., promoting or demoting them from admin status.
Expand Down Expand Up @@ -242,7 +327,7 @@
```json
{
"username": "sample123",
"password": "SecurePassword"
"password": "SecurePassword@3219"
}
```

Expand All @@ -269,4 +354,59 @@
|-----------------------------|----------------------------------------------------|
| 200 (OK) | Token verified, authenticated user's data returned |
| 401 (Unauthorized) | Missing/invalid/expired JWT |
| 500 (Internal Server Error) | Database or server error |
| 500 (Internal Server Error) | Database or server error |

## User Service Input Validation

This section outlines how the user service validates user details when creating or updating a user.

### Username
- Requirement(s)
- The username can only contain alphanumeric characters.

- Regex Used for Validation
- ```regex
/^[a-zA-Z0-9._-]+$/
```

- Valid Example
- ```
sampleUsername
```

### Email
- Requirement(s)
- The email cannot start with a period.
- The local part of the email cannot contain consecutive periods.
- The local part may include alphanumeric and specific special characters (`+_-.'`).
- The domain must start with an alphanumeric character
- The domain part must be alphanumeric and can include hyphens but not as the first or last character.
- The top-level domain must be composed of letters only and have at least 2 characters.

- Regex Used for Validation (Based on [zod's emailRegex](https://github.com/colinhacks/zod/blob/main/src/types.ts))
- ```
/^(?!\.)(?!.*\.\.)([A-Z0-9_'+\-\.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9\-]*\.)+[A-Z]{2,}$/i
```

- Valid Example
- ```
[email protected]
```

### Password
- Requirement(s)
- The password must contain at least one lowercase letter.
- The password must contain at least one uppercase letter.
- The password must contain at least one digit.
- The password must contain at least one special character (``?=.*[!"#$%&'()*+,-.:;<=>?@\\/\\[\]^_`{|}~]``).
- The password must be at least 8 characters long.

- Regex Used for Validation
- ```
/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.{8,})(?=.*[!"#$%&'()*+,-.:;<=>?@\\/\\[\]^_`{|}~])/
```

- Valid Example
- ```
SecurePassword@3219
```
48 changes: 25 additions & 23 deletions services/user/src/controller/user-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
registrationSchema,
updatePasswordSchema,
updateUsernameAndEmailSchema,
updateUserSchema,
UserValidationErrors,
} from '../types/custom';

Expand All @@ -44,10 +45,8 @@ export async function createUser(req: Request, res: Response) {
const createdUser = await _createUser(username, email, hashedPassword);
handleSuccess(res, 201, `Created new user ${username} successfully`, formatUserResponse(createdUser));
} else {
const required_errors = parseResult.error.errors.filter(
err => err.message == UserValidationErrors.REQUIRED,
);
if (required_errors.length > 0) {
const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED);
if (requiredErrors.length > 0) {
handleBadRequest(res, 'username and/or email and/or password are missing');
}
handleBadRequest(res, 'invalid username and/or email and/or password');
Expand Down Expand Up @@ -128,10 +127,8 @@ export async function updateUsernameAndEmail(req: Request, res: Response) {
handleSuccess(res, 200, `Updated data for user ${userId}`, formatUserResponse(updatedUser));
} else {
console.log(parseResult.error.errors);
const required_errors = parseResult.error.errors.filter(
err => err.message == UserValidationErrors.REQUIRED,
);
if (required_errors.length > 0) {
const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED);
if (requiredErrors.length > 0) {
handleBadRequest(res, 'username and/or email and/or password are missing');
return;
}
Expand Down Expand Up @@ -177,11 +174,9 @@ export async function updatePassword(req: Request, res: Response) {
)) as User;
handleSuccess(res, 200, `Updated data for user ${userId}`, formatUserResponse(updatedUser));
} else {
const required_errors = parseResult.error.errors.filter(
err => err.message == UserValidationErrors.REQUIRED,
);
if (required_errors.length > 0) {
handleBadRequest(res, 'old password and/or new password are missing');
const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED);
if (requiredErrors.length > 0) {
handleBadRequest(res, 'No field to update: username and email and password are all missing!');
}
handleBadRequest(res, 'invalid password');
}
Expand All @@ -194,12 +189,19 @@ export async function updatePassword(req: Request, res: Response) {

export async function updateUser(req: Request, res: Response) {
try {
const { username, email, password } = req.body;
if (!(username || email || password)) {
handleBadRequest(res, 'No field to update: username and email and password are all missing!');
const parseResult = updateUserSchema.safeParse(req.body);
if (!parseResult.success) {
const requiredErrors = parseResult.error.errors.filter(err => err.message == UserValidationErrors.REQUIRED);
if (requiredErrors.length > 0) {
handleBadRequest(res, 'No field to update: username and email and password are all missing!');
return;
}
handleBadRequest(res, 'invalid username and/or email and/or password');
return;
}

const { username, email, password } = req.body;

const userId = req.params.id;
if (!isValidObjectId(userId)) {
handleNotFound(res, `User ${userId} not found`);
Expand All @@ -210,6 +212,7 @@ export async function updateUser(req: Request, res: Response) {
handleNotFound(res, `User ${userId} not found`);
return;
}

if (username || email) {
const userByUsername = await _findUserByUsername(username);
if (userByUsername && userByUsername.id !== userId) {
Expand All @@ -223,14 +226,13 @@ export async function updateUser(req: Request, res: Response) {
}
}

if (!password) {
handleBadRequest(res, 'No field to update: password is missing!');
return;
}
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(password, salt);

const updatedUser = (await _updateUserById(userId, username, email, hashedPassword)) as User;
const updatedUser = (await _updateUserById(
userId,
username ?? user.username,
email ?? user.email,
password ? bcrypt.hashSync(password, salt) : user.password,
)) as User;
handleSuccess(res, 200, `Updated data for user ${userId}`, formatUserResponse(updatedUser));
} catch (err) {
console.error(err);
Expand Down
18 changes: 18 additions & 0 deletions services/user/src/types/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,24 @@ export const registrationSchema = z.object({
password: passwordSchema,
});

export const updateUserSchema = z
.object({
username: usernameSchema.optional(),
email: emailSchema.optional(),
password: passwordSchema.optional(),
})
.superRefine((data, ctx) => {
if (!data.username && !data.email && !data.password) {
// If none of the variables are present, assign error to each one
// Granular control over which is missing is not needed
ctx.addIssue({
path: ['username', 'password', 'email'],
message: UserValidationErrors.REQUIRED,
code: z.ZodIssueCode.custom,
});
}
});

export const updateUsernameAndEmailSchema = z.object({
username: usernameSchema,
email: emailSchema,
Expand Down

0 comments on commit fdd794d

Please sign in to comment.