├── .dockerignore
├── .env.example
├── .eslintignore
├── .eslintrc.json
├── .github
└── dependabot.yml
├── .gitignore
├── .husky
└── pre-commit
├── .prettierignore
├── .prettierrc
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── __test__
├── middleware
│ ├── isAuth.test.ts
│ └── xssMiddleware.test.ts
└── utils
│ ├── compressFilter.test.ts
│ └── sanitize.test.ts
├── assets
└── refresh_token_rotation_flow_diagram.png
├── docker-compose.yml
├── jest.config.js
├── package.json
├── prisma
├── migrations
│ ├── 20230307220106_init
│ │ └── migration.sql
│ ├── 20230308154922_user_password
│ │ └── migration.sql
│ ├── 20230309120100_refresh_token
│ │ └── migration.sql
│ ├── 20230309155008_removed_expires
│ │ └── migration.sql
│ ├── 20230317200854_resest_token
│ │ └── migration.sql
│ ├── 20230317205303_email_verification_token
│ │ └── migration.sql
│ ├── 20230318163942_refresh_token_changes
│ │ └── migration.sql
│ ├── 20230318164340_refresh_token_changes_back
│ │ └── migration.sql
│ ├── 20230330094356_changed_name
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── src
├── app.ts
├── config
│ ├── config.ts
│ ├── cookieConfig.ts
│ ├── cors.ts
│ ├── nodemailer.ts
│ └── prisma.ts
├── controller
│ ├── auth.controller.ts
│ ├── forgotPassword.controller.ts
│ └── verifyEmail.controller.ts
├── index.ts
├── middleware
│ ├── authLimiter.ts
│ ├── errorHandler.ts
│ ├── isAuth.ts
│ ├── logger.ts
│ ├── validate.ts
│ └── xssMiddleware.ts
├── routes
│ └── v1
│ │ ├── auth.route.ts
│ │ ├── index.ts
│ │ ├── password.route.ts
│ │ └── verifyEmail.route.ts
├── types
│ ├── env.d.ts
│ ├── express.d.ts
│ ├── jwt.d.ts
│ └── types.ts
├── utils
│ ├── compressFilter.util.ts
│ ├── generateTokens.util.ts
│ ├── sanitize.util.ts
│ └── sendEmail.util.ts
└── validations
│ ├── auth.validation.ts
│ ├── password.validation.ts
│ └── verifyEmail.validation.ts
├── tsconfig.build.json
├── tsconfig.json
├── tsconfig.test.json
├── yarn-error.log
└── yarn.lock
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | yarn-error.log
3 | coverage
4 | .env
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # App's running environment
2 | NODE_ENV=
3 |
4 | # App's running port
5 | PORT=
6 |
7 | # Server url
8 | SERVER_URL=
9 |
10 | # Cors origin url
11 | CORS_ORIGIN=
12 |
13 | # Run node -e "console.log(require('crypto').randomBytes(256).toString('base64'));" in your console to generate a secret
14 | ACCESS_TOKEN_SECRET=
15 |
16 | REFRESH_TOKEN_SECRET=
17 |
18 | ACCESS_TOKEN_EXPIRE=
19 |
20 | REFRESH_TOKEN_EXPIRE=
21 |
22 | # name of the refresh token cookie
23 | REFRESH_TOKEN_COOKIE_NAME=
24 |
25 | MYSQL_DATABASE=
26 | MYSQL_ROOT_PASSWORD=
27 |
28 | # Example: mysql://USER:PASSWORD@HOST:PORT/DATABASE
29 | DATABASE_URL=
30 |
31 | # Configuration for the emial service
32 | SMTP_HOST=
33 | SMTP_PORT=
34 | SMTP_USERNAME=
35 | SMTP_PASSWORD=
36 | EMAIL_FROM=
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | yarn.lock
4 | yarn-error.log
5 | logs
6 | coverage
7 | data
8 | *.config.js
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "es2022": true,
4 | "node": true,
5 | "jest": true
6 | },
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "prettier",
11 | "plugin:security/recommended",
12 | "standard-with-typescript",
13 | "plugin:node/recommended",
14 | "plugin:security/recommended"
15 | ],
16 | "overrides": [],
17 | "parser": "@typescript-eslint/parser",
18 | "parserOptions": {
19 | "ecmaVersion": 2022,
20 | "sourceType": "module",
21 | "project": "./tsconfig.json"
22 | },
23 | "plugins": ["@typescript-eslint", "promise", "import"],
24 | "rules": {
25 | "indent": "off",
26 | "linebreak-style": ["error", "unix"],
27 | "quotes": ["error", "single"],
28 | "semi": "off",
29 |
30 | "@typescript-eslint/indent": "off",
31 | "@typescript-eslint/semi": "off",
32 | "@typescript-eslint/member-delimiter-style": "off",
33 | "@typescript-eslint/strict-boolean-expressions": "off",
34 | "@typescript-eslint/explicit-function-return-type": "off",
35 | "@typescript-eslint/restrict-plus-operands": "off",
36 | "@typescript-eslint/restrict-template-expressions": "off",
37 | "@typescript-eslint/prefer-optional-chain": "off",
38 | "@typescript-eslint/no-misused-promises": "off",
39 | "@typescript-eslint/consistent-type-assertions": "off",
40 |
41 | // plugin: node/recommended
42 | "node/exports-style": ["error", "module.exports"],
43 | "node/file-extension-in-import": "off",
44 | "node/no-missing-import": "off",
45 | "node/prefer-global/buffer": ["error", "always"],
46 | "node/prefer-global/console": ["error", "always"],
47 | "node/prefer-global/process": ["error", "always"],
48 | "node/prefer-global/url-search-params": ["error", "always"],
49 | "node/prefer-global/url": ["error", "always"],
50 | "node/prefer-promises/dns": "error",
51 | "node/prefer-promises/fs": "error",
52 | "node/no-unsupported-features/es-syntax": "off",
53 | "node/no-extraneous-import": "off",
54 | "node/no-unsupported-features/es-builtins": "off",
55 |
56 | // plugin promise
57 | "promise/always-return": "error",
58 | "promise/no-return-wrap": "error",
59 | "promise/param-names": "error",
60 | "promise/catch-or-return": "error",
61 | "promise/no-native": "off",
62 | "promise/no-nesting": "warn",
63 | "promise/no-promise-in-callback": "off",
64 | "promise/no-callback-in-promise": "warn",
65 | "promise/avoid-new": "warn",
66 | "promise/no-new-statics": "error",
67 | "promise/no-return-in-finally": "warn",
68 | "promise/valid-params": "warn",
69 |
70 | "sort-imports": [
71 | "error",
72 | { "ignoreDeclarationSort": true, "ignoreCase": true }
73 | ]
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "yarn" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .env
2 | node_modules/
3 | dist/
4 | yarn.lock
5 | yarn-error.log
6 | logs/
7 | coverage/
8 | data
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npx lint-staged
5 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | yarn.lock
4 | yarn-error.log
5 | logs
6 | coverage
7 | data
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "tabWidth": 2,
4 | "semi": true,
5 | "singleQuote": true
6 | }
7 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | louiskaan.ay@gmail.com.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 |
2 | # Contributing to express-ts-auth-service
3 |
4 | First off, thanks for taking the time to contribute! ❤️
5 |
6 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
7 |
8 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
9 | > - Star the project
10 | > - Tweet about it
11 | > - Refer this project in your project's readme
12 | > - Mention the project at local meetups and tell your friends/colleagues
13 |
14 |
15 | ## Table of Contents
16 |
17 | - [Code of Conduct](#code-of-conduct)
18 | - [I Have a Question](#i-have-a-question)
19 | - [I Want To Contribute](#i-want-to-contribute)
20 | - [Reporting Bugs](#reporting-bugs)
21 | - [Suggesting Enhancements](#suggesting-enhancements)
22 | - [Your First Code Contribution](#your-first-code-contribution)
23 | - [Improving The Documentation](#improving-the-documentation)
24 | - [Styleguides](#styleguides)
25 | - [Commit Messages](#commit-messages)
26 | - [Join The Project Team](#join-the-project-team)
27 |
28 |
29 | ## Code of Conduct
30 |
31 | This project and everyone participating in it is governed by the
32 | [express-ts-auth-service Code of Conduct](https://github.com/Louis3797/express-ts-auth-service/blob/main/CODE_OF_CONDUCT.md).
33 | By participating, you are expected to uphold this code. Please report unacceptable behavior
34 | to <>.
35 |
36 |
37 | ## I Have a Question
38 |
39 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/Louis3797/express-ts-auth-service#readme).
40 |
41 | Before you ask a question, it is best to search for existing [Issues](https://github.com/Louis3797/express-ts-auth-service/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
42 |
43 | If you then still feel the need to ask a question and need clarification, we recommend the following:
44 |
45 | - Open an [Issue](https://github.com/Louis3797/express-ts-auth-service/issues/new).
46 | - Provide as much context as you can about what you're running into.
47 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
48 |
49 | We will then take care of the issue as soon as possible.
50 |
51 | ## I Want To Contribute
52 |
53 | > ### Legal Notice
54 | > When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
55 |
56 | ### Reporting Bugs
57 |
58 |
59 | #### Before Submitting a Bug Report
60 |
61 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
62 |
63 | - Make sure that you are using the latest version.
64 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/Louis3797/express-ts-auth-service#readme). If you are looking for support, you might want to check [this section](#i-have-a-question)).
65 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/Louis3797/express-ts-auth-service/issues?q=label%3Abug).
66 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
67 | - Collect information about the bug:
68 | - Stack trace (Traceback)
69 | - OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
70 | - Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
71 | - Possibly your input and the output
72 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions?
73 |
74 |
75 |
76 | We use GitHub issues to track bugs and errors. If you run into an issue with the project:
77 |
78 | - Open an [Issue](https://github.com/Louis3797/express-ts-auth-service/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
79 | - Explain the behavior you would expect and the actual behavior.
80 | - Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
81 | - Provide the information you collected in the previous section.
82 |
83 | Once it's filed:
84 |
85 | - The project team will label the issue accordingly.
86 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
87 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
88 |
89 |
90 |
91 |
92 | ### Suggesting Enhancements
93 |
94 | This section guides you through submitting an enhancement suggestion for express-ts-auth-service, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
95 |
96 |
97 | #### Before Submitting an Enhancement
98 |
99 | - Make sure that you are using the latest version.
100 | - Read the [documentation](https://github.com/Louis3797/express-ts-auth-service#readme) carefully and find out if the functionality is already covered, maybe by an individual configuration.
101 | - Perform a [search](https://github.com/Louis3797/express-ts-auth-service/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
102 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
103 |
104 |
105 | #### How Do I Submit a Good Enhancement Suggestion?
106 |
107 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/Louis3797/express-ts-auth-service/issues).
108 |
109 | - Use a **clear and descriptive title** for the issue to identify the suggestion.
110 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
111 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
112 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux.
113 | - **Explain why this enhancement would be useful** to most express-ts-auth-service users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
114 |
115 |
116 |
117 | ### Your First Code Contribution
118 |
122 |
123 | ### Improving The Documentation
124 |
128 |
129 | ## Styleguides
130 | ### Commit Messages
131 |
134 |
135 | ## Join The Project Team
136 |
137 |
138 |
139 | ## Attribution
140 | This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)!
141 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build stage
2 | FROM node:18-alpine3.16 AS build
3 |
4 | # Set the working directory to /app
5 | WORKDIR /app
6 |
7 | # Copy package.json and yarn.lock to the container
8 | COPY package.json yarn.lock ./
9 |
10 | # Install development dependencies
11 | RUN apk add --no-cache --virtual .build-deps \
12 | gcc \
13 | g++ \
14 | python3 \
15 | && yarn install --frozen-lockfile --production=false \
16 | && apk del .build-deps \
17 | && yarn cache clean
18 |
19 | # Copy the rest of the application code to the container
20 | COPY . .
21 |
22 | # Build the application
23 | RUN yarn build
24 |
25 | # Run stage
26 | FROM node:18-alpine3.16 AS run
27 |
28 | # Set the working directory to /app
29 | WORKDIR /app
30 |
31 | # Copy package.json and yarn.lock to the container
32 | COPY package.json yarn.lock ./
33 |
34 | # Install dumb-init
35 | RUN apk add --no-cache dumb-init
36 |
37 | # Add a non-root user to run the application
38 | RUN addgroup -g 1001 -S nodejs \
39 | && adduser -S nodejs -u 1001 \
40 | && chown -R nodejs:nodejs /app
41 |
42 | # Switch to the non-root user
43 | USER nodejs
44 |
45 | # Install production dependencies
46 | RUN yarn install --frozen-lockfile --production=true && yarn cache clean
47 |
48 | # Copy the built application code from the build stage
49 | COPY --chown=nodejs:nodejs --from=build /app/dist ./dist
50 |
51 | # Copy the prisma directory from the build stage
52 | COPY --chown=nodejs:nodejs --from=build /app/prisma ./prisma
53 |
54 | ENV NODE_ENV=production
55 | ENV PORT=3000
56 |
57 | # Generate prisma client
58 | RUN yarn prisma generate
59 |
60 | # Expose port for the application to listen on
61 | EXPOSE 4040
62 |
63 | # Start the application with dumb-init
64 | CMD ["dumb-init", "node", "dist/index.js"]
65 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Louis
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 |
9 |
10 |
11 |
Express-Ts-Auth-Service
12 |
13 |
14 | A pre-built authentication server that uses JSON Web Tokens (JWT) for authentication. It is built using Express.js, TypeScript and MySQL
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
46 |
47 |
48 |
49 |
50 | # Table of Contents
51 |
52 | - [Table of Contents](#table-of-contents)
53 | - [About the Project](#about-the-project)
54 | - [Tech Stack](#tech-stack)
55 | - [Features](#features)
56 | - [Endpoints](#endpoints)
57 | - [Project Structure](#project-structure)
58 | - [Database](#database)
59 | - [Account](#account)
60 | - [User](#user)
61 | - [RefreshToken](#refreshtoken)
62 | - [ResetToken](#resettoken)
63 | - [EmailVerificationToken](#emailverificationtoken)
64 | - [Refresh Token Rotation](#refresh-token-rotation)
65 | - [Environment Variables](#environment-variables)
66 | - [Getting Started](#getting-started)
67 | - [Prerequisites](#prerequisites)
68 | - [Installation](#installation)
69 | - [Linting](#linting)
70 | - [Running Tests](#running-tests)
71 | - [Run Locally](#run-locally)
72 | - [Run with Docker](#run-with-docker)
73 | - [Roadmap](#roadmap)
74 | - [Contributing](#contributing)
75 | - [Code of Conduct](#code-of-conduct)
76 | - [License](#license)
77 | - [Contact](#contact)
78 | - [Acknowledgements](#acknowledgements)
79 |
80 |
81 |
82 | ## About the Project
83 |
84 | This pre-built authentication server is designed to simplify the process of adding secure user authentication to your web or mobile application. It provides a ready-made solution that uses JSON Web Tokens (JWT) to ensure reliable and secure user sessions, saving you time and resources that would otherwise be required to develop an authentication system from scratch. Built using Express.js and TypeScript, this server is also highly customizable and can be extended to meet the specific needs of your application. By integrating our authentication server into your application, you can rest assured that your users' data and sessions are well protected, leaving you free to focus on other important aspects of your application.
85 |
86 |
87 |
88 | ### Tech Stack
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | ### Features
99 |
100 | - :black_nib: Written in TypeScript for type-safe code
101 | - :floppy_disk: Utilize a MySQL database to efficiently store user data
102 | - :speaking_head: Interacts with the database using the powerful Prisma ORM
103 | - :lock: Implements secure authentication measures with JWTs, ensuring secure access to sensitive data
104 | - :key: Implements robust password hashing using Argon2 for maximum security
105 | - :recycle: Incorporates refresh token rotation functionality to enhance the security
106 | - :white_check_mark: Includes email verification functionality for new user sign-ups
107 | - :new: Provides a reset password function for users who have forgotten their password
108 | - :rabbit2: Enables faster data transfer by implementing GZIP compression
109 | - :policeman: Implements essential security features using Helmet middleware
110 | - :cookie: Parses cookies seamlessly with cookie-parser middleware
111 | - :gear: Allows cross-origin resource sharing using CORS
112 | - :soap: Sanitizes request data against cross-site-scripting with xss middleware
113 | - :capital_abcd: Manages environment variables with ease using dotenv
114 | - :male_detective: Enforces high code quality standards with ESLint and Prettier
115 | - :horse_racing: Implements rate limiting to prevent abuse and improve server performance
116 | - :information_source: Accurately manages HTTP response status codes using http-status library
117 | - :warning: Validates user input with the powerful and flexible Joi library
118 | - :email: Facilitates sending of emails using nodemailer library
119 | - :memo: Enables detailed logging of server activities using winston library
120 | - :dog: Implements Git hooks with Husky to optimize development processes
121 | - :test_tube: Ensure reliability and robustness of the application with thorough testing using Jest and Supertest
122 |
123 |
124 |
125 | ### Endpoints
126 |
127 | ```
128 | POST /v1/auth/signup - Signup
129 | POST /v1/auth/login - Login
130 | POST /v1/auth/refresh - Refresh access token
131 | POST /v1/forgot-password - Send reset password email
132 | POST /v1/reset-password/:token - Reset password
133 | POST /v1/send-verification-email - Send verification email
134 | POST /v1/verify-email/:token - Verify email
135 | ```
136 |
137 |
138 |
139 | ### Project Structure
140 |
141 | ```
142 | ./src
143 | ├── config/ # Config files
144 | ├── controller/ # Route controllers
145 | ├── middleware/ # Custom middlewares
146 | ├── routes/ # Routes
147 | ├── types/ # Types
148 | ├── utils/ # Utility classes and functions
149 | ├── validations/ # Validation schemas
150 | ├── app.ts # Express App
151 | └── index.ts # App Entrypoint
152 | ```
153 |
154 |
155 |
156 | ### Database
157 |
158 | Our server relies on MySQL as its primary database management system to store and manage all relevant data. MySQL is a popular and widely used open-source relational database system that provides efficient, secure, and scalable storage and retrieval of data.
159 |
160 | To simplify and streamline the process of managing the data stored in the MySQL database, we utilize Prisma, which is a modern, type-safe ORM that supports various databases, including MySQL.
161 |
162 | Prisma helps us to write database queries in a more readable and intuitive way, making it easier to manage the data stored in our MySQL database. By using Prisma as our ORM of choice, we can also ensure that our application remains scalable, efficient, and maintainable.
163 |
164 | If you're interested in the structure of our database, you can take a look at the data model presented below, which provides an overview of the tables, columns, and relationships within the database.
165 |
166 | ```js
167 |
168 | model Account {
169 | id String @id @default(cuid())
170 | userId String
171 | type String
172 | provider String
173 | providerAccountId String
174 | refresh_token String? @db.Text
175 | access_token String? @db.Text
176 | expiresAt DateTime
177 | token_type String?
178 | scope String?
179 | id_token String? @db.Text
180 | session_state String?
181 |
182 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
183 |
184 | @@unique([provider, providerAccountId])
185 | }
186 |
187 | model User {
188 | id String @id @default(cuid())
189 | name String
190 | email String? @unique
191 | password String
192 | emailVerified DateTime?
193 | createdAt DateTime @default(now())
194 | accounts Account[]
195 | refreshTokens RefreshToken[]
196 | resetToken ResetToken[]
197 | emailVerificationToken EmailVerificationToken[]
198 | }
199 |
200 | model RefreshToken {
201 | id String @id @default(cuid())
202 | token String @unique
203 | user User @relation(fields: [userId], references: [id])
204 | userId String
205 | createdAt DateTime @default(now())
206 | }
207 |
208 | model ResetToken {
209 | id String @id @default(cuid())
210 | token String @unique
211 | expiresAt DateTime
212 | user User @relation(fields: [userId], references: [id])
213 | userId String
214 | createdAt DateTime @default(now())
215 | }
216 |
217 | model EmailVerificationToken {
218 | id String @id @default(cuid())
219 | token String @unique
220 | expiresAt DateTime
221 | user User @relation(fields: [userId], references: [id])
222 | userId String
223 | createdAt DateTime @default(now())
224 | }
225 | ```
226 |
227 | #### Account
228 |
229 | > Social auth is not yet implemented so that the entity can be different in the future
230 |
231 | The Account entity represents a linked social media account for a user. It has the following fields:
232 |
233 | - id: A unique identifier for the account.
234 | - userId: The ID of the user associated with the account.
235 | - type: The type of account, e.g. oauth.
236 | - provider: The provider of the account, e.g. facebook.
237 | - providerAccountId: The ID associated with the account from the provider's perspective.
238 | - refresh_token: A refresh token used to obtain a new access token.
239 | - access_token: An access token used to authenticate requests to the provider's API.
240 | - expiresAt: The expiration time of the access token.
241 | - token_type: The type of access token.
242 | - scope: The scope of the access token.
243 | - id_token: An ID token associated with the account.
244 | - session_state: The session state of the account.
245 |
246 | #### User
247 |
248 | The User entity represents a user of the application. It has the following fields:
249 |
250 | - id: A unique identifier for the user.
251 | - name: The name of the user.
252 | - email: The email address of the user.
253 | - password: The password of the user.
254 | - emailVerified: The date and time when the user's email address was verified.
255 | - createdAt: The date of creation.
256 | - accounts: A list of linked social media accounts for the user.
257 | - refreshTokens: A list of refresh tokens associated with the user.
258 | - resetToken: A list of reset tokens associated with the user.
259 | - emailVerificationToken: A list of email verification tokens associated with the user.
260 |
261 | #### RefreshToken
262 |
263 | The RefreshToken entity represents a refresh token used to obtain a new access token. It has the following fields:
264 |
265 | - id: A unique identifier for the refresh token.
266 | - token: The token itself.
267 | - user: The user associated with the refresh token.
268 | - userId: The ID of the user associated with the refresh token.
269 | - createdAt: The date of creation.
270 |
271 | #### ResetToken
272 |
273 | The ResetToken entity represents a reset token used to reset a user's password. It has the following fields:
274 |
275 | - id: A unique identifier for the refresh token.
276 | - token: The token itself.
277 | - expiresAt: The expiration time of the reset token.
278 | - user: The user associated with the reset token.
279 | - userId: The ID of the user associated with the reset token.
280 | - createdAt: The date of creation.
281 |
282 | #### EmailVerificationToken
283 |
284 | The EmailVerificationToken entity represents a token used to verify a user's email address. It has the following fields:
285 |
286 | - id: A unique identifier for the refresh token.
287 | - token: The token itself.
288 | - expiresAt: The expiration time of the email verification token.
289 | - user: The user associated with the email verification token.
290 | - userId: The ID of the user associated with the email verification token.
291 | - createdAt: The date of creation.
292 |
293 |
294 |
295 | ### Refresh Token Rotation
296 |
297 | Refresh token rotation is a security practice used to mitigate the risk of unauthorized access to a user's account or resources. When a user logs in to an application, the application issues an access token and a refresh token. The access token is used to access the user's resources, while the refresh token is used to obtain a new access token when the current one expires.
298 |
299 | In refresh token rotation, the application periodically rotates the refresh token, meaning it invalidates the old refresh token and issues a new one. This practice can limit the amount of time an attacker can use a stolen refresh token to gain access to the user's account or resources. By rotating the refresh token, the application reduces the risk of a long-lived refresh token being used to access the user's account or resources without their permission.
300 |
301 | 
302 |
303 |
304 |
305 | ### Environment Variables
306 |
307 | To run this project, you will need to add the following environment variables to your .env file
308 |
309 | ```
310 | # App's running environment
311 | NODE_ENV=
312 |
313 | # App's running port
314 | PORT=
315 |
316 | # Server url
317 | SERVER_URL=
318 |
319 | # Cors origin url
320 | CORS_ORIGIN=
321 |
322 | # Run node -e "console.log(require('crypto').randomBytes(256).toString('base64'));" in your console to generate a secret
323 | ACCESS_TOKEN_SECRET=
324 |
325 | REFRESH_TOKEN_SECRET=
326 |
327 | ACCESS_TOKEN_EXPIRE=
328 |
329 | REFRESH_TOKEN_EXPIRE=
330 |
331 | # name of the refresh token cookie
332 | REFRESH_TOKEN_COOKIE_NAME=
333 |
334 | MYSQL_DATABASE=
335 | MYSQL_ROOT_PASSWORD=
336 |
337 | # Example: mysql://USER:PASSWORD@HOST:PORT/DATABASE
338 | DATABASE_URL=
339 |
340 | # Configuration for the emial service
341 | SMTP_HOST=
342 | SMTP_PORT=
343 | SMTP_USERNAME=
344 | SMTP_PASSWORD=
345 | EMAIL_FROM=
346 | ```
347 |
348 | See .env.example for further details
349 |
350 |
351 |
352 | ## Getting Started
353 |
354 |
355 |
356 | ### Prerequisites
357 |
358 | This project uses Yarn as package manager
359 |
360 | ```bash
361 | npm install --global yarn
362 | ```
363 |
364 |
365 |
366 | ### Installation
367 |
368 | ```bash
369 | git clone https://github.com/Louis3797/express-ts-auth-service.git
370 | ```
371 |
372 | Go to the project directory
373 |
374 | ```bash
375 | cd express-ts-auth-service
376 | ```
377 |
378 | ```bash
379 | yarn install
380 | ```
381 |
382 | ### Linting
383 |
384 | ```bash
385 | # run ESLint
386 | yarn lint
387 |
388 | # fix ESLint errors
389 | yarn lint:fix
390 |
391 | # run prettier
392 | yarn prettier:check
393 |
394 | # fix prettier errors
395 | yarn prettier:format
396 |
397 | # fix prettier errors in specific file
398 | yarn prettier:format:file
399 | ```
400 |
401 |
402 |
403 | ### Running Tests
404 |
405 | To run tests, run the following command
406 |
407 | ```bash
408 | yarn test
409 | ```
410 |
411 | Run tests with watch flag
412 |
413 | ```bash
414 | yarn test:watch
415 | ```
416 |
417 | See test coverage
418 |
419 | ```bash
420 | yarn coverage
421 | ```
422 |
423 |
424 |
425 | ### Run Locally
426 |
427 | Start the server in development mode
428 |
429 | > Note: Dont forget to define the .env variables
430 |
431 | ```bash
432 | yarn dev
433 | ```
434 |
435 | Start the server in production mode
436 |
437 | ```bash
438 | yarn start
439 | ```
440 |
441 |
442 |
443 | ### Run with Docker
444 |
445 | Run docker compose
446 |
447 | ```bash
448 | cd express-ts-auth-service
449 | docker-compose up
450 | ```
451 |
452 |
453 | ## Roadmap
454 |
455 | - [ ] Winston + morgan for logging ?
456 | - [ ] Clean and order imports
457 | - [x] Order imports
458 | - [ ] Add index.ts files for cleaner imports
459 | - [x] Add xss attack prevention middleware
460 | - [ ] Add API Endpoint documentation
461 | - [ ] Social Auth
462 | - [ ] Google
463 | - [ ] Github
464 | - [ ] Facebook
465 | - [ ] Twitter
466 | - [ ] Better Error handeling
467 | - [ ] Custom Error classes like ```AccessTokenNotFoundError```
468 | - [ ] Integration Tests
469 |
470 |
471 | ## Contributing
472 |
473 |
474 |
475 |
476 |
477 | Contributions are always welcome!
478 |
479 | See `CONTRIBUTING.md` for ways to get started.
480 |
481 |
482 | ### Code of Conduct
483 |
484 | Please read the [Code of Conduct](https://github.com/Louis3797/express-ts-auth-service/blob/main/CODE_OF_CONDUCT.md)
485 |
486 |
487 |
488 | ## License
489 |
490 | Distributed under the MIT License. See LICENSE for more information.
491 |
492 |
493 |
494 | ## Contact
495 |
496 | Louis-Kaan Ay - louiskaan.ay@gmail.com
497 |
498 | Project Link: [https://github.com/Louis3797/express-ts-auth-service](https://github.com/Louis3797/express-ts-auth-service)
499 |
500 |
501 |
502 | ## Acknowledgements
503 |
504 | - [Readme Template](https://github.com/Louis3797/awesome-readme-template)
505 | - [Node Express Boilerplate](https://github.com/hagopj13/node-express-boilerplate)
506 | - [Express Ts Boilerplate](https://github.com/Louis3797/express-ts-boilerplate)
507 |
--------------------------------------------------------------------------------
/__test__/middleware/isAuth.test.ts:
--------------------------------------------------------------------------------
1 | import httpStatus from 'http-status';
2 | import type { NextFunction, Request, Response } from 'express';
3 | import isAuth from '../../src/middleware/isAuth';
4 | import jwt, { type JwtPayload } from 'jsonwebtoken';
5 | import config from '../../src/config/config';
6 |
7 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
8 | // @ts-expect-error
9 | const { sign } = jwt;
10 |
11 | describe('isAuth middleware', () => {
12 | let req: Request;
13 | let res: Response;
14 | let next: NextFunction;
15 |
16 | beforeEach(() => {
17 | req = {} as Request;
18 | res = {
19 | sendStatus: jest.fn()
20 | } as unknown as Response;
21 | next = jest.fn();
22 | });
23 |
24 | afterEach(() => {
25 | jest.resetAllMocks();
26 | });
27 |
28 | it('should return 401 if no authorization header is present', () => {
29 | isAuth(req, res, next);
30 |
31 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.UNAUTHORIZED);
32 | expect(next).not.toHaveBeenCalled();
33 | });
34 |
35 | it('should return 401 if authorization header does not start with "Bearer "', () => {
36 | req.headers = { authorization: 'InvalidToken' };
37 |
38 | isAuth(req, res, next);
39 |
40 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.UNAUTHORIZED);
41 | expect(next).not.toHaveBeenCalled();
42 | });
43 |
44 | it('should return 401 if token is undefined', () => {
45 | req.headers = { authorization: 'Bearer ' };
46 |
47 | isAuth(req, res, next);
48 |
49 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.UNAUTHORIZED);
50 | expect(next).not.toHaveBeenCalled();
51 | });
52 |
53 | it('should return 403 if token is invalid', () => {
54 | req.headers = { authorization: 'Bearer InvalidToken' };
55 |
56 | isAuth(req, res, next);
57 |
58 | expect(res.sendStatus).toHaveBeenCalledWith(httpStatus.FORBIDDEN);
59 | expect(next).not.toHaveBeenCalled();
60 | });
61 |
62 | it('should call next() if token is valid', () => {
63 | const payload: JwtPayload = { userId: '123' };
64 | const token = sign(payload, config.jwt.access_token.secret);
65 |
66 | req.headers = { authorization: `Bearer ${token}` };
67 |
68 | isAuth(req, res, next);
69 |
70 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
71 | // @ts-ignore
72 | expect(req.payload).toBeDefined();
73 | expect(next).toHaveBeenCalled();
74 | });
75 | });
76 |
--------------------------------------------------------------------------------
/__test__/middleware/xssMiddleware.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-floating-promises */
2 | import type { NextFunction, Request, Response } from 'express';
3 | import { xssMiddleware } from '../../src/middleware/xssMiddleware';
4 | import type { SanitizeOptions } from '../../src/types/types';
5 |
6 | describe('xssMiddleware', () => {
7 | let req: Request;
8 | let res: Response;
9 | let next: NextFunction;
10 |
11 | beforeEach(() => {
12 | req = {} as Request;
13 | res = {} as Response;
14 | next = jest.fn();
15 | });
16 |
17 | it('should sanitize req.body, req.query, and req.params', () => {
18 | const options: SanitizeOptions = {
19 | whiteList: { b: [] }
20 | };
21 | req.body = { a: '', b: 'good' };
22 | req.query = { a: '', b: 'good' };
23 | req.params = { a: '', b: 'good' };
24 |
25 | xssMiddleware(options)(req, res, next);
26 |
27 | expect(req.body).toEqual({
28 | a: '<script>bad()</script>',
29 | b: 'good'
30 | });
31 | expect(req.query).toEqual({
32 | a: '<script>bad()</script>',
33 | b: 'good'
34 | });
35 | expect(req.params).toEqual({
36 | a: '<script>bad()</script>',
37 | b: 'good'
38 | });
39 | });
40 |
41 | it('should call next', () => {
42 | xssMiddleware()(req, res, next);
43 | expect(next).toHaveBeenCalled();
44 | });
45 | });
46 |
--------------------------------------------------------------------------------
/__test__/utils/compressFilter.test.ts:
--------------------------------------------------------------------------------
1 | import compression from 'compression';
2 | import type { Request, Response } from 'express';
3 | import compressFilter from '../../src/utils/compressFilter.util';
4 |
5 | jest.mock('compression');
6 |
7 | describe('compressFilter', () => {
8 | const mockRequest = {} as Request;
9 | const mockResponse = {} as Response;
10 |
11 | beforeEach(() => {
12 | jest.clearAllMocks();
13 | });
14 |
15 | it('should return false if x-no-compression header is present', () => {
16 | mockRequest.headers = { 'x-no-compression': 'true' };
17 | const result = compressFilter(mockRequest, mockResponse);
18 | expect(result).toBe(false);
19 | expect(compression.filter).not.toHaveBeenCalled();
20 | });
21 |
22 | it('should use compression.filter if x-no-compression header is not present', () => {
23 | mockRequest.headers = {};
24 | (
25 | compression.filter as jest.MockedFunction
26 | ).mockReturnValueOnce(true);
27 | const result = compressFilter(mockRequest, mockResponse);
28 | expect(result).toBe(true);
29 | expect(compression.filter).toHaveBeenCalledWith(mockRequest, mockResponse);
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/__test__/utils/sanitize.test.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-confusing-void-expression */
2 | import type { SanitizeOptions } from '../../src/types/types';
3 | import { sanitize } from '../../src/utils/sanitize.util';
4 |
5 | describe('sanitize', () => {
6 | it('should return empty data as is', () => {
7 | expect(sanitize(null)).toBeNull();
8 | expect(sanitize(undefined)).toBeUndefined();
9 | expect(sanitize('')).toBe('');
10 | expect(sanitize({})).toEqual({});
11 | expect(sanitize([])).toEqual([]);
12 | });
13 |
14 | it('should sanitize strings', () => {
15 | const input = '';
16 | const expected = '<script>alert("hello world!")</script>';
17 | expect(sanitize(input)).toBe(expected);
18 | });
19 |
20 | it('should sanitize objects', () => {
21 | const input = {
22 | name: '',
23 | age: 20,
24 | address: {
25 | street: '123 Main St',
26 | city: 'Springfield',
27 | state: 'IL',
28 | zip: ''
29 | },
30 | friends: [
31 | { name: '', age: 22 },
32 | { name: 'Alice', age: '' }
33 | ]
34 | };
35 |
36 | const expected = {
37 | name: '<script>alert("hello world!");</script>',
38 | age: 20,
39 | address: {
40 | street: '123 Main St',
41 | city: 'Springfield',
42 | state: 'IL',
43 | zip: '<script>alert("hello world!");</script>'
44 | },
45 | friends: [
46 | {
47 | name: '<script>alert("hello world!");</script>',
48 | age: 22
49 | },
50 | {
51 | name: 'Alice',
52 | age: '<script>alert("hello world!");</script>'
53 | }
54 | ]
55 | };
56 |
57 | expect(sanitize(input)).toEqual(expected);
58 | });
59 |
60 | it('should sanitize arrays', () => {
61 | const input = [
62 | '',
63 | 20,
64 | ['test
']
65 | ];
66 | const expected = [
67 | '<script>alert("hello world!")</script>',
68 | 20,
69 | ['<div>test</div>']
70 | ];
71 | expect(sanitize(input)).toEqual(expected);
72 | });
73 |
74 | it('should sanitize an array of objects', () => {
75 | const input = [
76 | { name: '', age: 20 },
77 | { name: 'Alice', age: '' }
78 | ];
79 |
80 | const expected = [
81 | {
82 | name: '<script>alert("hello world!");</script>',
83 | age: 20
84 | },
85 | {
86 | name: 'Alice',
87 | age: '<script>alert("hello world!");</script>'
88 | }
89 | ];
90 | expect(sanitize(input)).toEqual(expected);
91 | });
92 |
93 | it('should allow custom XSS whiteList', () => {
94 | const input = '';
95 | const expected = '';
96 | const options: SanitizeOptions = {
97 | whiteList: {
98 | div: ['*'],
99 | a: ['href', 'target']
100 | }
101 | };
102 | expect(sanitize(input, options)).toBe(expected);
103 | });
104 | });
105 |
--------------------------------------------------------------------------------
/assets/refresh_token_rotation_flow_diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Louis3797/express-ts-auth-service/fc6722badf8b43c02adba9de7e11bb342c09a6f1/assets/refresh_token_rotation_flow_diagram.png
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 |
3 | services:
4 | # Express Server
5 | auth-service:
6 | build:
7 | context: .
8 | dockerfile: Dockerfile
9 | image: auth-service:latest
10 | container_name: auth-service
11 | ports:
12 | - '${PORT}:${PORT}'
13 | env_file:
14 | - ./.env
15 | environment:
16 | NODE_ENV: production
17 | DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD}@mysql:3307/${MYSQL_DATABASE}
18 | PORT: 4040
19 | depends_on:
20 | - mysql
21 | restart: unless-stopped
22 | command: ["dumb-init", "node", "dist/index.js"]
23 |
24 | # MySQL Database
25 | mysql:
26 | image: mysql:8.0
27 | container_name: mysql
28 | volumes:
29 | - db-data:/var/lib/mysql
30 | ports:
31 | - '3307:3306'
32 | environment:
33 | # ! dont use root user in prod
34 | MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
35 | MYSQL_DATABASE: ${MYSQL_DATABASE}
36 | MYSQL_USER: root
37 | MYSQL_PASSWORD: ${MYSQL_ROOT_PASSWORD}
38 | env_file:
39 | - ./.env
40 | restart: always
41 | volumes:
42 | db-data:
43 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest').JestConfigWithTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'node',
5 | clearMocks: true,
6 | collectCoverage: true,
7 | collectCoverageFrom: ['src/**/*.ts'],
8 | coverageDirectory: 'coverage',
9 | coverageReporters: ['json', 'html', 'text'],
10 | modulePathIgnorePatterns: [
11 | './dist',
12 | './coverage',
13 | './logs',
14 | './prisma',
15 | './assets',
16 | './node_modules',
17 | 'index.ts',
18 | 'app.ts',
19 | 'src/validations', // no need for testing validations
20 | 'src/routes'
21 | ],
22 | transform: {
23 | '^.+\\.m?[tj]sx?$': [
24 | 'ts-jest',
25 | {
26 | tsconfig: './tsconfig.test.json'
27 | }
28 | ]
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "express-ts-auth-service",
3 | "version": "1.0.0",
4 | "description": "A ready-to-use authentication service build with express.js, that provides secure and reliable authentication using JSON Web Tokens (JWT) and refresh token rotation",
5 | "author": "Louis-Kaan Ay",
6 | "license": "MIT",
7 | "private": false,
8 | "main": "./dist/index.js",
9 | "type": "commonjs",
10 | "scripts": {
11 | "dev": "tsc --project './tsconfig.build.json' --watch && cross-env NODE_ENV=development nodemon --trace-warnings ./dist/index.js",
12 | "build": "tsc --project './tsconfig.build.json'",
13 | "start": "yarn run build && cross-env NODE_ENV=production node --trace-warnings ./dist/index.js",
14 | "watch": "tsc --project './tsconfig.build.json' --watch",
15 | "lint": "eslint src/**/*.ts __test__/**/*.test.ts",
16 | "lint:fix": "eslint --fix --ext .",
17 | "prettier:format": "prettier --write .",
18 | "prettier:check": "prettier --check .",
19 | "prettier:format:file": "prettier --write ",
20 | "test": "jest",
21 | "test:watch": "jest -i --watchAll",
22 | "coverage": "jest -i --coverage",
23 | "prepare": "husky install"
24 | },
25 | "dependencies": {
26 | "@prisma/client": "^4.11.0",
27 | "argon2": "^0.30.3",
28 | "compression": "^1.7.4",
29 | "cookie-parser": "^1.4.6",
30 | "cors": "^2.8.5",
31 | "dotenv": "^16.0.3",
32 | "eslint": "^8.35.0",
33 | "express": "^4.18.2",
34 | "express-rate-limit": "^6.7.0",
35 | "helmet": "^6.0.1",
36 | "http-status": "^1.6.2",
37 | "joi": "^17.8.4",
38 | "jsonwebtoken": "^9.0.0",
39 | "nodemailer": "^6.9.1",
40 | "ts-node": "^10.9.1",
41 | "utility-types": "^3.10.0",
42 | "uuid": "^9.0.0",
43 | "winston": "^3.8.2",
44 | "xss": "^1.0.14"
45 | },
46 | "devDependencies": {
47 | "@types/argon2": "^0.15.0",
48 | "@types/compression": "^1.7.2",
49 | "@types/cookie-parser": "^1.4.3",
50 | "@types/cors": "^2.8.13",
51 | "@types/dotenv": "^8.2.0",
52 | "@types/express": "^4.17.17",
53 | "@types/jest": "^29.4.0",
54 | "@types/jsonwebtoken": "^9.0.1",
55 | "@types/nodemailer": "^6.4.7",
56 | "@types/supertest": "^2.0.12",
57 | "@types/uuid": "^9.0.1",
58 | "@typescript-eslint/eslint-plugin": "^5.54.0",
59 | "@typescript-eslint/parser": "^5.54.0",
60 | "cross-env": "^7.0.3",
61 | "eslint-config-prettier": "^8.6.0",
62 | "eslint-config-standard": "^17.0.0",
63 | "eslint-config-standard-with-typescript": "^34.0.0",
64 | "eslint-plugin-import": "^2.27.5",
65 | "eslint-plugin-n": "^15.6.1",
66 | "eslint-plugin-node": "^11.1.0",
67 | "eslint-plugin-promise": "^6.1.1",
68 | "eslint-plugin-security": "^1.7.1",
69 | "eslint-plugin-standard": "^5.0.0",
70 | "husky": "^8.0.3",
71 | "jest": "^29.4.3",
72 | "nodemon": "^2.0.21",
73 | "prettier": "^2.8.4",
74 | "prettier-eslint": "^15.0.1",
75 | "prisma": "^4.11.0",
76 | "supertest": "^6.3.3",
77 | "ts-jest": "^29.0.5",
78 | "typescript": "5.0.2"
79 | },
80 | "repository": {
81 | "type": "git",
82 | "url": "https://github.com/Louis3797/express-ts-auth-service"
83 | },
84 | "bugs": {
85 | "url": "https://github.com/Louis3797/express-ts-auth-service/issues"
86 | },
87 | "homepage": "https://github.com/Louis3797/express-ts-auth-service",
88 | "lint-staged": {
89 | "**/*.{js,jsx,ts,tsx}": [
90 | "npx prettier --write",
91 | "npx eslint --fix"
92 | ]
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/prisma/migrations/20230307220106_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `Account` (
3 | `id` VARCHAR(191) NOT NULL,
4 | `userId` VARCHAR(191) NOT NULL,
5 | `type` VARCHAR(191) NOT NULL,
6 | `provider` VARCHAR(191) NOT NULL,
7 | `providerAccountId` VARCHAR(191) NOT NULL,
8 | `refresh_token` TEXT NULL,
9 | `access_token` TEXT NULL,
10 | `expires_at` INTEGER NULL,
11 | `token_type` VARCHAR(191) NULL,
12 | `scope` VARCHAR(191) NULL,
13 | `id_token` TEXT NULL,
14 | `session_state` VARCHAR(191) NULL,
15 |
16 | UNIQUE INDEX `Account_provider_providerAccountId_key`(`provider`, `providerAccountId`),
17 | PRIMARY KEY (`id`)
18 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
19 |
20 | -- CreateTable
21 | CREATE TABLE `User` (
22 | `id` VARCHAR(191) NOT NULL,
23 | `name` VARCHAR(191) NULL,
24 | `email` VARCHAR(191) NULL,
25 | `emailVerified` DATETIME(3) NULL,
26 | `image` VARCHAR(191) NULL,
27 |
28 | UNIQUE INDEX `User_email_key`(`email`),
29 | PRIMARY KEY (`id`)
30 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
31 |
32 | -- AddForeignKey
33 | ALTER TABLE `Account` ADD CONSTRAINT `Account_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
34 |
--------------------------------------------------------------------------------
/prisma/migrations/20230308154922_user_password/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `image` on the `User` table. All the data in the column will be lost.
5 | - Added the required column `password` to the `User` table without a default value. This is not possible if the table is not empty.
6 | - Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
7 |
8 | */
9 | -- AlterTable
10 | ALTER TABLE `User` DROP COLUMN `image`,
11 | ADD COLUMN `password` VARCHAR(191) NOT NULL,
12 | MODIFY `name` VARCHAR(191) NOT NULL;
13 |
--------------------------------------------------------------------------------
/prisma/migrations/20230309120100_refresh_token/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `RefreshToken` (
3 | `token` VARCHAR(191) NOT NULL,
4 | `expires` DATETIME(3) NOT NULL,
5 | `userId` VARCHAR(191) NULL,
6 |
7 | PRIMARY KEY (`token`)
8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
9 |
10 | -- AddForeignKey
11 | ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20230309155008_removed_expires/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `expires` on the `RefreshToken` table. All the data in the column will be lost.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE `RefreshToken` DROP COLUMN `expires`;
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20230317200854_resest_token/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Made the column `userId` on table `RefreshToken` required. This step will fail if there are existing NULL values in that column.
5 |
6 | */
7 | -- DropForeignKey
8 | ALTER TABLE `RefreshToken` DROP FOREIGN KEY `RefreshToken_userId_fkey`;
9 |
10 | -- AlterTable
11 | ALTER TABLE `RefreshToken` MODIFY `userId` VARCHAR(191) NOT NULL;
12 |
13 | -- CreateTable
14 | CREATE TABLE `ResetToken` (
15 | `token` VARCHAR(191) NOT NULL,
16 | `expiresAt` DATETIME(3) NOT NULL,
17 | `userId` VARCHAR(191) NOT NULL,
18 |
19 | PRIMARY KEY (`token`)
20 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
21 |
22 | -- AddForeignKey
23 | ALTER TABLE `RefreshToken` ADD CONSTRAINT `RefreshToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
24 |
25 | -- AddForeignKey
26 | ALTER TABLE `ResetToken` ADD CONSTRAINT `ResetToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
27 |
--------------------------------------------------------------------------------
/prisma/migrations/20230317205303_email_verification_token/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE `EmailVerificationToken` (
3 | `token` VARCHAR(191) NOT NULL,
4 | `expiresAt` DATETIME(3) NOT NULL,
5 | `userId` VARCHAR(191) NOT NULL,
6 |
7 | PRIMARY KEY (`token`)
8 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
9 |
10 | -- AddForeignKey
11 | ALTER TABLE `EmailVerificationToken` ADD CONSTRAINT `EmailVerificationToken_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `User`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20230318163942_refresh_token_changes/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
5 | - The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE `RefreshToken` DROP PRIMARY KEY,
10 | ADD COLUMN `id` VARCHAR(191) NOT NULL,
11 | ADD PRIMARY KEY (`id`);
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20230318164340_refresh_token_changes_back/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
5 | - You are about to drop the column `id` on the `RefreshToken` table. All the data in the column will be lost.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE `RefreshToken` DROP PRIMARY KEY,
10 | DROP COLUMN `id`,
11 | ADD PRIMARY KEY (`token`);
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20230330094356_changed_name/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `expires_at` on the `Account` table. All the data in the column will be lost.
5 | - The primary key for the `EmailVerificationToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
6 | - The primary key for the `RefreshToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
7 | - The primary key for the `ResetToken` table will be changed. If it partially fails, the table could be left without primary key constraint.
8 | - A unique constraint covering the columns `[token]` on the table `EmailVerificationToken` will be added. If there are existing duplicate values, this will fail.
9 | - A unique constraint covering the columns `[token]` on the table `RefreshToken` will be added. If there are existing duplicate values, this will fail.
10 | - A unique constraint covering the columns `[token]` on the table `ResetToken` will be added. If there are existing duplicate values, this will fail.
11 | - Added the required column `expiresAt` to the `Account` table without a default value. This is not possible if the table is not empty.
12 | - The required column `id` was added to the `EmailVerificationToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
13 | - The required column `id` was added to the `RefreshToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
14 | - The required column `id` was added to the `ResetToken` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required.
15 |
16 | */
17 | -- AlterTable
18 | ALTER TABLE `Account` DROP COLUMN `expires_at`,
19 | ADD COLUMN `expiresAt` DATETIME(3) NOT NULL;
20 |
21 | -- AlterTable
22 | ALTER TABLE `EmailVerificationToken` DROP PRIMARY KEY,
23 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
24 | ADD COLUMN `id` VARCHAR(191) NOT NULL,
25 | ADD PRIMARY KEY (`id`);
26 |
27 | -- AlterTable
28 | ALTER TABLE `RefreshToken` DROP PRIMARY KEY,
29 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
30 | ADD COLUMN `id` VARCHAR(191) NOT NULL,
31 | ADD PRIMARY KEY (`id`);
32 |
33 | -- AlterTable
34 | ALTER TABLE `ResetToken` DROP PRIMARY KEY,
35 | ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
36 | ADD COLUMN `id` VARCHAR(191) NOT NULL,
37 | ADD PRIMARY KEY (`id`);
38 |
39 | -- AlterTable
40 | ALTER TABLE `User` ADD COLUMN `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3);
41 |
42 | -- CreateIndex
43 | CREATE UNIQUE INDEX `EmailVerificationToken_token_key` ON `EmailVerificationToken`(`token`);
44 |
45 | -- CreateIndex
46 | CREATE UNIQUE INDEX `RefreshToken_token_key` ON `RefreshToken`(`token`);
47 |
48 | -- CreateIndex
49 | CREATE UNIQUE INDEX `ResetToken_token_key` ON `ResetToken`(`token`);
50 |
--------------------------------------------------------------------------------
/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 = "mysql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | generator client {
5 | provider = "prisma-client-js"
6 | }
7 |
8 | datasource db {
9 | provider = "mysql"
10 | url = env("DATABASE_URL")
11 | }
12 |
13 | model Account {
14 | id String @id @default(cuid())
15 | userId String
16 | type String
17 | provider String
18 | providerAccountId String
19 | refresh_token String? @db.Text
20 | access_token String? @db.Text
21 | expiresAt DateTime
22 | token_type String?
23 | scope String?
24 | id_token String? @db.Text
25 | session_state String?
26 |
27 | user User @relation(fields: [userId], references: [id], onDelete: Cascade)
28 |
29 | @@unique([provider, providerAccountId])
30 | }
31 |
32 | model User {
33 | id String @id @default(cuid())
34 | name String
35 | email String? @unique
36 | password String
37 | emailVerified DateTime?
38 | createdAt DateTime @default(now())
39 | accounts Account[]
40 | refreshTokens RefreshToken[]
41 | resetToken ResetToken[]
42 | emailVerificationToken EmailVerificationToken[]
43 | }
44 |
45 | model RefreshToken {
46 | id String @id @default(cuid())
47 | token String @unique
48 | user User @relation(fields: [userId], references: [id])
49 | userId String
50 | createdAt DateTime @default(now())
51 | }
52 |
53 | model ResetToken {
54 | id String @id @default(cuid())
55 | token String @unique
56 | expiresAt DateTime
57 | user User @relation(fields: [userId], references: [id])
58 | userId String
59 | createdAt DateTime @default(now())
60 | }
61 |
62 | model EmailVerificationToken {
63 | id String @id @default(cuid())
64 | token String @unique
65 | expiresAt DateTime
66 | user User @relation(fields: [userId], references: [id])
67 | userId String
68 | createdAt DateTime @default(now())
69 | }
70 |
--------------------------------------------------------------------------------
/src/app.ts:
--------------------------------------------------------------------------------
1 | import express, { type Express } from 'express';
2 | import cors from 'cors';
3 | import helmet from 'helmet';
4 | import compression from 'compression';
5 | import cookieParser from 'cookie-parser';
6 | import compressFilter from './utils/compressFilter.util';
7 | import { authRouter, passwordRouter, verifyEmailRouter } from './routes/v1';
8 | import isAuth from './middleware/isAuth';
9 | import { errorHandler } from './middleware/errorHandler';
10 | import config from './config/config';
11 | import authLimiter from './middleware/authLimiter';
12 | import { xssMiddleware } from './middleware/xssMiddleware';
13 | import path from 'path';
14 | import corsConfig from './config/cors';
15 |
16 | const app: Express = express();
17 |
18 | // Helmet is used to secure this app by configuring the http-header
19 | app.use(helmet.frameguard({ action: 'deny' }));
20 |
21 | // parse json request body
22 | app.use(express.json());
23 |
24 | // parse urlencoded request body
25 | app.use(express.urlencoded({ extended: true }));
26 |
27 | app.use(xssMiddleware());
28 |
29 | app.use(cookieParser());
30 |
31 | // Compression is used to reduce the size of the response body
32 | app.use(compression({ filter: compressFilter }));
33 |
34 | app.use(cors(corsConfig));
35 |
36 | if (config.node_env === 'production') {
37 | app.use('/api/v1/auth', authLimiter);
38 | }
39 |
40 | app.use('/api/v1/auth', authRouter);
41 |
42 | app.use('/api/v1', passwordRouter);
43 |
44 | app.use('/api/v1', verifyEmailRouter);
45 |
46 | app.get('/secret', isAuth, (_req, res) => {
47 | res.json({
48 | message: 'You can see me'
49 | });
50 | });
51 |
52 | app.all('*', (req, res) => {
53 | res.status(404);
54 | if (req.accepts('html')) {
55 | res.sendFile(path.join(__dirname, 'views', '404.html'));
56 | } else if (req.accepts('json')) {
57 | res.json({ error: '404 Not Found' });
58 | } else {
59 | res.type('txt').send('404 Not Found');
60 | }
61 | });
62 |
63 | app.use(errorHandler);
64 |
65 | export default app;
66 |
--------------------------------------------------------------------------------
/src/config/config.ts:
--------------------------------------------------------------------------------
1 | import * as dotenv from 'dotenv';
2 | import path from 'path';
3 | import Joi from 'joi';
4 |
5 | dotenv.config({
6 | path: path.resolve(__dirname, '../../.env')
7 | });
8 |
9 | const envSchema = Joi.object().keys({
10 | NODE_ENV: Joi.string().valid('production', 'development', 'test').required(),
11 | PORT: Joi.string().required().default('4000'),
12 | SERVER_URL: Joi.string().required(),
13 | CORS_ORIGIN: Joi.string().required().default('*'),
14 | ACCESS_TOKEN_SECRET: Joi.string().min(8).required(),
15 | ACCESS_TOKEN_EXPIRE: Joi.string().required().default('20m'),
16 | REFRESH_TOKEN_SECRET: Joi.string().min(8).required(),
17 | REFRESH_TOKEN_EXPIRE: Joi.string().required().default('1d'),
18 | REFRESH_TOKEN_COOKIE_NAME: Joi.string().required().default('jid'),
19 | MYSQL_DATABASE: Joi.string().required(),
20 | MYSQL_ROOT_PASSWORD: Joi.string().required(),
21 | DATABASE_URL: Joi.string().required(),
22 | SMTP_HOST: Joi.string().required(),
23 | SMTP_PORT: Joi.string().default('587'),
24 | SMTP_USERNAME: Joi.string().required(),
25 | SMTP_PASSWORD: Joi.string().required(),
26 | EMAIL_FROM: Joi.string().email().required()
27 | });
28 |
29 | const { value: validatedEnv, error } = envSchema
30 | .prefs({ errors: { label: 'key' } })
31 | .validate(process.env, { abortEarly: false, stripUnknown: true });
32 |
33 | if (error) {
34 | throw new Error(
35 | `Environment variable validation error: \n${error.details
36 | .map((detail) => detail.message)
37 | .join('\n')}`
38 | );
39 | }
40 |
41 | const config = {
42 | node_env: validatedEnv.NODE_ENV,
43 | server: {
44 | port: validatedEnv.PORT,
45 | url: validatedEnv.SERVER_URL
46 | },
47 | cors: {
48 | cors_origin: validatedEnv.CORS_ORIGIN
49 | },
50 | jwt: {
51 | access_token: {
52 | secret: validatedEnv.ACCESS_TOKEN_SECRET,
53 | expire: validatedEnv.ACCESS_TOKEN_EXPIRE
54 | },
55 | refresh_token: {
56 | secret: validatedEnv.REFRESH_TOKEN_SECRET,
57 | expire: validatedEnv.REFRESH_TOKEN_EXPIRE,
58 | cookie_name: validatedEnv.REFRESH_TOKEN_COOKIE_NAME
59 | }
60 | },
61 | email: {
62 | smtp: {
63 | host: validatedEnv.SMTP_HOST,
64 | port: validatedEnv.SMTP_PORT,
65 | auth: {
66 | username: validatedEnv.SMTP_USERNAME,
67 | password: validatedEnv.SMTP_PASSWORD
68 | }
69 | },
70 | from: validatedEnv.EMAIL_FROM
71 | }
72 | } as const;
73 |
74 | export default config;
75 |
--------------------------------------------------------------------------------
/src/config/cookieConfig.ts:
--------------------------------------------------------------------------------
1 | import type { CookieOptions } from 'express';
2 |
3 | export const refreshTokenCookieConfig: CookieOptions = {
4 | httpOnly: true,
5 | sameSite: 'none',
6 | secure: true,
7 | maxAge: 24 * 60 * 60 * 1000
8 | };
9 |
10 | export const clearRefreshTokenCookieConfig: CookieOptions = {
11 | httpOnly: true,
12 | sameSite: 'none',
13 | secure: true
14 | };
15 |
--------------------------------------------------------------------------------
/src/config/cors.ts:
--------------------------------------------------------------------------------
1 | import { type CorsOptions } from 'cors';
2 | import config from './config';
3 |
4 | const whitelist = String(config.cors.cors_origin).split('|') ?? [];
5 |
6 | const corsConfig: Readonly = {
7 | origin (
8 | origin: string | undefined,
9 | callback: (
10 | err: Error | null,
11 | origin?: boolean | string | RegExp | Array
12 | ) => void
13 | ) {
14 | if (!origin || whitelist.some((val) => origin.match(val))) {
15 | callback(null, true);
16 | } else {
17 | callback(new Error('Not allowed by CORS'));
18 | }
19 | },
20 | maxAge: 86400,
21 | headers: [
22 | 'Accept',
23 | 'Authorization',
24 | 'Content-Type',
25 | 'If-None-Match',
26 | 'BX-User-Token',
27 | 'Trace-Id'
28 | ],
29 | exposedHeaders: ['WWW-Authenticate', 'Server-Authorization'],
30 | credentials: true
31 | } as CorsOptions;
32 |
33 | export default corsConfig;
34 |
--------------------------------------------------------------------------------
/src/config/nodemailer.ts:
--------------------------------------------------------------------------------
1 | import nodemailer, { type Transporter } from 'nodemailer';
2 | import logger from '../middleware/logger';
3 | import config from './config';
4 |
5 | let transporter: Transporter | null = null;
6 |
7 | const createTestAccount = async () => {
8 | try {
9 | const account = await nodemailer.createTestAccount();
10 | transporter = nodemailer.createTransport({
11 | host: account.smtp.host,
12 | port: account.smtp.port,
13 | secure: account.smtp.secure,
14 | auth: {
15 | user: account.user,
16 | pass: account.pass
17 | }
18 | });
19 | logger.info(`Test account created: ${account.user}`);
20 | console.log(account);
21 | } catch (error) {
22 | console.error('Failed to create a test account:', error);
23 | }
24 | };
25 |
26 | if (config.node_env === 'production') {
27 | transporter = nodemailer.createTransport({
28 | host: config.email.smtp.host,
29 | port: parseInt(config.email.smtp.port),
30 | secure: false, // true for 465, false for other ports
31 | auth: {
32 | user: config.email.smtp.auth.username,
33 | pass: config.email.smtp.auth.password
34 | }
35 | });
36 | } else {
37 | void createTestAccount();
38 | }
39 |
40 | export default transporter;
41 |
--------------------------------------------------------------------------------
/src/config/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from '@prisma/client';
2 | import config from './config';
3 |
4 | declare global {
5 | // eslint-disable-next-line no-var
6 | var prisma: PrismaClient | undefined;
7 | }
8 |
9 | const prismaClient: PrismaClient = new PrismaClient();
10 |
11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
12 | // @ts-ignore
13 | if (config.node_env !== 'production') globalThis.prisma = prismaClient;
14 |
15 | export default prismaClient;
16 |
--------------------------------------------------------------------------------
/src/controller/auth.controller.ts:
--------------------------------------------------------------------------------
1 | import type { Request, Response } from 'express';
2 | import httpStatus from 'http-status';
3 | import { randomUUID } from 'crypto';
4 | import * as argon2 from 'argon2';
5 | import jwt, { type JwtPayload } from 'jsonwebtoken';
6 | import prismaClient from '../config/prisma';
7 | import type {
8 | TypedRequest,
9 | UserLoginCredentials,
10 | UserSignUpCredentials
11 | } from '../types/types';
12 | import {
13 | createAccessToken,
14 | createRefreshToken
15 | } from '../utils/generateTokens.util';
16 | import config from '../config/config';
17 |
18 | import {
19 | clearRefreshTokenCookieConfig,
20 | refreshTokenCookieConfig
21 | } from '../config/cookieConfig';
22 |
23 | import { sendVerifyEmail } from '../utils/sendEmail.util';
24 | import logger from '../middleware/logger';
25 |
26 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
27 | // @ts-expect-error
28 | const { verify } = jwt;
29 |
30 | /**
31 | * This function handles the signup process for new users. It expects a request object with the following properties:
32 | *
33 | * @param {TypedRequest} req - The request object that includes user's username, email, and password.
34 | * @param {Response} res - The response object that will be used to send the HTTP response.
35 | *
36 | * @returns {Response} Returns an HTTP response that includes one of the following:
37 | * - A 400 BAD REQUEST status code and an error message if the request body is missing any required parameters.
38 | * - A 409 CONFLICT status code if the user email already exists in the database.
39 | * - A 201 CREATED status code and a success message if the new user is successfully created and a verification email is sent.
40 | * - A 500 INTERNAL SERVER ERROR status code if there is an error in the server.
41 | */
42 | export const handleSignUp = async (
43 | req: TypedRequest,
44 | res: Response
45 | ) => {
46 | const { username, email, password } = req.body;
47 |
48 | // check req.body values
49 | if (!username || !email || !password) {
50 | return res.status(httpStatus.BAD_REQUEST).json({
51 | message: 'Username, email and password are required!'
52 | });
53 | }
54 |
55 | const checkUserEmail = await prismaClient.user.findUnique({
56 | where: {
57 | email
58 | }
59 | });
60 |
61 | if (checkUserEmail) return res.sendStatus(httpStatus.CONFLICT); // email is already in db
62 |
63 | try {
64 | const hashedPassword = await argon2.hash(password);
65 |
66 | const newUser = await prismaClient.user.create({
67 | data: {
68 | name: username,
69 | email,
70 | password: hashedPassword
71 | }
72 | });
73 |
74 | const token = randomUUID();
75 | const expiresAt = new Date(Date.now() + 3600000); // Token expires in 1 hour
76 |
77 | await prismaClient.emailVerificationToken.create({
78 | data: {
79 | token,
80 | expiresAt,
81 | userId: newUser.id
82 | }
83 | });
84 |
85 | // Send an email with the verification link
86 | sendVerifyEmail(email, token);
87 |
88 | res.status(httpStatus.CREATED).json({ message: 'New user created' });
89 | } catch (err) {
90 | res.status(httpStatus.INTERNAL_SERVER_ERROR);
91 | }
92 | };
93 |
94 | /**
95 | * This function handles the login process for users. It expects a request object with the following properties:
96 | *
97 | * @param {TypedRequest} req - The request object that includes user's email and password.
98 | * @param {Response} res - The response object that will be used to send the HTTP response.
99 | *
100 | * @returns {Response} Returns an HTTP response that includes one of the following:
101 | * - A 400 BAD REQUEST status code and an error message if the request body is missing any required parameters.
102 | * - A 401 UNAUTHORIZED status code if the user email does not exist in the database or the email is not verified or the password is incorrect.
103 | * - A 200 OK status code and an access token if the login is successful and a new refresh token is stored in the database and a new refresh token cookie is set.
104 | * - A 500 INTERNAL SERVER ERROR status code if there is an error in the server.
105 | */
106 | export const handleLogin = async (
107 | req: TypedRequest,
108 | res: Response
109 | ) => {
110 | const cookies = req.cookies;
111 |
112 | const { email, password } = req.body;
113 |
114 | if (!email || !password) {
115 | return res
116 | .status(httpStatus.BAD_REQUEST)
117 | .json({ message: 'Email and password are required!' });
118 | }
119 |
120 | const user = await prismaClient.user.findUnique({
121 | where: {
122 | email
123 | }
124 | });
125 |
126 | if (!user) return res.sendStatus(httpStatus.UNAUTHORIZED);
127 |
128 | // check if email is verified
129 | if (!user.emailVerified) {
130 | res.status(httpStatus.UNAUTHORIZED).json({
131 | message: 'Your email is not verified! Please confirm your email!'
132 | });
133 | }
134 |
135 | // Generating the dummy hash dynamically may introduce a slight performance overhead, as argon2.hash()
136 | // is a computationally expensive operation.
137 | // However, this is generally negligible compared to the security benefits it provides
138 | const dummyPassword = 'dummy_password';
139 | const dummyHash = await argon2.hash(dummyPassword);
140 |
141 | // Use the user's hash if found, otherwise use the dummy hash
142 | const userPasswordHash = user ? user.password : dummyHash;
143 |
144 | // check password
145 | try {
146 | const isPasswordValid = await argon2.verify(userPasswordHash, password);
147 |
148 | // Check if email is verified
149 | // Check for verified email after verifying the password to prevent user enumeration attacks
150 | if (!user.emailVerified) {
151 | return res.status(httpStatus.UNAUTHORIZED).json({
152 | message: 'Your email is not verified! Please confirm your email!'
153 | });
154 | }
155 |
156 | // If password is invalid, return unauthorized
157 | if (!isPasswordValid) {
158 | return res.status(httpStatus.UNAUTHORIZED).json({
159 | message: 'Invalid email or password!'
160 | });
161 | }
162 |
163 | // if there is a refresh token in the req.cookie, then we need to check if this
164 | // refresh token exists in the database and belongs to the current user than we need to delete it
165 | // if the token does not belong to the current user, then we delete all refresh tokens
166 | // of the user stored in the db to be on the safe site
167 | // we also clear the cookie in both cases
168 | if (cookies?.[config.jwt.refresh_token.cookie_name]) {
169 | // check if the given refresh token is from the current user
170 | const checkRefreshToken = await prismaClient.refreshToken.findUnique({
171 | where: {
172 | token: cookies[config.jwt.refresh_token.cookie_name]
173 | }
174 | });
175 |
176 | // if this token does not exists int the database or belongs to another user,
177 | // then we clear all refresh tokens from the user in the db
178 | if (!checkRefreshToken || checkRefreshToken.userId !== user.id) {
179 | await prismaClient.refreshToken.deleteMany({
180 | where: {
181 | userId: user.id
182 | }
183 | });
184 | } else {
185 | // else everything is fine and we just need to delete the one token
186 | await prismaClient.refreshToken.delete({
187 | where: {
188 | token: cookies[config.jwt.refresh_token.cookie_name]
189 | }
190 | });
191 | }
192 |
193 | // also clear the refresh token in the cookie
194 | res.clearCookie(
195 | config.jwt.refresh_token.cookie_name,
196 | clearRefreshTokenCookieConfig
197 | );
198 | }
199 |
200 | const accessToken = createAccessToken(user.id);
201 |
202 | const newRefreshToken = createRefreshToken(user.id);
203 |
204 | // store new refresh token in db
205 | await prismaClient.refreshToken.create({
206 | data: {
207 | token: newRefreshToken,
208 | userId: user.id
209 | }
210 | });
211 |
212 | // save refresh token in cookie
213 | res.cookie(
214 | config.jwt.refresh_token.cookie_name,
215 | newRefreshToken,
216 | refreshTokenCookieConfig
217 | );
218 |
219 | // send access token per json to user so it can be stored in the localStorage
220 | return res.json({ accessToken });
221 | } catch (err) {
222 | return res.status(httpStatus.INTERNAL_SERVER_ERROR);
223 | }
224 | };
225 |
226 | /**
227 | * This function handles the logout process for users. It expects a request object with the following properties:
228 | *
229 | * @param {TypedRequest} req - The request object that includes a cookie with a valid refresh token
230 | * @param {Response} res - The response object that will be used to send the HTTP response.
231 | *
232 | * @returns {Response} Returns an HTTP response that includes one of the following:
233 | * - A 204 NO CONTENT status code if the refresh token cookie is undefined
234 | * - A 204 NO CONTENT status code if the refresh token does not exists in the database
235 | * - A 204 NO CONTENT status code if the refresh token cookie is successfully cleared
236 | */
237 | export const handleLogout = async (req: TypedRequest, res: Response) => {
238 | const cookies = req.cookies;
239 |
240 | if (!cookies[config.jwt.refresh_token.cookie_name]) {
241 | return res.sendStatus(httpStatus.NO_CONTENT); // No content
242 | }
243 | const refreshToken = cookies[config.jwt.refresh_token.cookie_name];
244 |
245 | // Is refreshToken in db?
246 | const foundRft = await prismaClient.refreshToken.findUnique({
247 | where: { token: refreshToken }
248 | });
249 |
250 | if (!foundRft) {
251 | res.clearCookie(
252 | config.jwt.refresh_token.cookie_name,
253 | clearRefreshTokenCookieConfig
254 | );
255 | return res.sendStatus(httpStatus.NO_CONTENT);
256 | }
257 |
258 | // Delete refreshToken in db
259 | await prismaClient.refreshToken.delete({
260 | where: { token: refreshToken }
261 | });
262 |
263 | res.clearCookie(
264 | config.jwt.refresh_token.cookie_name,
265 | clearRefreshTokenCookieConfig
266 | );
267 | return res.sendStatus(httpStatus.NO_CONTENT);
268 | };
269 |
270 | /**
271 | * This function handles the refresh process for users. It expects a request object with the following properties:
272 | *
273 | * @param {Request} req - The request object that includes a cookie with a valid refresh token
274 | * @param {Response} res - The response object that will be used to send the HTTP response.
275 | *
276 | * @returns {Response} Returns an HTTP response that includes one of the following:
277 | * - A 401 UNAUTHORIZED status code if the refresh token cookie is undefined
278 | * - A 403 FORBIDDEN status code if a refresh token reuse was detected but the token wasn't valid
279 | * - A 403 FORBIDDEN status code if a refresh token reuse was detected but the token was valid
280 | * - A 403 FORBIDDEN status code if the token wasn't valid
281 | * - A 200 OK status code if the token was valid and the user was granted a new refresh and access token
282 | */
283 | export const handleRefresh = async (req: Request, res: Response) => {
284 | const refreshToken: string | undefined =
285 | req.cookies[config.jwt.refresh_token.cookie_name];
286 |
287 | if (!refreshToken) return res.sendStatus(httpStatus.UNAUTHORIZED);
288 |
289 | // clear refresh cookie
290 | res.clearCookie(
291 | config.jwt.refresh_token.cookie_name,
292 | clearRefreshTokenCookieConfig
293 | );
294 |
295 | // check if refresh token is in db
296 | const foundRefreshToken = await prismaClient.refreshToken.findUnique({
297 | where: {
298 | token: refreshToken
299 | }
300 | });
301 |
302 | // Detected refresh token reuse!
303 | if (!foundRefreshToken) {
304 | verify(
305 | refreshToken,
306 | config.jwt.refresh_token.secret,
307 | async (err: unknown, payload: JwtPayload) => {
308 | if (err) return res.sendStatus(httpStatus.FORBIDDEN);
309 |
310 | logger.warn('Attempted refresh token reuse!');
311 |
312 | // Delete all tokens of the user because we detected that a token was stolen from him
313 | await prismaClient.refreshToken.deleteMany({
314 | where: {
315 | userId: payload.userId
316 | }
317 | });
318 | }
319 | );
320 | return res.status(httpStatus.FORBIDDEN);
321 | }
322 |
323 | // delete from db
324 | await prismaClient.refreshToken.delete({
325 | where: {
326 | token: refreshToken
327 | }
328 | });
329 |
330 | // evaluate jwt
331 | verify(
332 | refreshToken,
333 | config.jwt.refresh_token.secret,
334 | async (err: unknown, payload: JwtPayload) => {
335 | if (err || foundRefreshToken.userId !== payload.userId) {
336 | return res.sendStatus(httpStatus.FORBIDDEN);
337 | }
338 |
339 | // Refresh token was still valid
340 | const accessToken = createAccessToken(payload.userId);
341 |
342 | const newRefreshToken = createRefreshToken(payload.userId);
343 |
344 | // add refresh token to db
345 | await prismaClient.refreshToken
346 | .create({
347 | data: {
348 | token: newRefreshToken,
349 | userId: payload.userId
350 | }
351 | })
352 | .catch((err: Error) => {
353 | logger.error(err);
354 | });
355 |
356 | // Creates Secure Cookie with refresh token
357 | res.cookie(
358 | config.jwt.refresh_token.cookie_name,
359 | newRefreshToken,
360 | refreshTokenCookieConfig
361 | );
362 |
363 | return res.json({ accessToken });
364 | }
365 | );
366 | };
367 |
--------------------------------------------------------------------------------
/src/controller/forgotPassword.controller.ts:
--------------------------------------------------------------------------------
1 | import type { Response } from 'express';
2 | import httpStatus from 'http-status';
3 | import { randomUUID } from 'crypto';
4 | import * as argon2 from 'argon2';
5 | import prismaClient from '../config/prisma';
6 | import type {
7 | EmailRequestBody,
8 | ResetPasswordRequestBodyType,
9 | TypedRequest
10 | } from '../types/types';
11 | import { sendResetEmail } from '../utils/sendEmail.util';
12 |
13 | /**
14 | * Sends Forgot password email
15 | * @param req
16 | * @param res
17 | * @returns
18 | */
19 | export const handleForgotPassword = async (
20 | req: TypedRequest,
21 | res: Response
22 | ) => {
23 | const { email } = req.body;
24 |
25 | // check req.body values
26 | if (!email) {
27 | return res.status(httpStatus.BAD_REQUEST).json({
28 | message: 'Email is required!'
29 | });
30 | }
31 |
32 | // Check if the email exists in the database
33 | const user = await prismaClient.user.findUnique({ where: { email } });
34 |
35 | // check if email is verified
36 | if (!user || user.emailVerified) {
37 | return res.send(httpStatus.UNAUTHORIZED).json({
38 | message: 'Your email is not verified! Please confirm your email!'
39 | });
40 | }
41 |
42 | // Generate a reset token and save it to the database
43 | const resetToken = randomUUID();
44 | const expiresAt = new Date(Date.now() + 3600000); // Token expires in 1 hour
45 | await prismaClient.resetToken.create({
46 | data: {
47 | token: resetToken,
48 | expiresAt,
49 | userId: user.id
50 | }
51 | });
52 |
53 | // Send an email with the reset link
54 | sendResetEmail(email, resetToken);
55 |
56 | // Return a success message
57 | return res
58 | .status(httpStatus.OK)
59 | .json({ message: 'Password reset email sent' });
60 | };
61 |
62 | /**
63 | * Handles Password reset
64 | * @param req
65 | * @param res
66 | * @returns
67 | */
68 | export const handleResetPassword = async (
69 | req: TypedRequest,
70 | res: Response
71 | ) => {
72 | const { token } = req.params;
73 | const { newPassword } = req.body;
74 |
75 | if (!token) return res.sendStatus(httpStatus.NOT_FOUND);
76 |
77 | if (!newPassword) {
78 | return res
79 | .status(httpStatus.BAD_REQUEST)
80 | .json({ message: 'New password is required!' });
81 | }
82 |
83 | // Check if the token exists in the database and is not expired
84 | const resetToken = await prismaClient.resetToken.findFirst({
85 | where: { token, expiresAt: { gt: new Date() } }
86 | });
87 |
88 | if (!resetToken) {
89 | return res
90 | .status(httpStatus.NOT_FOUND)
91 | .json({ error: 'Invalid or expired token' });
92 | }
93 |
94 | // Update the user's password in the database
95 | const hashedPassword = await argon2.hash(newPassword);
96 | await prismaClient.user.update({
97 | where: { id: resetToken.userId },
98 | data: { password: hashedPassword }
99 | });
100 |
101 | // Delete the reset and all other reset tokens that the user owns from the database
102 | await prismaClient.resetToken.deleteMany({
103 | where: { userId: resetToken.userId }
104 | });
105 |
106 | // Delete also all refresh tokens
107 | await prismaClient.refreshToken.deleteMany({
108 | where: {
109 | userId: resetToken.userId
110 | }
111 | });
112 |
113 | // Return a success message
114 | return res
115 | .status(httpStatus.OK)
116 | .json({ message: 'Password reset successful' });
117 | };
118 |
--------------------------------------------------------------------------------
/src/controller/verifyEmail.controller.ts:
--------------------------------------------------------------------------------
1 | import type { Request, Response } from 'express';
2 | import httpStatus from 'http-status';
3 | import { randomUUID } from 'crypto';
4 | import prismaClient from '../config/prisma';
5 | import type { EmailRequestBody, TypedRequest } from '../types/types';
6 | import { sendVerifyEmail } from '../utils/sendEmail.util';
7 |
8 | /**
9 | * Sends Verification email
10 | * @param req
11 | * @param res
12 | * @returns
13 | */
14 | export const sendVerificationEmail = async (
15 | req: TypedRequest,
16 | res: Response
17 | ) => {
18 | const { email } = req.body;
19 |
20 | if (!email) {
21 | return res
22 | .status(httpStatus.BAD_REQUEST)
23 | .json({ message: 'Email is required!' });
24 | }
25 |
26 | // Check if the email exists in the database
27 | const user = await prismaClient.user.findUnique({
28 | where: { email },
29 | select: { id: true, emailVerified: true }
30 | });
31 |
32 | if (!user) {
33 | return res
34 | .status(httpStatus.UNAUTHORIZED)
35 | .json({ error: 'Email not found' });
36 | }
37 |
38 | // Check if the user's email is already verified
39 | if (user.emailVerified) {
40 | return res
41 | .status(httpStatus.CONFLICT)
42 | .json({ error: 'Email already verified' });
43 | }
44 |
45 | // Check if there is an existing verification token that has not expired
46 | const existingToken = await prismaClient.emailVerificationToken.findFirst({
47 | where: {
48 | user: { id: user.id },
49 | expiresAt: { gt: new Date() }
50 | }
51 | });
52 |
53 | if (existingToken) {
54 | return res
55 | .status(httpStatus.BAD_REQUEST)
56 | .json({ error: 'Verification email already sent' });
57 | }
58 |
59 | // Generate a new verification token and save it to the database
60 | const token = randomUUID();
61 | const expiresAt = new Date(Date.now() + 3600000); // Token expires in 1 hour
62 | await prismaClient.emailVerificationToken.create({
63 | data: {
64 | token,
65 | expiresAt,
66 | userId: user.id
67 | }
68 | });
69 |
70 | // Send an email with the new verification link
71 | sendVerifyEmail(email, token);
72 |
73 | // Return a success message
74 | return res.status(httpStatus.OK).json({ message: 'Verification email sent' });
75 | };
76 |
77 | export const handleVerifyEmail = async (req: Request, res: Response) => {
78 | const { token } = req.params;
79 |
80 | if (!token) return res.sendStatus(httpStatus.NOT_FOUND);
81 |
82 | // Check if the token exists in the database and is not expired
83 | const verificationToken = await prisma?.emailVerificationToken.findUnique({
84 | where: { token }
85 | });
86 |
87 | if (!verificationToken || verificationToken.expiresAt < new Date()) {
88 | return res
89 | .status(httpStatus.NOT_FOUND)
90 | .json({ error: 'Invalid or expired token' });
91 | }
92 |
93 | // Update the user's email verification status in the database
94 | await prismaClient.user.update({
95 | where: { id: verificationToken.userId },
96 | data: { emailVerified: new Date() }
97 | });
98 |
99 | // Delete the verification tokens that the user owns form the database
100 | await prismaClient.emailVerificationToken.deleteMany({
101 | where: { userId: verificationToken.userId }
102 | });
103 |
104 | // Return a success message
105 | return res.status(200).json({ message: 'Email verification successful' });
106 | };
107 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import app from './app';
2 | import config from './config/config';
3 | import logger from './middleware/logger';
4 |
5 | const server = app.listen(Number(config.server.port), () => {
6 | logger.log('info', `Server is running on Port: ${config.server.port}`);
7 | });
8 |
9 | process.on('SIGTERM', () => {
10 | logger.info('SIGTERM signal received.');
11 | logger.info('Closing server.');
12 | server.close((err) => {
13 | logger.info('Server closed.');
14 | // eslint-disable-next-line no-process-exit
15 | process.exit(err ? 1 : 0);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/middleware/authLimiter.ts:
--------------------------------------------------------------------------------
1 | import { rateLimit } from 'express-rate-limit';
2 |
3 | const authLimiter = rateLimit({
4 | windowMs: 15 * 60 * 1000,
5 | max: 20,
6 | skipSuccessfulRequests: true
7 | });
8 |
9 | export default authLimiter;
10 |
--------------------------------------------------------------------------------
/src/middleware/errorHandler.ts:
--------------------------------------------------------------------------------
1 | import type { Request, Response } from 'express';
2 | import logger from './logger';
3 |
4 | export const errorHandler = (
5 | err: Error,
6 | _req: Request,
7 | res: Response
8 | ): void => {
9 | logger.error(err);
10 |
11 | res.status(500).json({ message: err.message });
12 | };
13 |
--------------------------------------------------------------------------------
/src/middleware/isAuth.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-ts-comment */
2 | import type { NextFunction, Request, Response } from 'express';
3 | import httpStatus from 'http-status';
4 |
5 | import jwt, { type JwtPayload } from 'jsonwebtoken';
6 | import config from '../config/config';
7 |
8 | // Why does 'jsonwebtoken' not support es6 module support ?????
9 | // Maybe in future this will be added.....
10 | // GitHub Issue for this problem: https://github.com/auth0/node-jsonwebtoken/issues/655
11 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
12 | // @ts-expect-error
13 | const { verify } = jwt;
14 |
15 | const isAuth = (req: Request, res: Response, next: NextFunction) => {
16 | // token looks like 'Bearer vnjaknvijdaknvikbnvreiudfnvriengviewjkdsbnvierj'
17 |
18 | const authHeader = req.headers?.authorization;
19 |
20 | if (!authHeader || !authHeader?.startsWith('Bearer ')) {
21 | return res.sendStatus(httpStatus.UNAUTHORIZED);
22 | }
23 |
24 | const token: string | undefined = authHeader.split(' ')[1];
25 |
26 | if (!token) return res.sendStatus(httpStatus.UNAUTHORIZED);
27 |
28 | verify(
29 | token,
30 | config.jwt.access_token.secret,
31 | (err: unknown, payload: JwtPayload) => {
32 | if (err) return res.sendStatus(httpStatus.FORBIDDEN); // invalid token
33 | req.payload = payload;
34 |
35 | next();
36 | }
37 | );
38 | };
39 |
40 | export default isAuth;
41 |
--------------------------------------------------------------------------------
/src/middleware/logger.ts:
--------------------------------------------------------------------------------
1 | import config from '../config/config';
2 | import { createLogger, format, transports } from 'winston';
3 |
4 | const logger = createLogger({
5 | level: config.node_env === 'production' ? 'info' : 'debug',
6 | format: format.combine(
7 | format.timestamp({
8 | format: 'YYYY-MM-DD HH:mm:ss'
9 | }),
10 | format.errors({ stack: true }),
11 | format.splat(),
12 | format.json(),
13 | format.printf(({ timestamp, level, message, stack }) => {
14 | return `${timestamp} [${level.toUpperCase()}] ${message} ${
15 | stack ? `\n${stack}` : ''
16 | }`;
17 | })
18 | ),
19 | transports: [
20 | new transports.Console({
21 | stderrLevels: ['error']
22 | }),
23 | new transports.File({
24 | filename: 'logs/error.log',
25 | level: 'error'
26 | }),
27 | new transports.File({ filename: 'logs/combined.log' })
28 | ]
29 | });
30 | export default logger;
31 |
--------------------------------------------------------------------------------
/src/middleware/validate.ts:
--------------------------------------------------------------------------------
1 | import type { NextFunction, Request, Response } from 'express';
2 | import httpStatus from 'http-status';
3 | import Joi, { type ObjectSchema } from 'joi';
4 | import type { RequireAtLeastOne } from '../types/types';
5 |
6 | type RequestValidationSchema = RequireAtLeastOne<
7 | Record<'body' | 'query' | 'params', ObjectSchema>
8 | >;
9 |
10 | /**
11 | * This functions handles the validation of the given request validation schema
12 | *
13 | * @param {RequestValidationSchema} schema - The schema object can contain optional body, query, and params keys, each with a Joi schema object
14 | *
15 | * @returns Returns an HTTP response 400 BAD REQUEST if a validation error occurs or calls next if no error occurs
16 | *
17 | */
18 | const validate =
19 | (schema: RequestValidationSchema) =>
20 | (req: Request, res: Response, next: NextFunction) => {
21 | const { error } = Joi.object(schema).validate(
22 | {
23 | body: req.body,
24 | query: req.query,
25 | params: req.params
26 | },
27 | { abortEarly: false, stripUnknown: true }
28 | );
29 | if (!error) {
30 | next();
31 | } else {
32 | const errors = error?.details.map((err) => ({
33 | field: err.path.join(', '),
34 | message: err.message
35 | }));
36 |
37 | res.status(httpStatus.BAD_REQUEST).json({ errors });
38 | }
39 | };
40 |
41 | export default validate;
42 |
--------------------------------------------------------------------------------
/src/middleware/xssMiddleware.ts:
--------------------------------------------------------------------------------
1 | import type { ParamsDictionary } from 'express-serve-static-core';
2 | import type { ParsedQs } from 'qs';
3 | import { sanitize } from '../utils/sanitize.util';
4 | import type { ExpressMiddleware, SanitizeOptions } from '../types/types';
5 |
6 | export const xssMiddleware = (options?: SanitizeOptions): ExpressMiddleware => {
7 | return (req, _res, next) => {
8 | req.body = sanitize(req.body, options);
9 | req.query = sanitize(req.query, options) as unknown as ParsedQs;
10 | req.params = sanitize(req.params, options) as unknown as ParamsDictionary;
11 |
12 | next();
13 | };
14 | };
15 |
--------------------------------------------------------------------------------
/src/routes/v1/auth.route.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import validate from '../../middleware/validate';
3 | import { loginSchema, signupSchema } from '../../validations/auth.validation';
4 | import * as authController from '../../controller/auth.controller';
5 |
6 | const authRouter = Router();
7 |
8 | authRouter.post('/signup', validate(signupSchema), authController.handleSignUp);
9 |
10 | authRouter.post('/login', validate(loginSchema), authController.handleLogin);
11 |
12 | authRouter.post('/logout', authController.handleLogout);
13 |
14 | authRouter.post('/refresh', authController.handleRefresh);
15 |
16 | export default authRouter;
17 |
--------------------------------------------------------------------------------
/src/routes/v1/index.ts:
--------------------------------------------------------------------------------
1 | import authRouter from './auth.route';
2 | import passwordRouter from './password.route';
3 | import verifyEmailRouter from './verifyEmail.route';
4 |
5 | export { authRouter, verifyEmailRouter, passwordRouter };
6 |
--------------------------------------------------------------------------------
/src/routes/v1/password.route.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import validate from '../../middleware/validate';
3 | import {
4 | forgotPasswordSchema,
5 | resetPasswordSchema
6 | } from '../../validations/password.validation';
7 | import * as passwordController from '../../controller/forgotPassword.controller';
8 |
9 | const passwordRouter = Router();
10 |
11 | passwordRouter.post(
12 | '/forgot-password',
13 | validate(forgotPasswordSchema),
14 | passwordController.handleForgotPassword
15 | );
16 | passwordRouter.post(
17 | '/reset-password/:token',
18 | validate(resetPasswordSchema),
19 | passwordController.handleResetPassword
20 | );
21 |
22 | export default passwordRouter;
23 |
--------------------------------------------------------------------------------
/src/routes/v1/verifyEmail.route.ts:
--------------------------------------------------------------------------------
1 | import { Router } from 'express';
2 | import validate from '../../middleware/validate';
3 | import {
4 | sendVerifyEmailSchema,
5 | verifyEmailSchema
6 | } from '../../validations/verifyEmail.validation';
7 | import * as emailController from '../../controller/verifyEmail.controller';
8 |
9 | const verifyEmailRouter = Router();
10 |
11 | verifyEmailRouter.post(
12 | '/send-verification-email',
13 | validate(sendVerifyEmailSchema),
14 | emailController.sendVerificationEmail
15 | );
16 |
17 | verifyEmailRouter.post(
18 | '/verify-email/:token',
19 | validate(verifyEmailSchema),
20 | emailController.handleVerifyEmail
21 | );
22 |
23 | export default verifyEmailRouter;
24 |
--------------------------------------------------------------------------------
/src/types/env.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace NodeJS {
2 | export interface ProcessEnv {
3 | readonly NODE_ENV: 'production' | 'development' | 'test';
4 | readonly PORT: string;
5 | readonly SERVER_URL: string;
6 | readonly CORS_ORIGIN: string;
7 | readonly ACCESS_TOKEN_SECRET: string;
8 | readonly ACCESS_TOKEN_EXPIRE: string;
9 | readonly REFRESH_TOKEN_SECRET: string;
10 | readonly REFRESH_TOKEN_EXPIRE: string;
11 | readonly REFRESH_TOKEN_COOKIE_NAME: string;
12 | readonly MYSQL_DATABASE: string;
13 | readonly MYSQL_ROOT_PASSWORD: string;
14 | readonly DATABASE_URL: string;
15 | readonly SMTP_HOST: string;
16 | readonly SMTP_PORT: string;
17 | readonly SMTP_USERNAME: string;
18 | readonly SMTP_PASSWORD: string;
19 | readonly EMAIL_FROM: string;
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/types/express.d.ts:
--------------------------------------------------------------------------------
1 | import type { JwtPayload } from 'jsonwebtoken';
2 | declare global {
3 | namespace Express {
4 | export interface Request {
5 | payload?: JwtPayload;
6 |
7 | cookies: {
8 | jid?: string;
9 | };
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/types/jwt.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'jsonwebtoken' {
2 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
3 | export interface JwtPayload {
4 | userId: string;
5 | }
6 |
7 | // eslint-disable-next-line @typescript-eslint/no-empty-interface
8 | export interface Jwt {}
9 | }
10 |
--------------------------------------------------------------------------------
/src/types/types.ts:
--------------------------------------------------------------------------------
1 | import type { NextFunction, Request, Response } from 'express';
2 | import type { DeepPartial } from 'utility-types';
3 | import type { IFilterXSSOptions } from 'xss';
4 |
5 | // See this for the following types
6 | // https://stackoverflow.com/questions/34508081/how-to-add-typescript-definitions-to-express-req-res
7 | // https://stackoverflow.com/questions/61132262/typescript-deep-partial
8 |
9 | export type RequireAtLeastOne = {
10 | [K in keyof T]-?: Required> &
11 | Partial>>;
12 | }[keyof T];
13 |
14 | // More strictly typed Express.Request type
15 | export type TypedRequest<
16 | ReqBody = Record,
17 | QueryString = Record
18 | > = Request<
19 | Record,
20 | Record,
21 | DeepPartial,
22 | DeepPartial
23 | >;
24 |
25 | // More strictly typed express middleware type
26 | export type ExpressMiddleware<
27 | ReqBody = Record,
28 | Res = Record,
29 | QueryString = Record
30 | > = (
31 | req: TypedRequest,
32 | res: Response,
33 | next: NextFunction
34 | ) => Promise | void;
35 |
36 | // Example usage from Stackoverflow:
37 | // type Req = { email: string; password: string };
38 |
39 | // type Res = { message: string };
40 |
41 | // export const signupUser: ExpressMiddleware = async (req, res) => {
42 | // /* strongly typed `req.body`. yay autocomplete 🎉 */
43 | // res.json({ message: 'you have signed up' }) // strongly typed response obj
44 | // };
45 | export interface UserSignUpCredentials {
46 | username: string;
47 | email: string;
48 | password: string;
49 | }
50 |
51 | export type UserLoginCredentials = Omit;
52 |
53 | export interface EmailRequestBody {
54 | email: string;
55 | }
56 |
57 | export interface ResetPasswordRequestBodyType {
58 | newPassword: string;
59 | }
60 |
61 | export type Sanitized = T extends (...args: unknown[]) => unknown
62 | ? T // if T is a function, return it as is
63 | : T extends object
64 | ? {
65 | readonly [K in keyof T]: Sanitized;
66 | }
67 | : T;
68 |
69 | export type SanitizeOptions = IFilterXSSOptions & {
70 | whiteList?: IFilterXSSOptions['whiteList'];
71 | };
72 |
--------------------------------------------------------------------------------
/src/utils/compressFilter.util.ts:
--------------------------------------------------------------------------------
1 | import compression from 'compression';
2 | import type { Request, Response } from 'express';
3 |
4 | /**
5 | * Filter Function for the compression middleware
6 | * @param req HTTPs Request
7 | * @param res HTTPs Response
8 | * @returns Returns false if request header contains x-no-compression
9 | */
10 | const compressFilter = (req: Request, res: Response): boolean => {
11 | if (req.headers['x-no-compression']) {
12 | // don't compress responses with this request header
13 | return false;
14 | }
15 |
16 | // fallback to standard filter function
17 | return compression.filter(req, res);
18 | };
19 |
20 | export default compressFilter;
21 |
--------------------------------------------------------------------------------
/src/utils/generateTokens.util.ts:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken';
2 | import config from '../config/config';
3 |
4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
5 | // @ts-expect-error
6 | const { sign } = jwt;
7 |
8 | /**
9 | * This functions generates a valid access token
10 | *
11 | * @param {number | string} userId - The user id of the user that owns this jwt
12 | * @returns Returns a valid access token
13 | */
14 | export const createAccessToken = (userId: number | string): string => {
15 | return sign({ userID: userId }, config.jwt.access_token.secret, {
16 | expiresIn: config.jwt.access_token.expire
17 | });
18 | };
19 |
20 | /**
21 | * This functions generates a valid refresh token
22 | *
23 | * @param {number | string} userId - The user id of the user that owns this jwt
24 | * @returns Returns a valid refresh token
25 | */
26 | export const createRefreshToken = (userId: number | string): string => {
27 | return sign({ userId }, config.jwt.refresh_token.secret, {
28 | expiresIn: config.jwt.refresh_token.expire
29 | });
30 | };
31 |
--------------------------------------------------------------------------------
/src/utils/sanitize.util.ts:
--------------------------------------------------------------------------------
1 | import type { Sanitized, SanitizeOptions } from '../types/types';
2 | import xss from 'xss';
3 |
4 | /**
5 | * Sanitizes the provided data by applying XSS filtering and returning a copy of the data with all properties and values set as readonly.
6 | *
7 | * @param data The data to be sanitized.
8 | * @param options The options to configure the XSS filtering process.
9 | *
10 | * @returns A sanitized copy of the provided data with all properties and values set as readonly.
11 | */
12 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-constraint
13 | export const sanitize = (
14 | data: T,
15 | options?: SanitizeOptions
16 | ): Sanitized => {
17 | // If data is falsy, return as is
18 | if (!data) {
19 | return data as unknown as Sanitized;
20 | }
21 |
22 | // If data is an array, sanitize each item in the array and return the sanitized array
23 | if (Array.isArray(data)) {
24 | const sanitizedArray = data.map((item) =>
25 | sanitize(item, options)
26 | ) as unknown as Sanitized;
27 | return sanitizedArray;
28 | }
29 |
30 | // If data is an object, sanitize each property value in the object and return the sanitized object
31 | if (typeof data === 'object' && data !== null) {
32 | const sanitizedObject = {} as { [K in keyof T]: Sanitized };
33 |
34 | // Sanitize each property value in the object
35 | for (const [key, value] of Object.entries(data)) {
36 | sanitizedObject[key as keyof T] = sanitize(value, options);
37 | }
38 | return sanitizedObject as Sanitized;
39 | }
40 |
41 | // If data is a string, apply XSS filtering and return the sanitized string
42 | if (typeof data === 'string') {
43 | const xssOptions: SanitizeOptions = {
44 | stripIgnoreTagBody: options?.stripIgnoreTagBody ?? false,
45 | whiteList: options?.whiteList ?? {},
46 | ...options
47 | };
48 | return xss(data, xssOptions) as Sanitized;
49 | }
50 |
51 | // If data is not an array, object, or string, return as is
52 | return data as Sanitized;
53 | };
54 |
--------------------------------------------------------------------------------
/src/utils/sendEmail.util.ts:
--------------------------------------------------------------------------------
1 | import logger from '../middleware/logger';
2 | import transporter from '../config/nodemailer';
3 | import config from '../config/config';
4 |
5 | /**
6 | * This function sends an email to the given email with the reset password link
7 | *
8 | * @param {string} email - The email of the user
9 | * @param {string} token - The reset password token
10 | */
11 | export const sendResetEmail = (email: string, token: string) => {
12 | const resetLink = `${config.server.url}/api/v1/reset-password/${token}`;
13 | const mailOptions = {
14 | from: config.email.from,
15 | to: email,
16 | subject: 'Password reset',
17 | html: `
18 | Please reset your password by clicking the button below:
19 |
22 | `
23 | };
24 | console.log(resetLink);
25 | transporter?.sendMail(mailOptions, (error, info) => {
26 | if (error) {
27 | logger.error(error);
28 | } else {
29 | logger.info('Reset password email sent: ' + info.response);
30 | }
31 | });
32 | };
33 |
34 | /**
35 | * This function sends an email to the given email with the email verification link
36 | *
37 | * @param {string} email - The email of the user
38 | * @param {string} token - The email verification token
39 | */
40 | export const sendVerifyEmail = (email: string, token: string) => {
41 | const verifyLink = `${config.server.url}/api/v1/verify-email/${token}`;
42 | const mailOptions = {
43 | from: config.email.from,
44 | to: email,
45 | subject: 'Email verification',
46 | html: `
47 | Please verify your email by clicking the button below:
48 |
51 | `
52 | };
53 | console.log(verifyLink);
54 | transporter?.sendMail(mailOptions, (error, info) => {
55 | if (error) {
56 | logger.error(error);
57 | } else {
58 | logger.info('Verify email sent: ' + info.response);
59 | }
60 | });
61 | };
62 |
--------------------------------------------------------------------------------
/src/validations/auth.validation.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 | import type {
3 | UserLoginCredentials,
4 | UserSignUpCredentials
5 | } from '../types/types';
6 |
7 | export const signupSchema = {
8 | body: Joi.object().keys({
9 | email: Joi.string().required().email(),
10 | password: Joi.string().required().min(6).max(150),
11 | username: Joi.string().required().min(2).max(50)
12 | })
13 | };
14 |
15 | export const loginSchema = {
16 | body: Joi.object().keys({
17 | email: Joi.string().required().email(),
18 | password: Joi.string().required().min(6).max(150)
19 | })
20 | };
21 |
--------------------------------------------------------------------------------
/src/validations/password.validation.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 | import type { EmailRequestBody } from '../types/types';
3 |
4 | export const forgotPasswordSchema = {
5 | body: Joi.object().keys({
6 | email: Joi.string().required().email()
7 | })
8 | };
9 |
10 | export const resetPasswordSchema = {
11 | body: Joi.object().keys({
12 | newPassword: Joi.string().required().min(6).max(150)
13 | }),
14 | params: Joi.object().keys({
15 | token: Joi.string().regex(
16 | /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*$/
17 | )
18 | })
19 | };
20 |
--------------------------------------------------------------------------------
/src/validations/verifyEmail.validation.ts:
--------------------------------------------------------------------------------
1 | import Joi from 'joi';
2 | import type { EmailRequestBody } from '../types/types';
3 |
4 | export const sendVerifyEmailSchema = {
5 | body: Joi.object().keys({
6 | email: Joi.string().required().email()
7 | })
8 | };
9 |
10 | export const verifyEmailSchema = {
11 | params: Joi.object().keys({
12 | token: Joi.string().regex(
13 | /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_.+/=]*$/
14 | )
15 | })
16 | };
17 |
--------------------------------------------------------------------------------
/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "esModuleInterop": true
5 | },
6 | "include": ["src/**/*.ts"],
7 | "exclude": ["node_modules", "__test__", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "declaration": true,
5 | "removeComments": true,
6 | "emitDecoratorMetadata": true,
7 | "experimentalDecorators": true,
8 | "allowSyntheticDefaultImports": true,
9 | "target": "es2022",
10 | "sourceMap": true,
11 | "outDir": "dist",
12 | "baseUrl": ".",
13 | "sourceRoot": "./src",
14 | "incremental": true,
15 | "skipLibCheck": true,
16 | "typeRoots": ["./node_modules/@types", "./src/types"],
17 | "lib": ["es2022", "dom"],
18 | "moduleResolution": "node",
19 | "resolveJsonModule": true,
20 |
21 | // strict settings
22 | "strict": true,
23 | "strictNullChecks": true,
24 | "allowUnusedLabels": false,
25 | "noImplicitAny": true,
26 | "noImplicitThis": true,
27 | "allowUnreachableCode": false,
28 | "exactOptionalPropertyTypes": true,
29 | "noFallthroughCasesInSwitch": true,
30 | "noImplicitOverride": true,
31 | "noImplicitReturns": false, // is false bc of some error with next() and other returns
32 | "noPropertyAccessFromIndexSignature": true,
33 | "noUncheckedIndexedAccess": true,
34 | "noUnusedLocals": true,
35 | "noUnusedParameters": true,
36 | "forceConsistentCasingInFileNames": true,
37 | },
38 | }
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "types": ["jest", "node"],
5 | "baseUrl": ".",
6 | "esModuleInterop": true,
7 | "module": "commonjs"
8 | },
9 | "include": ["jest.config.ts", "src/**/*.ts", "__test__/**/*.test.ts", "**/*.test.ts"]
10 | }
11 |
--------------------------------------------------------------------------------