diff --git a/README.md b/README.md index 947daac..aac3325 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,19 @@ # node-mentoring-shop -[https://d17btkcdsmqrmh.cloudfront.net/node-gmp/docs/nosql/homework](https://d17btkcdsmqrmh.cloudfront.net/node-gmp/docs/nosql/homework) +[https://d17btkcdsmqrmh.cloudfront.net/node-gmp/docs/authorization/homework](https://d17btkcdsmqrmh.cloudfront.net/node-gmp/docs/authorization/homework) We are going to create an Express application for online shop which sells different types of products (like e.g Amazon). -In this task we will modify the application we created in [Express and Layered Architecture](https://github.com/kandalova/node-mentoring-shop/pull/1) module by moving data storage to NoSQL database. - -The application has 4 primary entities: - -User - can add some products to the cart and then order them ([scheme](https://github.com/kandalova/node-mentoring-shop/blob/task_6_shop_express.js/src/scheme/UserScheme.ts)). - -Product - represents product information that user can order ([scheme](https://github.com/kandalova/node-mentoring-shop/blob/task_6_shop_express.js/src/scheme/ProductScheme.ts)). - -Cart - contains a list of products and their amount that user wants to order ([scheme](https://github.com/kandalova/node-mentoring-shop/blob/task_6_shop_express.js/src/scheme/CartScheme.ts)). - -Order - contains list of products from cart that user has ordered ([scheme](https://github.com/kandalova/node-mentoring-shop/blob/task_6_shop_express.js/src/scheme/OrderScheme.ts)). - -**Relations between entities:** - -Each User can have only one non-deleted Cart at a time. -Each Cart is attached to a specific User. - -One User can have multiple Order. Each Order is attached to a specific User. - -Cart contains a list of products that user wants to order with the amount of those products specified. +In this task we will need to modify existing [Express application](https://github.com/kandalova/node-mentoring-shop/pull/3) by extending user model, adding authorization and authentication flows. **Implementation criteria:** -- TypeScript is used. -- Data is stored in `MongoDB` database. -- `Docker` image is used for local development (check Running [MongoDB as a Docker Container](https://www.baeldung.com/linux/mongodb-as-docker-container#2-building-container-using-a-compose-file) for an example of docker-compose file). -- `Mongoose` is used as ODM for querying. -- `Data Access Layer` is rewritten to query `MongoDB`. -- Models are created based on entity schemas used in Express and Layered Architecture module. -- Models have proper relations between each other based on information specified above. - -**API endpoints** - -[swagger](https://github.com/kandalova/node-mentoring-shop/blob/task_6_shop_express.js/swagger.md) - -image +- User entity is added - contains id, email (unique), password, role (admin or simple user). Password is stored as hashed value in the database. +- [Bcrypt](https://www.npmjs.com/package/bcrypt) module is used for hashing passwords. +- New API endpoint is added for user **sign up** by email and password e.g `/register`. It creates user entity in the database. +- New API endpoint is added for user **sign in** by email and password e.g `/login`. It returns JWT token which contains user information. Pay attention that password is not encoded in token payload. JWT token expires in 2 hours. +- JWT token is passed in `Authorization` header for each request (except sign in and sign up) in the following format `Authorization: Bearer ` +- Authentication middleware is added to check if token provided is valid and if user encoded in token exists. If no, `401 Unauthorized` status code is returned. Otherwise, user can do action they intended to do. +- Only admin users can delete user cart. Authorization middleware is added for this purpose. If token provided doesn't belong to admin member, `403 Forbidden` status code is returned. **Setup for startring (once)** @@ -60,8 +35,26 @@ In the project root folder **First time starting** - Create your first database in MongoDB. Then, create your first collection (Example - User). +- It's possible to use seeder in `index.ts` to mock `Product` model. +- `Register` and `login` user, use returned token in requests's headers. + +**API endpoints** + +[swagger](https://github.com/kandalova/node-mentoring-shop/blob/task_9_authorization/swagger.md) + +![image](https://github.com/kandalova/node-mentoring-shop/assets/26093763/cfdc8b06-f10c-4c05-8d9c-335a30572dde) + +**How to convert `yaml` to `md`** + +`widdershins swagger.yaml --language_tabs 'http:HTTP' -o swagger.md` **Results** -![image](https://github.com/kandalova/node-mentoring-shop/assets/26093763/9e525a2f-c86b-4ecd-9f4e-5f8fe95a6904) +image + +image + +image + +image diff --git a/package-lock.json b/package-lock.json index abf2a2e..d863849 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,20 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "bcryptjs": "^2.4.3", + "dotenv": "^16.3.1", "express": "^4.18.2", "joi": "^17.11.0", + "jsonwebtoken": "^9.0.2", "lodash.clonedeep": "^4.5.0", "mongodb": "^6.2.0", "mongoose": "^7.6.4", "uuid": "^9.0.1" }, "devDependencies": { + "@types/bcryptjs": "^2.4.5", "@types/express": "^4.17.18", + "@types/jsonwebtoken": "^9.0.4", "@types/lodash.clonedeep": "^4.5.7", "@types/mongoose": "^5.11.97", "@types/node": "^20.8.10", @@ -309,6 +314,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/bcryptjs": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.5.tgz", + "integrity": "sha512-tOF6TivOIvq+TWQm78335CMdyVJhpBG3NUdWQDAp95ax4E2rSKbws/ELHLk5EBoucwx/tHt3/hhLOHwWJgVrSw==", + "dev": true + }, "node_modules/@types/body-parser": { "version": "1.19.3", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.3.tgz", @@ -364,6 +375,15 @@ "integrity": "sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ==", "dev": true }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.4.tgz", + "integrity": "sha512-8UYapdmR0QlxgvJmyE8lP7guxD0UGVMfknsdtCFZh4ovShdBl3iOI4zdvqBHrB/IS+xUj3PSx73Qkey1fhWz+g==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/lodash": { "version": "4.14.199", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.199.tgz", @@ -876,6 +896,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", @@ -951,6 +976,11 @@ "node": ">=16.20.1" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1185,6 +1215,25 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1946,6 +1995,46 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", + "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "dependencies": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, "node_modules/kareem": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", @@ -1996,17 +2085,51 @@ "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, "dependencies": { "yallist": "^4.0.0" }, @@ -2680,7 +2803,6 @@ "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -3139,8 +3261,7 @@ "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yn": { "version": "3.1.1", diff --git a/package.json b/package.json index c1c88b9..b03d3b4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,9 @@ "author": "", "license": "ISC", "devDependencies": { + "@types/bcryptjs": "^2.4.5", "@types/express": "^4.17.18", + "@types/jsonwebtoken": "^9.0.4", "@types/lodash.clonedeep": "^4.5.7", "@types/mongoose": "^5.11.97", "@types/node": "^20.8.10", @@ -25,8 +27,11 @@ "typescript": "^5.2.2" }, "dependencies": { + "bcryptjs": "^2.4.3", + "dotenv": "^16.3.1", "express": "^4.18.2", "joi": "^17.11.0", + "jsonwebtoken": "^9.0.2", "lodash.clonedeep": "^4.5.0", "mongodb": "^6.2.0", "mongoose": "^7.6.4", diff --git a/src/config/database.ts b/src/config/database.ts new file mode 100644 index 0000000..da5998a --- /dev/null +++ b/src/config/database.ts @@ -0,0 +1,29 @@ +import { connect, disconnect } from "mongoose"; + +export const connectDB = async () => { + const { MONGO_URI } = process.env; + console.log(MONGO_URI) + + if (!MONGO_URI) { + console.log("Please provide DataBase URI to connect. exiting now..."); + process.exit(1); + } + + try { + await connect(MONGO_URI); + console.log("Successfully connected to database"); + + // listen (ctrl-c) + process.on("SIGINT", async () => { + await disconnect(); + console.log("DataBase is disconnected"); + process.exit(); + }); + + } + catch (err) { + console.log("DataBase connection failed. exiting now..."); + console.error(err); + process.exit(1); + } +} diff --git a/src/controller/cart.controller.ts b/src/controller/cart.controller.ts index a6bd7b7..0a0df00 100644 --- a/src/controller/cart.controller.ts +++ b/src/controller/cart.controller.ts @@ -1,6 +1,8 @@ import express, { NextFunction, Request, Response } from "express"; -import { createOrGetCart, createOrder, deleteCart, updateCart } from "../service/cart.service"; import Joi from "joi"; +import { isAdmin } from "../middleware/isAdmin"; +import { userHeaderHandler } from "../middleware/auth"; +import { createOrGetCart, createOrder, deleteCart, updateCart } from "../service/cart.service"; import { getPutSchemeError } from "../utils/errors"; const cartRouter = express.Router(); @@ -23,7 +25,7 @@ const putValidator = async (req: Request, _: Response, next: NextFunction) => { cartRouter.get("/", async (req: Request, res: Response, next: NextFunction) => { try { - const userId = req.header('x-user-id')!; + const userId = req.user.user_id; const cart = await createOrGetCart(userId); res.send(cart); } @@ -32,7 +34,7 @@ cartRouter.get("/", async (req: Request, res: Response, next: NextFunction) => { } }); -cartRouter.delete("/", async (req: Request, res: Response, next: NextFunction) => { +cartRouter.delete("/", isAdmin, userHeaderHandler, async (req: Request, res: Response, next: NextFunction) => { try { const userId = req.header('x-user-id')!; const data = await deleteCart(userId); @@ -45,7 +47,7 @@ cartRouter.delete("/", async (req: Request, res: Response, next: NextFunction) = cartRouter.post("/checkout", async (req: Request, res: Response, next: NextFunction) => { try { - const userId = req.header('x-user-id')!; + const userId = req.user.user_id; const order = await createOrder(userId, req.body); res.send(order); } @@ -56,7 +58,7 @@ cartRouter.post("/checkout", async (req: Request, res: Response, next: NextFunct cartRouter.put("/", putValidator, async (req: Request, res: Response, next: NextFunction) => { try { - const userId = req.header('x-user-id')!; + const userId = req.user.user_id; const order = await updateCart(userId, req.body); res.send(order); } diff --git a/src/controller/user.controller.ts b/src/controller/user.controller.ts new file mode 100644 index 0000000..911f344 --- /dev/null +++ b/src/controller/user.controller.ts @@ -0,0 +1,64 @@ +import express, { NextFunction, Request, Response } from "express"; +import Joi from "joi"; +import { createUser, loginUser } from "../service/user.service"; +import { getPutSchemeError } from "../utils/errors"; + +const registerSchema = Joi.object({ + name: Joi.string().required(), + isAdmin: Joi.boolean().required(), + email: Joi.string().email().required(), + password: Joi.string().length(10).required(), +}) + +const loginSchema = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().length(10).required(), +}) + +const registerValidator = async (req: Request, _: Response, next: NextFunction) => { + try { + await registerSchema.validateAsync(req.body); + next(); + } + catch (err) { + next(getPutSchemeError()); + } +} + +const loginValidator = async (req: Request, _: Response, next: NextFunction) => { + try { + await loginSchema.validateAsync(req.body); + next(); + } + catch (err) { + next(getPutSchemeError()); + } +} + +const userRouter = express.Router(); +userRouter.use(express.json()); + +userRouter.post("/register", registerValidator, async (req: Request, res: Response, next: NextFunction) => { + try { + const { name, isAdmin, email, password } = req.body; + await createUser(name, isAdmin, email, password); + res.status(201).send('User successfully registered'); + } + catch (err) { + next(err); + } +}); + +userRouter.post("/login", loginValidator, async (req: Request, res: Response, next: NextFunction) => { + try { + const { email, password } = req.body; + const token = await loginUser(email, password); + res.send(token) + } + catch (err) { + next(err); + } +}); + + +export default userRouter; diff --git a/src/db/cart.db.js b/src/db/cart.db.js deleted file mode 100644 index 263d7f4..0000000 --- a/src/db/cart.db.js +++ /dev/null @@ -1 +0,0 @@ -export const carts = []; diff --git a/src/db/db.js b/src/db/db.js index 9f039a3..17ef717 100644 --- a/src/db/db.js +++ b/src/db/db.js @@ -1,9 +1,2 @@ import { products } from "./product.db.js" -import { carts } from "./cart.db.js" -import { orders } from "./order.db.js" -import { users } from "./user.db.js"; - -export const userDB = users; export const productDB = products; -export const cartDB = carts; -export const orderDB = orders; diff --git a/src/db/order.db.js b/src/db/order.db.js deleted file mode 100644 index abdacfb..0000000 --- a/src/db/order.db.js +++ /dev/null @@ -1 +0,0 @@ -export const orders=[]; diff --git a/src/db/user.db.js b/src/db/user.db.js deleted file mode 100644 index 558bc33..0000000 --- a/src/db/user.db.js +++ /dev/null @@ -1,6 +0,0 @@ -export const users = [ - '99c27b1a-0ae2-486d-9d72-9c82579cf361', - 'ed3d57d6-c699-4909-910a-364745332f88', - 'e73b79aa-9cb0-4406-97fd-df231243beec', - 'cf35167a-574d-49e3-a0ed-affd8d746d6c', -] diff --git a/src/index.ts b/src/index.ts index 06157ba..52eead3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,79 +1,52 @@ -import express, { NextFunction, Request, Response } from "express"; +import express from "express"; +import { config } from "dotenv"; +import { connectDB } from "./config/database.ts"; import cartRouter from "./controller/cart.controller.ts"; import productRouter from "./controller/product.controller.ts"; -import { ResponseError, getForbidenError, getUnauthorizedError } from "./utils/errors.ts"; -import { getResponseError } from "./utils/utils.ts"; -import { connect, disconnect } from "mongoose"; -import { UserModel } from "./scheme/UserScheme.ts"; +import userRouter from "./controller/user.controller.ts"; +import { CurrentUser, verifyToken } from "./middleware/auth.ts"; +import { errorHandler } from "./middleware/error.ts"; //for seeders -// import { runUserSeeder } from "./seeder/userSeeder.ts"; // import { ProductModel } from "./scheme/ProductScheme.ts"; // import { runProductSeeder } from "./seeder/productSeeder.ts"; -//for cart check -// import { CartModel } from "./scheme/CartScheme.ts"; -export const app = express(); -const PORT = 3000; - -const headerHandler = async (req: Request, _: Response, next: NextFunction) => { - try { - const userId = req.header('x-user-id') ?? ''; - if (!userId) { - next(getForbidenError()); - } - const user = await UserModel.findById(userId); - if (!user) { - next(getUnauthorizedError()); +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Express { + interface Request { + user: CurrentUser } - next(); - } - catch (err) { - next(err); - } -} - -const errorHandler = async (err: Error | ResponseError, _req: Request, res: Response, _next: NextFunction) => { - if (err instanceof ResponseError) { - res.status(err.status); - } - else { - res.status(500); } - res.send(await getResponseError(err.message)); } -app.use('/api/profile/cart', headerHandler, cartRouter, errorHandler); -app.use('/api/products', headerHandler, productRouter, errorHandler); - export const init = (async () => { try { - await connect("mongodb://127.0.0.1:27017/NM_SHOP"); + config(); + const { API_PORT } = process.env; + const PORT = API_PORT || 3000; + + await connectDB(); + const app = express(); + + app.use('/api', verifyToken); + app.use('/api/profile/cart', cartRouter); + app.use('/api/products', productRouter); + app.use('/user', userRouter); + app.use(errorHandler); + + app.listen(PORT, () => { console.log(`Server is Fire at http://localhost:${PORT}`); }); - //mock data for first run - // const user = await UserModel.findOne(); - // if (!user) { - // await runUserSeeder(); - // } + + //mock product data for first run // const product = await ProductModel.findOne(); // if (!product) { // await runProductSeeder(); // } - //check cart - // const doc = new CartModel({items: [{ count: 5 }]}); - // console.log('doc', doc); - // console.log(doc.items[0].ownerDocument()===doc); } catch (err) { console.log(err); } })(); - -// listen (ctrl-c) -process.on("SIGINT", async () => { - await disconnect(); - console.log("Server is disconnected"); - process.exit(); -}); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..0880cbe --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,48 @@ +import { NextFunction, Request, Response } from "express"; +import * as jwt from "jsonwebtoken"; +import { UserModel } from "../scheme/UserScheme"; +import { getAuthError, getTokenError, getUnauthorizedError } from "../utils/errors"; + +export interface CurrentUser { + user_id: string + email: string, + role: string +} + +export const verifyToken = async (req: Request, res: Response, next: NextFunction) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader) { + next(getAuthError()); + } + const [tokenType, token] = authHeader!.split(" "); + if (tokenType !== "Bearer") { + next(getTokenError(403)); + } + else { + const user = jwt.verify(token, process.env.TOKEN_KEY!) as CurrentUser; + req.user = user; + next(); + } + } + catch (err) { + next(getTokenError()); + } +} + +export const userHeaderHandler = async (req: Request, _: Response, next: NextFunction) => { + try { + const userId = req.header('x-user-id') ?? ''; + if (!userId) { + next(getUnauthorizedError()); + } + const user = await UserModel.findById(userId); + if (!user) { + next(getUnauthorizedError()); + } + next(); + } + catch (err) { + next(err); + } +} diff --git a/src/middleware/error.ts b/src/middleware/error.ts new file mode 100644 index 0000000..380d76e --- /dev/null +++ b/src/middleware/error.ts @@ -0,0 +1,13 @@ +import { NextFunction, Request, Response } from "express"; +import { ResponseError } from "../utils/errors"; +import { getResponseError } from "../utils/utils"; + +export const errorHandler = async (err: Error | ResponseError, _req: Request, res: Response, _next: NextFunction) => { + if (err instanceof ResponseError) { + res.status(err.status); + } + else { + res.status(500); + } + res.send(await getResponseError(err.message)); +} diff --git a/src/middleware/isAdmin.ts b/src/middleware/isAdmin.ts new file mode 100644 index 0000000..b0d317a --- /dev/null +++ b/src/middleware/isAdmin.ts @@ -0,0 +1,12 @@ +import { NextFunction, Request, Response } from "express"; +import { getAdminError } from "../utils/errors"; + +export const isAdmin = async (req: Request, res: Response, next: NextFunction) => { + const currentUser = req.user; + if (currentUser.role !== 'admin') { + next(getAdminError()); + } + else { + next(); + } +} diff --git a/src/repository/cart.repository.ts b/src/repository/cart.repository.ts deleted file mode 100644 index 4dafc3c..0000000 --- a/src/repository/cart.repository.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { cartDB } from "../db/db"; -import { IPopulatedCartItem } from "../scheme/CartScheme"; -import { getUUID } from "../utils/utils"; - -interface ICart { - id: string; // uuid - userId: string; - isDeleted: boolean; - items: IPopulatedCartItem[]; -} - -enum CartEditableProperties { - Items = 'items', - IsDeleted = "isDeleted" -} - -export const generateCart = (userId: string): ICart => { - const newCart: ICart = { - id: getUUID(), - userId: userId, - isDeleted: false, - items: [], - } - return newCart; -} - -export const pushCart = async (cart: ICart) => { - await cartDB.push(cart); -} - -export const findUserCart = async (userId: string): Promise => { - const cart = await cartDB.find((cart) => cart.userId === userId && !cart.isDeleted); - return cart; -} - -export const findUserCartIdx = async (userId: string) => { - const index = await cartDB.findIndex((cart) => cart.userId === userId && !cart.isDeleted); - return index; -} - -export const findUserCartIdxByID = async (userId: string, cartId: string) => { - const index = await cartDB.findIndex((cart) => cart.userId === userId && cart.id === cartId && !cart.isDeleted); - return index; -} - -export const updateCartProperty = async (cartIdx: number, property: CartEditableProperties, value: boolean | IPopulatedCartItem[]) => { - cartDB[cartIdx][property] = value; -} - -export const getCartByIdx = async (cartIdx: number): Promise => { - return await cartDB[cartIdx]; -} -export const getCartProductIdx = async (cartIdx: number, productId: string) => { - const index = await cartDB[cartIdx].items.findIndex((item: IPopulatedCartItem) => item.product.product === productId); - return index; -} - -export const pushCartItem = async (cartIdx: number, product: IPopulatedCartItem) => { - await cartDB[cartIdx].items.push(product) -} - -export const updateCartItemCount = async (cartIdx: number, productIdx: number, count: number) => { - const cartItem = cartDB[cartIdx].items[productIdx]; - cartItem.count = count; -} - -export const deleteCartItem = async (cartIdx: number, productIdx: number) => { - const cartItems = cartDB[cartIdx].items; - await cartItems.splice(productIdx, 1); -} diff --git a/src/repository/order.repository.ts b/src/repository/order.repository.ts deleted file mode 100644 index 950dc29..0000000 --- a/src/repository/order.repository.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { orderDB } from "../db/db"; -import { IPopulatedCartItem } from "../scheme/CartScheme"; - -type ORDER_STATUS = 'created' | 'completed'; - -interface IPopulatedOrder { - id: string, - userId: string; - cartId: string; - items: IPopulatedCartItem[] - payment: { - type: string, - address?: unknown, - creditCard?: unknown, - }, - delivery: { - type: string, - address: unknown, - }, - comments: string, - status: ORDER_STATUS; - total: number; -} - -export const pushOrder = async (order: IPopulatedOrder): Promise => { - await orderDB.push(order); - return orderDB.length - 1; -} - -export const getOrderByIdx = async (idx: number): Promise => { - return await orderDB[idx]; -} diff --git a/src/repository/product.repository.ts b/src/repository/product.repository.ts deleted file mode 100644 index 32ff596..0000000 --- a/src/repository/product.repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { productDB } from "../db/db"; -import { IProduct } from "../scheme/ProductScheme"; - -export const findProduct = async (id: string): Promise => { - const product = await productDB.find((product) => product.id === id); - return product; -} - -export const findProducts = async (): Promise => { - return productDB; -} diff --git a/src/repository/user.repository.ts b/src/repository/user.repository.ts deleted file mode 100644 index 6651535..0000000 --- a/src/repository/user.repository.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { userDB } from "../db/db"; - -export const findUser = async (userId: string) => { - return await userDB.find((id) => id === userId); -} diff --git a/src/scheme/UserScheme.ts b/src/scheme/UserScheme.ts index d3b8c38..34bdb33 100644 --- a/src/scheme/UserScheme.ts +++ b/src/scheme/UserScheme.ts @@ -2,10 +2,16 @@ import { Schema, model } from "mongoose"; export interface IUser { name: string, + email: string, + password: string, + role: string } const userSchema = new Schema({ name: { type: String, required: true }, + email: { type: String, unique: true, required: true }, + password: { type: String, required: true }, + role: { type: String, required: true }, }, { versionKey: false }) export const UserModel = model("User", userSchema); diff --git a/src/seeder/userSeeder.ts b/src/seeder/userSeeder.ts deleted file mode 100644 index a8c3548..0000000 --- a/src/seeder/userSeeder.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { userDB } from "../db/db" -import { UserModel } from "../scheme/UserScheme"; - -export const runUserSeeder = async () => { - userDB.forEach(async (value, i) => { - const newUser = new UserModel({ name: `User-${i}` }); - await newUser.save(); - }); -} diff --git a/src/service/user.service.ts b/src/service/user.service.ts new file mode 100644 index 0000000..c61728d --- /dev/null +++ b/src/service/user.service.ts @@ -0,0 +1,39 @@ +import bcrypt from "bcryptjs"; +import * as jwt from "jsonwebtoken"; +import { UserModel } from "../scheme/UserScheme"; +import { throwLoginUserError, throwRegisterError } from "../utils/errors"; + +const findUserOrFail = async (email: string) => { + return UserModel.findOne({ email }).orFail(() => throwLoginUserError()); +} + +export const createUser = async (name: string, isAdmin: boolean, email: string, password: string) => { + const oldUser = await UserModel.findOne({ email }); + if (oldUser) { + throwRegisterError(); + } + const encryptedPassword = await bcrypt.hash(password, 10); + await UserModel.create({ + name, + email: email.toLowerCase(), + password: encryptedPassword, + role: isAdmin ? "admin" : "customer" + }); +} + +export const loginUser = async (email: string, password: string) => { + const user = await findUserOrFail(email); + if (!isTheSameCrypt(password, user.password)) { + throwLoginUserError() + } + const token = jwt.sign( + { user_id: user._id.toString(), email, role: user.role }, + process.env.TOKEN_KEY!, + { expiresIn: '2h' } + ); + return { token }; +} + +const isTheSameCrypt = async (str1: string, str2: string) => { + await bcrypt.compare(str1, str2) +} diff --git a/src/utils/cartUtils.ts b/src/utils/cartUtils.ts index d66e008..e8c456f 100644 --- a/src/utils/cartUtils.ts +++ b/src/utils/cartUtils.ts @@ -1,4 +1,4 @@ -import { IPopulatedCartItem, ICartResponse, ICart, IDeleteCartResponse, OmitCart, PopulatedCart } from "../scheme/CartScheme"; +import { IPopulatedCartItem, ICartResponse, ICart, IDeleteCartResponse, OmitCart, PopulatedCart } from "../scheme/CartScheme"; import { IOrderInfo } from "../scheme/OrderScheme"; export const getOmitCart = ({ _id, items }: PopulatedCart | ICart): OmitCart => { diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 9832f11..207a01c 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -15,26 +15,10 @@ const getResposeError = (message: string, status: number): ResponseError => { return err; } -export const throwNoUser = (id: string) => { - throw new Error(`No user ${id}`); -} - -export const throwCartExistsError = (id: string): ResponseError => { - throw new ResponseError(`Cart for user ${id} already exists`, 400); -} - -export const throwNoCartExistsById = (id: string, cartId: string) => { - throw new Error(`No cart ${cartId} for user ${id}`); -} - export const throwNoCartExists = (id: string) => { throw new ResponseError(`No cart for user ${id}`, 400); } -export const getNoCartExists = (id: string): ResponseError => { - return new ResponseError(`No cart for user ${id}`, 400); -} - export const throwNoProductExists = () => { throw new ResponseError(`No product for user`, 400); } @@ -43,18 +27,30 @@ export const throwEmptyCart = (id: string) => { throw new Error(`Cart for user ${id} is empty`); } -export const throwUnauthorized = (status = 401) => { - throw getResposeError(`Header x-user-id is missing or no user with such id`, status) -} - export const getUnauthorizedError = (status = 401) => { return getResposeError(`Header x-user-id is missing or no user with such id`, status) } -export const getForbidenError = (status = 403) => { - return getResposeError(`You must be authorized user`, status) -} - export const getPutSchemeError = (status = 400) => { return getResposeError(`Bad request body`, status) } + +export const throwRegisterError = (status = 409) => { + throw getResposeError(`User Already Exist. Please Login`, status) +} + +export const throwLoginUserError = (status = 400) => { + throw getResposeError(`Invalid Credentials`, status) +} + +export const getAuthError = (status = 401) => { + return getResposeError(`Token is required`, status); +} + +export const getTokenError = (status = 401) => { + return getResposeError(`Invalid Token`, status); +} + +export const getAdminError = (status = 403) => { + return getResposeError(`Forbidden`, status); +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 5662690..0c76711 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,24 +1,5 @@ -import { v4 as uuid } from 'uuid'; -import cloneDeep from 'lodash.clonedeep'; -import { NextFunction } from 'express'; import { IResponse, IResponseError } from '../scheme/ServiceUtils'; -export const getUUID = () => { - return uuid(); -}; - -export const createDeepCopy = (arr: T[]): T[] => { - return cloneDeep(arr); -} - -export const promiseHandler = async (prom: Promise, next: NextFunction) => { - try { - return await prom; - } catch (error) { - next(error); - } -} - export const getResponseError = async (message: string): Promise => { return { data: null, diff --git a/swagger.md b/swagger.md index 6e1231d..ad11ff4 100644 --- a/swagger.md +++ b/swagger.md @@ -1,14 +1,12 @@ --- -title: API v1.0.0 +title: shop api v1.0.0 language_tabs: - - http: http - - HTTP: HTTP + - "'http": HTTP' language_clients: - - http: "" - - HTTP: "" + - "'http": "" toc_footers: [] includes: [] -search: false +search: true highlight_theme: darkula headingLevel: 2 @@ -16,42 +14,24 @@ headingLevel: 2 -

null v1.0.0

+

shop api v1.0.0

> Scroll down for code samples, example requests and responses. Select a language for code samples from the tabs above or the mobile navigation menu. -

cart

+# Authentication -## get__api_profile_cart - -> Code samples +- HTTP Authentication, scheme: bearer -```http -GET /api/profile/cart HTTP/1.1 +

cart

-Accept: application/json -x-user-id: eb5a26af-6e4c-4f31-a9b1-3450d42ac66c - -``` - -```http -GET /api/profile/cart HTTP/1.1 - -Accept: application/json -x-user-id: eb5a26af-6e4c-4f31-a9b1-3450d42ac66c +## get__api_profile_cart -``` +> Code samples `GET /api/profile/cart` *Get user cart or create if it is missing* -

Parameters

- -|Name|In|Type|Required|Description| -|---|---|---|---|---| -|x-user-id|header|string(uuid)|true|User id (uuid)| - > Example responses > 200 Response @@ -60,11 +40,11 @@ x-user-id: eb5a26af-6e4c-4f31-a9b1-3450d42ac66c { "data": { "cart": { - "id": "eb5a26af-6e4c-4f31-a9b1-3450d42ac66c", + "id": "6546650c159ab6e8bb59f925", "items": [ { "product": { - "id": "891389f0-4312-42d6-a650-6fda0959c734", + "id": "65426846103aa5e276433519", "title": "Book", "description": "Interesting book", "price": 200 @@ -85,7 +65,7 @@ x-user-id: eb5a26af-6e4c-4f31-a9b1-3450d42ac66c { "data": null, "error": { - "message": "User is not authorized" + "message": "Token is required" } } ``` @@ -96,7 +76,7 @@ x-user-id: eb5a26af-6e4c-4f31-a9b1-3450d42ac66c { "data": null, "error": { - "message": "You must be authorized user" + "message": "Invalid Token" } } ``` @@ -117,36 +97,19 @@ x-user-id: eb5a26af-6e4c-4f31-a9b1-3450d42ac66c |Status|Meaning|Description|Schema| |---|---|---|---| |200|[OK](https://tools.ietf.org/html/rfc7231#section-6.3.1)|Returns user cart|[CartResponse](#schemacartresponse)| -|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Unauthorized (when no user matching authorization header is found)|[CartResponse](#schemacartresponse)| -|403|[Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3)|Forbidden (when authorization header is missing)|[CartResponse](#schemacartresponse)| +|401|[Unauthorized](https://tools.ietf.org/html/rfc7235#section-3.1)|Unauthorized (when no token is found)|[CartResponse](#schemacartresponse)| +|403|[Forbidden](https://tools.ietf.org/html/rfc7231#section-6.5.3)|Forbidden (when token is missed or invalid)|[CartResponse](#schemacartresponse)| |500|[Internal Server Error](https://tools.ietf.org/html/rfc7231#section-6.6.1)|Internal server error|[CartResponse](#schemacartresponse)| -