├── .all-contributorsrc ├── .commitlintrc.json ├── .eslintignore ├── .eslintrc.js ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .kodiak.toml ├── .prettierrc ├── LICENSE ├── README.md ├── env ├── local.env ├── production.env └── test.env ├── jest.setup.ts ├── nest-cli.json ├── package.json ├── prisma ├── .env ├── migrations │ ├── 20211021152800_initial_migration │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── app.controller.spec.ts ├── app.controller.ts ├── app.module.ts ├── auth │ ├── auth-user.ts │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── jwt-payload.ts │ ├── jwt.strategy.ts │ └── models │ │ ├── index.ts │ │ ├── request │ │ ├── change-email.request.ts │ │ ├── change-password.request.ts │ │ ├── check-email.request.ts │ │ ├── check-username.request.ts │ │ ├── login.request.ts │ │ ├── reset-password.request.ts │ │ └── signup.request.ts │ │ └── response │ │ ├── check-email.response.ts │ │ ├── check-username.response.ts │ │ └── login.response.ts ├── common │ ├── guards │ │ └── throttler-behind-proxy.guard.ts │ └── services │ │ └── prisma.service.ts ├── config.ts ├── mail-sender │ ├── mail-sender.module.ts │ ├── mail-sender.service.spec.ts │ ├── mail-sender.service.ts │ └── templates │ │ ├── change-mail.html.ts │ │ ├── change-password-info.html.ts │ │ ├── confirm-mail.html.ts │ │ ├── index.ts │ │ └── reset-password.html.ts ├── main.ts └── user │ ├── models │ ├── index.ts │ ├── request │ │ └── update-user-request.model.ts │ └── user.response.ts │ ├── user.controller.spec.ts │ ├── user.controller.ts │ ├── user.decorator.ts │ ├── user.module.ts │ ├── user.service.spec.ts │ └── user.service.ts ├── test ├── app.e2e-spec.ts ├── auth.e2e-spec.ts ├── jest-e2e.json └── utils │ └── mock-user.ts ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "ahmetuysal", 10 | "name": "Ahmet Uysal", 11 | "avatar_url": "https://avatars.githubusercontent.com/u/26417668?v=4", 12 | "profile": "https://github.com/ahmetuysal", 13 | "contributions": [ 14 | "code", 15 | "doc", 16 | "test" 17 | ] 18 | }, 19 | { 20 | "login": "dimitrisnl", 21 | "name": "Dimitrios Lytras", 22 | "avatar_url": "https://avatars.githubusercontent.com/u/4951004?v=4", 23 | "profile": "https://dnlytras.com", 24 | "contributions": [ 25 | "code" 26 | ] 27 | } 28 | ], 29 | "contributorsPerLine": 7, 30 | "projectName": "nest-hackathon-starter", 31 | "projectOwner": "ahmetuysal", 32 | "repoType": "github", 33 | "repoHost": "https://github.com", 34 | "skipCi": true 35 | } 36 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | 3 | # Logs 4 | logs 5 | *.log 6 | 7 | # Runtime data 8 | pids 9 | *.pid 10 | *.seed 11 | 12 | # Directory for instrumented libs generated by jscoverage/JSCover 13 | lib-cov 14 | 15 | # Coverage directory used by tools like istanbul 16 | coverage 17 | 18 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 19 | .grunt 20 | 21 | # node-waf configuration 22 | .lock-wscript 23 | 24 | # Compiled binary addons (http://nodejs.org/api/addons.html) 25 | build/Release 26 | .eslintcache 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | 32 | # OSX 33 | .DS_Store 34 | 35 | # App packaged 36 | dist 37 | 38 | .idea 39 | npm-debug.log.* 40 | __snapshots__ 41 | 42 | # Package.json 43 | package.json 44 | .travis.yml 45 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # eslint 37 | .eslintcache 38 | -------------------------------------------------------------------------------- /.kodiak.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [merge] 4 | delete_branch_on_merge = true 5 | 6 | [merge.automerge_dependencies] 7 | usernames = ["dependabot"] 8 | versions = ["minor", "patch"] -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ahmet Uysal 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nest Hackathon Starter 2 | 3 | 4 | 5 | [](#contributors-) 6 | 7 | 8 | 9 | This project contains boilerplate for creating APIs using [Nest](https://nestjs.com), a progressive [Node.js](http://nodejs.org) framework for building efficient and scalable server-side applications. 10 | 11 | It is mostly built to be used as a starting point in hackathons and implements common operations such as sign up, JWT authentication, mail validation, model validation and database access. 12 | 13 | You can also look at my [Angular Hackathon Starter](https://github.com/ahmetuysal/angular-hackathon-starter) template that shares the same contract with this API. 14 | 15 | ## Features 16 | 17 | 1. **PostgreSQL with Prisma** 18 | 19 | 2. **JWT Authentication** 20 | 21 | 3. **Mail Verification** 22 | 23 | 4. **Mail Change** 24 | 25 | 5. **Password Reset** 26 | 27 | 6. **Request Validation** 28 | 29 | 7. **Customizable Mail Templates** 30 | 31 | 8. **Swagger API Documentation** 32 | 33 | 9. **Security Techniques** 34 | 35 | 10. **Logger** 36 | 37 | ## Getting Started 38 | 39 | ### Installation 40 | 41 | 1. Make sure that you have [Node.js](https://nodejs.org)(>= 10.13.0, except for v13) installed. 42 | 2. Clone this repository by running `git clone https://github.com/ahmetuysal/nest-hackathon-starter.git ` or [directly create your own GitHub repository using this template](https://github.com/ahmetuysal/nest-hackathon-starter/generate). 43 | 3. Move to the appropriate directory: `cd `. 44 | 4. Run `yarn` to install dependencies. 45 | 46 | ### Configuration Files 47 | 48 | #### [Prisma](https://github.com/prisma/prisma) Configurations 49 | 50 | This template uses Postgres by default. If you want to use another database, follow instructions in the [official Nest recipe on Prisma](https://docs.nestjs.com/recipes/prisma). 51 | 52 | If you wish to use another database you will also have to edit the connection string on [`prisma/.env`](prisma/.env) file accordingly. 53 | 54 | Template includes three different environment options by default. Most of the time you will use the `local` 55 | environment when developing and `production` environment on production. You will need to fill out corresponding 56 | environment files in [`env`](env) directory. 57 | 58 | ```dosini 59 | DATABASE_HOST=__YOUR_DATABASE_URL__ 60 | DATABASE_PORT=5432 61 | DATABASE_USERNAME=__YOUR_USERNAME__ 62 | DATABASE_PASSWORD=__YOUR_PASSWORD__ 63 | DATABASE_NAME=__YOUR_DATABASE__ 64 | ``` 65 | 66 | #### JWT Configurations 67 | 68 | A secret key is needed in encryption process. Generate a secret key using a service like [randomkeygen](https://randomkeygen.com/). 69 | 70 | Enter your secret key to [`config.ts`](src/config.ts) file. You can also the change expiration time, default is 86400 seconds(1 day). 71 | 72 | ```js 73 | jwt: { 74 | secretOrKey: '__JWT_SECRET_KEY__', 75 | expiresIn: 86400, 76 | }, 77 | ``` 78 | 79 | #### [NodeMailer✉️](https://github.com/nodemailer/nodemailer) Configurations 80 | 81 | A delivery provider is required for sending mails with Nodemailer. I mostly use [SendGrid](https://sendgrid.com) to send mails, however, Nodemailer can work with any service with SMTP transport. 82 | 83 | To get a SendGrid API key: 84 | 85 | - Create a free account from [https://signup.sendgrid.com/](https://signup.sendgrid.com/) 86 | - Confirm your account via the activation email and login. 87 | - Create an API Key with mail sending capability. 88 | 89 | Enter your API key and sender credentials to [`config.ts`](src/config.ts) file. Sender credentials are the sender name and sender mail that will be seen by your users. 90 | 91 | ```js 92 | mail: 93 | service: { 94 | host: 'smtp.sendgrid.net', 95 | port: 587, 96 | secure: false, 97 | user: 'apikey', 98 | pass: '__SENDGRID_API_KEY__', 99 | }, 100 | senderCredentials: { 101 | name: '__SENDER_NAME__', 102 | email: '__SENDER_EMAIL__', 103 | }, 104 | }, 105 | ``` 106 | 107 | #### Mail Template Configurations 108 | 109 | Mail templates are highly customizable and heavily depend on configurations. Enter your project's information to [`config.ts`](src/config.ts). Urls are used as references in the templates. If your mail verification logic is independent from your front-end application, you can use API's own mail verification endpoint, e.g. `http://localhost:3000/auth/verify`, as `mailVerificationUrl`. Otherwise, send a HTTP `GET` request to verification endpoint with token added as a parameter named token, e.g, `http://localhost:3000/auth/verify?token=__VERIFICATION_TOKEN__` 110 | 111 | ```js 112 | project: { 113 | name: '__YOUR_PROJECT_NAME__', 114 | address: '__YOUR_PROJECT_ADDRESS__', 115 | logoUrl: 'https://__YOUR_PROJECT_LOGO_URL__', 116 | slogan: 'Made with ❤️ in Istanbul', 117 | color: '#123456', 118 | // You can enter as many social links as you want 119 | socials: [ 120 | ['GitHub', '__Project_GitHub_URL__'], 121 | ['__Social_Media_1__', '__Social_Media_1_URL__'], 122 | ['__Social_Media_2__', '__Social_Media_2_URL__'], 123 | ], 124 | url: 'http://localhost:4200', 125 | mailVerificationUrl: 'http://localhost:3000/auth/verify', 126 | mailChangeUrl: 'http://localhost:3000/auth/change-email', 127 | resetPasswordUrl: 'http://localhost:4200/reset-password', 128 | termsOfServiceUrl: 'http://localhost:4200/legal/terms', 129 | }, 130 | ``` 131 | 132 | ### Migrations 133 | 134 | Please refer to the official [Prisma Migrate Guide](https://www.prisma.io/docs/guides/database/developing-with-prisma-migrate) to get more info about Prisma migrations. 135 | 136 | ```bash 137 | # generate migration for local environment 138 | $ yarn migrate:dev:create 139 | # run migrations in local environment 140 | $ yarn migrate:dev 141 | 142 | # deploy migration to prod environment 143 | $ yarn migrate:deploy:prod 144 | ``` 145 | 146 | ### Running the app 147 | 148 | ```bash 149 | # development mode 150 | $ yarn start:dev 151 | 152 | # production 153 | $ yarn build 154 | $ yarn start:prod 155 | ``` 156 | 157 | ### Running the tests 158 | 159 | ```bash 160 | # unit tests 161 | $ yarn test 162 | 163 | # e2e tests 164 | $ yarn test:e2e 165 | 166 | # test coverage 167 | $ yarn test:cov 168 | ``` 169 | 170 | ## Contributors ✨ 171 | 172 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 173 | 174 | 175 | 176 | 177 | 178 | 179 | Ahmet Uysal💻 📖 ⚠️ 180 | Dimitrios Lytras💻 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 190 | 191 | ## Support Nest 192 | 193 | Nest is an MIT-licensed open source project. If you'd like to join support Nest, please [read more here](https://docs.nestjs.com/support). 194 | 195 | ## License 196 | 197 | Licenced under [MIT License](LICENSE). Nest is also MIT licensed. 198 | -------------------------------------------------------------------------------- /env/local.env: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=localhost 2 | DATABASE_PORT=5432 3 | DATABASE_USERNAME=test 4 | DATABASE_PASSWORD=test 5 | DATABASE_NAME=starter 6 | -------------------------------------------------------------------------------- /env/production.env: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=__YOUR_PRODUCTION_DATABASE_URL__ 2 | DATABASE_PORT=5432 3 | DATABASE_USERNAME=__YOUR_USERNAME__ 4 | DATABASE_PASSWORD=__YOUR_PASSWORD__ 5 | DATABASE_NAME=__YOUR_DATABASE__ 6 | -------------------------------------------------------------------------------- /env/test.env: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=localhost 2 | DATABASE_PORT=5432 3 | DATABASE_USERNAME=test 4 | DATABASE_PASSWORD=test 5 | DATABASE_NAME=starter 6 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import 'jest-extended'; 3 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "plugins": [ 7 | { 8 | "name": "@nestjs/swagger/plugin", 9 | "options": { 10 | "dtoFileNameSuffix": [".model.ts", ".request.ts", ".response.ts"] 11 | } 12 | } 13 | ] 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-hackathon-starter", 3 | "version": "0.1.0", 4 | "description": "Hackathon starter project for NestJS.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/ahmetuysal/nest-hackathon-starter" 8 | }, 9 | "author": "Ahmet Uysal", 10 | "license": "MIT", 11 | "scripts": { 12 | "build": "rimraf dist && tsc -p tsconfig.build.json", 13 | "clean": "rimraf dist", 14 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 15 | "start": "dotenv -e env/local.env -- nest start", 16 | "start:dev": "dotenv -e env/local.env -- nest start --watch", 17 | "start:debug": "dotenv -e env/local.env -- nest start --debug --watch", 18 | "start:prod": "dotenv -e env/production.env -- node dist/main", 19 | "lint": "eslint \"{src,test}/**/*.ts\" --fix", 20 | "test": "dotenv -e env/test.env -- jest", 21 | "test:watch": "dotenv -e env/test.env -- jest --watch", 22 | "test:cov": "dotenv -e env/test.env -- jest --coverage", 23 | "test:debug": "dotenv -e env/test.env -- node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 24 | "test:e2e": "dotenv -e env/test.env -- jest --config ./test/jest-e2e.json", 25 | "migrate:dev": "dotenv -e env/local.env -- prisma migrate dev --preview-feature", 26 | "migrate:dev:create": "dotenv -e env/local.env -- prisma migrate dev --create-only --preview-feature", 27 | "migrate:dev:pull": "dotenv -e env/local.env -- prisma db pull", 28 | "migrate:reset": "dotenv -e env/local.env -- prisma migrate reset --preview-feature", 29 | "migrate:deploy:prod": "dotenv -e env/production.env -- npx prisma migrate deploy --preview-feature", 30 | "migrate:deploy:dev": "dotenv -e env/dev.env -- npx prisma migrate deploy --preview-feature", 31 | "migrate:status": "npx prisma migrate status --preview-feature", 32 | "migrate:resolve": "npx prisma migrate resolve --preview-feature", 33 | "prisma:studio": "npx prisma studio", 34 | "prisma:generate": "npx prisma generate", 35 | "prisma:generate:watch": "npx prisma generate --watch", 36 | "postinstall": "npm run prisma:generate" 37 | }, 38 | "lint-staged": { 39 | "*.{js,ts}": [ 40 | "cross-env NODE_ENV=development eslint --cache", 41 | "dotenv -e env/test.env -- cross-env NODE_ENV=test jest --bail --findRelatedTests" 42 | ], 43 | "{*.json,.{prettierrc}}": [ 44 | "prettier --ignore-path .eslintignore --parser json --write" 45 | ], 46 | "*.{html,md,yml}": [ 47 | "prettier --ignore-path .eslintignore --single-quote --write" 48 | ] 49 | }, 50 | "dependencies": { 51 | "@nestjs/common": "^8.1.1", 52 | "@nestjs/config": "1.0.2", 53 | "@nestjs/core": "^8.1.1", 54 | "@nestjs/jwt": "^8.0.0", 55 | "@nestjs/passport": "^8.0.1", 56 | "@nestjs/platform-express": "^8.1.1", 57 | "@nestjs/swagger": "^5.1.2", 58 | "@nestjs/throttler": "2.0.0", 59 | "@prisma/client": "3.3.0", 60 | "bcrypt": "^5.0.1", 61 | "class-transformer": "^0.4.0", 62 | "class-validator": "^0.14.0", 63 | "dotenv": "^10.0.0", 64 | "dotenv-cli": "4.1.1", 65 | "helmet": "^4.6.0", 66 | "nanoid": "^3.1.31", 67 | "nodemailer": "^6.7.0", 68 | "passport": "^0.6.0", 69 | "passport-jwt": "^4.0.0", 70 | "reflect-metadata": "^0.1.12", 71 | "request-ip": "^2.1.3", 72 | "rimraf": "^3.0.2", 73 | "rxjs": "^7.4.0", 74 | "swagger-ui-express": "^4.1.4" 75 | }, 76 | "devDependencies": { 77 | "@commitlint/cli": "^11.0.0", 78 | "@commitlint/config-conventional": "^11.0.0", 79 | "@nestjs/cli": "^8.1.3", 80 | "@nestjs/schematics": "^8.0.4", 81 | "@nestjs/testing": "^8.1.1", 82 | "@types/bcrypt": "^3.0.0", 83 | "@types/express": "^4.17.13", 84 | "@types/jest": "^27.0.2", 85 | "@types/nanoid": "^2.1.0", 86 | "@types/node": "^16.11.1", 87 | "@types/nodemailer": "^6.4.0", 88 | "@types/passport-jwt": "^3.0.3", 89 | "@types/supertest": "^2.0.11", 90 | "@typescript-eslint/eslint-plugin": "^4.29.2", 91 | "@typescript-eslint/parser": "^4.29.2", 92 | "cross-env": "^7.0.2", 93 | "eslint": "^7.32.0", 94 | "eslint-config-airbnb-typescript": "^12.0.0", 95 | "eslint-config-prettier": "^8.3.0", 96 | "eslint-plugin-import": "^2.22.0", 97 | "eslint-plugin-prettier": "^3.4.1", 98 | "husky": "^4.2.5", 99 | "jest": "^27.3.0", 100 | "jest-extended": "1.1.0", 101 | "jest-mock-extended": "2.0.4", 102 | "lint-staged": "^10.2.11", 103 | "prettier": "^2.4.1", 104 | "prisma": "3.3.0", 105 | "source-map-support": "^0.5.20", 106 | "supertest": "^6.1.6", 107 | "ts-jest": "^27.0.7", 108 | "ts-loader": "^9.2.6", 109 | "ts-node": "^10.3.0", 110 | "tsconfig-paths": "^3.11.0", 111 | "typescript": "^4.4.4" 112 | }, 113 | "jest": { 114 | "moduleFileExtensions": [ 115 | "js", 116 | "json", 117 | "ts" 118 | ], 119 | "roots": [ 120 | "src", 121 | "test" 122 | ], 123 | "setupFilesAfterEnv": [ 124 | "jest-extended", 125 | "./jest.setup.ts" 126 | ], 127 | "testRegex": ".spec.ts$", 128 | "transform": { 129 | "^.+\\.(t|j)s$": "ts-jest" 130 | }, 131 | "coverageDirectory": "../coverage", 132 | "testEnvironment": "node" 133 | }, 134 | "husky": { 135 | "hooks": { 136 | "pre-commit": "lint-staged", 137 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /prisma/.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}" 2 | -------------------------------------------------------------------------------- /prisma/migrations/20211021152800_initial_migration/migration.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; 2 | 3 | -- CreateTable 4 | CREATE TABLE "email-change" ( 5 | "token" CHAR(21) NOT NULL, 6 | "newEmail" TEXT NOT NULL, 7 | "userId" INTEGER NOT NULL, 8 | "validUntil" TIMESTAMP(6) NOT NULL DEFAULT (timezone('utc'::text, now()) + '2 days'::interval), 9 | 10 | CONSTRAINT "email-change_pkey" PRIMARY KEY ("token") 11 | ); 12 | 13 | -- CreateTable 14 | CREATE TABLE "email-verification" ( 15 | "token" CHAR(21) NOT NULL, 16 | "userId" INTEGER NOT NULL, 17 | "validUntil" TIMESTAMP(6) NOT NULL DEFAULT (timezone('utc'::text, now()) + '2 days'::interval), 18 | 19 | CONSTRAINT "email-verification_pkey" PRIMARY KEY ("token") 20 | ); 21 | 22 | -- CreateTable 23 | CREATE TABLE "password-reset" ( 24 | "token" CHAR(21) NOT NULL, 25 | "userId" INTEGER NOT NULL, 26 | "validUntil" TIMESTAMP(6) NOT NULL DEFAULT (timezone('utc'::text, now()) + '2 days'::interval), 27 | 28 | CONSTRAINT "password-reset_pkey" PRIMARY KEY ("token") 29 | ); 30 | 31 | -- CreateTable 32 | CREATE TABLE "user" ( 33 | "id" SERIAL NOT NULL, 34 | "username" TEXT NOT NULL, 35 | "email" TEXT NOT NULL, 36 | "passwordHash" TEXT NOT NULL, 37 | "firstName" TEXT NOT NULL, 38 | "lastName" TEXT NOT NULL, 39 | "middleName" TEXT, 40 | "image" TEXT, 41 | "emailVerified" BOOLEAN NOT NULL DEFAULT false, 42 | "birthDate" DATE, 43 | "registrationDate" TIMESTAMP(6) NOT NULL DEFAULT timezone('UTC'::text, now()), 44 | 45 | CONSTRAINT "user_pkey" PRIMARY KEY ("id") 46 | ); 47 | 48 | -- CreateIndex 49 | CREATE UNIQUE INDEX "email-change_userId_key" ON "email-change"("userId"); 50 | 51 | -- CreateIndex 52 | CREATE UNIQUE INDEX "email-verification_userId_key" ON "email-verification"("userId"); 53 | 54 | -- CreateIndex 55 | CREATE UNIQUE INDEX "password-reset_userId_key" ON "password-reset"("userId"); 56 | 57 | -- CreateIndex 58 | CREATE UNIQUE INDEX "user_username_key" ON "user"("username"); 59 | 60 | -- CreateIndex 61 | CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); 62 | 63 | -- AddForeignKey 64 | ALTER TABLE "email-change" ADD CONSTRAINT "email-change_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 65 | 66 | -- AddForeignKey 67 | ALTER TABLE "email-verification" ADD CONSTRAINT "email-verification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 68 | 69 | -- AddForeignKey 70 | ALTER TABLE "password-reset" ADD CONSTRAINT "password-reset_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; 71 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model EmailChange { 11 | token String @id @db.Char(21) 12 | newEmail String 13 | userId Int @unique 14 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 15 | validUntil DateTime @default(dbgenerated("(timezone('utc'::text, now()) + '2 days'::interval)")) @db.Timestamp(6) 16 | 17 | @@map("email-change") 18 | } 19 | 20 | model EmailVerification { 21 | token String @id @db.Char(21) 22 | userId Int @unique 23 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 24 | validUntil DateTime @default(dbgenerated("(timezone('utc'::text, now()) + '2 days'::interval)")) @db.Timestamp(6) 25 | 26 | @@map("email-verification") 27 | } 28 | 29 | model PasswordReset { 30 | token String @id @db.Char(21) 31 | userId Int @unique 32 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 33 | validUntil DateTime @default(dbgenerated("(timezone('utc'::text, now()) + '2 days'::interval)")) @db.Timestamp(6) 34 | 35 | @@map("password-reset") 36 | } 37 | 38 | model User { 39 | id Int @id @default(autoincrement()) 40 | username String @unique 41 | email String @unique 42 | passwordHash String 43 | firstName String 44 | lastName String 45 | middleName String? 46 | image String? 47 | emailVerified Boolean @default(false) 48 | birthDate DateTime? @db.Date 49 | registrationDate DateTime @default(dbgenerated("timezone('UTC'::text, now())")) @db.Timestamp(6) 50 | emailChange EmailChange? 51 | emailVerification EmailVerification? 52 | passwordReset PasswordReset? 53 | 54 | 55 | @@map("user") 56 | } 57 | -------------------------------------------------------------------------------- /src/app.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AppController } from './app.controller'; 3 | 4 | describe('AppController', () => { 5 | let controller: AppController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AppController], 10 | }).compile(); 11 | 12 | controller = module.get(AppController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | 4 | @ApiTags('health-check') 5 | @Controller('') 6 | export class AppController { 7 | @Get() 8 | @HttpCode(HttpStatus.OK) 9 | // eslint-disable-next-line @typescript-eslint/no-empty-function 10 | healthCheck(): void {} 11 | } 12 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ThrottlerModule } from '@nestjs/throttler'; 3 | import { APP_GUARD } from '@nestjs/core'; 4 | import { UserModule } from './user/user.module'; 5 | import { AuthModule } from './auth/auth.module'; 6 | import { MailSenderModule } from './mail-sender/mail-sender.module'; 7 | import { ThrottlerBehindProxyGuard } from './common/guards/throttler-behind-proxy.guard'; 8 | import { AppController } from './app.controller'; 9 | 10 | @Module({ 11 | imports: [ 12 | ThrottlerModule.forRoot({ 13 | ttl: 60, 14 | limit: 50, 15 | }), 16 | UserModule, 17 | AuthModule, 18 | MailSenderModule, 19 | ], 20 | providers: [ 21 | { 22 | provide: APP_GUARD, 23 | useClass: ThrottlerBehindProxyGuard, 24 | }, 25 | ], 26 | controllers: [AppController], 27 | }) 28 | export class AppModule {} 29 | -------------------------------------------------------------------------------- /src/auth/auth-user.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | 3 | export type AuthUser = User; 4 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | import { AuthController } from './auth.controller'; 5 | import { AuthService } from './auth.service'; 6 | import { MailSenderService } from '../mail-sender/mail-sender.service'; 7 | import { UserService } from '../user/user.service'; 8 | import config from '../config'; 9 | import { PrismaService } from '../common/services/prisma.service'; 10 | 11 | describe('Auth Controller', () => { 12 | let controller: AuthController; 13 | let spyService: AuthService; 14 | 15 | beforeEach(async () => { 16 | const module: TestingModule = await Test.createTestingModule({ 17 | controllers: [AuthController], 18 | imports: [ 19 | JwtModule.register({ 20 | secret: config.jwt.secretOrKey, 21 | signOptions: { 22 | expiresIn: config.jwt.expiresIn, 23 | }, 24 | }), 25 | PassportModule.register({ defaultStrategy: 'jwt' }), 26 | ], 27 | providers: [AuthService, MailSenderService, UserService, PrismaService], 28 | }).compile(); 29 | 30 | controller = module.get(AuthController); 31 | spyService = module.get(AuthService); 32 | }); 33 | 34 | it('should be defined', () => { 35 | expect(controller).toBeDefined(); 36 | expect(spyService).toBeDefined(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | HttpStatus, 7 | Param, 8 | Post, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 13 | import { AuthGuard } from '@nestjs/passport'; 14 | import { AuthService } from './auth.service'; 15 | import { Usr } from '../user/user.decorator'; 16 | import { 17 | ChangeEmailRequest, 18 | ChangePasswordRequest, 19 | CheckEmailRequest, 20 | CheckEmailResponse, 21 | CheckUsernameRequest, 22 | CheckUsernameResponse, 23 | LoginRequest, 24 | LoginResponse, 25 | ResetPasswordRequest, 26 | SignupRequest, 27 | } from './models'; 28 | import { UserResponse } from '../user/models'; 29 | import { AuthUser } from './auth-user'; 30 | 31 | @ApiTags('auth') 32 | @Controller('auth') 33 | export class AuthController { 34 | constructor(private readonly authService: AuthService) {} 35 | 36 | @Post('check-username') 37 | @HttpCode(HttpStatus.OK) 38 | async checkUsernameAvailability( 39 | @Body() checkUsernameRequest: CheckUsernameRequest, 40 | ): Promise { 41 | const isAvailable = await this.authService.isUsernameAvailable( 42 | checkUsernameRequest.username, 43 | ); 44 | return new CheckUsernameResponse(isAvailable); 45 | } 46 | 47 | @Post('check-email') 48 | @HttpCode(HttpStatus.OK) 49 | async checkEmailAvailability( 50 | @Body() checkEmailRequest: CheckEmailRequest, 51 | ): Promise { 52 | const isAvailable = await this.authService.isEmailAvailable( 53 | checkEmailRequest.email, 54 | ); 55 | return new CheckEmailResponse(isAvailable); 56 | } 57 | 58 | @Post('signup') 59 | @HttpCode(HttpStatus.CREATED) 60 | async signup(@Body() signupRequest: SignupRequest): Promise { 61 | await this.authService.signup(signupRequest); 62 | } 63 | 64 | @Post('login') 65 | @HttpCode(HttpStatus.OK) 66 | async login(@Body() loginRequest: LoginRequest): Promise { 67 | return new LoginResponse(await this.authService.login(loginRequest)); 68 | } 69 | 70 | @ApiBearerAuth() 71 | @Get() 72 | @HttpCode(HttpStatus.OK) 73 | @UseGuards(AuthGuard()) 74 | async getUserWithToken(@Usr() user: AuthUser): Promise { 75 | return UserResponse.fromUserEntity(user); 76 | } 77 | 78 | @Get('verify') 79 | @HttpCode(HttpStatus.OK) 80 | async verifyMail(@Query('token') token: string): Promise { 81 | await this.authService.verifyEmail(token); 82 | } 83 | 84 | @ApiBearerAuth() 85 | @Post('change-email') 86 | @HttpCode(HttpStatus.OK) 87 | @UseGuards(AuthGuard()) 88 | async sendChangeEmailMail( 89 | @Usr() user: AuthUser, 90 | @Body() changeEmailRequest: ChangeEmailRequest, 91 | ): Promise { 92 | await this.authService.sendChangeEmailMail( 93 | changeEmailRequest, 94 | user.id, 95 | user.firstName, 96 | user.email, 97 | ); 98 | } 99 | 100 | @Get('change-email') 101 | @HttpCode(HttpStatus.OK) 102 | async changeEmail(@Query('token') token: string): Promise { 103 | await this.authService.changeEmail(token); 104 | } 105 | 106 | @Post('forgot-password/:email') 107 | @HttpCode(HttpStatus.OK) 108 | async sendResetPassword(@Param('email') email: string): Promise { 109 | await this.authService.sendResetPasswordMail(email); 110 | } 111 | 112 | @Post('change-password') 113 | @HttpCode(HttpStatus.OK) 114 | @UseGuards(AuthGuard()) 115 | async changePassword( 116 | @Body() changePasswordRequest: ChangePasswordRequest, 117 | @Usr() user: AuthUser, 118 | ): Promise { 119 | await this.authService.changePassword( 120 | changePasswordRequest, 121 | user.id, 122 | user.firstName, 123 | user.email, 124 | ); 125 | } 126 | 127 | @Post('reset-password') 128 | @HttpCode(HttpStatus.OK) 129 | async resetPassword( 130 | @Body() resetPasswordRequest: ResetPasswordRequest, 131 | ): Promise { 132 | await this.authService.resetPassword(resetPasswordRequest); 133 | } 134 | 135 | @Post('resend-verification') 136 | @HttpCode(HttpStatus.OK) 137 | @UseGuards(AuthGuard()) 138 | async resendVerificationMail(@Usr() user: AuthUser): Promise { 139 | await this.authService.resendVerificationMail( 140 | user.firstName, 141 | user.email, 142 | user.id, 143 | ); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { AuthService } from './auth.service'; 5 | import { UserModule } from '../user/user.module'; 6 | import { AuthController } from './auth.controller'; 7 | import config from '../config'; 8 | import { JwtStrategy } from './jwt.strategy'; 9 | import { MailSenderModule } from '../mail-sender/mail-sender.module'; 10 | import { PrismaService } from '../common/services/prisma.service'; 11 | 12 | @Module({ 13 | imports: [ 14 | UserModule, 15 | PassportModule.register({ defaultStrategy: 'jwt' }), 16 | JwtModule.register({ 17 | secret: config.jwt.secretOrKey, 18 | signOptions: { 19 | expiresIn: config.jwt.expiresIn, 20 | }, 21 | }), 22 | MailSenderModule, 23 | ], 24 | providers: [AuthService, JwtStrategy, PrismaService], 25 | controllers: [AuthController], 26 | }) 27 | export class AuthModule {} 28 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { JwtModule, JwtService } from '@nestjs/jwt'; 3 | import { mockDeep, MockProxy } from 'jest-mock-extended'; 4 | import { AuthService } from './auth.service'; 5 | import { MailSenderService } from '../mail-sender/mail-sender.service'; 6 | import { UserService } from '../user/user.service'; 7 | import config from '../config'; 8 | import { PrismaService } from '../common/services/prisma.service'; 9 | 10 | describe('AuthService', () => { 11 | let service: AuthService; 12 | // mock services 13 | let spyMailSenderService: MailSenderService; 14 | let spyUserService: UserService; 15 | let spyJwtService: JwtService; 16 | let spyPrismaService: MockProxy; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | imports: [ 21 | JwtModule.register({ 22 | secret: config.jwt.secretOrKey, 23 | signOptions: { 24 | expiresIn: config.jwt.expiresIn, 25 | }, 26 | }), 27 | ], 28 | providers: [ 29 | AuthService, 30 | MailSenderService, 31 | UserService, 32 | { 33 | provide: PrismaService, 34 | useFactory: () => mockDeep(), 35 | }, 36 | ], 37 | }).compile(); 38 | 39 | service = module.get(AuthService); 40 | spyMailSenderService = module.get(MailSenderService); 41 | spyUserService = module.get(UserService); 42 | spyJwtService = module.get(JwtService); 43 | spyPrismaService = module.get(PrismaService) as MockProxy; 44 | }); 45 | 46 | it('should be defined', () => { 47 | expect(service).toBeDefined(); 48 | expect(spyMailSenderService).toBeDefined(); 49 | expect(spyUserService).toBeDefined(); 50 | expect(spyJwtService).toBeDefined(); 51 | expect(spyPrismaService).toBeDefined(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConflictException, 3 | Injectable, 4 | Logger, 5 | NotFoundException, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import * as bcrypt from 'bcrypt'; 9 | import { JwtService } from '@nestjs/jwt'; 10 | import { nanoid } from 'nanoid'; 11 | import { Prisma } from '@prisma/client'; 12 | import { UserService } from '../user/user.service'; 13 | import { JwtPayload } from './jwt-payload'; 14 | import { MailSenderService } from '../mail-sender/mail-sender.service'; 15 | import { 16 | ChangeEmailRequest, 17 | ChangePasswordRequest, 18 | LoginRequest, 19 | ResetPasswordRequest, 20 | SignupRequest, 21 | } from './models'; 22 | import { AuthUser } from './auth-user'; 23 | import { PrismaService } from '../common/services/prisma.service'; 24 | 25 | @Injectable() 26 | export class AuthService { 27 | constructor( 28 | private readonly prisma: PrismaService, 29 | private readonly userService: UserService, 30 | private readonly jwtService: JwtService, 31 | private readonly mailSenderService: MailSenderService, 32 | ) {} 33 | 34 | async signup(signupRequest: SignupRequest): Promise { 35 | const emailVerificationToken = nanoid(); 36 | 37 | try { 38 | await this.prisma.user.create({ 39 | data: { 40 | username: signupRequest.username.toLowerCase(), 41 | email: signupRequest.email.toLowerCase(), 42 | passwordHash: await bcrypt.hash(signupRequest.password, 10), 43 | firstName: signupRequest.firstName, 44 | lastName: signupRequest.lastName, 45 | middleName: signupRequest.middleName, 46 | emailVerification: { 47 | create: { 48 | token: emailVerificationToken, 49 | }, 50 | }, 51 | }, 52 | select: null, 53 | }); 54 | } catch (e) { 55 | if (e instanceof Prisma.PrismaClientKnownRequestError) { 56 | if (e.code === 'P2002') { 57 | // unique constraint 58 | throw new ConflictException(); 59 | } else throw e; 60 | } else throw e; 61 | } 62 | 63 | await this.mailSenderService.sendVerifyEmailMail( 64 | signupRequest.firstName, 65 | signupRequest.email, 66 | emailVerificationToken, 67 | ); 68 | } 69 | 70 | async resendVerificationMail( 71 | name: string, 72 | email: string, 73 | userId: number, 74 | ): Promise { 75 | // delete old email verification tokens if exist 76 | const deletePrevEmailVerificationIfExist = 77 | this.prisma.emailVerification.deleteMany({ 78 | where: { userId }, 79 | }); 80 | 81 | const token = nanoid(); 82 | 83 | const createEmailVerification = this.prisma.emailVerification.create({ 84 | data: { 85 | userId, 86 | token, 87 | }, 88 | select: null, 89 | }); 90 | 91 | await this.prisma.$transaction([ 92 | deletePrevEmailVerificationIfExist, 93 | createEmailVerification, 94 | ]); 95 | 96 | await this.mailSenderService.sendVerifyEmailMail(name, email, token); 97 | } 98 | 99 | async verifyEmail(token: string): Promise { 100 | const emailVerification = await this.prisma.emailVerification.findUnique({ 101 | where: { token }, 102 | }); 103 | 104 | if ( 105 | emailVerification !== null && 106 | emailVerification.validUntil > new Date() 107 | ) { 108 | await this.prisma.user.update({ 109 | where: { id: emailVerification.userId }, 110 | data: { 111 | emailVerified: true, 112 | }, 113 | select: null, 114 | }); 115 | } else { 116 | Logger.log(`Verify email called with invalid email token ${token}`); 117 | throw new NotFoundException(); 118 | } 119 | } 120 | 121 | async sendChangeEmailMail( 122 | changeEmailRequest: ChangeEmailRequest, 123 | userId: number, 124 | name: string, 125 | oldEmail: string, 126 | ): Promise { 127 | const emailAvailable = await this.isEmailAvailable( 128 | changeEmailRequest.newEmail, 129 | ); 130 | if (!emailAvailable) { 131 | Logger.log( 132 | `User with id ${userId} tried to change its email to already used ${changeEmailRequest.newEmail}`, 133 | ); 134 | throw new ConflictException(); 135 | } 136 | 137 | const deletePrevEmailChangeIfExist = this.prisma.emailChange.deleteMany({ 138 | where: { userId }, 139 | }); 140 | 141 | const token = nanoid(); 142 | 143 | const createEmailChange = this.prisma.emailChange.create({ 144 | data: { 145 | userId, 146 | token, 147 | newEmail: changeEmailRequest.newEmail, 148 | }, 149 | select: null, 150 | }); 151 | 152 | await this.prisma.$transaction([ 153 | deletePrevEmailChangeIfExist, 154 | createEmailChange, 155 | ]); 156 | 157 | await this.mailSenderService.sendChangeEmailMail(name, oldEmail, token); 158 | } 159 | 160 | async changeEmail(token: string): Promise { 161 | const emailChange = await this.prisma.emailChange.findUnique({ 162 | where: { token }, 163 | }); 164 | 165 | if (emailChange !== null && emailChange.validUntil > new Date()) { 166 | await this.prisma.user.update({ 167 | where: { id: emailChange.userId }, 168 | data: { 169 | email: emailChange.newEmail.toLowerCase(), 170 | }, 171 | select: null, 172 | }); 173 | } else { 174 | Logger.log(`Invalid email change token ${token} is rejected.`); 175 | throw new NotFoundException(); 176 | } 177 | } 178 | 179 | async sendResetPasswordMail(email: string): Promise { 180 | const user = await this.prisma.user.findUnique({ 181 | where: { email: email.toLowerCase() }, 182 | select: { 183 | id: true, 184 | firstName: true, 185 | email: true, 186 | }, 187 | }); 188 | 189 | if (user === null) { 190 | throw new NotFoundException(); 191 | } 192 | 193 | const deletePrevPasswordResetIfExist = this.prisma.passwordReset.deleteMany( 194 | { 195 | where: { userId: user.id }, 196 | }, 197 | ); 198 | 199 | const token = nanoid(); 200 | 201 | const createPasswordReset = this.prisma.passwordReset.create({ 202 | data: { 203 | userId: user.id, 204 | token, 205 | }, 206 | select: null, 207 | }); 208 | 209 | await this.prisma.$transaction([ 210 | deletePrevPasswordResetIfExist, 211 | createPasswordReset, 212 | ]); 213 | 214 | await this.mailSenderService.sendResetPasswordMail( 215 | user.firstName, 216 | user.email, 217 | token, 218 | ); 219 | } 220 | 221 | async resetPassword( 222 | resetPasswordRequest: ResetPasswordRequest, 223 | ): Promise { 224 | const passwordReset = await this.prisma.passwordReset.findUnique({ 225 | where: { token: resetPasswordRequest.token }, 226 | }); 227 | 228 | if (passwordReset !== null && passwordReset.validUntil > new Date()) { 229 | await this.prisma.user.update({ 230 | where: { id: passwordReset.userId }, 231 | data: { 232 | passwordHash: await bcrypt.hash(resetPasswordRequest.newPassword, 10), 233 | }, 234 | select: null, 235 | }); 236 | } else { 237 | Logger.log( 238 | `Invalid reset password token ${resetPasswordRequest.token} is rejected`, 239 | ); 240 | throw new NotFoundException(); 241 | } 242 | } 243 | 244 | async changePassword( 245 | changePasswordRequest: ChangePasswordRequest, 246 | userId: number, 247 | name: string, 248 | email: string, 249 | ): Promise { 250 | await this.prisma.user.update({ 251 | where: { 252 | id: userId, 253 | }, 254 | data: { 255 | passwordHash: await bcrypt.hash(changePasswordRequest.newPassword, 10), 256 | }, 257 | select: null, 258 | }); 259 | 260 | // no need to wait for information email 261 | this.mailSenderService.sendPasswordChangeInfoMail(name, email); 262 | } 263 | 264 | async validateUser(payload: JwtPayload): Promise { 265 | const user = await this.prisma.user.findUnique({ 266 | where: { id: payload.id }, 267 | }); 268 | 269 | if ( 270 | user !== null && 271 | user.email === payload.email && 272 | user.username === payload.username 273 | ) { 274 | return user; 275 | } 276 | throw new UnauthorizedException(); 277 | } 278 | 279 | async login(loginRequest: LoginRequest): Promise { 280 | const normalizedIdentifier = loginRequest.identifier.toLowerCase(); 281 | const user = await this.prisma.user.findFirst({ 282 | where: { 283 | OR: [ 284 | { 285 | username: normalizedIdentifier, 286 | }, 287 | { 288 | email: normalizedIdentifier, 289 | }, 290 | ], 291 | }, 292 | select: { 293 | id: true, 294 | passwordHash: true, 295 | email: true, 296 | username: true, 297 | }, 298 | }); 299 | 300 | if ( 301 | user === null || 302 | !bcrypt.compareSync(loginRequest.password, user.passwordHash) 303 | ) { 304 | throw new UnauthorizedException(); 305 | } 306 | 307 | const payload: JwtPayload = { 308 | id: user.id, 309 | email: user.email, 310 | username: user.username, 311 | }; 312 | 313 | return this.jwtService.signAsync(payload); 314 | } 315 | 316 | async isUsernameAvailable(username: string): Promise { 317 | const user = await this.prisma.user.findUnique({ 318 | where: { username: username.toLowerCase() }, 319 | select: { username: true }, 320 | }); 321 | return user === null; 322 | } 323 | 324 | async isEmailAvailable(email: string): Promise { 325 | const user = await this.prisma.user.findUnique({ 326 | where: { email: email.toLowerCase() }, 327 | select: { email: true }, 328 | }); 329 | return user === null; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/auth/jwt-payload.ts: -------------------------------------------------------------------------------- 1 | export interface JwtPayload { 2 | id: number; 3 | username: string; 4 | email: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 4 | import { AuthService } from './auth.service'; 5 | import { JwtPayload } from './jwt-payload'; 6 | import config from '../config'; 7 | import { AuthUser } from './auth-user'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor(private readonly authService: AuthService) { 12 | super({ 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | ignoreExpiration: false, 15 | secretOrKey: config.jwt.secretOrKey, 16 | }); 17 | } 18 | 19 | async validate(payload: JwtPayload): Promise { 20 | const user = await this.authService.validateUser(payload); 21 | if (!user) { 22 | throw new UnauthorizedException(); 23 | } 24 | return user; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/auth/models/index.ts: -------------------------------------------------------------------------------- 1 | export { ChangeEmailRequest } from './request/change-email.request'; 2 | export { ChangePasswordRequest } from './request/change-password.request'; 3 | export { CheckEmailRequest } from './request/check-email.request'; 4 | export { CheckUsernameRequest } from './request/check-username.request'; 5 | export { LoginRequest } from './request/login.request'; 6 | export { ResetPasswordRequest } from './request/reset-password.request'; 7 | export { SignupRequest } from './request/signup.request'; 8 | 9 | export { CheckEmailResponse } from './response/check-email.response'; 10 | export { CheckUsernameResponse } from './response/check-username.response'; 11 | export { LoginResponse } from './response/login.response'; 12 | -------------------------------------------------------------------------------- /src/auth/models/request/change-email.request.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty } from 'class-validator'; 2 | 3 | export class ChangeEmailRequest { 4 | @IsNotEmpty() 5 | @IsEmail() 6 | newEmail: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/models/request/change-password.request.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, MinLength } from 'class-validator'; 2 | 3 | export class ChangePasswordRequest { 4 | @IsNotEmpty() 5 | @MinLength(8) 6 | newPassword: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/models/request/check-email.request.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty } from 'class-validator'; 2 | 3 | export class CheckEmailRequest { 4 | @IsNotEmpty() 5 | @IsEmail() 6 | email: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/models/request/check-username.request.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, MaxLength } from 'class-validator'; 2 | 3 | export class CheckUsernameRequest { 4 | @IsNotEmpty() 5 | @MaxLength(20) 6 | username: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/models/request/login.request.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, MinLength } from 'class-validator'; 2 | 3 | export class LoginRequest { 4 | @IsNotEmpty() 5 | // username or email 6 | identifier: string; 7 | 8 | @IsNotEmpty() 9 | @MinLength(8) 10 | password: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/models/request/reset-password.request.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, Length, MinLength } from 'class-validator'; 2 | 3 | export class ResetPasswordRequest { 4 | @IsNotEmpty() 5 | @Length(21) 6 | token: string; 7 | 8 | @IsNotEmpty() 9 | @MinLength(8) 10 | newPassword: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/auth/models/request/signup.request.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsOptional, 5 | Matches, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | export class SignupRequest { 11 | @IsNotEmpty() 12 | @IsEmail() 13 | email: string; 14 | 15 | @IsNotEmpty() 16 | // alphanumeric characters and - are valid 17 | // you can change this as you like 18 | @Matches(RegExp('^[a-zA-Z0-9\\-]+$')) 19 | @MaxLength(20) 20 | username: string; 21 | 22 | @IsNotEmpty() 23 | @MinLength(8) 24 | password: string; 25 | 26 | @IsNotEmpty() 27 | @Matches(RegExp('^[A-Za-zıöüçğşİÖÜÇĞŞñÑáéíóúÁÉÍÓÚ ]+$')) 28 | @MaxLength(20) 29 | firstName: string; 30 | 31 | @IsNotEmpty() 32 | @Matches(RegExp('^[A-Za-zıöüçğşİÖÜÇĞŞñÑáéíóúÁÉÍÓÚ ]+$')) 33 | @MaxLength(20) 34 | lastName: string; 35 | 36 | @IsOptional() 37 | @IsNotEmpty() 38 | @Matches(RegExp('^[A-Za-zıöüçğşİÖÜÇĞŞñÑáéíóúÁÉÍÓÚ ]+$')) 39 | @MaxLength(20) 40 | middleName?: string; 41 | } 42 | -------------------------------------------------------------------------------- /src/auth/models/response/check-email.response.ts: -------------------------------------------------------------------------------- 1 | export class CheckEmailResponse { 2 | isEmailAvailable: boolean; 3 | 4 | constructor(isEmailAvailable: boolean) { 5 | this.isEmailAvailable = isEmailAvailable; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/models/response/check-username.response.ts: -------------------------------------------------------------------------------- 1 | export class CheckUsernameResponse { 2 | isUsernameAvailable: boolean; 3 | 4 | constructor(isUsernameAvailable: boolean) { 5 | this.isUsernameAvailable = isUsernameAvailable; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/models/response/login.response.ts: -------------------------------------------------------------------------------- 1 | export class LoginResponse { 2 | token: string; 3 | 4 | constructor(token: string) { 5 | this.token = token; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/common/guards/throttler-behind-proxy.guard.ts: -------------------------------------------------------------------------------- 1 | import { ThrottlerGuard } from '@nestjs/throttler'; 2 | import { Injectable } from '@nestjs/common'; 3 | import * as requestIp from 'request-ip'; 4 | 5 | @Injectable() 6 | export class ThrottlerBehindProxyGuard extends ThrottlerGuard { 7 | protected getTracker(req: Record): string { 8 | return ( 9 | requestIp.getClientIp(req as unknown as requestIp.Request) ?? 'null-ip' 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/common/services/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | @Injectable() 5 | export class PrismaService 6 | extends PrismaClient 7 | implements OnModuleInit, OnModuleDestroy 8 | { 9 | constructor() { 10 | super({ 11 | log: ['error', 'warn'], 12 | }); 13 | } 14 | 15 | async onModuleInit(): Promise { 16 | await this.$connect(); 17 | } 18 | 19 | async onModuleDestroy(): Promise { 20 | await this.$disconnect(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | jwt: { 3 | secretOrKey: '__JWT_SECRET_KEY__', 4 | expiresIn: 86400, 5 | }, 6 | // You can also use any other email sending services 7 | mail: { 8 | service: { 9 | host: 'smtp.sendgrid.net', 10 | port: 587, 11 | secure: false, 12 | user: 'apikey', 13 | pass: '__SENDGRID_API_KEY__', 14 | }, 15 | senderCredentials: { 16 | name: '__SENDER_NAME__', 17 | email: '__SENDER_EMAIL__', 18 | }, 19 | }, 20 | // these are used in the mail templates 21 | project: { 22 | name: '__YOUR_PROJECT_NAME__', 23 | address: '__YOUR_PROJECT_ADDRESS__', 24 | logoUrl: 'https://__YOUR_PROJECT_LOGO_URL__', 25 | slogan: 'Made with ❤️ in Istanbul', 26 | color: '#123456', 27 | socials: [ 28 | ['GitHub', '__Project_GitHub_URL__'], 29 | ['__Social_Media_1__', '__Social_Media_1_URL__'], 30 | ['__Social_Media_2__', '__Social_Media_2_URL__'], 31 | ], 32 | url: 'http://localhost:4200', 33 | mailVerificationUrl: 'http://localhost:3000/auth/verify', 34 | mailChangeUrl: 'http://localhost:3000/auth/change-email', 35 | resetPasswordUrl: 'http://localhost:4200/reset-password', 36 | termsOfServiceUrl: 'http://localhost:4200/legal/terms', 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/mail-sender/mail-sender.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MailSenderService } from './mail-sender.service'; 3 | 4 | @Module({ 5 | providers: [MailSenderService], 6 | exports: [MailSenderService], 7 | }) 8 | export class MailSenderModule {} 9 | -------------------------------------------------------------------------------- /src/mail-sender/mail-sender.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { MailSenderService } from './mail-sender.service'; 3 | 4 | describe('MailSenderService', () => { 5 | let service: MailSenderService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [MailSenderService], 10 | }).compile(); 11 | 12 | service = module.get(MailSenderService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/mail-sender/mail-sender.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { createTransport } from 'nodemailer'; 3 | import * as Mail from 'nodemailer/lib/mailer'; 4 | 5 | import config from '../config'; 6 | import { 7 | changeMail, 8 | changePasswordInfo, 9 | confirmMail, 10 | resetPassword, 11 | } from './templates'; 12 | 13 | @Injectable() 14 | export class MailSenderService { 15 | private transporter: Mail; 16 | 17 | private socials: string; 18 | 19 | private logger = new Logger('MailSenderService'); 20 | 21 | constructor() { 22 | this.transporter = createTransport({ 23 | auth: { 24 | user: config.mail.service.user, 25 | pass: config.mail.service.pass, 26 | }, 27 | host: config.mail.service.host, 28 | port: config.mail.service.port, 29 | secure: config.mail.service.secure, 30 | }); 31 | this.socials = config.project.socials 32 | .map( 33 | (social) => 34 | `${social[0]}`, 35 | ) 36 | .join(''); 37 | } 38 | 39 | async sendVerifyEmailMail( 40 | name: string, 41 | email: string, 42 | token: string, 43 | ): Promise { 44 | const buttonLink = `${config.project.mailVerificationUrl}?token=${token}`; 45 | 46 | const mail = confirmMail 47 | .replace(new RegExp('--PersonName--', 'g'), name) 48 | .replace(new RegExp('--ProjectName--', 'g'), config.project.name) 49 | .replace(new RegExp('--ProjectAddress--', 'g'), config.project.address) 50 | .replace(new RegExp('--ProjectLogo--', 'g'), config.project.logoUrl) 51 | .replace(new RegExp('--ProjectSlogan--', 'g'), config.project.slogan) 52 | .replace(new RegExp('--ProjectColor--', 'g'), config.project.color) 53 | .replace(new RegExp('--ProjectLink--', 'g'), config.project.url) 54 | .replace(new RegExp('--Socials--', 'g'), this.socials) 55 | .replace(new RegExp('--ButtonLink--', 'g'), buttonLink) 56 | .replace( 57 | new RegExp('--TermsOfServiceLink--', 'g'), 58 | config.project.termsOfServiceUrl, 59 | ); 60 | 61 | const mailOptions = { 62 | from: `"${config.mail.senderCredentials.name}" <${config.mail.senderCredentials.email}>`, 63 | to: email, // list of receivers (separated by ,) 64 | subject: `Welcome to ${config.project.name} ${name}! Confirm Your Email`, 65 | html: mail, 66 | }; 67 | 68 | return new Promise((resolve) => 69 | this.transporter.sendMail(mailOptions, async (error) => { 70 | if (error) { 71 | this.logger.warn( 72 | 'Mail sending failed, check your service credentials.', 73 | ); 74 | resolve(false); 75 | } 76 | resolve(true); 77 | }), 78 | ); 79 | } 80 | 81 | async sendChangeEmailMail( 82 | name: string, 83 | email: string, 84 | token: string, 85 | ): Promise { 86 | const buttonLink = `${config.project.mailChangeUrl}?token=${token}`; 87 | 88 | const mail = changeMail 89 | .replace(new RegExp('--PersonName--', 'g'), name) 90 | .replace(new RegExp('--ProjectName--', 'g'), config.project.name) 91 | .replace(new RegExp('--ProjectAddress--', 'g'), config.project.address) 92 | .replace(new RegExp('--ProjectLogo--', 'g'), config.project.logoUrl) 93 | .replace(new RegExp('--ProjectSlogan--', 'g'), config.project.slogan) 94 | .replace(new RegExp('--ProjectColor--', 'g'), config.project.color) 95 | .replace(new RegExp('--ProjectLink--', 'g'), config.project.url) 96 | .replace(new RegExp('--Socials--', 'g'), this.socials) 97 | .replace(new RegExp('--ButtonLink--', 'g'), buttonLink); 98 | 99 | const mailOptions = { 100 | from: `"${config.mail.senderCredentials.name}" <${config.mail.senderCredentials.email}>`, 101 | to: email, // list of receivers (separated by ,) 102 | subject: `Change Your ${config.project.name} Account's Email`, 103 | html: mail, 104 | }; 105 | 106 | return new Promise((resolve) => 107 | this.transporter.sendMail(mailOptions, async (error) => { 108 | if (error) { 109 | this.logger.warn( 110 | 'Mail sending failed, check your service credentials.', 111 | ); 112 | resolve(false); 113 | } 114 | resolve(true); 115 | }), 116 | ); 117 | } 118 | 119 | async sendResetPasswordMail( 120 | name: string, 121 | email: string, 122 | token: string, 123 | ): Promise { 124 | const buttonLink = `${config.project.resetPasswordUrl}?token=${token}`; 125 | 126 | const mail = resetPassword 127 | .replace(new RegExp('--PersonName--', 'g'), name) 128 | .replace(new RegExp('--ProjectName--', 'g'), config.project.name) 129 | .replace(new RegExp('--ProjectAddress--', 'g'), config.project.address) 130 | .replace(new RegExp('--ProjectLogo--', 'g'), config.project.logoUrl) 131 | .replace(new RegExp('--ProjectSlogan--', 'g'), config.project.slogan) 132 | .replace(new RegExp('--ProjectColor--', 'g'), config.project.color) 133 | .replace(new RegExp('--ProjectLink--', 'g'), config.project.url) 134 | .replace(new RegExp('--Socials--', 'g'), this.socials) 135 | .replace(new RegExp('--ButtonLink--', 'g'), buttonLink); 136 | 137 | const mailOptions = { 138 | from: `"${config.mail.senderCredentials.name}" <${config.mail.senderCredentials.email}>`, 139 | to: email, // list of receivers (separated by ,) 140 | subject: `Reset Your ${config.project.name} Account's Password`, 141 | html: mail, 142 | }; 143 | 144 | return new Promise((resolve) => 145 | this.transporter.sendMail(mailOptions, async (error) => { 146 | if (error) { 147 | this.logger.warn( 148 | 'Mail sending failed, check your service credentials.', 149 | ); 150 | resolve(false); 151 | } 152 | resolve(true); 153 | }), 154 | ); 155 | } 156 | 157 | async sendPasswordChangeInfoMail( 158 | name: string, 159 | email: string, 160 | ): Promise { 161 | const buttonLink = config.project.url; 162 | const mail = changePasswordInfo 163 | .replace(new RegExp('--PersonName--', 'g'), name) 164 | .replace(new RegExp('--ProjectName--', 'g'), config.project.name) 165 | .replace(new RegExp('--ProjectAddress--', 'g'), config.project.address) 166 | .replace(new RegExp('--ProjectLogo--', 'g'), config.project.logoUrl) 167 | .replace(new RegExp('--ProjectSlogan--', 'g'), config.project.slogan) 168 | .replace(new RegExp('--ProjectColor--', 'g'), config.project.color) 169 | .replace(new RegExp('--ProjectLink--', 'g'), config.project.url) 170 | .replace(new RegExp('--Socials--', 'g'), this.socials) 171 | .replace(new RegExp('--ButtonLink--', 'g'), buttonLink); 172 | 173 | const mailOptions = { 174 | from: `"${config.mail.senderCredentials.name}" <${config.mail.senderCredentials.email}>`, 175 | to: email, // list of receivers (separated by ,) 176 | subject: `Your ${config.project.name} Account's Password is Changed`, 177 | html: mail, 178 | }; 179 | 180 | return new Promise((resolve) => 181 | this.transporter.sendMail(mailOptions, async (error) => { 182 | if (error) { 183 | this.logger.warn( 184 | 'Mail sending failed, check your service credentials.', 185 | ); 186 | resolve(false); 187 | } 188 | resolve(true); 189 | }), 190 | ); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/mail-sender/templates/change-mail.html.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: max-line-length 2 | export const changeMail = 3 | ' Hey --PersonName-- 👋,Do you want to update your --ProjectName-- account\'s email as --NewEmail--? Update Email Address --ProjectSlogan-- © --ProjectName-- --ProjectAddress-- --Socials-- '; 4 | -------------------------------------------------------------------------------- /src/mail-sender/templates/change-password-info.html.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: max-line-length 2 | export const changePasswordInfo = 3 | ' Hey --PersonName-- 👋,Your --ProjectName-- account\'s password is successfully updated. Go to --ProjectName-- --ProjectSlogan-- © --ProjectName-- --ProjectAddress-- --Socials-- '; 4 | -------------------------------------------------------------------------------- /src/mail-sender/templates/confirm-mail.html.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: max-line-length 2 | export const confirmMail = 3 | ' Hey --PersonName-- 👋,Thanks for joining --ProjectName--!Let\'s confirm your email address. By clicking on the following link, you are confirming your email address and agreeing to --ProjectName--\'s Terms of Service. Confirm Email Address --ProjectSlogan-- © --ProjectName-- --ProjectAddress-- --Socials-- '; 4 | -------------------------------------------------------------------------------- /src/mail-sender/templates/index.ts: -------------------------------------------------------------------------------- 1 | export { changeMail } from './change-mail.html'; 2 | export { changePasswordInfo } from './change-password-info.html'; 3 | export { confirmMail } from './confirm-mail.html'; 4 | export { resetPassword } from './reset-password.html'; 5 | -------------------------------------------------------------------------------- /src/mail-sender/templates/reset-password.html.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable-next-line: max-line-length 2 | export const resetPassword = 3 | ' Hey --PersonName-- 👋,Did you forget your --ProjectName-- password?Let\'s reset your password. Reset Password --ProjectSlogan-- © --ProjectName-- --ProjectAddress-- --Socials-- '; 4 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import * as helmet from 'helmet'; 5 | import * as requestIp from 'request-ip'; 6 | import { AppModule } from './app.module'; 7 | 8 | async function bootstrap() { 9 | // CORS is enabled 10 | const app = await NestFactory.create(AppModule, { cors: true }); 11 | 12 | // Request Validation 13 | app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); 14 | 15 | app.use(requestIp.mw()); 16 | 17 | // Helmet Middleware against known security vulnerabilities 18 | app.use(helmet()); 19 | 20 | // Swagger API Documentation 21 | const options = new DocumentBuilder() 22 | .setTitle('NestJS Hackathon Starter by @ahmetuysal') 23 | .setDescription('NestJS Hackathon Starter API description') 24 | .setVersion('0.1.0') 25 | .addBearerAuth() 26 | .build(); 27 | const document = SwaggerModule.createDocument(app, options); 28 | SwaggerModule.setup('api', app, document); 29 | 30 | await app.listen(process.env.PORT || 3000, '127.0.0.1'); 31 | } 32 | 33 | bootstrap(); 34 | -------------------------------------------------------------------------------- /src/user/models/index.ts: -------------------------------------------------------------------------------- 1 | export { UserResponse } from './user.response'; 2 | 3 | export { UpdateUserRequest } from './request/update-user-request.model'; 4 | -------------------------------------------------------------------------------- /src/user/models/request/update-user-request.model.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsNotEmpty, 3 | IsOptional, 4 | IsUrl, 5 | Matches, 6 | MaxLength, 7 | } from 'class-validator'; 8 | 9 | export class UpdateUserRequest { 10 | @IsOptional() 11 | @IsNotEmpty() 12 | @Matches(RegExp('^[a-zA-Z0-9\\-]+$')) 13 | @MaxLength(20) 14 | username?: string; 15 | 16 | @IsOptional() 17 | @IsNotEmpty() 18 | @Matches(RegExp('^[A-Za-zıöüçğşİÖÜÇĞŞñÑáéíóúÁÉÍÓÚ]+$')) 19 | @MaxLength(20) 20 | firstName?: string; 21 | 22 | @IsOptional() 23 | @IsNotEmpty() 24 | @Matches(RegExp('^[A-Za-zıöüçğşİÖÜÇĞŞñÑáéíóúÁÉÍÓÚ ]+$')) 25 | @MaxLength(40) 26 | lastName?: string; 27 | 28 | @IsOptional() 29 | @IsNotEmpty() 30 | @MaxLength(40) 31 | @Matches(RegExp('^[A-Za-zıöüçğşİÖÜÇĞŞñÑáéíóúÁÉÍÓÚ ]+$')) 32 | middleName?: string; 33 | 34 | @IsOptional() 35 | @IsUrl() 36 | image?: string; 37 | 38 | @IsOptional() 39 | @Matches(RegExp('([12]\\d{3}-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01]))')) 40 | birthDate?: string | null; // ISO Date 41 | } 42 | -------------------------------------------------------------------------------- /src/user/models/user.response.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@prisma/client'; 2 | 3 | export class UserResponse { 4 | id: number; 5 | 6 | username: string; 7 | 8 | email: string; 9 | 10 | emailVerified: boolean; 11 | 12 | name: string; 13 | 14 | image: string | null; 15 | 16 | birthDate: Date | null; // ISO Date 17 | 18 | registrationDate: Date; // ISO Date 19 | 20 | static fromUserEntity(entity: User): UserResponse { 21 | const response = new UserResponse(); 22 | response.id = entity.id; 23 | response.username = entity.username; 24 | response.email = entity.email; 25 | response.emailVerified = entity.emailVerified; 26 | response.name = [entity.firstName, entity.middleName, entity.lastName] 27 | .filter((s) => s !== null) 28 | .join(' '); 29 | response.image = entity.image; 30 | response.birthDate = entity.birthDate; 31 | response.registrationDate = entity.registrationDate; 32 | return response; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { UserController } from './user.controller'; 4 | import { UserService } from './user.service'; 5 | import { PrismaService } from '../common/services/prisma.service'; 6 | 7 | describe('User Controller', () => { 8 | let controller: UserController; 9 | let spyService: UserService; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | controllers: [UserController], 14 | providers: [UserService, PrismaService], 15 | imports: [PassportModule.register({ defaultStrategy: 'jwt' })], 16 | }).compile(); 17 | 18 | controller = module.get(UserController); 19 | spyService = module.get(UserService); 20 | }); 21 | 22 | it('should be defined', () => { 23 | expect(controller).toBeDefined(); 24 | expect(spyService).toBeDefined(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Param, 7 | ParseIntPipe, 8 | Put, 9 | UnauthorizedException, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 13 | import { AuthGuard } from '@nestjs/passport'; 14 | import { UserService } from './user.service'; 15 | import { Usr } from './user.decorator'; 16 | import { UpdateUserRequest } from './models'; 17 | import { AuthUser } from '../auth/auth-user'; 18 | 19 | @ApiTags('users') 20 | @Controller('users') 21 | export class UserController { 22 | constructor(private readonly userService: UserService) {} 23 | 24 | @ApiBearerAuth() 25 | @Put(':id') 26 | @HttpCode(HttpStatus.OK) 27 | @UseGuards(AuthGuard()) 28 | async updateUser( 29 | @Param('id', ParseIntPipe) id: number, 30 | @Body() updateRequest: UpdateUserRequest, 31 | @Usr() user: AuthUser, 32 | ): Promise { 33 | if (id !== user.id) { 34 | throw new UnauthorizedException(); 35 | } 36 | await this.userService.updateUser(id, updateRequest); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/user/user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | /** 4 | * retrieve the current user with a decorator 5 | * example of a controller method: 6 | * @Post() 7 | * someMethod(@Usr() user: User) { 8 | * // do something with the user 9 | * } 10 | */ 11 | export const Usr = createParamDecorator( 12 | (data: unknown, ctx: ExecutionContext) => { 13 | const request = ctx.switchToHttp().getRequest(); 14 | return request.user; 15 | }, 16 | ); 17 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { UserService } from './user.service'; 4 | import { UserController } from './user.controller'; 5 | import { PrismaService } from '../common/services/prisma.service'; 6 | 7 | @Module({ 8 | imports: [PassportModule.register({ defaultStrategy: 'jwt' })], 9 | providers: [UserService, PrismaService], 10 | exports: [UserService], 11 | controllers: [UserController], 12 | }) 13 | export class UserModule {} 14 | -------------------------------------------------------------------------------- /src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { mockDeep } from 'jest-mock-extended'; 3 | import { DeepMockProxy } from 'jest-mock-extended/lib/Mock'; 4 | import { UserService } from './user.service'; 5 | import { PrismaService } from '../common/services/prisma.service'; 6 | import { mockUser } from '../../test/utils/mock-user'; 7 | 8 | describe('UserService', () => { 9 | let service: UserService; 10 | let spyPrismaService: DeepMockProxy; 11 | 12 | beforeEach(async () => { 13 | const module: TestingModule = await Test.createTestingModule({ 14 | providers: [ 15 | UserService, 16 | { 17 | provide: PrismaService, 18 | useFactory: () => mockDeep(), 19 | }, 20 | ], 21 | }).compile(); 22 | 23 | service = module.get(UserService); 24 | spyPrismaService = module.get( 25 | PrismaService, 26 | ) as DeepMockProxy; 27 | }); 28 | 29 | describe('getUserEntityById', () => { 30 | it('should call repository with correct id', async () => { 31 | const id = 12313242; 32 | 33 | await service.getUserEntityById(id); 34 | 35 | expect(spyPrismaService.user.findUnique).toBeCalledTimes(1); 36 | expect(spyPrismaService.user.findUnique).toHaveBeenCalledWith({ 37 | where: { id }, 38 | }); 39 | }); 40 | 41 | it('should return the result from repository', async () => { 42 | const userId = 123123; 43 | const user = mockUser({ id: userId }); 44 | 45 | spyPrismaService.user.findUnique.mockResolvedValue(user); 46 | 47 | expect(await service.getUserEntityById(userId)).toStrictEqual(user); 48 | }); 49 | }); 50 | 51 | describe('getUserEntityByUsername', () => { 52 | it('should call repository with given username', async () => { 53 | const username = 'userName'; 54 | 55 | await service.getUserEntityByUsername(username); 56 | 57 | expect(spyPrismaService.user.findUnique).toBeCalledTimes(1); 58 | expect(spyPrismaService.user.findUnique).toBeCalledWith({ 59 | where: { username: username.toLowerCase() }, 60 | }); 61 | }); 62 | 63 | it('should return the result from repository', async () => { 64 | const username = 'username'; 65 | 66 | const user = mockUser({ username }); 67 | 68 | spyPrismaService.user.findUnique.mockResolvedValue(user); 69 | 70 | expect(await service.getUserEntityByUsername(username)).toStrictEqual( 71 | user, 72 | ); 73 | }); 74 | }); 75 | 76 | it('should be defined', () => { 77 | expect(service).toBeDefined(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException, Injectable, Logger } from '@nestjs/common'; 2 | import { PrismaService } from '../common/services/prisma.service'; 3 | import { AuthUser } from '../auth/auth-user'; 4 | import { UpdateUserRequest, UserResponse } from './models'; 5 | 6 | @Injectable() 7 | export class UserService { 8 | constructor(private readonly prisma: PrismaService) {} 9 | 10 | public async getUserEntityById(id: number): Promise { 11 | return this.prisma.user.findUnique({ 12 | where: { id }, 13 | }); 14 | } 15 | 16 | public async getUserEntityByUsername( 17 | username: string, 18 | ): Promise { 19 | const normalizedUsername = username.toLowerCase(); 20 | return this.prisma.user.findUnique({ 21 | where: { username: normalizedUsername }, 22 | }); 23 | } 24 | 25 | async updateUser( 26 | userId: number, 27 | updateRequest: UpdateUserRequest, 28 | ): Promise { 29 | try { 30 | const updatedUser = await this.prisma.user.update({ 31 | where: { id: userId }, 32 | data: { 33 | ...updateRequest, 34 | birthDate: 35 | updateRequest.birthDate !== null && 36 | updateRequest.birthDate !== undefined 37 | ? new Date(updateRequest.birthDate) 38 | : updateRequest.birthDate, 39 | }, 40 | }); 41 | 42 | return UserResponse.fromUserEntity(updatedUser); 43 | } catch (err) { 44 | Logger.error(JSON.stringify(err)); 45 | throw new ConflictException(); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { AppModule } from '../src/app.module'; 6 | 7 | describe('AppController (e2e)', () => { 8 | let app; 9 | 10 | beforeEach(async () => { 11 | const moduleFixture: TestingModule = await Test.createTestingModule({ 12 | imports: [ 13 | AppModule, 14 | ConfigModule.forRoot({ 15 | envFilePath: 'env/test.env', 16 | }), 17 | ], 18 | }).compile(); 19 | 20 | app = moduleFixture.createNestApplication(); 21 | // Request Validation 22 | app.useGlobalPipes( 23 | new ValidationPipe({ 24 | whitelist: true, 25 | transform: true, 26 | }), 27 | ); 28 | 29 | await app.init(); 30 | }); 31 | 32 | afterAll(async () => { 33 | await app.close(); 34 | }); 35 | 36 | it('/ (GET)', () => request(app.getHttpServer()).get('/').expect(200)); 37 | }); 38 | -------------------------------------------------------------------------------- /test/auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { AppModule } from '../src/app.module'; 5 | import { SignupRequest } from '../src/auth/models'; 6 | 7 | describe('AuthController (e2e)', () => { 8 | let app; 9 | 10 | beforeAll(async () => { 11 | const moduleFixture: TestingModule = await Test.createTestingModule({ 12 | imports: [AppModule], 13 | }).compile(); 14 | 15 | app = moduleFixture.createNestApplication(); 16 | // Request Validation 17 | app.useGlobalPipes( 18 | new ValidationPipe({ 19 | whitelist: true, 20 | transform: true, 21 | }), 22 | ); 23 | await app.init(); 24 | }); 25 | 26 | afterAll(async () => { 27 | await app.close(); 28 | }); 29 | 30 | describe('/auth/signup POST', () => { 31 | it('should not accept usernames with underscore', () => { 32 | const signupRequest: SignupRequest = { 33 | email: 'auysal16@ku.edu.tr', 34 | firstName: 'Ahmet', 35 | lastName: 'Uysal', 36 | password: 'password', 37 | username: 'invalid_username', 38 | }; 39 | 40 | return request(app.getHttpServer()) 41 | .post('/auth/signup') 42 | .send(signupRequest) 43 | .expect(400) 44 | .expect({ 45 | statusCode: 400, 46 | error: 'Bad Request', 47 | message: [ 48 | 'username must match /^[a-zA-Z0-9\\-]+$/ regular expression', 49 | ], 50 | }); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/utils/mock-user.ts: -------------------------------------------------------------------------------- 1 | import { AuthUser } from '../../src/auth/auth-user'; 2 | 3 | export const mockUser = (fields?: Partial): AuthUser => ({ 4 | firstName: 'Ahmet', 5 | middleName: null, 6 | lastName: 'Uysal', 7 | username: 'ahmet', 8 | image: null, 9 | birthDate: new Date('1998-09-21'), 10 | registrationDate: new Date(), 11 | email: 'auysal16@ku.edu.tr', 12 | id: 1, 13 | emailVerified: true, 14 | passwordHash: 'passwordHash', 15 | ...fields, 16 | }); 17 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "es2017", 9 | "sourceMap": true, 10 | "skipLibCheck": true, 11 | "strictNullChecks": true, 12 | "outDir": "./dist", 13 | "baseUrl": "./", 14 | "incremental": true, 15 | "allowJs": true 16 | }, 17 | "exclude": ["node_modules", "dist"] 18 | } 19 | --------------------------------------------------------------------------------
--ProjectSlogan--
© --ProjectName-- --ProjectAddress--
--Socials--
By clicking on the following link, you are confirming your email address and agreeing to --ProjectName--\'s Terms of Service.