├── .eslintrc.js ├── .example.env ├── .gitignore ├── .prettierrc ├── .vscode └── launch.json ├── README.md ├── docker-compose.yml ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── prisma ├── migrations │ ├── 20221114164905_init │ │ └── migration.sql │ ├── 20221222085522_init │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── src ├── app.module.ts ├── auth │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.ts │ ├── decorator │ │ └── get-user.decorator.ts │ ├── dto │ │ └── auth.dto.ts │ ├── guards │ │ ├── jwt-refresh.guard.ts │ │ └── jwt.guard.ts │ ├── interfaces │ │ ├── IRequestWithUser.ts │ │ └── ITokenPayload.ts │ └── strategy │ │ ├── jwt-refresh-token.strategy.ts │ │ └── jwt.strategy.ts ├── main.ts ├── prisma │ ├── prisma.module.ts │ └── prisma.service.ts └── user │ ├── user.controller.ts │ ├── user.module.ts │ └── user.service.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── thunder-tests ├── thunderActivity.json ├── thunderCollection.json ├── thunderEnvironment.json └── thunderclient.json ├── tsconfig.build.json └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir : __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=postgres://postgres:root@localhost:5432/nest 2 | JWT_ACCESS_TOKEN_SECRET=62c4658fff3c8c02b9e3ce686b106776f6b7695fa057759dc5c8c196c084aa860fd7157a8b42f284e79ea5e93ac8b2f15b14821140c7bfd6a88e2ef92b755f1d 3 | JWT_REFRESH_TOKEN_SECRET=96d836715f4b882776af7df2dc49aa234cb1bf9edc7a901521b930f1a5490609c9f301bafb3b9af583653c0160c3d36f5d0806acf030c1268b45974eaaa6a74e -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | #envs 38 | .env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:8080", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | image: postgres 4 | ports: 5 | - 54329:5432 6 | restart: always 7 | environment: 8 | POSTGRES_DB: nest 9 | POSTRGRES_USER: root 10 | POSTGRES_PASSWORD: root 11 | cache: 12 | image: redis 13 | restart: always 14 | ports: 15 | - '6379:6379' 16 | environment: 17 | - ALLOW_EMPTY_PASSWORD=yes 18 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-app", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main.js", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs-modules/mailer": "^1.7.1", 25 | "@nestjs/common": "^8.0.0", 26 | "@nestjs/config": "^2.0.1", 27 | "@nestjs/core": "^8.0.0", 28 | "@nestjs/jwt": "^8.0.1", 29 | "@nestjs/passport": "^8.2.1", 30 | "@nestjs/platform-express": "^8.0.0", 31 | "@prisma/client": "^4.6.1", 32 | "@types/ua-parser-js": "^0.7.36", 33 | "argon2": "^0.28.5", 34 | "class-transformer": "^0.5.1", 35 | "class-validator": "^0.13.2", 36 | "cookie-parser": "^1.4.6", 37 | "csurf": "^1.11.0", 38 | "dayjs": "^1.11.3", 39 | "passport": "^0.5.3", 40 | "passport-jwt": "^4.0.0", 41 | "passport-local": "^1.0.0", 42 | "reflect-metadata": "^0.1.13", 43 | "rimraf": "^3.0.2", 44 | "rxjs": "^7.2.0", 45 | "ua-parser-js": "^1.0.2" 46 | }, 47 | "devDependencies": { 48 | "@nestjs/cli": "^8.0.0", 49 | "@nestjs/schematics": "^8.0.0", 50 | "@nestjs/testing": "^8.0.0", 51 | "@types/cookie-parser": "^1.4.3", 52 | "@types/csurf": "^1.11.2", 53 | "@types/express": "^4.17.13", 54 | "@types/jest": "27.5.0", 55 | "@types/node": "^16.11.44", 56 | "@types/nodemailer": "^6.4.4", 57 | "@types/passport-jwt": "^3.0.6", 58 | "@types/passport-local": "^1.0.34", 59 | "@types/supertest": "^2.0.11", 60 | "@typescript-eslint/eslint-plugin": "^5.0.0", 61 | "@typescript-eslint/parser": "^5.0.0", 62 | "eslint": "^8.0.1", 63 | "eslint-config-prettier": "^8.3.0", 64 | "eslint-plugin-prettier": "^4.0.0", 65 | "jest": "28.0.3", 66 | "prettier": "^2.3.2", 67 | "prisma": "^4.6.1", 68 | "source-map-support": "^0.5.20", 69 | "supertest": "^6.1.3", 70 | "ts-jest": "28.0.1", 71 | "ts-loader": "^9.2.3", 72 | "ts-node": "^10.0.0", 73 | "tsconfig-paths": "4.0.0", 74 | "typescript": "^4.3.5" 75 | }, 76 | "jest": { 77 | "moduleFileExtensions": [ 78 | "js", 79 | "json", 80 | "ts" 81 | ], 82 | "rootDir": "src", 83 | "testRegex": ".*\\.spec\\.ts$", 84 | "transform": { 85 | "^.+\\.(t|j)s$": "ts-jest" 86 | }, 87 | "collectCoverageFrom": [ 88 | "**/*.(t|j)s" 89 | ], 90 | "coverageDirectory": "../coverage", 91 | "testEnvironment": "node" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /prisma/migrations/20221114164905_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "users" ( 3 | "id" SERIAL NOT NULL, 4 | "email" TEXT, 5 | "password" TEXT, 6 | "firstName" TEXT, 7 | "lastName" TEXT, 8 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 9 | "updatedAt" TIMESTAMP(3) NOT NULL, 10 | 11 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "tokens" ( 16 | "id" TEXT NOT NULL, 17 | "userId" INTEGER NOT NULL, 18 | "refreshToken" TEXT NOT NULL, 19 | "device" TEXT DEFAULT E'unkown device', 20 | "app" TEXT DEFAULT E'unkown app', 21 | "expiresAt" TIMESTAMP(3) NOT NULL, 22 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 23 | "updatedAt" TIMESTAMP(3) NOT NULL, 24 | 25 | CONSTRAINT "tokens_pkey" PRIMARY KEY ("id") 26 | ); 27 | 28 | -- CreateTable 29 | CREATE TABLE "providers" ( 30 | "id" SERIAL NOT NULL, 31 | "provider_id" TEXT NOT NULL, 32 | "provider_name" TEXT NOT NULL, 33 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 34 | "updatedAt" TIMESTAMP(3) NOT NULL, 35 | "userId" INTEGER, 36 | 37 | CONSTRAINT "providers_pkey" PRIMARY KEY ("id") 38 | ); 39 | 40 | -- CreateIndex 41 | CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); 42 | 43 | -- AddForeignKey 44 | ALTER TABLE "tokens" ADD CONSTRAINT "tokens_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 45 | 46 | -- AddForeignKey 47 | ALTER TABLE "providers" ADD CONSTRAINT "providers_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; 48 | -------------------------------------------------------------------------------- /prisma/migrations/20221222085522_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `app` on the `tokens` table. All the data in the column will be lost. 5 | - You are about to drop the column `device` on the `tokens` table. All the data in the column will be lost. 6 | - You are about to drop the `providers` table. If the table is not empty, all the data it contains will be lost. 7 | 8 | */ 9 | -- DropForeignKey 10 | ALTER TABLE "providers" DROP CONSTRAINT "providers_userId_fkey"; 11 | 12 | -- AlterTable 13 | ALTER TABLE "tokens" DROP COLUMN "app", 14 | DROP COLUMN "device"; 15 | 16 | -- DropTable 17 | DROP TABLE "providers"; 18 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | //url = "postgres://fzgacfhwunpyqs:805f72233b2137976981addc6e4345a8abf351451b6080e11cf37f8ea09ecf0a@ec2-3-219-229-143.compute-1.amazonaws.com:5432/dbsvi97m6ls9nc" 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | datasource db { 11 | provider = "postgresql" 12 | url = env("DATABASE_URL") 13 | } 14 | 15 | model User { 16 | id Int @id @default(autoincrement()) 17 | email String? @unique 18 | password String? 19 | firstName String? 20 | lastName String? 21 | tokens Token[] 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt 24 | 25 | @@map("users") 26 | } 27 | 28 | model Token { 29 | id String @id @default(uuid()) 30 | userId Int 31 | refreshToken String 32 | expiresAt DateTime 33 | createdAt DateTime @default(now()) 34 | updatedAt DateTime @updatedAt 35 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 36 | 37 | @@map("tokens") 38 | } 39 | 40 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from './auth/auth.module'; 3 | import { UserModule } from './user/user.module'; 4 | import { PrismaModule } from './prisma/prisma.module'; 5 | import { ConfigModule } from '@nestjs/config'; 6 | 7 | @Module({ 8 | imports: [ 9 | AuthModule, 10 | UserModule, 11 | PrismaModule, 12 | ConfigModule.forRoot({ 13 | isGlobal: true, 14 | }), 15 | ], 16 | }) 17 | export class AppModule {} 18 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | HttpCode, 6 | Post, 7 | Req, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { AuthService } from './auth.service'; 11 | import { AuthDto } from './dto/auth.dto'; 12 | import { IRequestWithUser } from './interfaces/IRequestWithUser'; 13 | import JwtRefreshGuard from './guards/jwt-refresh.guard'; 14 | import { JwtAuthGuard } from './guards/jwt.guard'; 15 | 16 | @Controller('auth') 17 | export class AuthController { 18 | constructor(private readonly authService: AuthService) {} 19 | 20 | @Post('sign-up') 21 | signUp(@Body() dto: AuthDto) { 22 | return this.authService.signUp(dto); 23 | } 24 | 25 | @Post('sign-in') 26 | async signIn(@Body() dto: AuthDto) { 27 | const tokens = await this.authService.signIn(dto); 28 | return tokens; 29 | } 30 | 31 | @UseGuards(JwtAuthGuard) 32 | @Post('sign-out') 33 | @HttpCode(200) 34 | async signOut(@Req() request: IRequestWithUser) { 35 | const tokenId = request.header('Token-Id'); 36 | await this.authService.signOut(tokenId); 37 | } 38 | 39 | @UseGuards(JwtRefreshGuard) 40 | @Get('refresh') 41 | async refresh(@Req() request: IRequestWithUser) { 42 | //request.res.setHeader('Set-Cookie', accessToken); next-auth creates cookie no need here 43 | return request.user; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { AuthController } from './auth.controller'; 5 | import { AuthService } from './auth.service'; 6 | import { JwtStrategy } from './strategy/jwt.strategy'; 7 | import { JwtRefreshTokenStrategy } from './strategy/jwt-refresh-token.strategy'; 8 | 9 | @Module({ 10 | imports: [ 11 | JwtModule.registerAsync({ 12 | inject: [ConfigService], 13 | useFactory: async (config: ConfigService) => ({ 14 | secret: config.get('JWT_ACCESS_TOKEN_SECRET'), 15 | }), 16 | }), 17 | ], 18 | controllers: [AuthController], 19 | providers: [AuthService, JwtStrategy, JwtRefreshTokenStrategy], 20 | exports: [AuthService], 21 | }) 22 | export class AuthModule {} 23 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ForbiddenException, 3 | HttpException, 4 | HttpStatus, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import { PrismaService } from '../prisma/prisma.service'; 8 | import { AuthDto } from './dto/auth.dto'; 9 | import * as argon from 'argon2'; 10 | import { PrismaClientKnownRequestError } from '@prisma/client/runtime'; 11 | import { JwtService } from '@nestjs/jwt'; 12 | import { ConfigService } from '@nestjs/config'; 13 | import { ITokenPayload } from './interfaces/ITokenPayload'; 14 | 15 | import * as dayjs from 'dayjs'; 16 | 17 | import { User } from '@prisma/client'; 18 | 19 | const JWT_ACCESS_TOKEN_EXPIRATION_TIME = '5s'; 20 | const JWT_REFRESH_TOKEN_EXPIRATION_TIME = '1d'; 21 | 22 | const getAccessExpiry = () => dayjs().add(5, 's').toDate(); 23 | const getRefreshExpiry = () => dayjs().add(1, 'd').toDate(); 24 | 25 | @Injectable() 26 | export class AuthService { 27 | constructor( 28 | private prismaService: PrismaService, 29 | private jwtService: JwtService, 30 | private configService: ConfigService, 31 | ) {} 32 | 33 | async handeleSigin(user: User) { 34 | const { refreshToken } = await this.getJwtRefreshToken( 35 | user.id, 36 | user?.email, 37 | ); 38 | const { accessToken } = await this.getJwtAccessToken(user.id, user?.email); 39 | 40 | try { 41 | const hash = await argon.hash(refreshToken); 42 | const token = await this.prismaService.token.create({ 43 | data: { 44 | expiresAt: getRefreshExpiry(), 45 | refreshToken: hash, 46 | user: { 47 | connect: { 48 | id: user.id, 49 | }, 50 | }, 51 | }, 52 | }); 53 | 54 | return { 55 | accessToken, 56 | refreshToken, 57 | tokenId: token.id, 58 | accessTokenExpires: getAccessExpiry(), 59 | user: { 60 | id: user.id, 61 | email: user.email, 62 | }, 63 | }; 64 | } catch (error) { 65 | console.log(error); 66 | } 67 | } 68 | 69 | async signUp(dto: AuthDto) { 70 | const password = await argon.hash(dto.password); 71 | try { 72 | const user = await this.prismaService.user.create({ 73 | data: { 74 | email: dto.email, 75 | password, 76 | }, 77 | }); 78 | 79 | return await this.handeleSigin(user); 80 | } catch (err) { 81 | if (err instanceof PrismaClientKnownRequestError) { 82 | if (err.code === 'P2002') { 83 | throw new ForbiddenException('Credentials taken'); 84 | } 85 | } 86 | throw err; 87 | } 88 | } 89 | 90 | async signIn(dto: AuthDto) { 91 | //find a user 92 | const user = await this.prismaService.user.findUnique({ 93 | where: { 94 | email: dto.email, 95 | }, 96 | }); 97 | 98 | //if the there is no user throw exception 99 | if (!user) { 100 | throw new ForbiddenException('Credentials incorrect'); 101 | } 102 | 103 | if (user.password == null) { 104 | //this email didn't sign in using form 105 | //here i send a mail explaining the situation 106 | throw new ForbiddenException( 107 | 'Credentilas incorrect or this email was gotten from a social account', 108 | ); 109 | } 110 | // compare password 111 | const isMatch = await argon.verify(user.password, dto.password); 112 | 113 | if (!isMatch) { 114 | throw new ForbiddenException('Credentilas incorrect'); 115 | } 116 | 117 | return await this.handeleSigin(user); 118 | } 119 | 120 | async signOut(tokenId: string) { 121 | try { 122 | await this.prismaService.token.delete({ 123 | where: { 124 | id: tokenId, 125 | }, 126 | }); 127 | } catch (error) { 128 | throw new HttpException('Bad Request', HttpStatus.BAD_REQUEST); 129 | } 130 | } 131 | 132 | async getUserIfRefreshTokenMatches( 133 | refreshToken: string, 134 | tokenId: string, 135 | payload: ITokenPayload, 136 | ) { 137 | const foundToken = await this.prismaService.token.findUnique({ 138 | where: { 139 | id: tokenId, 140 | }, 141 | }); 142 | 143 | const isMatch = await argon.verify( 144 | foundToken.refreshToken ?? '', 145 | refreshToken, 146 | ); 147 | 148 | const issuedAt = dayjs.unix(payload.iat); 149 | const diff = dayjs().diff(issuedAt, 'seconds'); 150 | 151 | if (foundToken == null) { 152 | //refresh token is valid but the id is not in database 153 | //TODO:inform the user with the payload sub 154 | throw new HttpException('Unauthorized', HttpStatus.UNAUTHORIZED); 155 | } 156 | 157 | if (isMatch) { 158 | return await this.generateTokens(payload, tokenId); 159 | } else { 160 | //less than 1 minute leeway allows refresh for network concurrency 161 | if (diff < 60 * 1 * 1) { 162 | console.log('leeway'); 163 | return await this.generateTokens(payload, tokenId); 164 | } 165 | 166 | //refresh token is valid but not in db 167 | //possible re-use!!! delete all refresh tokens(sessions) belonging to the sub 168 | if (payload.sub !== foundToken.userId) { 169 | //the sub of the token isn't the id of the token in db 170 | // log out all session of this payalod id, reFreshToken has been compromised 171 | await this.prismaService.token.deleteMany({ 172 | where: { 173 | userId: payload.sub, 174 | }, 175 | }); 176 | throw new HttpException('Forbidden', HttpStatus.FORBIDDEN); 177 | } 178 | 179 | throw new HttpException('Something went wrong', HttpStatus.BAD_REQUEST); 180 | } 181 | } 182 | 183 | public async getJwtRefreshToken(sub: number, email: string) { 184 | const payload: ITokenPayload = { sub, email }; 185 | const refreshToken = await this.jwtService.signAsync(payload, { 186 | secret: this.configService.get('JWT_REFRESH_TOKEN_SECRET'), 187 | expiresIn: JWT_REFRESH_TOKEN_EXPIRATION_TIME, 188 | }); 189 | return { 190 | refreshToken, 191 | }; 192 | } 193 | 194 | async getJwtAccessToken( 195 | sub: number, 196 | email?: string, 197 | isSecondFactorAuthenticated = false, 198 | ) { 199 | const payload: ITokenPayload = { sub, email, isSecondFactorAuthenticated }; 200 | const accessToken = await this.jwtService.signAsync(payload, { 201 | secret: this.configService.get('JWT_ACCESS_TOKEN_SECRET'), 202 | expiresIn: JWT_ACCESS_TOKEN_EXPIRATION_TIME, 203 | }); 204 | return { 205 | accessToken, 206 | }; 207 | } 208 | 209 | private async generateTokens(payload: ITokenPayload, tokenId: string) { 210 | const { accessToken } = await this.getJwtAccessToken( 211 | payload.sub, 212 | payload.email, 213 | ); 214 | 215 | const { refreshToken: newRefreshToken } = await this.getJwtRefreshToken( 216 | payload.sub, 217 | payload.email, 218 | ); 219 | 220 | const hash = await argon.hash(newRefreshToken); 221 | 222 | await this.prismaService.token.update({ 223 | where: { 224 | id: tokenId, 225 | }, 226 | data: { 227 | refreshToken: hash, 228 | }, 229 | }); 230 | 231 | return { 232 | accessToken, 233 | refreshToken: newRefreshToken, 234 | tokenId: tokenId, 235 | accessTokenExpires: getAccessExpiry(), 236 | user: { 237 | id: payload.sub, 238 | email: payload.email, 239 | }, 240 | }; 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /src/auth/decorator/get-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | export const GetUser = createParamDecorator( 4 | (data: string | undefined, ctx: ExecutionContext) => { 5 | const request = ctx.switchToHttp().getRequest(); 6 | if (data) { 7 | return request.user[data]; 8 | } 9 | return request.user; 10 | }, 11 | ); 12 | -------------------------------------------------------------------------------- /src/auth/dto/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class AuthDto { 4 | @IsEmail() 5 | @IsNotEmpty() 6 | @IsString() 7 | email: string; 8 | 9 | @IsNotEmpty() 10 | @IsString() 11 | password: string; 12 | } 13 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-refresh.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export default class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {} 6 | -------------------------------------------------------------------------------- /src/auth/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtAuthGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/auth/interfaces/IRequestWithUser.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { User } from '@prisma/client'; 3 | export interface IRequestWithUser extends Request { 4 | user: User; 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/interfaces/ITokenPayload.ts: -------------------------------------------------------------------------------- 1 | export interface ITokenPayload { 2 | sub: number; 3 | email: string; 4 | isSecondFactorAuthenticated?: boolean; 5 | iat?: any; 6 | } 7 | -------------------------------------------------------------------------------- /src/auth/strategy/jwt-refresh-token.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { Request } from 'express'; 6 | import { ITokenPayload } from '../interfaces/ITokenPayload'; 7 | import { AuthService } from '../auth.service'; 8 | 9 | @Injectable() 10 | export class JwtRefreshTokenStrategy extends PassportStrategy( 11 | Strategy, 12 | 'jwt-refresh-token', 13 | ) { 14 | constructor(configService: ConfigService, private authService: AuthService) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 17 | secretOrKey: configService.get('JWT_REFRESH_TOKEN_SECRET'), 18 | passReqToCallback: true, 19 | }); 20 | } 21 | 22 | async validate(request: Request, payload: ITokenPayload) { 23 | const refreshToken = request.header('Authorization').split(' ')[1]; 24 | const tokenId = request.header('Token-Id'); 25 | 26 | return this.authService.getUserIfRefreshTokenMatches( 27 | refreshToken, 28 | tokenId, 29 | payload, 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/auth/strategy/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | 6 | @Injectable() 7 | export class JwtStrategy extends PassportStrategy(Strategy) { 8 | constructor(config: ConfigService) { 9 | super({ 10 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 11 | secretOrKey: config.get('JWT_ACCESS_TOKEN_SECRET'), 12 | }); 13 | } 14 | 15 | async validate(payload: any) { 16 | return { userId: payload.sub, email: payload.email }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | //import * as csurf from 'csurf'; //deprecated 5 | // eslint-disable-next-line @typescript-eslint/no-var-requires 6 | const cookieParser = require('cookie-parser'); 7 | 8 | async function bootstrap() { 9 | const allowedOrigins = [ 10 | 'http://localhost:3000', 11 | 'https://melodic-kitten-c0528c.netlify.app', 12 | ]; 13 | 14 | const app = await NestFactory.create(AppModule); 15 | 16 | app.useGlobalPipes(new ValidationPipe({ whitelist: true })); 17 | 18 | //Cookie Parser 19 | app.use(cookieParser()); 20 | 21 | //CORS 22 | app.enableCors({ 23 | origin: (origin, callback) => { 24 | if (allowedOrigins.indexOf(origin) !== -1 || !origin) { 25 | callback(null, true); 26 | } else { 27 | callback(new Error('Not allowed by CORS')); 28 | } 29 | }, 30 | optionsSuccessStatus: 200, 31 | credentials: true, 32 | }); 33 | 34 | //CSRF Protection 35 | /* app.use(csurf({cookie:true})); 36 | app.use('/csrf',(req,res)=>{ 37 | return res.send(req.csrfToken()) 38 | }) */ 39 | 40 | await app.listen(process.env.PORT || 8000); 41 | } 42 | 43 | bootstrap(); 44 | -------------------------------------------------------------------------------- /src/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { PrismaService } from './prisma.service'; 3 | 4 | @Global() 5 | @Module({ 6 | providers: [PrismaService], 7 | exports: [PrismaService], 8 | }) 9 | export class PrismaModule {} 10 | -------------------------------------------------------------------------------- /src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { Injectable, OnModuleInit, INestApplication } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | 5 | @Injectable() 6 | export class PrismaService extends PrismaClient implements OnModuleInit { 7 | constructor(config: ConfigService) { 8 | super({ 9 | datasources: { 10 | db: { 11 | url: config.get('DATABASE_URL'), 12 | }, 13 | }, 14 | }); 15 | } 16 | async onModuleInit() { 17 | await this.$connect(); 18 | } 19 | 20 | async enableShutdownHooks(app: INestApplication) { 21 | this.$on('beforeExit', async () => { 22 | await app.close(); 23 | }); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable prettier/prettier */ 2 | import { Controller, Get, UseGuards } from '@nestjs/common'; 3 | import { GetUser } from '../auth/decorator/get-user.decorator'; 4 | import { JwtAuthGuard } from '../auth/guards/jwt.guard'; 5 | import * as dayjs from 'dayjs'; 6 | 7 | @Controller('user') 8 | export class UserController { 9 | @UseGuards(JwtAuthGuard) 10 | @Get('me') 11 | getMe(@GetUser() user: any) { 12 | return ( 13 | 'User-Id: ' + 14 | user?.userId + 15 | ' requested @ ' + 16 | dayjs(Date.now()).format('hh:m:s a') 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthModule } from 'src/auth/auth.module'; 3 | import { UserController } from './user.controller'; 4 | import { UserService } from './user.service'; 5 | 6 | @Module({ 7 | imports: [AuthModule], 8 | controllers: [UserController], 9 | providers: [UserService], 10 | exports: [UserService], 11 | }) 12 | export class UserModule {} 13 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { PrismaService } from 'src/prisma/prisma.service'; 3 | 4 | @Injectable() 5 | export class UserService { 6 | constructor(private readonly prismaService: PrismaService) {} 7 | 8 | async getByEmail(email: string) { 9 | const user = await this.prismaService.user.findFirst({ 10 | where: { 11 | email, 12 | }, 13 | }); 14 | if (user) { 15 | return user; 16 | } 17 | throw new HttpException( 18 | 'User with this email does not exist', 19 | HttpStatus.NOT_FOUND, 20 | ); 21 | } 22 | 23 | async getById(id: number) { 24 | const user = await this.prismaService.user.findFirst({ 25 | where: { 26 | id, 27 | }, 28 | }); 29 | if (user) { 30 | return user; 31 | } 32 | throw new HttpException( 33 | 'User with this id does not exist', 34 | HttpStatus.NOT_FOUND, 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /thunder-tests/thunderActivity.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "ff1c61d1-038d-4dc9-b5e6-a3a02ab94672", 4 | "colId": "history", 5 | "containerId": "", 6 | "name": "https://www.thunderclient.com/sign-out", 7 | "url": "https://www.thunderclient.com/sign-out", 8 | "method": "POST", 9 | "sortNum": 0, 10 | "created": "2022-12-14T11:17:35.066Z", 11 | "modified": "2022-12-14T11:17:35.066Z", 12 | "headers": [], 13 | "params": [], 14 | "tests": [] 15 | } 16 | ] -------------------------------------------------------------------------------- /thunder-tests/thunderCollection.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "e71da0e2-e559-44d8-99fc-b53e71acc436", 4 | "colName": "authentication", 5 | "created": "2022-11-14T17:30:01.103Z", 6 | "sortNum": 10000, 7 | "folders": [] 8 | } 9 | ] -------------------------------------------------------------------------------- /thunder-tests/thunderEnvironment.json: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /thunder-tests/thunderclient.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "9f53e2f5-b870-4cb6-84ef-10673160d2ca", 4 | "colId": "e71da0e2-e559-44d8-99fc-b53e71acc436", 5 | "containerId": "", 6 | "name": "sign-up", 7 | "url": "localhost:8000/auth/sign-up", 8 | "method": "POST", 9 | "sortNum": 10000, 10 | "created": "2022-11-14T17:30:37.251Z", 11 | "modified": "2022-11-14T17:31:39.796Z", 12 | "headers": [], 13 | "params": [], 14 | "body": { 15 | "type": "formencoded", 16 | "raw": "", 17 | "form": [ 18 | { 19 | "name": "email", 20 | "value": "iamstarcode@gmail.com" 21 | }, 22 | { 23 | "name": "password", 24 | "value": "iamstarcode@gmail.com" 25 | } 26 | ] 27 | }, 28 | "tests": [] 29 | }, 30 | { 31 | "_id": "7926d882-0119-49b2-b406-8d2f2daace9e", 32 | "colId": "e71da0e2-e559-44d8-99fc-b53e71acc436", 33 | "containerId": "", 34 | "name": "sign-in", 35 | "url": "localhost:8000/auth/sign-in", 36 | "method": "POST", 37 | "sortNum": 20000, 38 | "created": "2022-11-14T17:32:55.876Z", 39 | "modified": "2022-11-14T17:35:04.852Z", 40 | "headers": [ 41 | { 42 | "name": "x-user-agent", 43 | "value": "\"{ appType: 'Firefox', device: 'Linux x86_64' }\"" 44 | } 45 | ], 46 | "params": [], 47 | "body": { 48 | "type": "formencoded", 49 | "raw": "", 50 | "form": [ 51 | { 52 | "name": "email", 53 | "value": "iamstarcode@gmail.com" 54 | }, 55 | { 56 | "name": "password", 57 | "value": "iamstarcode@gmail.com" 58 | } 59 | ] 60 | }, 61 | "tests": [] 62 | }, 63 | { 64 | "_id": "331939c4-0dfd-49d1-b872-d44d458dd600", 65 | "colId": "e71da0e2-e559-44d8-99fc-b53e71acc436", 66 | "containerId": "", 67 | "name": "sign-out", 68 | "url": "localhost:8000/auth/sign-out", 69 | "method": "POST", 70 | "sortNum": 30000, 71 | "created": "2022-12-14T11:18:01.417Z", 72 | "modified": "2022-12-14T11:24:33.717Z", 73 | "headers": [ 74 | { 75 | "name": "Token-Id", 76 | "value": "b6ea1b22-78fa-4768-8779-86dd500b6d2a" 77 | } 78 | ], 79 | "params": [], 80 | "auth": { 81 | "type": "bearer", 82 | "bearer": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsImVtYWlsIjoiaWFtc3RhcmNvZGVAZ21haWwuY29tIiwiaXNTZWNvbmRGYWN0b3JBdXRoZW50aWNhdGVkIjpmYWxzZSwiaWF0IjoxNjcxMDE3MDE4LCJleHAiOjE2NzEwMzUwMTh9._Ev5FQzMlYHXedJahg1MuomicuOMEtEAMltQDbmxXmU" 83 | }, 84 | "tests": [] 85 | } 86 | ] -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------