├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── api-server ├── .babelrc ├── .dockerignore ├── .eslintrc ├── .gitignore ├── .sample-env ├── Dockerfile ├── README.md ├── docker-compose.yml ├── jsconfig.json ├── logs │ └── .gitignore ├── nodemon.json ├── package-lock.json ├── package.json └── src │ ├── config │ ├── app.js │ ├── index.js │ ├── mail.js │ └── token.js │ ├── controllers │ ├── AuthController.js │ └── UsersController.js │ ├── decorators │ └── TryCatchErrorDecorator.js │ ├── exeptions │ ├── AppError.js │ ├── ClientError.js │ └── ValidationError.js │ ├── index.js │ ├── middleware │ ├── Authorize.js │ ├── ErrorHandler.js │ └── Validate.js │ ├── models │ └── User.js │ ├── routes │ ├── auth.js │ ├── index.js │ └── users.js │ ├── schemas │ ├── auth.js │ └── index.js │ ├── services │ ├── MailService.js │ ├── PasswordService.js │ ├── TokenService.js │ └── ValidateService.js │ ├── templates │ └── mail │ │ ├── confirmRestorePassword.ejs │ │ ├── layout │ │ ├── footer.ejs │ │ └── header.ejs │ │ ├── restorePassword.ejs │ │ └── singup.ejs │ └── utils │ ├── logger.js │ └── randomize.js ├── client ├── .eslintrc ├── .gitignore ├── .prettierrc ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── img │ │ └── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.jsx │ ├── config │ ├── app.js │ └── menu.js │ ├── feature │ ├── Auth │ │ ├── ConfirmRestorePassword │ │ │ ├── actions.js │ │ │ ├── container │ │ │ │ ├── index.jsx │ │ │ │ └── index.module.scss │ │ │ ├── index.js │ │ │ └── reducer.js │ │ ├── RestorePassword │ │ │ ├── actions.js │ │ │ ├── components │ │ │ │ └── RestorePasswordForm │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.module.scss │ │ │ ├── container │ │ │ │ ├── index.jsx │ │ │ │ └── index.module.scss │ │ │ ├── index.js │ │ │ └── reducer.js │ │ ├── Signin │ │ │ ├── actions.js │ │ │ ├── components │ │ │ │ └── SigninForm │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.module.scss │ │ │ ├── container │ │ │ │ ├── index.jsx │ │ │ │ └── index.module.scss │ │ │ ├── index.js │ │ │ └── reducer.js │ │ ├── Signup │ │ │ ├── actions.js │ │ │ ├── components │ │ │ │ └── SignupForm │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.module.scss │ │ │ ├── container │ │ │ │ ├── index.jsx │ │ │ │ └── index.module.scss │ │ │ ├── index.js │ │ │ └── reducer.js │ │ ├── index.js │ │ ├── reducers.js │ │ └── routes.js │ ├── Cabinet │ │ ├── Home │ │ │ ├── container │ │ │ │ └── index.jsx │ │ │ └── index.js │ │ ├── Users │ │ │ ├── actions.js │ │ │ ├── container │ │ │ │ ├── index.jsx │ │ │ │ └── index.module.scss │ │ │ ├── index.js │ │ │ └── reducer.js │ │ ├── index.js │ │ ├── reducers.js │ │ └── routes.js │ ├── Common │ │ ├── AuthRoute │ │ │ └── index.jsx │ │ ├── Layout │ │ │ ├── components │ │ │ │ ├── Auth │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.module.scss │ │ │ │ └── Cabinet │ │ │ │ │ ├── Footer │ │ │ │ │ └── index.jsx │ │ │ │ │ ├── Header │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.module.scss │ │ │ │ │ ├── Sidebar │ │ │ │ │ └── index.jsx │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── index.module.scss │ │ │ └── index.jsx │ │ └── Loader │ │ │ └── index.jsx │ └── NotFound │ │ ├── container │ │ ├── index.jsx │ │ └── index.module.scss │ │ ├── index.js │ │ └── route.js │ ├── helpers │ ├── api.js │ ├── auth.js │ └── history.js │ ├── index.jsx │ ├── index.scss │ ├── reducers.js │ ├── routes.js │ ├── serviceWorker.js │ └── store.js └── package-lock.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [package.json] 11 | indent_style = space 12 | indent_size = 2 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IntellijIdea files 2 | *.iml 3 | .idea 4 | .vscode 5 | out 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Talanov Maxim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple app Node.js + Express.js + MongoDB Authentication API and React Js Client 2 | 3 | ## Description project 4 | 5 | ### Server side 6 | 7 | - Simple structure project 8 | - JWT auth with refresh token (made by description https://gist.github.com/zmts/802dc9c3510d79fd40f9dc38a12bccfc ) 9 | - Rest api registration, authorization, restore and confirm restore 10 | - Autogenerate password 11 | - Send email from mailgun 12 | - Validation schema wrapped AJV 13 | - Send email with template from .ejs 14 | - Responsive email template https://github.com/leemunroe/responsive-html-email-template 15 | - Catched email to .eml files on dev mode 16 | - Error decorator 17 | - Handler error and logging 18 | - Docker compose 19 | 20 | ### Client side 21 | 22 | - Simple structure project 23 | - Create react app 24 | - Design library Ant.Design 25 | - Redux for state menagment 26 | - Token is stored in LocalStorage 27 | - Uses Axios interceptors for refresh token 28 | 29 | ### Preview 30 | 31 | ![Signin](http://off-transition.ru/github/1.png) 32 | ![Cabinet](http://off-transition.ru/github/2.png) 33 | 34 | Detailed description of the launch application 35 |  in each of these folders 36 | 37 | The project will change over time, if you have advice on how to do better write to me `minorforyounot[replace]gmail.com` -------------------------------------------------------------------------------- /api-server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "11" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | ["@babel/plugin-proposal-decorators", { "legacy": true }] 14 | ], 15 | "env": { 16 | "dev": { 17 | "sourceMaps": "inline", 18 | "retainLines": true 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /api-server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | logs 3 | npm-debug.log 4 | Dockerfile* 5 | docker-compose* 6 | .dockerignore 7 | .git 8 | .gitignore 9 | */bin 10 | */obj 11 | README.md 12 | LICENSE 13 | .vscode -------------------------------------------------------------------------------- /api-server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb-base", "prettier"], 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "no-console": 0, 6 | "no-underscore-dangle": 0, 7 | "camelcase": 0, 8 | "no-unused-vars": 0 9 | } 10 | } -------------------------------------------------------------------------------- /api-server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .env 4 | -------------------------------------------------------------------------------- /api-server/.sample-env: -------------------------------------------------------------------------------- 1 | # host options 2 | PORT=3003 3 | HOST='http://localhost' 4 | FRONTEND_HOST='http://localhost:3000' 5 | 6 | # mongo uri 7 | MONGO_URI='mongodb://mongo/test' 8 | 9 | # secrets for api token 10 | SECRET_TOKEN_ACCESS='secret' 11 | EXPIRE_TOKEN_ACCESS='1d' 12 | 13 | SECRET_TOKEN_REFRESH='secret' 14 | EXPIRE_TOKEN_REFRESH='30d' 15 | 16 | SECRET_TOKEN_RESTORE_PASSWORD='secret' 17 | EXPIRE_TOKEN_RESTORE_PASSWORD='5min' 18 | 19 | TOKEN_LIMIT_COUNT_DIVICE=5 20 | 21 | #mail options 22 | API_KEY_MAILGUN='test' 23 | DOMAIN_MAILGUN='test' 24 | FROM_DEFAULT_MAIL='Test ' -------------------------------------------------------------------------------- /api-server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:10.13-alpine 2 | WORKDIR /usr/src/app 3 | COPY ["package.json", "package-lock.json*", "npm-shrinkwrap.json*", "./"] 4 | RUN npm install 5 | COPY . . 6 | EXPOSE 3003 -------------------------------------------------------------------------------- /api-server/README.md: -------------------------------------------------------------------------------- 1 | # Node Js Express Api Server 2 | 3 | Configuration file app `.sample-env` rename to `.env`. 4 | 5 | Runs the app from Docker 6 | 7 | `sudo docker-compose up` 8 | 9 | Host app `http://localhost:3003` 10 | 11 | AdminMongo `http://0.0.0.0:1234` (Mongo uri in docker containers `mongodb://mongo`) 12 | 13 | Mongo (local) `mongodb://localhost:27018` 14 | 15 | Runs the app in the development mode. 16 | 17 | `npm run dev` 18 | 19 | Runs the app in the production mode. 20 | 21 | `npm run start` 22 | 23 | ## Method API 24 | 25 | ### Register new user 26 | 27 | `POST: api/auth/signup` 28 | 29 | Params 30 | 31 | `email:test@mail.ru name:test` 32 | 33 | Password auto generate, find in local email to (open mail client) `/api-server/logs/mail` 34 | 35 | ### Get token 36 | 37 | `POST: /api/auth/signin` 38 | 39 | Params 40 | 41 | `email:test@mail.ru password:test` 42 | 43 | ### Refresh token 44 | 45 | `POST: api/auth/refresh-tokens` 46 | 47 | Params 48 | 49 | `refreshToken:idtoken::token` 50 | 51 | ### Restore password 52 | 53 | `POST: api/auth/restore-password` 54 | 55 | Params 56 | 57 | `email:test@mail.ru` 58 | 59 | ### Confirm restore password 60 | 61 | `POST: api/auth/confirm-restore-password` 62 | 63 | Params 64 | 65 | `token:token` 66 | 67 | Token find in local email to `/api-server/logs/mail` 68 | 69 | ### Logout (with Authorization: Bearer token) 70 | 71 | `POST: api/auth/logout` 72 | 73 | ### Exemple url Users List (with Authorization: Bearer token) 74 | 75 | `GET: api/users` 76 | 77 | ## Error and logging 78 | 79 | `/api-server/logs` Write logs error\combined log to prod mode 80 | 81 | `/api-server/logs/mail` Dev mode catch send email and save .eml file 82 | 83 | For logging use `winston` to `/api-server/src/utils/logger.js` 84 | 85 | Http request console log `morgan` 86 | 87 | Email sending errors are also additionally logged with the error type `EMAIL_ERROR`. 88 | 89 | Handle error to `/api-server/src/middleware/ErrorHandler.js` 90 | 91 | ### Error validation 92 | 93 | For validate data use AJV (https://github.com/epoberezkin/ajv) 94 | 95 | Schemas validation to `/api-server/src/schemas` 96 | 97 | Example validation route `router.post("/auth/signin", Validate.prepare(authSchemas.signin)` 98 | 99 | Example validation error (http code 422): 100 | 101 | ```js 102 | { 103 | "code": 422, 104 | "message": "Unprocessable Entity", 105 | "errorValidation": { 106 | "fields": { 107 | "email": [ 108 | "Should have required property 'email'" 109 | ], 110 | "password": [ 111 | "Should have required property 'password'" 112 | ] 113 | } 114 | } 115 | } 116 | ``` 117 | 118 | The project will change over time, if you have advice on how to do better write to me `minorforyounot[replace]gmail.com` 119 | -------------------------------------------------------------------------------- /api-server/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | 3 | services: 4 | backend: 5 | build: . 6 | ports: 7 | - 3003:3003 8 | volumes: 9 | - ./:/usr/src/app 10 | command: npm run dev 11 | links: 12 | - mongo 13 | mongo: 14 | image: mongo 15 | ports: 16 | - 27018:27017 17 | adminmongo: 18 | image: mrvautin/adminmongo 19 | environment: 20 | - HOST=0.0.0.0 21 | ports: 22 | - 1234:1234 23 | links: 24 | - mongo -------------------------------------------------------------------------------- /api-server/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "experimentalDecorators": true 4 | } 5 | } -------------------------------------------------------------------------------- /api-server/logs/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | */ 3 | !.gitignore -------------------------------------------------------------------------------- /api-server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["node_modules"], 3 | "env": { 4 | "NODE_ENV": "development", 5 | "BABEL_DISABLE_CACHE": 1, 6 | "PORT": 3003, 7 | "URL": "http://localhost" 8 | }, 9 | "execMap": { 10 | "js": "babel-node" 11 | }, 12 | "ext": ".js,.json,.gql", 13 | "watch": "./" 14 | } -------------------------------------------------------------------------------- /api-server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-react-mongo-auth-server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "NODE_ENV=production babel-node src/index.js", 8 | "dev": "nodemon --exec NODE_ENV=development babel-node src/index.js", 9 | "debug": "nodemon --exec NODE_ENV=development DEBUG=http,mail,express:* babel-node src/index.js" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@babel/cli": "^7.13.14", 15 | "@babel/core": "^7.13.14", 16 | "@babel/node": "^7.13.13", 17 | "@babel/plugin-proposal-decorators": "^7.13.5", 18 | "@babel/plugin-transform-runtime": "^7.13.10", 19 | "@babel/preset-env": "^7.13.12", 20 | "babel-eslint": "^10.0.1", 21 | "eslint": "^7.23.0", 22 | "eslint-config-airbnb-base": "^14.2.1", 23 | "eslint-config-prettier": "^8.1.0", 24 | "eslint-plugin-import": "^2.22.1", 25 | "nodemon": "^2.0.7" 26 | }, 27 | "dependencies": { 28 | "ajv": "^6.12.6", 29 | "ajv-errors": "^1.0.1", 30 | "bcryptjs": "^2.4.3", 31 | "body-parser": "^1.19.0", 32 | "cors": "^2.8.5", 33 | "dotenv": "^8.2.0", 34 | "ejs-promise": "^0.3.3", 35 | "express": "^4.17.1", 36 | "http-errors": "^1.8.0", 37 | "jsonwebtoken": "^8.5.1", 38 | "kamil-mailcomposer": "^0.1.34", 39 | "mailgun-js": "^0.22.0", 40 | "mimelib": "^0.3.1", 41 | "mongoose": "^5.12.3", 42 | "mongoose-unique-validator": "^2.0.3", 43 | "morgan": "^1.10.0", 44 | "randomatic": "^3.1.1", 45 | "winston": "^3.3.3" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /api-server/src/config/app.js: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import path from "path"; 3 | 4 | const root = path.join.bind(this, __dirname, "../../"); 5 | dotenv.config({ path: root(".env") }); 6 | 7 | if (!process.env.HOST || !process.env.PORT) { 8 | throw new Error("Can`t find .env config varibles for work app"); 9 | } 10 | 11 | const isDev = process.env.NODE_ENV === "development"; 12 | const isProd = !isDev; 13 | 14 | export default { 15 | host: process.env.HOST, 16 | port: process.env.PORT, 17 | frontendHost: process.env.FRONTEND_HOST, 18 | mongoUri: process.env.MONGO_URI, 19 | isDev, 20 | isProd 21 | }; 22 | -------------------------------------------------------------------------------- /api-server/src/config/index.js: -------------------------------------------------------------------------------- 1 | import app from "./app"; 2 | import token from "./token"; 3 | import mail from "./mail"; 4 | 5 | export default { app, token, mail }; 6 | -------------------------------------------------------------------------------- /api-server/src/config/mail.js: -------------------------------------------------------------------------------- 1 | const apiKey = process.env.API_KEY_MAILGUN; 2 | const domain = process.env.DOMAIN_MAILGUN; 3 | const from = process.env.FROM_DEFAULT_MAIL; 4 | 5 | export default { 6 | apiKey, 7 | domain, 8 | from 9 | }; 10 | -------------------------------------------------------------------------------- /api-server/src/config/token.js: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import path from "path"; 3 | 4 | const root = path.join.bind(this, __dirname, "../../"); 5 | dotenv.config({ path: root(".env") }); 6 | 7 | const secretAccess = process.env.SECRET_TOKEN_ACCESS; 8 | const expireAccess = process.env.EXPIRE_TOKEN_ACCESS; 9 | 10 | const secretRefresh = process.env.SECRET_TOKEN_REFRESH; 11 | const expireRefresh = process.env.EXPIRE_TOKEN_REFRESH; 12 | 13 | const secretRestore = process.env.SECRET_TOKEN_RESTORE_PASSWORD; 14 | const expireRestore = process.env.EXPIRE_TOKEN_RESTORE_PASSWORD; 15 | 16 | const countTokenLimit = process.env.TOKEN_LIMIT_COUNT_DIVICE; 17 | 18 | export default { 19 | secretAccess, 20 | expireAccess, 21 | secretRefresh, 22 | expireRefresh, 23 | countTokenLimit, 24 | secretRestore, 25 | expireRestore 26 | }; 27 | -------------------------------------------------------------------------------- /api-server/src/controllers/AuthController.js: -------------------------------------------------------------------------------- 1 | import UserModel from "../models/User"; 2 | import PasswordService from "../services/PasswordService"; 3 | import ClientError from "../exeptions/ClientError"; 4 | import TryCatchErrorDecorator from "../decorators/TryCatchErrorDecorator"; 5 | import TokenService from "../services/TokenService"; 6 | import AppError from "../exeptions/AppError"; 7 | import MailService from "../services/MailService"; 8 | import randomize from "../utils/randomize"; 9 | import config from "../config/app"; 10 | 11 | class AuthController { 12 | @TryCatchErrorDecorator 13 | static async signin(req, res) { 14 | const user = await UserModel.findOne({ email: req.body.email }); 15 | if (!user) { 16 | throw new ClientError("User not found", 404); 17 | } 18 | 19 | const checkPassword = await PasswordService.checkPassword( 20 | req.body.password, 21 | user.password 22 | ); 23 | 24 | if (!checkPassword) { 25 | throw new ClientError("Incorrect email or password", 401); 26 | } 27 | 28 | const accessToken = await TokenService.createAccessToken(user); 29 | const refreshTokenHash = await TokenService.createRefreshToken(user); 30 | const refreshToken = await TokenService.addRefreshTokenUser( 31 | user, 32 | refreshTokenHash 33 | ); 34 | 35 | res.json({ 36 | accessToken, 37 | refreshToken, 38 | user: { 39 | id: user.id, 40 | name: user.name, 41 | email: user.email 42 | } 43 | }); 44 | } 45 | 46 | @TryCatchErrorDecorator 47 | static async signup(req, res) { 48 | const isAlreadyUser = await UserModel.findOne({ email: req.body.email }); 49 | if (isAlreadyUser) { 50 | throw new ClientError("This email is already registered", 409); 51 | } 52 | 53 | const password = randomize.generateString(12); 54 | 55 | const user = new UserModel({ 56 | name: req.body.name, 57 | email: req.body.email, 58 | password: await PasswordService.hashPassword(password) 59 | }); 60 | 61 | await user.save(); 62 | 63 | MailService.sendWithTemplate( 64 | { 65 | to: user.email, 66 | subject: "Thanks for registering, your password is inside" 67 | }, 68 | { 69 | template: "singup", 70 | data: { 71 | email: user.email, 72 | password 73 | } 74 | } 75 | ); 76 | 77 | res.json({ status: "success" }); 78 | } 79 | 80 | @TryCatchErrorDecorator 81 | static async refreshTokens(req, res) { 82 | const refreshTokenRequest = req.body.refreshToken; 83 | 84 | const verifyData = await TokenService.verifyRefreshToken( 85 | refreshTokenRequest 86 | ); 87 | 88 | if (!verifyData) { 89 | throw new ClientError("Refresh token invalid or expired", 400); 90 | } 91 | 92 | const user = await UserModel.findOne({ _id: verifyData.id }); 93 | 94 | const isValid = await TokenService.checkRefreshTokenUser( 95 | user, 96 | refreshTokenRequest 97 | ); 98 | 99 | if (!isValid) { 100 | throw new ClientError("Refresh token invalid or expired", 400); 101 | } 102 | 103 | await TokenService.removeRefreshTokenUser(user, refreshTokenRequest); 104 | 105 | const accessToken = await TokenService.createAccessToken(user); 106 | const refreshTokenHash = await TokenService.createRefreshToken(user); 107 | const refreshToken = await TokenService.addRefreshTokenUser( 108 | user, 109 | refreshTokenHash 110 | ); 111 | 112 | res.json({ accessToken, refreshToken }); 113 | } 114 | 115 | @TryCatchErrorDecorator 116 | static async logout(req, res, next) { 117 | const user = await UserModel.findOne({ _id: req.userId }); 118 | if (!user) { 119 | throw new AppError("UserId not found in request", 401); 120 | } 121 | 122 | user.refreshTokens = []; 123 | await user.save(); 124 | 125 | res.json({ status: "success" }); 126 | } 127 | 128 | @TryCatchErrorDecorator 129 | static async restorePassword(req, res, next) { 130 | const user = await UserModel.findOne({ email: req.body.email }); 131 | 132 | if (!user) { 133 | throw new ClientError("User not found", 404); 134 | } 135 | 136 | const token = await TokenService.createRestorePasswordToken(user); 137 | 138 | MailService.sendWithTemplate( 139 | { 140 | to: user.email, 141 | subject: "Restore password" 142 | }, 143 | { 144 | template: "restorePassword", 145 | data: { 146 | host: config.frontendHost, 147 | token 148 | } 149 | } 150 | ); 151 | 152 | res.json({ status: "success" }); 153 | } 154 | 155 | @TryCatchErrorDecorator 156 | static async confirmRestorePassword(req, res, next) { 157 | const tokenRequest = req.body.token; 158 | 159 | const verifyData = await TokenService.verifyRestorePasswordToken( 160 | tokenRequest 161 | ); 162 | 163 | if (!verifyData) { 164 | throw new ClientError("Refresh token invalid or expired", 400); 165 | } 166 | 167 | const user = await UserModel.findOne({ _id: verifyData.id }); 168 | const password = randomize.generateString(12); 169 | 170 | user.password = await PasswordService.hashPassword(password); 171 | await user.save(); 172 | 173 | MailService.sendWithTemplate( 174 | { 175 | to: user.email, 176 | subject: "New password" 177 | }, 178 | { 179 | template: "confirmRestorePassword", 180 | data: { 181 | password 182 | } 183 | } 184 | ); 185 | 186 | res.json({ status: "success" }); 187 | } 188 | } 189 | 190 | export default AuthController; 191 | -------------------------------------------------------------------------------- /api-server/src/controllers/UsersController.js: -------------------------------------------------------------------------------- 1 | import UserModel from "../models/User"; 2 | import TryCatchErrorDecorator from "../decorators/TryCatchErrorDecorator"; 3 | 4 | class UsersController { 5 | @TryCatchErrorDecorator 6 | static async index(req, res) { 7 | const users = await UserModel.find().select("_id name email"); 8 | 9 | res.json(users); 10 | } 11 | } 12 | 13 | export default UsersController; 14 | -------------------------------------------------------------------------------- /api-server/src/decorators/TryCatchErrorDecorator.js: -------------------------------------------------------------------------------- 1 | export default (target, propertyKey, descriptor) => { 2 | const fn = descriptor.value; 3 | 4 | return { 5 | async value(req, res, next) { 6 | try { 7 | await fn.call(this, req, res, next); 8 | } catch (error) { 9 | next(error); 10 | } 11 | } 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /api-server/src/exeptions/AppError.js: -------------------------------------------------------------------------------- 1 | class AppError extends Error { 2 | constructor(message, httpCode = 500) { 3 | super(message); 4 | this.status = httpCode; 5 | } 6 | } 7 | 8 | export default AppError; 9 | -------------------------------------------------------------------------------- /api-server/src/exeptions/ClientError.js: -------------------------------------------------------------------------------- 1 | class ClientError extends Error { 2 | constructor(message, httpCode = 500) { 3 | super(message); 4 | this.status = httpCode; 5 | } 6 | } 7 | 8 | export default ClientError; 9 | -------------------------------------------------------------------------------- /api-server/src/exeptions/ValidationError.js: -------------------------------------------------------------------------------- 1 | class ValidationError extends Error { 2 | constructor(validationErrors, httpCode = 422) { 3 | super(); 4 | this.validationErrors = validationErrors; 5 | this.status = httpCode; 6 | } 7 | } 8 | 9 | export default ValidationError; 10 | -------------------------------------------------------------------------------- /api-server/src/index.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import express from "express"; 3 | import bodyParser from "body-parser"; 4 | import cors from "cors"; 5 | import morgan from "morgan"; 6 | import httpError from "http-errors"; 7 | import routes from "./routes"; 8 | import errorHandler from "./middleware/ErrorHandler"; 9 | import config from "./config/app"; 10 | 11 | const app = express(); 12 | 13 | const morganFormat = config.isDev ? "dev" : "combined"; 14 | app.use(morgan(morganFormat)); 15 | 16 | mongoose 17 | .connect(config.mongoUri, { useNewUrlParser: true }) 18 | .catch(err => console.log(err)); 19 | 20 | app.use(bodyParser.urlencoded({ extended: true })); 21 | app.use(bodyParser.json()); 22 | app.use(cors()); 23 | 24 | app.use("/api", ...routes); 25 | 26 | app.use((req, res, next) => { 27 | next(httpError(404)); 28 | }); 29 | 30 | app.use(errorHandler); 31 | 32 | app.listen(config.port, () => { 33 | console.log(`Server started ${config.host}:${config.port}`); 34 | }); 35 | -------------------------------------------------------------------------------- /api-server/src/middleware/Authorize.js: -------------------------------------------------------------------------------- 1 | import TryCatchErrorDecorator from "../decorators/TryCatchErrorDecorator"; 2 | import ClientError from "../exeptions/ClientError"; 3 | import TokenService from "../services/TokenService"; 4 | 5 | class Authorize { 6 | @TryCatchErrorDecorator 7 | static async check(req, res, next) { 8 | if (req.headers.authorization) { 9 | const token = req.headers.authorization.split(" ")[1]; 10 | 11 | if (!token) { 12 | throw new ClientError("Access token not found in request", 400); 13 | } 14 | 15 | const verifyData = await TokenService.verifyAccessToken(token); 16 | 17 | if (!verifyData) { 18 | throw new ClientError("Refresh token invalid or expired", 401); 19 | } 20 | 21 | req.userId = verifyData.id; 22 | return next(); 23 | } 24 | 25 | throw new ClientError("Unauthorized", 401); 26 | } 27 | } 28 | 29 | export default Authorize; 30 | -------------------------------------------------------------------------------- /api-server/src/middleware/ErrorHandler.js: -------------------------------------------------------------------------------- 1 | import httpErrors from "http-errors"; 2 | import ValidationError from "../exeptions/ValidationError"; 3 | import ClientError from "../exeptions/ClientError"; 4 | import logger from "../utils/logger"; 5 | import config from "../config/app"; 6 | 7 | export default function(err, req, res, next) { 8 | const status = err.status || 500; 9 | const httpError = httpErrors(status); 10 | const errorMessage = err.message || "Unknown error"; 11 | const response = { code: status }; 12 | 13 | if (err instanceof ValidationError) { 14 | Object.assign(response, { message: httpError.message }); 15 | Object.assign(response, { errorValidation: err.validationErrors }); 16 | } else if (err instanceof ClientError) { 17 | Object.assign(response, { message: errorMessage }); 18 | logger.info(errorMessage, { url: req.originalUrl, method: req.method }); 19 | } else { 20 | Object.assign(response, { message: httpError.message }); 21 | logger.error(err.stack, { url: req.originalUrl, method: req.method }); 22 | } 23 | 24 | if (config.isDev) { 25 | Object.assign(response, { error: err.stack }); 26 | } 27 | 28 | res.status(status); 29 | res.json(response); 30 | } 31 | -------------------------------------------------------------------------------- /api-server/src/middleware/Validate.js: -------------------------------------------------------------------------------- 1 | import ValidateService from "../services/ValidateService"; 2 | 3 | class Validate { 4 | static prepare(schema, keyValidate = "body") { 5 | return async (req, res, next) => { 6 | try { 7 | await ValidateService.validate(req[keyValidate], schema); 8 | next(); 9 | } catch (error) { 10 | next(error); 11 | } 12 | }; 13 | } 14 | } 15 | 16 | export default Validate; 17 | -------------------------------------------------------------------------------- /api-server/src/models/User.js: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from "mongoose"; 2 | import uniqueValidator from "mongoose-unique-validator"; 3 | 4 | const refreshTokens = new Schema({ 5 | token: { 6 | type: String, 7 | required: true 8 | } 9 | }); 10 | 11 | const UserSchema = new Schema( 12 | { 13 | name: { 14 | type: String, 15 | required: true, 16 | minlength: 2, 17 | trim: true 18 | }, 19 | password: { 20 | type: String, 21 | required: true, 22 | minlength: 8 23 | }, 24 | email: { 25 | type: String, 26 | required: true, 27 | unique: true, 28 | trim: true 29 | }, 30 | refreshTokens: [refreshTokens] 31 | }, 32 | { 33 | timestamps: true 34 | } 35 | ); 36 | 37 | mongoose.set("useCreateIndex", true); 38 | UserSchema.plugin(uniqueValidator); 39 | 40 | export default mongoose.model("User", UserSchema); 41 | -------------------------------------------------------------------------------- /api-server/src/routes/auth.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import AuthController from "../controllers/AuthController"; 3 | import Validate from "../middleware/Validate"; 4 | import Authorize from "../middleware/Authorize"; 5 | import authSchemas from "../schemas/auth"; 6 | 7 | const router = Router(); 8 | 9 | router.post( 10 | "/auth/signin", 11 | Validate.prepare(authSchemas.signin), 12 | AuthController.signin 13 | ); 14 | router.post( 15 | "/auth/signup", 16 | Validate.prepare(authSchemas.signup), 17 | AuthController.signup 18 | ); 19 | router.post( 20 | "/auth/refresh-tokens", 21 | Validate.prepare(authSchemas.refreshTokens), 22 | AuthController.refreshTokens 23 | ); 24 | router.post("/auth/logout", Authorize.check, AuthController.logout); 25 | router.post( 26 | "/auth/restore-password", 27 | Validate.prepare(authSchemas.restorePassword), 28 | AuthController.restorePassword 29 | ); 30 | router.post( 31 | "/auth/confirm-restore-password", 32 | Validate.prepare(authSchemas.confirmRestorePassword), 33 | AuthController.confirmRestorePassword 34 | ); 35 | 36 | export default router; 37 | -------------------------------------------------------------------------------- /api-server/src/routes/index.js: -------------------------------------------------------------------------------- 1 | import users from "./users"; 2 | import auth from "./auth"; 3 | 4 | export default [users, auth]; 5 | -------------------------------------------------------------------------------- /api-server/src/routes/users.js: -------------------------------------------------------------------------------- 1 | import { Router } from "express"; 2 | import UsersController from "../controllers/UsersController"; 3 | import Authorize from "../middleware/Authorize"; 4 | 5 | const router = Router(); 6 | 7 | router.get("/users", Authorize.check, UsersController.index); 8 | 9 | export default router; 10 | -------------------------------------------------------------------------------- /api-server/src/schemas/auth.js: -------------------------------------------------------------------------------- 1 | const signin = { 2 | type: "object", 3 | required: ["email", "password"], 4 | properties: { 5 | email: { 6 | type: "string", 7 | format: "email", 8 | errorMessage: { 9 | format: "Field 'email' incorrect", 10 | type: "Field 'email' should be a string" 11 | } 12 | }, 13 | password: { 14 | type: "string", 15 | errorMessage: { 16 | type: "Field 'password' should be a string" 17 | } 18 | } 19 | } 20 | }; 21 | 22 | const signup = { 23 | type: "object", 24 | required: ["email", "name"], 25 | properties: { 26 | email: { 27 | type: "string", 28 | format: "email", 29 | errorMessage: { 30 | format: "Field 'email' incorrect", 31 | type: "Field 'email' should be a string" 32 | } 33 | }, 34 | name: { 35 | type: "string", 36 | minLength: 2, 37 | maxLength: 30, 38 | pattern: "^[a-zA-Z0-9_ ]*$", 39 | errorMessage: { 40 | pattern: "Field 'name' can contain only letters and spaces", 41 | type: "Field 'name' should be a string" 42 | } 43 | } 44 | } 45 | }; 46 | 47 | const refreshTokens = { 48 | type: "object", 49 | required: ["refreshToken"], 50 | properties: { 51 | refreshToken: { 52 | type: "string", 53 | pattern: "^(.*)::(.*)$", 54 | errorMessage: { 55 | type: "Field 'refreshToken' should be a string", 56 | pattern: "Incorrect format 'refreshToken'" 57 | } 58 | } 59 | } 60 | }; 61 | 62 | const restorePassword = { 63 | type: "object", 64 | required: ["email"], 65 | properties: { 66 | email: { 67 | type: "string", 68 | format: "email", 69 | errorMessage: { 70 | format: "Field 'email' incorrect", 71 | type: "Field 'email' should be a string" 72 | } 73 | } 74 | } 75 | }; 76 | 77 | const confirmRestorePassword = { 78 | type: "object", 79 | required: ["token"], 80 | properties: { 81 | token: { 82 | type: "string", 83 | errorMessage: { 84 | type: "Field 'name' should be a string" 85 | } 86 | } 87 | } 88 | }; 89 | 90 | export default { 91 | signin, 92 | signup, 93 | refreshTokens, 94 | restorePassword, 95 | confirmRestorePassword 96 | }; 97 | -------------------------------------------------------------------------------- /api-server/src/schemas/index.js: -------------------------------------------------------------------------------- 1 | import auth from "./auth"; 2 | 3 | export default { auth }; 4 | -------------------------------------------------------------------------------- /api-server/src/services/MailService.js: -------------------------------------------------------------------------------- 1 | import { MailComposer as KamilMailcomposer } from "kamil-mailcomposer"; 2 | import mailgunModule from "mailgun-js"; 3 | import fs from "fs"; 4 | import path from "path"; 5 | import ejs from "ejs-promise"; 6 | import AppError from "../exeptions/AppError"; 7 | import config from "../config"; 8 | import logger from "../utils/logger"; 9 | 10 | const root = path.join.bind(this, __dirname, "../../"); 11 | const srcPath = path.join.bind(this, __dirname, "../"); 12 | 13 | const saveEmailInFile = async data => { 14 | try { 15 | const mailComposer = new KamilMailcomposer(); 16 | mailComposer.addHeader("x-mailer", "Nodemailer 1.0"); 17 | mailComposer.setMessageOption(data); 18 | mailComposer.streamMessage(); 19 | 20 | const pathFolder = root("logs/mail"); 21 | if (!fs.existsSync(pathFolder)) { 22 | fs.mkdirSync(pathFolder); 23 | } 24 | 25 | mailComposer.pipe( 26 | fs.createWriteStream(`${pathFolder}/${new Date().getTime()}.eml`) 27 | ); 28 | 29 | return true; 30 | } catch (err) { 31 | throw new AppError(err.message); 32 | } 33 | }; 34 | 35 | const compileTemplate = async (template, data, options = {}) => { 36 | try { 37 | const file = path.join(srcPath(`templates/mail/${template}.ejs`)); 38 | 39 | if (!file) { 40 | throw new AppError( 41 | `Could not find template: ${template} in path: ${file}` 42 | ); 43 | } 44 | 45 | return await ejs.renderFile(file, data, options, (err, result) => { 46 | if (err) { 47 | throw new AppError(err.message); 48 | } 49 | return result; 50 | }); 51 | } catch (err) { 52 | throw new AppError(err.message); 53 | } 54 | }; 55 | 56 | /* eslint no-param-reassign: ["error", { "props": false }] */ 57 | const send = async data => { 58 | try { 59 | const mailgun = mailgunModule({ 60 | apiKey: config.mail.apiKey, 61 | domain: config.mail.domain 62 | }); 63 | 64 | if (!data.from) { 65 | data.from = config.mail.from; 66 | } 67 | 68 | if (config.app.isDev) { 69 | return saveEmailInFile(data); 70 | } 71 | 72 | return await mailgun.messages().send(data); 73 | } catch (err) { 74 | logger.error(err.message, { type: "EMAIL_ERROR", data }); 75 | 76 | throw new AppError(err.message); 77 | } 78 | }; 79 | 80 | const sendWithTemplate = async (data, templateOptions) => { 81 | try { 82 | const template = templateOptions.template || ""; 83 | const dataTemplate = templateOptions.data || {}; 84 | const options = templateOptions.options || {}; 85 | 86 | if (!template) { 87 | throw new AppError(`Could not find template name in options`); 88 | } 89 | 90 | data.html = await compileTemplate(template, dataTemplate, options); 91 | 92 | return await send(data); 93 | } catch (err) { 94 | throw new AppError(err.message); 95 | } 96 | }; 97 | 98 | export default { send, sendWithTemplate }; 99 | -------------------------------------------------------------------------------- /api-server/src/services/PasswordService.js: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcryptjs"; 2 | import AppError from "../exeptions/AppError"; 3 | 4 | const checkPassword = async (password, passwordHash) => { 5 | try { 6 | return await bcrypt.compare(password, passwordHash); 7 | } catch (err) { 8 | throw new AppError(err.message); 9 | } 10 | }; 11 | 12 | const hashPassword = async password => { 13 | try { 14 | const salt = await bcrypt.genSalt(12); 15 | const hash = await bcrypt.hash(password, salt); 16 | 17 | return hash; 18 | } catch (err) { 19 | throw new AppError(err.message); 20 | } 21 | }; 22 | 23 | export default { 24 | checkPassword, 25 | hashPassword 26 | }; 27 | -------------------------------------------------------------------------------- /api-server/src/services/TokenService.js: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import mongoose from "mongoose"; 3 | import config from "../config/token"; 4 | import AppError from "../exeptions/AppError"; 5 | 6 | const sign = async (playload, secretToken, options) => { 7 | try { 8 | const token = await jwt.sign(playload, secretToken, options); 9 | return token; 10 | } catch (err) { 11 | throw new AppError(err.message); 12 | } 13 | }; 14 | 15 | const createAccessToken = async user => { 16 | try { 17 | const payload = { 18 | id: user._id 19 | }; 20 | 21 | const options = { 22 | algorithm: "HS512", 23 | subject: user._id.toString(), 24 | expiresIn: config.expireAccess 25 | }; 26 | 27 | const token = await sign(payload, config.secretAccess, options); 28 | 29 | return token; 30 | } catch (err) { 31 | throw new AppError(err.message); 32 | } 33 | }; 34 | 35 | const createRefreshToken = async user => { 36 | try { 37 | const payload = { 38 | id: user._id 39 | }; 40 | 41 | const options = { 42 | algorithm: "HS512", 43 | subject: user._id.toString(), 44 | expiresIn: config.expireRefresh 45 | }; 46 | 47 | const token = await sign(payload, config.secretRefresh, options); 48 | 49 | return token; 50 | } catch (err) { 51 | throw new AppError(err.message); 52 | } 53 | }; 54 | 55 | const createRestorePasswordToken = async user => { 56 | try { 57 | const payload = { 58 | id: user._id 59 | }; 60 | 61 | const options = { 62 | algorithm: "HS512", 63 | subject: user._id.toString(), 64 | expiresIn: config.expireRestore 65 | }; 66 | 67 | const token = await sign(payload, config.secretRestore, options); 68 | 69 | return token; 70 | } catch (err) { 71 | throw new AppError(err.message); 72 | } 73 | }; 74 | 75 | const removeRefreshTokenUser = async (user, token) => { 76 | try { 77 | const refreshTokenId = token.split("::")[0]; 78 | 79 | const refreshTokensFiltered = user.refreshTokens.filter(refreshToken => { 80 | return refreshToken._id.toString() !== refreshTokenId.toString(); 81 | }); 82 | 83 | user.refreshTokens = refreshTokensFiltered; 84 | await user.save(); 85 | 86 | return true; 87 | } catch (err) { 88 | throw new AppError(err.message); 89 | } 90 | }; 91 | 92 | /* eslint no-param-reassign: ["error", { "props": false }] */ 93 | const addRefreshTokenUser = async (user, token) => { 94 | try { 95 | /* token limit count restriction */ 96 | 97 | if (user.refreshTokens.length >= config.countTokenLimit) { 98 | user.refreshTokens = []; 99 | } 100 | 101 | const objectId = mongoose.Types.ObjectId(); 102 | user.refreshTokens.push({ _id: objectId, token }); 103 | await user.save(); 104 | 105 | return `${objectId}::${token}`; 106 | } catch (err) { 107 | throw new AppError(err.message); 108 | } 109 | }; 110 | 111 | const verifyRefreshToken = async token => { 112 | try { 113 | const refreshTokenHash = token.split("::")[1]; 114 | const data = await jwt.verify(refreshTokenHash, config.secretRefresh); 115 | 116 | return data; 117 | } catch (err) { 118 | return false; 119 | } 120 | }; 121 | 122 | const verifyAccessToken = async token => { 123 | try { 124 | const data = await jwt.verify(token, config.secretAccess); 125 | 126 | return data; 127 | } catch (err) { 128 | return false; 129 | } 130 | }; 131 | 132 | const verifyRestorePasswordToken = async token => { 133 | try { 134 | const data = await jwt.verify(token, config.secretRestore); 135 | 136 | return data; 137 | } catch (err) { 138 | return false; 139 | } 140 | }; 141 | 142 | const checkRefreshTokenUser = async (user, token) => { 143 | try { 144 | const refreshTokenId = token.split("::")[0]; 145 | 146 | const isValid = user.refreshTokens.find( 147 | refreshToken => refreshToken._id.toString() === refreshTokenId.toString() 148 | ); 149 | 150 | return !!isValid; 151 | } catch (err) { 152 | throw new AppError(err.message); 153 | } 154 | }; 155 | 156 | export default { 157 | sign, 158 | createAccessToken, 159 | createRefreshToken, 160 | addRefreshTokenUser, 161 | removeRefreshTokenUser, 162 | verifyRefreshToken, 163 | checkRefreshTokenUser, 164 | verifyAccessToken, 165 | createRestorePasswordToken, 166 | verifyRestorePasswordToken 167 | }; 168 | -------------------------------------------------------------------------------- /api-server/src/services/ValidateService.js: -------------------------------------------------------------------------------- 1 | import Ajv from "ajv"; 2 | import AjvErrors from "ajv-errors"; 3 | import ValidationError from "../exeptions/ValidationError"; 4 | import AppError from "../exeptions/AppError"; 5 | 6 | const normaliseErrorMessages = errors => { 7 | const fields = errors.reduce((acc, e) => { 8 | if (e.dataPath.length && (e.dataPath[0] === "/" || e.dataPath[0] === ".")) { 9 | acc[e.dataPath.slice(1)] = [ 10 | e.message.toUpperCase()[0] + e.message.slice(1) 11 | ]; 12 | } else { 13 | const key = e.params.missingProperty || e.params.additionalProperty; 14 | acc[key] = [e.message.toUpperCase()[0] + e.message.slice(1)]; 15 | } 16 | return acc; 17 | }, {}); 18 | 19 | return { fields }; 20 | }; 21 | 22 | const validate = async (data, schema) => { 23 | try { 24 | const ajv = new Ajv({ allErrors: true, jsonPointers: true }); 25 | AjvErrors(ajv); 26 | const validateFunction = ajv.compile(schema); 27 | 28 | const valid = await validateFunction(data); 29 | if (!valid) { 30 | throw new ValidationError( 31 | normaliseErrorMessages(validateFunction.errors) 32 | ); 33 | } 34 | 35 | return valid; 36 | } catch (err) { 37 | if (err instanceof ValidationError) { 38 | throw err; 39 | } else { 40 | throw new AppError(err.message); 41 | } 42 | } 43 | }; 44 | 45 | export default { validate }; 46 | -------------------------------------------------------------------------------- /api-server/src/templates/mail/confirmRestorePassword.ejs: -------------------------------------------------------------------------------- 1 | <% include ./layout/header %> 2 | 3 | 4 | 5 | 6 | 7 | 8 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | <% include ./layout/footer %> -------------------------------------------------------------------------------- /api-server/src/templates/mail/layout/footer.ejs: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | 14 | 15 |   16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /api-server/src/templates/mail/layout/header.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Simple Transactional Email 7 | 295 | 296 | 297 | This is preheader text. Some clients will show this text as a preview. 298 | 299 | 300 | 301 |