new release with reactjs

This commit is contained in:
adhamhaddad 2023-05-25 21:11:51 +03:00
parent c9595ccb61
commit 20aae2dc2d
86 changed files with 3029 additions and 240 deletions

View File

@ -48,24 +48,20 @@ This project uses `eslint` and `prettier`. all configurations for this project i
4- `GRANT ALL PRIVILEGES ON DATABASE authentication TO admin;` 4- `GRANT ALL PRIVILEGES ON DATABASE authentication TO admin;`
**[2]** Second to install the node_modules run `npm install` or `yarn install`. After installation is done **[2]** Second to install the node_modules for the backend, navigate to the frontend directory and run `npm install` or `yarn install`. After installation is done
migrate up the database table schema with `npm run migrate:up` or `yarn migrate:up` migrate up the database table schema with `npm run migrate:up` or `yarn migrate:up`
or run the `script.sh` in `sql` folder from root directory with `./script.sh` or run the `script.sh` in `sql` folder from root directory with `./script.sh`
after that start the api in dev mode with `npm run dev` or `yarn dev`. after that start the api in dev mode with `npm run dev` or `yarn dev`.
**[3]** Third, to install the node_modules for the frontend, navigate to the frontend directory and run `npm install` or `yarn install`. Once the installation is complete, start the frontend server in development mode by running `npm run dev` or `yarn dev`.
## Unit Tests ## Unit Tests
Unit test available using Jasmine with this command: `npm run test` Unit test available using Jasmine with this command: `npm run test` or `yarn test`
## Important Note
```
To use refreshToken in the front end I provided a file called react.js in the documents folder.
```
## Available Scripts ## Available Scripts
In the project directory, you can run: In the project backend directory, you can run:
##### `npm run dev` or `yarn dev` ##### `npm run dev` or `yarn dev`

View File

@ -29,4 +29,4 @@ JWT_REFRESH_TOKEN_EXPIRATION=86400
# URL # URL
BACKEND_HOST=http://127.0.0.1:8000 BACKEND_HOST=http://127.0.0.1:8000
FRONTEND_HOST=http://127.0.0.1:3000 FRONTEND_HOST=http://127.0.0.1:5173

View File

@ -43,7 +43,6 @@ CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
first_name VARCHAR(100) NOT NULL, first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL,
username VARCHAR(100) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
password TEXT NOT NULL, password TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

View File

@ -1,6 +1,7 @@
import { createUser } from './register'; import { createUser } from './register';
import { authUser } from './login'; import { authUser } from './login';
import { authMe } from '../../utils/token';
import { updatePassword } from './updatePassword'; import { updatePassword } from './updatePassword';
import { refreshAccessToken } from './refreshAccessToken'; import { refreshAccessToken } from './refreshAccessToken';
export { createUser, authUser, updatePassword, refreshAccessToken }; export { createUser, authUser, authMe, updatePassword, refreshAccessToken };

View File

@ -28,14 +28,27 @@ export const authUser = async (req: Request, res: Response) => {
maxAge: configs.refresh_expires maxAge: configs.refresh_expires
}); });
/*
Note:
If you are not using frontend server like React, Angular.
it's better to remove the refreshToken from the response body
*/
res.status(200).json({ res.status(200).json({
status: true, status: true,
data: { user: { ...response }, accessToken }, data: { user: { ...response }, accessToken, refreshToken },
message: 'User authenticated successfully.' message: 'User authenticated successfully.'
}); });
} catch (error) { } catch (error) {
res.status(400).json({ if ((error as Error).message.includes('Password')) {
message: (error as Error).message return res
}); .status(400)
.json({ errors: [{ password: (error as Error).message }] });
}
if ((error as Error).message.includes('Email')) {
return res
.status(400)
.json({ errors: [{ email: (error as Error).message }] });
}
res.status(400).json({ errors: (error as Error).message });
} }
}; };

View File

@ -17,19 +17,17 @@ export const refreshAccessToken = async (req: Request, res: Response) => {
// Verify the refresh token // Verify the refresh token
const decoded = await verifyRefreshToken(token); const decoded = await verifyRefreshToken(token);
// Generate a new access and refresh tokens // Generate a new access and refresh tokens
const { id, first_name, last_name, username, email } = decoded; const { id, first_name, last_name, email } = decoded;
const accessToken = await setAccessToken({ const accessToken = await setAccessToken({
id, id,
first_name, first_name,
last_name, last_name,
username,
email email
}); });
const refreshToken = await setRefreshToken({ const refreshToken = await setRefreshToken({
id, id,
first_name, first_name,
last_name, last_name,
username,
email email
}); });
@ -51,7 +49,7 @@ export const refreshAccessToken = async (req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
res.status(401).json({ res.status(401).json({
message: (error as Error).message error: (error as Error).message
}); });
} }
}; };

View File

@ -13,7 +13,7 @@ export const deleteUser = async (req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
message: (error as Error).message error: (error as Error).message
}); });
} }
}; };

View File

@ -13,7 +13,7 @@ export const getUser = async (req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
message: (error as Error).message error: (error as Error).message
}); });
} }
}; };

View File

@ -13,7 +13,7 @@ export const updateUser = async (req: Request, res: Response) => {
}); });
} catch (error) { } catch (error) {
res.status(400).json({ res.status(400).json({
message: (error as Error).message error: (error as Error).message
}); });
} }
}; };

View File

@ -0,0 +1,23 @@
import { Request, Response, NextFunction } from 'express';
import { body, oneOf } from 'express-validator';
import { validate } from '../validationResult';
export const validateLogin = [
body('email')
.exists()
.withMessage('Email is missing from the body')
.notEmpty()
.withMessage('Email is empty')
.isEmail()
.withMessage('Email is not valid')
.normalizeEmail()
.withMessage('Email is not normalized'),
body('password')
.exists()
.withMessage('password is missing from the body')
.notEmpty()
.withMessage('password is empty')
.isLength({ min: 8 })
.withMessage('password must be at least 8 characters long'),
(req: Request, res: Response, next: NextFunction) => validate(req, res, next)
];

View File

@ -5,27 +5,20 @@ import { validate } from '../validationResult';
export const validateCreateUser = [ export const validateCreateUser = [
body('first_name') body('first_name')
.exists() .exists()
.withMessage("first_name does'nt exists in the body.") .withMessage("First Name does'nt exists in the body.")
.notEmpty() .notEmpty()
.withMessage('first_name is empty') .withMessage('First Name is empty')
.isString() .isString()
.isLength({ min: 5, max: 50 }) .isLength({ min: 5, max: 50 })
.withMessage('first_name must be at least 5 and maximum 50 letters'), .withMessage('First Name must be at least 5 and maximum 50 letters'),
body('last_name') body('last_name')
.exists() .exists()
.withMessage("last_name does'nt exists in the body.") .withMessage("Last Name does'nt exists in the body.")
.notEmpty() .notEmpty()
.withMessage('last_name is empty') .withMessage('Last Name is empty')
.isString() .isString()
.isLength({ min: 5, max: 50 }) .isLength({ min: 5, max: 50 })
.withMessage('last_name must be at least 5 and maximum 50 letters'), .withMessage('Last Name must be at least 5 and maximum 50 letters'),
body('username')
.exists()
.withMessage('username is missing from the body')
.notEmpty()
.withMessage('username is empty')
.isString()
.withMessage('username must be string'),
body('email') body('email')
.exists() .exists()
.withMessage('Email is missing from the body') .withMessage('Email is missing from the body')
@ -37,10 +30,10 @@ export const validateCreateUser = [
.withMessage('Email is not normalized'), .withMessage('Email is not normalized'),
body('password') body('password')
.exists() .exists()
.withMessage('password is missing from the body') .withMessage('Password is missing from the body')
.notEmpty() .notEmpty()
.withMessage('password is empty') .withMessage('Password is empty')
.isLength({ min: 8 }) .isLength({ min: 8 })
.withMessage('password must be at least 8 characters long'), .withMessage('Password must be at least 8 characters long'),
(req: Request, res: Response, next: NextFunction) => validate(req, res, next) (req: Request, res: Response, next: NextFunction) => validate(req, res, next)
]; ];

View File

@ -24,13 +24,6 @@ export const validateUpdateUser = [
.isString() .isString()
.isLength({ min: 5, max: 50 }) .isLength({ min: 5, max: 50 })
.withMessage('last_name must be at least 5 and maximum 50 letters'), .withMessage('last_name must be at least 5 and maximum 50 letters'),
body('username')
.exists()
.withMessage('username is missing from the body')
.notEmpty()
.withMessage('username is empty')
.isString()
.withMessage('username must be string'),
body('email') body('email')
.exists() .exists()
.withMessage('Email is missing from the body') .withMessage('Email is missing from the body')

View File

@ -10,7 +10,7 @@ export const validate = async (
if (!errors.isEmpty()) { if (!errors.isEmpty()) {
const errorArray = errors const errorArray = errors
.array() .array()
.map((error) => ({ [error.param]: [error.msg] })); .map((error) => ({ [error.param]: error.msg }));
return res.status(422).json({ errors: errorArray }); return res.status(422).json({ errors: errorArray });
} }
next(); next();

View File

@ -37,7 +37,7 @@ class Auth {
const check = await compare(u.password, hash); const check = await compare(u.password, hash);
if (check) { if (check) {
const query = { const query = {
text: 'SELECT id, first_name, last_name, username, email FROM users WHERE email=$1', text: 'SELECT id, first_name, last_name, email FROM users WHERE email=$1',
values: [u.email] values: [u.email]
}; };
const result = await connection.query(query); const result = await connection.query(query);
@ -51,7 +51,7 @@ class Auth {
async authMe(id: string): Promise<UserType & AuthType> { async authMe(id: string): Promise<UserType & AuthType> {
return this.withConnection(async (connection: PoolClient) => { return this.withConnection(async (connection: PoolClient) => {
const query = { const query = {
text: 'SELECT id, first_name, last_name, username, email FROM users WHERE id=$1', text: 'SELECT id, first_name, last_name, email FROM users WHERE id=$1',
values: [id] values: [id]
}; };
const result = await connection.query(query); const result = await connection.query(query);

View File

@ -18,7 +18,6 @@ describe('User Model', () => {
const newUser1 = { const newUser1 = {
first_name: 'Adham', first_name: 'Adham',
last_name: 'Haddad', last_name: 'Haddad',
username: 'adhamhaddad',
email: 'adhamhaddad.dev@gmail.com', email: 'adhamhaddad.dev@gmail.com',
password: 'adham123' password: 'adham123'
} as UserType; } as UserType;
@ -26,7 +25,6 @@ describe('User Model', () => {
const updateUser1 = { const updateUser1 = {
first_name: 'Adham', first_name: 'Adham',
last_name: 'Ashraf', last_name: 'Ashraf',
username: 'adhamhaddad1',
email: 'adhamhaddad.dev@gmail.com' email: 'adhamhaddad.dev@gmail.com'
} as UserType; } as UserType;
@ -48,7 +46,6 @@ describe('User Model', () => {
id: 1, id: 1,
first_name: 'Adham', first_name: 'Adham',
last_name: 'Haddad', last_name: 'Haddad',
username: 'adhamhaddad',
email: 'adhamhaddad.dev@gmail.com' email: 'adhamhaddad.dev@gmail.com'
} as UserType); } as UserType);
}); });
@ -59,7 +56,6 @@ describe('User Model', () => {
id: 1, id: 1,
first_name: 'Adham', first_name: 'Adham',
last_name: 'Haddad', last_name: 'Haddad',
username: 'adhamhaddad',
email: 'adhamhaddad.dev@gmail.com' email: 'adhamhaddad.dev@gmail.com'
} as UserType); } as UserType);
}); });
@ -70,7 +66,6 @@ describe('User Model', () => {
id: 1, id: 1,
first_name: 'Adham', first_name: 'Adham',
last_name: 'Ashraf', last_name: 'Ashraf',
username: 'adhamhaddad1',
email: 'adhamhaddad.dev@gmail.com' email: 'adhamhaddad.dev@gmail.com'
} as UserType); } as UserType);
}); });
@ -118,7 +113,6 @@ describe('Auth Model', () => {
id: 1, id: 1,
first_name: 'Adham', first_name: 'Adham',
last_name: 'Ashraf', last_name: 'Ashraf',
username: 'adhamhaddad1',
email: 'adhamhaddad.dev@gmail.com' email: 'adhamhaddad.dev@gmail.com'
} as UserType); } as UserType);
}); });

View File

@ -3,10 +3,9 @@ import { pgClient } from '../database';
import { hash } from '../utils/password'; import { hash } from '../utils/password';
export type UserType = { export type UserType = {
id: number; id: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
username: string;
email: string; email: string;
password: string; password: string;
created_at: Date; created_at: Date;
@ -31,11 +30,11 @@ class User {
const password = await hash(u.password); const password = await hash(u.password);
const query = { const query = {
text: ` text: `
INSERT INTO users (first_name, last_name, username, email, password) INSERT INTO users (first_name, last_name, email, password)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4)
RETURNING id, first_name, last_name, username, email RETURNING id, first_name, last_name, email
`, `,
values: [u.first_name, u.last_name, u.username, u.email, password] values: [u.first_name, u.last_name, u.email, password]
}; };
const result = await connection.query(query); const result = await connection.query(query);
return result.rows[0]; return result.rows[0];
@ -44,7 +43,7 @@ class User {
async getUser(id: string): Promise<UserType> { async getUser(id: string): Promise<UserType> {
return this.withConnection(async (connection: PoolClient) => { return this.withConnection(async (connection: PoolClient) => {
const query = { const query = {
text: 'SELECT id, first_name, last_name, username, email FROM users WHERE id=$1', text: 'SELECT id, first_name, last_name, email FROM users WHERE id=$1',
values: [id] values: [id]
}; };
const result = await connection.query(query); const result = await connection.query(query);
@ -55,11 +54,11 @@ class User {
return this.withConnection(async (connection: PoolClient) => { return this.withConnection(async (connection: PoolClient) => {
const query = { const query = {
text: ` text: `
UPDATE users SET first_name=$2, last_name=$3, username=$4, email=$5, updated_at=CURRENT_TIMESTAMP UPDATE users SET first_name=$2, last_name=$3, email=$4, updated_at=CURRENT_TIMESTAMP
WHERE id=$1 WHERE id=$1
RETURNING id, first_name, last_name, username, email RETURNING id, first_name, last_name, email
`, `,
values: [id, u.first_name, u.last_name, u.username, u.email] values: [id, u.first_name, u.last_name, u.email]
}; };
const result = await connection.query(query); const result = await connection.query(query);
return result.rows[0]; return result.rows[0];

View File

@ -7,6 +7,7 @@ import {
import { import {
createUser, createUser,
authUser, authUser,
authMe,
refreshAccessToken, refreshAccessToken,
updatePassword updatePassword
} from '../../controllers/auth'; } from '../../controllers/auth';
@ -18,6 +19,7 @@ router
.post('/register', validateRegister, createUser) .post('/register', validateRegister, createUser)
.post('/login', validateLogin, authUser) .post('/login', validateLogin, authUser)
.patch('/reset-password', validateUpdatePassword, verifyToken, updatePassword) .patch('/reset-password', validateUpdatePassword, verifyToken, updatePassword)
.post('/refresh-token', refreshAccessToken); .post('/refresh-token', refreshAccessToken)
.get('/auth-me', authMe);
export default router; export default router;

View File

@ -22,6 +22,7 @@ const corsOptions = {
'Content-Type', 'Content-Type',
'Accept', 'Accept',
'X-Access-Token', 'X-Access-Token',
'X-Refresh-Token',
'Authorization', 'Authorization',
'Access-Control-Allow-Origin', 'Access-Control-Allow-Origin',
'Access-Control-Allow-Headers', 'Access-Control-Allow-Headers',
@ -29,7 +30,8 @@ const corsOptions = {
], ],
methods: 'GET,HEAD,OPTIONS,PUT,PATCH,POST,DELETE', methods: 'GET,HEAD,OPTIONS,PUT,PATCH,POST,DELETE',
preflightContinue: true, preflightContinue: true,
origin: '*' origin: true,
credentials: true
}; };
// Middlewares // Middlewares

View File

@ -0,0 +1,11 @@
import bcrypt from 'bcrypt';
import configs from '../configs';
export const hash = async (password: string) =>
await bcrypt.hash(
`${configs.pepper}${password}${configs.pepper}`,
configs.salt
);
export const compare = async (password: string, hash: string) =>
await bcrypt.compare(`${configs.pepper}${password}${configs.pepper}`, hash);

View File

@ -0,0 +1,104 @@
import { Request as ExpressRequest, Response } from 'express';
import jwt from 'jsonwebtoken';
import fs from 'fs';
import path from 'path';
import { redisClient } from '../../database';
import configs from '../../configs';
import { setAccessToken, verifyRefreshToken, DecodedToken, Payload } from '.';
const publicAccessKey = path.join(
__dirname,
'..',
'..',
'..',
'keys',
'accessToken',
'public.key'
);
interface Request extends ExpressRequest {
user?: DecodedToken;
}
export const authMe = async (req: Request, res: Response) => {
try {
const authorization = req.headers.authorization as string;
if (!authorization) {
return res.status(401).json({
message: 'Not Authorized'
});
}
const [bearer, token] = authorization.split(' ');
if (bearer !== 'Bearer' || !token) {
throw new Error(
'Invalid Authorization header format. Format is "Bearer <token>".'
);
}
try {
const publicKey = await fs.promises.readFile(publicAccessKey, 'utf8');
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'Nodejs-Refresh-Token'
}) as DecodedToken;
const cachedToken = await redisClient.get(`access_token:${decoded.id}`);
if (!cachedToken || cachedToken !== token) {
throw new Error('Access token not found or expired');
}
const { id, first_name, last_name, email } = decoded;
req.user = { id: id };
return res.status(200).json({
data: {
user: { id, first_name, last_name, email },
accessToken: token
}
});
} catch (err) {
if ((err as Error).name !== 'TokenExpiredError') {
throw new Error('Invalid access token');
}
// Get Refresh-Token
const refreshToken = req.get('X-Refresh-Token') as string;
if (!refreshToken) {
throw new Error('Refresh token missing');
}
const [bearer, token] = refreshToken.split(' ');
if (bearer !== 'Bearer' || !token) {
throw new Error(
'Invalid Authorization header format. Format is "Bearer <token>".'
);
}
const decoded = await verifyRefreshToken(token);
const { id, first_name, last_name, email } = decoded;
const newAccessToken = await setAccessToken({
id,
first_name,
last_name,
email
});
// Attach user object to request and proceed with new access token
req.user = { id: String(decoded.id) };
res.cookie('accessToken', newAccessToken, {
httpOnly: true,
sameSite: 'strict',
secure: false,
maxAge: configs.access_expires
});
return res.status(200).json({
data: {
user: {
id,
first_name,
last_name,
email
},
accessToken: newAccessToken
}
});
}
} catch (err) {
res.status(401).json({ message: (err as Error).message });
}
};

View File

@ -2,16 +2,19 @@ import { setAccessToken } from './setAccessToken';
import { setRefreshToken } from './setRefreshToken'; import { setRefreshToken } from './setRefreshToken';
import { verifyAccessToken } from './verifyAccessToken'; import { verifyAccessToken } from './verifyAccessToken';
import { verifyRefreshToken } from './verifyRefreshToken'; import { verifyRefreshToken } from './verifyRefreshToken';
import { authMe } from './authMe';
interface Payload { interface Payload {
id: number; id: string;
first_name: string; first_name: string;
last_name: string; last_name: string;
username: string;
email: string; email: string;
} }
interface DecodedToken { interface DecodedToken {
id: string; id: string;
first_name?: string;
last_name?: string;
email?: string;
} }
export { export {
@ -19,6 +22,7 @@ export {
setRefreshToken, setRefreshToken,
verifyAccessToken, verifyAccessToken,
verifyRefreshToken, verifyRefreshToken,
authMe,
Payload, Payload,
DecodedToken DecodedToken
}; };

View File

@ -3,9 +3,8 @@ import jwt from 'jsonwebtoken';
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { redisClient } from '../../database'; import { redisClient } from '../../database';
import { DecodedToken } from '.';
import { verifyRefreshToken, setAccessToken } from '.';
import configs from '../../configs'; import configs from '../../configs';
import { verifyRefreshToken, setAccessToken, DecodedToken } from '.';
const publicAccessKey = path.join( const publicAccessKey = path.join(
__dirname, __dirname,
@ -49,29 +48,35 @@ export const verifyAccessToken = async (
throw new Error('Access token not found or expired'); throw new Error('Access token not found or expired');
} }
req.user = { id: decoded.id }; req.user = { id: decoded.id };
return next(); return next();
} catch (err) { } catch (err) {
if ((err as Error).name !== 'TokenExpiredError') { if ((err as Error).name !== 'TokenExpiredError') {
throw new Error('Invalid access token'); throw new Error('Invalid access token');
} }
// Get Refresh-Token
const refreshToken = req.get('X-Refresh-Token') as string;
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) { if (!refreshToken) {
throw new Error('Refresh token missing'); throw new Error('Refresh token missing');
} }
const [bearer, token] = refreshToken.split(' ');
const decoded = await verifyRefreshToken(refreshToken); if (bearer !== 'Bearer' || !token) {
const { id, first_name, last_name, username, email } = decoded; throw new Error(
'Invalid Authorization header format. Format is "Bearer <token>".'
);
}
const decoded = await verifyRefreshToken(token);
const { id, first_name, last_name, email } = decoded;
const newAccessToken = await setAccessToken({ const newAccessToken = await setAccessToken({
id, id,
first_name, first_name,
last_name, last_name,
username,
email email
}); });
// Attach user object to request and proceed with new access token // Attach user object to request and proceed with new access token
req.user = { id: String(decoded.id) }; req.user = { id };
res.cookie('accessToken', newAccessToken, { res.cookie('accessToken', newAccessToken, {
httpOnly: true, httpOnly: true,
sameSite: 'strict', sameSite: 'strict',

View File

@ -12,7 +12,6 @@ The request body should be a JSON object with the following properties:
{ {
"first_name": "Adham", "first_name": "Adham",
"last_name": "Haddad", "last_name": "Haddad",
"username": "adhamhaddad",
"email": "adhamhaddad.dev@gmail.com", "email": "adhamhaddad.dev@gmail.com",
"password": "secret-password" "password": "secret-password"
} }
@ -27,11 +26,12 @@ If the user is successfully created, the server will respond with a status code
"id": 1, "id": 1,
"first_name": "Adham", "first_name": "Adham",
"last_name": "Haddad", "last_name": "Haddad",
"username": "adhamhaddad",
"email": "adhamhaddad.dev@gmail.com" "email": "adhamhaddad.dev@gmail.com"
} }
``` ```
<hr />
## POST /auth/login ## POST /auth/login
Authenticate user. Authenticate user.
@ -57,23 +57,28 @@ If the user is exists and authenticated successfully, the server will respond wi
"id": 1, "id": 1,
"first_name": "Adham", "first_name": "Adham",
"last_name": "Haddad", "last_name": "Haddad",
"username": "adhamhaddad", "email": "adhamhaddad.dev@gmail.com"
"email": "adhamhaddad.dev@gmail.com",
}, },
"accessToken": "<Access-Token>" "accessToken": "<Access-Token>",
"refreshToken": "<Refresh-Token>"
} }
``` ```
## POST /auth/refresh-token <hr />
## POST /auth/auth-me
Refresh the access and refresh tokens. Refresh the access and refresh tokens.
### Request Headers ### Request Headers
The request headers should have a Cookies with the following properties: The request headers should have the following properties:
```json ```json
refreshToken="<Refresh-Token>" "headers": {
"Authorization": "Bearer <Access-Token>",
"X-Refresh-Token": "Bearer <Refresh-Token>"
}
``` ```
### Response ### Response
@ -82,21 +87,52 @@ If the refresh token is exists in redis and valid, the server will respond with
```json ```json
{ {
"accessToken": "<Access-Token>", "accessToken": "<Access-Token>"
} }
``` ```
<hr />
## POST /auth/refresh-token
Refresh the access and refresh tokens.
### Request Headers
The request headers should have the following properties:
```json
"headers": {
"Authorization": "Bearer <Access-Token>",
"X-Refresh-Token": "Bearer <Refresh-Token>"
}
```
### Response
If the refresh token is exists in redis and valid, the server will respond with a status code of 200 and a JSON object representing a new tokens:
```json
{
"accessToken": "<Access-Token>"
}
```
<hr />
## GET /users/:userId ## GET /users/:userId
Get a user by id. Get a user by id.
### Request Headers ### Request Headers
The request headers should have a Cookies with the following properties: The request headers should have the following properties:
```json ```json
accessToken="<Access-Token>" "headers": {
refreshToken="<Refresh-Token>" "Authorization": "Bearer <Access-Token>",
"X-Refresh-Token": "Bearer <Refresh-Token>"
}
``` ```
### Response ### Response
@ -108,47 +144,6 @@ If the user is exists, the server will respond with a status code of 200 and a J
"id": 1, "id": 1,
"first_name": "Adham", "first_name": "Adham",
"last_name": "Haddad", "last_name": "Haddad",
"username": "adhamhaddad",
"email": "adhamhaddad.dev@gmail.com"
}
```
## PATCH /users/:userId
Get a user by id.
### Request Headers
The request headers should have a Cookies with the following properties:
```json
accessToken="<Access-Token>"
refreshToken="<Refresh-Token>"
```
### Request Body
The request body should be a JSON object with the following properties:
```json
{
"first_name": "Adham",
"last_name": "Ashraf",
"username": "adhamhaddad",
"email": "adhamhaddad.dev@gmail.com"
}
```
### Response
If the user is exists and updated, the server will respond with a status code of 204 and a JSON object representing the received user:
```json
{
"id": 1,
"first_name": "Adham",
"last_name": "Ashraf",
"username": "adhamhaddad",
"email": "adhamhaddad.dev@gmail.com" "email": "adhamhaddad.dev@gmail.com"
} }
``` ```

67
documents/react.js vendored
View File

@ -1,67 +0,0 @@
import React, { useState } from 'react';
const Login = () => {
const [accessToken, setAccessToken] = useState(null);
const [refreshToken, setRefreshToken] = useState(null);
const [values, setValues] = useState({
email: '',
password: ''
});
const handleChange = (prop) => (event) => {
setValues((prev) => ({ ...prev, [prop]: event.target.value }));
};
const handleSubmit = async (event) => {
event.preventDefault();
const response = await fetch('/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (response.ok) {
// The login was successful, extract the access token from the response
const { accessToken } = data;
// Retrieve the refresh token from the HTTP-only cookie
const cookies = document.cookie.split('; ');
const refreshTokenCookie = cookies.find((cookie) =>
cookie.startsWith('refreshToken=')
);
const refreshToken = refreshTokenCookie
? refreshTokenCookie.split('=')[1]
: null;
// Store the access token and refresh token in state
setAccessToken(accessToken);
setRefreshToken(refreshToken);
} else {
// The login failed, display the error message
setError(data.message);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type='email'
name='email'
value={values.email}
onChange={handleChange('email')}
/>
<input
type='password'
name='password'
value={values.password}
onChange={handleChange('password')}
/>
<button type='submit'>Login</button>
</form>
);
};
export default Login;

15
frontend/.eslintrc.cjs Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

29
frontend/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.4.0",
"js-cookie": "^3.0.5",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "5"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^8.38.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"vite": "^4.3.2"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

33
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,33 @@
import React from 'react';
import { Switch, Route, Redirect } from 'react-router-dom';
import { useAuth } from '@hooks';
import RegisterPage from '@pages/register';
import LoginPage from '@pages/login';
import HomePage from '@pages/home';
function App() {
const { isLogged, user } = useAuth();
return user ? (
<>
<Switch>
<Route path='/login' exact>
<Redirect to='/' />
</Route>
<Route path='/register' exact>
<Redirect to='/' />
</Route>
<Route path='/' exact>
<HomePage />
</Route>
</Switch>
</>
) : (
<>
<Route exact path='/login' component={LoginPage} />
<Route exact path='/register' component={RegisterPage} />
<Route exact path='*' render={() => <Redirect exact to='/login' />} />
</>
);
}
export default App;

View File

@ -0,0 +1,34 @@
import React from 'react';
import styles from '@styles/input.module.css';
const Input = ({
id,
label,
type,
placeholder,
value,
style,
isValid,
onChange,
onBlur
}) => {
return (
<div style={style} className={styles['input-box']}>
<label htmlFor={id} className={styles['input-box_label']}>
{label}
</label>
<input
className={`${styles['input-box_input']} ${
isValid ? styles['invalid'] : null
}`}
type={type}
placeholder={placeholder}
value={value}
onChange={onChange}
onBlur={onBlur}
/>
{isValid && <p className={styles['input-error']}>{isValid}</p>}
</div>
);
};
export default Input;

View File

@ -0,0 +1,69 @@
import axios from 'axios';
import { useState } from 'react';
import { API_URL } from './env';
import Cookies from 'js-cookie';
const api = axios.create({
baseURL: API_URL,
headers: {
Accept: 'application/json'
}
});
api.defaults.withCredentials = true;
api.interceptors.request.use((config) => {
const accessToken = localStorage.getItem('accessToken');
const refreshToken = Cookies.get('refreshToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
if (refreshToken) {
config.headers['X-Refresh-Token'] = `Bearer ${refreshToken}`;
}
if (config.data instanceof FormData) {
config.headers['Content-Type'] = 'multipart/form-data';
}
return config;
});
api.interceptors.response.use(
(response) => response.data,
(error) => Promise.reject(error)
);
function useApi() {
const [loading, setLoading] = useState(false);
async function get(url, options = {}) {
setLoading(true);
try {
const response = await api.get(url, options);
setLoading(false);
return response;
} catch (error) {
setLoading(false);
throw error;
}
}
async function post(url, data) {
setLoading(true);
try {
const response = await api.post(url, data);
setLoading(false);
return response;
} catch (error) {
setLoading(false);
throw error;
}
}
return { get, post, loading };
}
export default useApi;

View File

@ -0,0 +1 @@
export const API_URL = 'http://localhost:3000';

View File

@ -0,0 +1,2 @@
export * from './env';
export { default as useApi } from './api';

View File

@ -0,0 +1,129 @@
import React, { useState, createContext, useEffect } from 'react';
import { useHistory } from 'react-router-dom';
import Cookies from 'js-cookie';
import { useApi } from '@config';
const auth = {
user: localStorage.getItem('user'),
accessToken: null,
refreshToken: null,
isLogged: false,
setLoading: () => {},
register: () => {},
login: () => {},
logout: () => {}
};
export const AuthContext = createContext(auth);
const AuthProvider = ({ children }) => {
const [user, setUser] = useState(auth.user && JSON.parse(auth.user));
const [accessToken, setAccessToken] = useState(null);
const [refreshToken, setRefreshToken] = useState(null);
const { get, post } = useApi();
const isLoggedIn = !!accessToken;
const history = useHistory();
useEffect(() => {
const initAuth = async () => {
const accessToken = window.localStorage.getItem('accessToken');
if (accessToken) {
await get('/auth/auth-me')
.then(async (response) => {
const { user: userData, accessToken: AccessToken } = response.data;
if (accessToken) {
window.localStorage.setItem('user', JSON.stringify(userData));
window.localStorage.setItem('accessToken', AccessToken);
}
setUser(userData);
setAccessToken(AccessToken);
})
.catch(() => {
localStorage.removeItem('user');
localStorage.removeItem('refreshToken');
localStorage.removeItem('accessToken');
setUser(null);
setAccessToken(null);
setRefreshToken(null);
});
}
};
initAuth();
}, []);
const handleLogin = async (params, errorCallback) => {
try {
const response = await post('/auth/login', params);
// Extract user, accessToken, refreshToken from the response body
const { user, accessToken, refreshToken } = response.data;
// Store accessToken and refreshToken in state
setAccessToken(accessToken);
setRefreshToken(refreshToken);
// Store user in state
setUser(user);
// Set user data in localStorage
window.localStorage.setItem('user', JSON.stringify(user));
// Set accessToken in localStorage
window.localStorage.setItem('accessToken', accessToken);
// Set refreshToken in cookies
Cookies.set('refreshToken', refreshToken, {
expires: 30,
secure: true,
sameSite: 'strict'
});
history.replace('/');
} catch (err) {
if (errorCallback) errorCallback(err);
}
};
const handleRegister = async (params, errorCallback) => {
try {
const response = await post('/auth/register', params);
if (response.data.error) {
if (errorCallback) errorCallback(response.data.error);
} else {
handleLogin({ email: params.email, password: params.password });
}
} catch (err) {
if (errorCallback) errorCallback(err);
}
};
const handleLogout = () => {
// Remove states
setUser(null);
setAccessToken(null);
setRefreshToken(null);
// Remove user data from localStorage
window.localStorage.removeItem('user');
// Remove accessToken from localStorage
window.localStorage.removeItem('accessToken');
// Remove refreshToken from cookies
Cookies.remove('refreshToken', {
expires: 30,
secure: true,
sameSite: 'strict'
});
history.replace('/login');
};
const values = {
user: user,
accessToken: accessToken,
refreshToken: refreshToken,
isLogged: isLoggedIn,
login: handleLogin,
register: handleRegister,
logout: handleLogout
};
return <AuthContext.Provider value={values}>{children}</AuthContext.Provider>;
};
export default AuthProvider;

View File

@ -0,0 +1,2 @@
import { useAuth } from './useAuth';
export { useAuth };

View File

@ -0,0 +1,4 @@
import { useContext } from 'react';
import { AuthContext } from '@context/AuthContext';
export const useAuth = () => useContext(AuthContext);

16
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,16 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import AuthProvider from '@context/AuthContext.jsx';
import App from './App.jsx';
import '@styles/index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<App />
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,29 @@
import React from 'react';
import Cookies from 'js-cookie';
import { useAuth } from '@hooks';
import styles from '@styles/home.module.css';
const Home = () => {
const { logout } = useAuth();
return (
<div className={styles['home-page']}>
<h2>Welcome</h2>
<hr />
<p className={styles['localStorage-data']}>
<strong>accessToken:</strong> {localStorage.getItem('accessToken')}
</p>
<p className={styles['localStorage-data']}>
<strong>refreshToken:</strong> {Cookies.get('refreshToken')}
</p>
<p className={styles['localStorage-data']}>
<strong>user:</strong>
{localStorage.getItem('user')}
</p>
<hr />
<button className={styles['logout-button']} onClick={logout}>
Logout
</button>
</div>
);
};
export default Home;

View File

@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '@hooks';
import Input from '@UI/input';
import styles from '@styles/form.module.css';
const Login = () => {
const [values, setValues] = useState({
email: '',
password: ''
});
const [error, setError] = useState({
email: null,
password: null
});
const { login } = useAuth();
const handleChange = (prop) => (event) => {
if (error.email || error.password) {
setError({
email: null,
password: null
});
}
setValues((prev) => ({ ...prev, [prop]: event.target.value }));
};
const onFormSubmit = (event) => {
event.preventDefault();
login(values, (err) => {
const errors = err.response.data.errors;
errors.forEach((error) => {
if (error.email) {
setError((prev) => ({ ...prev, email: error.email }));
}
if (error.password) {
setError((prev) => ({ ...prev, password: error.password }));
}
});
});
};
const Inputs = [
{
id: 'email',
label: 'Email',
type: 'email',
value: values.email,
isValid: error.email,
onChange: handleChange('email')
},
{
id: 'password',
label: 'Password',
type: 'password',
value: values.password,
isValid: error.password,
onChange: handleChange('password')
}
];
useEffect(() => {
return () => {
setValues({ email: '', password: '' });
};
}, []);
return (
<div className={styles['login-page']}>
<h2>Login Page</h2>
<form onSubmit={onFormSubmit} className={styles['form']}>
{Inputs.map((input) => (
<Input key={input.id} {...input} />
))}
<button type='submit'>Log In</button>
</form>
<p>
Don't have account? <Link to='/register'>Register</Link>
</p>
</div>
);
};
export default Login;

View File

@ -0,0 +1,114 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useAuth } from '@hooks';
import Input from '@UI/input';
import styles from '@styles/form.module.css';
const Register = () => {
const [values, setValues] = useState({
first_name: '',
last_name: '',
email: '',
password: ''
});
const [error, setError] = useState({
first_name: null,
last_name: null,
email: null,
password: null
});
const { register } = useAuth();
const handleChange = (prop) => (event) => {
setValues((prev) => ({ ...prev, [prop]: event.target.value }));
};
const handleSubmit = (event) => {
event.preventDefault();
register(values, (err) => {
const errors = err.response.data.errors;
errors.forEach((error) => {
if (error.first_name) {
setError((prev) => ({ ...prev, first_name: error.first_name }));
}
if (error.last_name) {
setError((prev) => ({ ...prev, last_name: error.last_name }));
}
if (error.email) {
setError((prev) => ({ ...prev, email: error.email }));
}
if (error.password) {
setError((prev) => ({ ...prev, password: error.password }));
}
});
});
};
const Inputs = [
{
id: 'first_name',
label: 'First Name',
type: 'text',
value: values.first_name,
isValid: error.first_name,
onChange: handleChange('first_name')
},
{
id: 'last_name',
label: 'Last Name',
type: 'text',
value: values.last_name,
isValid: error.last_name,
onChange: handleChange('last_name')
},
{
id: 'email',
label: 'Email Address',
type: 'email',
value: values.email,
isValid: error.email,
onChange: handleChange('email')
},
{
id: 'password',
label: 'New Password',
type: 'password',
value: values.password,
isValid: error.password,
onChange: handleChange('password')
}
];
useEffect(() => {
return () => {
setValues({
first_name: '',
last_name: '',
email: '',
password: ''
});
setError({
first_name: null,
last_name: null,
email: null,
password: null
});
};
}, []);
return (
<div className={styles['register-page']}>
<h2>Register Page</h2>
<form onSubmit={handleSubmit} className={styles['form']}>
{Inputs.map((input) => (
<Input key={input.id} {...input} />
))}
<button type='submit'>Register</button>
</form>
<p>
Already have an account? <Link to='/login'>Login</Link>
</p>
</div>
);
};
export default Register;

View File

@ -0,0 +1,56 @@
.login-page,
.register-page {
transform: translateY(20%);
}
.register-page p,
.register-page h2,
.login-page p,
.login-page h2 {
text-align: center;
}
.form {
width: 300px;
margin: auto;
}
.form input {
width: 100%;
}
.form input:focus {
border: none;
outline: 2px solid #FFF;
}
.form button {
display: block;
width: 100%;
padding: 10px;
margin: 10px 0px;
font-weight: bold;
border-radius: 4px;
border: none;
background-color: #FFF;
color: #000;
cursor: pointer;
transition: background .5s ease-in-out;
}
.form button:hover {
background-color: #ddd;
transition: background .5s ease-in-out;
}
@media screen and (max-width: 480px) {
.register-page h2,
.login-page h2 {
margin-top: 10px;
}
.form {
width: 100%;
margin: none;
}
}

View File

@ -0,0 +1,21 @@
.home-page {
padding: 10px;
}
.localStorage-data {
font-size: 12px;
margin: 10px 0px;
word-break: break-all;
}
.localStorage-data strong {
display: block;
font-size: 14px;
}
.logout-button {
display: block;
width: 100px;
height: 40px;
margin: 10px 0px;
}

View File

@ -0,0 +1,29 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
* {
padding: 0;
margin: 0;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
-ms-box-sizing: border-box;
-o-box-sizing: border-box;
box-sizing: border-box;
}
a {
font-weight: 500;
color: inherit;
text-decoration: inherit;
}

View File

@ -0,0 +1,21 @@
.input-box {
margin: 4px 0px;
}
.input-box_label {
display: block;
}
.input-box_input {
height: 35px;
border-radius: 4px;
border: 1px solid #888;
padding: 0px 10px;
}
.input-box_input.invalid {
border: 1px solid red
}
.input-error {
font-size: 12px;
}

19
frontend/vite.config.js Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import path from 'path';
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@styles': path.resolve(__dirname, 'src/styles'),
'@common': path.resolve(__dirname, 'src/components/common'),
'@UI': path.resolve(__dirname, 'src/components/UI'),
'@hooks': path.resolve(__dirname, 'src/hooks'),
'@config': path.resolve(__dirname, 'src/config'),
'@pages': path.resolve(__dirname, 'src/pages'),
'@context': path.resolve(__dirname, 'src/context')
}
}
})

2022
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,35 +0,0 @@
import { Request, Response, NextFunction } from 'express';
import { body, oneOf } from 'express-validator';
import { validate } from '../validationResult';
export const validateLogin = [
oneOf(
[
body('username')
.exists()
.withMessage('username is missing from the body')
.notEmpty()
.withMessage('username is empty')
.isString()
.withMessage('username must be string'),
body('email')
.exists()
.withMessage('Email is missing from the body')
.notEmpty()
.withMessage('Email is empty')
.isEmail()
.withMessage('Email is not valid')
.normalizeEmail()
.withMessage('Email is not normalized')
],
'Must be at least one of email or username'
),
body('password')
.exists()
.withMessage('password is missing from the body')
.notEmpty()
.withMessage('password is empty')
.isLength({ min: 8 })
.withMessage('password must be at least 8 characters long'),
(req: Request, res: Response, next: NextFunction) => validate(req, res, next)
];

View File

@ -1,8 +0,0 @@
import bcrypt from 'bcrypt';
import configs from '../configs';
export const hash = (password: string) =>
bcrypt.hash(`${configs.pepper}${password}${configs.pepper}`, configs.salt);
export const compare = (password: string, hash: string) =>
bcrypt.compare(`${configs.pepper}${password}${configs.pepper}`, hash);