├── .prettierrc ├── .husky └── pre-commit ├── src ├── auth │ ├── models │ │ ├── roles.model.ts │ │ └── token.model.ts │ ├── decorators │ │ ├── public.decorator.ts │ │ └── roles.decorator.ts │ ├── guards │ │ ├── local-auth.guard.ts │ │ ├── jwt-refresh.guard.ts │ │ ├── jwt-auth.guard.ts │ │ └── roles.guard.ts │ ├── dto │ │ └── login.dto.ts │ ├── strategies │ │ ├── local.strategy.ts │ │ ├── jwt.strategy.ts │ │ └── jwt-refresh.strategy.ts │ ├── auth.module.ts │ ├── controllers │ │ └── auth.controller.ts │ └── services │ │ └── auth.service.ts ├── environments.ts ├── utils │ └── entities │ │ └── default.entity.ts ├── users │ ├── users.module.ts │ ├── entities │ │ └── user.entity.ts │ ├── dto │ │ └── create-user.dto.ts │ ├── controllers │ │ └── users.controller.ts │ └── services │ │ └── users.service.ts ├── config.ts ├── app.module.ts └── main.ts ├── tsconfig.build.json ├── nest-cli.json ├── test ├── jest-e2e.json ├── utils.ts └── app.e2e-spec.ts ├── .env.example ├── .env.test ├── ormconfig.js ├── .gitignore ├── tsconfig.json ├── docker-compose.yml ├── .eslintrc.js ├── db └── migrations │ └── 1647339231280-table_user.ts ├── package.json └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /src/auth/models/roles.model.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | CUSTOMER = 'customer', 3 | ADMIN = 'admin', 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /src/environments.ts: -------------------------------------------------------------------------------- 1 | export const enviroments = { 2 | dev: '.env', 3 | test: '.env.test', 4 | stag: '.stag.env', 5 | prod: '.prod.env', 6 | }; 7 | -------------------------------------------------------------------------------- /src/auth/models/token.model.ts: -------------------------------------------------------------------------------- 1 | import { Role } from './roles.model'; 2 | 3 | export interface PayloadToken { 4 | id: number; 5 | role: Role; 6 | } 7 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "plugins": ["@nestjs/swagger/plugin"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/auth/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const IS_PUBLIC_KEY = 'isPublic'; 4 | 5 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 6 | -------------------------------------------------------------------------------- /src/auth/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/auth/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | import { Role } from '../models/roles.model'; 3 | 4 | export const ROLE_KEY = 'role'; 5 | 6 | export const Roles = (...roles: Role[]) => SetMetadata(ROLE_KEY, roles); 7 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | NODE_ENV=dev 2 | DATABASE_PORT=8080 3 | 4 | POSTGRES_NAME=template 5 | POSTGRES_PORT=5432 6 | POSTGRES_PASSWORD=templateUserPass 7 | POSTGRES_USER=templateUser 8 | POSTGRES_HOST=localhost 9 | 10 | JWT_SECRET=secret 11 | JWT_REFRESH_SECRET=refreshSecret 12 | 13 | ACCESS_TOKEN_EXPIRATION=1d 14 | REFRESH_TOKEN_EXPIRATION=1y -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | NODE_ENV=test 2 | DATABASE_PORT=8080 3 | 4 | POSTGRES_HOST=localhost 5 | POSTGRES_NAME=templateTest 6 | POSTGRES_USER=templateTestUser 7 | POSTGRES_PASSWORD=templateTestPass 8 | POSTGRES_PORT=5433 9 | 10 | JWT_SECRET=secretTest 11 | JWT_REFRESH_SECRET=refreshSecretTest 12 | 13 | ACCESS_TOKEN_EXPIRATION=1d 14 | REFRESH_TOKEN_EXPIRATION=1y 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/utils/entities/default.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CreateDateColumn, 3 | PrimaryGeneratedColumn, 4 | UpdateDateColumn, 5 | } from 'typeorm'; 6 | 7 | export class DefaultEntity { 8 | @PrimaryGeneratedColumn() 9 | id: number; 10 | 11 | @CreateDateColumn({ 12 | name: 'created_at', 13 | }) 14 | createdAt: Date; 15 | 16 | @UpdateDateColumn({ 17 | name: 'updated_at', 18 | }) 19 | updatedAt: Date; 20 | } 21 | -------------------------------------------------------------------------------- /ormconfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'postgres', 3 | host: process.env.POSTGRES_HOST, 4 | port: +process.env.POSTGRES_PORT, 5 | username: process.env.POSTGRES_USER, 6 | password: process.env.POSTGRES_PASSWORD, 7 | database: process.env.POSTGRES_NAME, 8 | entities: ['dist/**/entities/*.entity{.ts,.js}'], 9 | migrations: ['dist/db/migrations/*{.ts,.js}'], 10 | seeds: ['dist/db/seeds/*.js'], 11 | factories: ['dist/db/factories/*.js'], 12 | cli: { 13 | migrationsDir: 'db/migrations', 14 | }, 15 | ssl: false, 16 | }; 17 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | import { JwtStrategy } from '../auth/strategies/jwt.strategy'; 4 | import { UsersController } from './controllers/users.controller'; 5 | import { User } from './entities/user.entity'; 6 | import { UsersService } from './services/users.service'; 7 | 8 | @Module({ 9 | imports: [TypeOrmModule.forFeature([User])], 10 | controllers: [UsersController], 11 | providers: [UsersService, JwtStrategy], 12 | exports: [UsersService], 13 | }) 14 | export class UsersModule {} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | package-lock.json 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | pnpm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # OS 17 | .DS_Store 18 | 19 | # Tests 20 | /coverage 21 | /.nyc_output 22 | 23 | # IDEs and editors 24 | /.idea 25 | .project 26 | .classpath 27 | .c9/ 28 | *.launch 29 | .settings/ 30 | *.sublime-workspace 31 | 32 | # IDE - VSCode 33 | .vscode/* 34 | !.vscode/settings.json 35 | !.vscode/tasks.json 36 | !.vscode/launch.json 37 | !.vscode/extensions.json 38 | 39 | .env -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/auth/dto/login.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; 3 | 4 | export class LoginDto { 5 | @ApiProperty() 6 | @IsString() 7 | @IsEmail() 8 | readonly email: string; 9 | 10 | @ApiProperty() 11 | @IsString() 12 | @IsNotEmpty() 13 | readonly password: string; 14 | } 15 | 16 | export class PostLoginResponse { 17 | @ApiProperty() 18 | readonly accessToken: string; 19 | 20 | @ApiProperty() 21 | readonly refreshToken: string; 22 | } 23 | 24 | export class GetRefreshResponse { 25 | @ApiProperty() 26 | readonly accessToken: string; 27 | } 28 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '../src/auth/models/roles.model'; 2 | import { CreateAdminDto } from '../src/users/dto/create-user.dto'; 3 | 4 | export const userAdmin: CreateAdminDto = { 5 | email: 'test@example.com', 6 | password: '$2b$10$4KXr.qChGtoo5b8aYQNuH.L5cWLDIXz/N2ollt5vttSSquHD9Ng2C', //password = test123 7 | firstName: 'Jordi', 8 | lastName: 'Cher', 9 | role: Role.ADMIN, 10 | }; 11 | 12 | export const userLogin = { 13 | ...userAdmin, 14 | password: 'test123', 15 | }; 16 | 17 | export const userCustomer = { 18 | email: 'test@customer.com', 19 | password: 'test123', 20 | firstName: 'Jordi', 21 | lastName: 'Cher', 22 | }; 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | postgres: 4 | image: 'postgres' 5 | container_name: 'template_postgres' 6 | environment: 7 | POSTGRES_DB: 'template' 8 | POSTGRES_USER: 'templateUser' 9 | POSTGRES_PASSWORD: 'templateUserPass' 10 | ALLOW_IP_RANGE: '0.0.0.0/0' 11 | ports: 12 | - '5432:5432' 13 | postgresTest: 14 | image: 'postgres' 15 | container_name: 'template_test_postgres' 16 | environment: 17 | POSTGRES_DB: 'templateTest' 18 | POSTGRES_USER: 'templateTestUser' 19 | POSTGRES_PASSWORD: 'templateTestPass' 20 | ALLOW_IP_RANGE: '0.0.0.0/0' 21 | ports: 22 | - '5433:5432' 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:prettier/recommended', 11 | ], 12 | root: true, 13 | env: { 14 | node: true, 15 | jest: true, 16 | }, 17 | ignorePatterns: ['.eslintrc.js'], 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('config', () => { 4 | return { 5 | database: { 6 | port: process.env.DATABASE_PORT, 7 | }, 8 | postgres: { 9 | host: process.env.POSTGRES_HOST, 10 | port: parseInt(process.env.POSTGRES_PORT, 10) || 5432, 11 | name: process.env.POSTGRES_NAME, 12 | password: process.env.POSTGRES_PASSWORD, 13 | user: process.env.POSTGRES_USER, 14 | }, 15 | jwt: { 16 | jwtSecret: process.env.JWT_SECRET, 17 | jwtRefreshSecret: process.env.JWT_REFRESH_SECRET, 18 | refreshTokenExpiration: process.env.REFRESH_TOKEN_EXPIRATION, 19 | accessTokenExpiration: process.env.ACCESS_TOKEN_EXPIRATION, 20 | }, 21 | }; 22 | }); 23 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-local'; 4 | import { AuthService } from '../services/auth.service'; 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy, 'local') { 8 | constructor(private authService: AuthService) { 9 | super({ 10 | usernameField: 'email', 11 | passwordField: 'password', 12 | }); 13 | } 14 | 15 | async validate(email: string, password: string) { 16 | const user = await this.authService.validateUser(email, password); 17 | if (!user) { 18 | throw new UnauthorizedException('not allow'); 19 | } 20 | 21 | return user; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { ExtractJwt, Strategy } from 'passport-jwt'; 5 | import config from '../../config'; 6 | import { PayloadToken } from '../models/token.model'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 10 | constructor( 11 | @Inject(config.KEY) 12 | configService: ConfigType, 13 | ) { 14 | super({ 15 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 16 | ignoreExpiration: false, 17 | secretOrKey: configService.jwt.jwtSecret, 18 | }); 19 | } 20 | 21 | validate(payload: PayloadToken) { 22 | return payload; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcrypt'; 2 | import { BeforeInsert, Column, Entity } from 'typeorm'; 3 | import { Role } from '../../auth/models/roles.model'; 4 | import { DefaultEntity } from '../../utils/entities/default.entity'; 5 | 6 | @Entity('users') 7 | export class User extends DefaultEntity { 8 | @Column({ unique: true }) 9 | email: string; 10 | 11 | @Column({ select: false }) 12 | password: string; 13 | 14 | @Column({ select: false, nullable: true, name: 'refresh_token' }) 15 | refreshToken: string; 16 | 17 | @Column({ 18 | name: 'first_name', 19 | }) 20 | firstName: string; 21 | 22 | @Column({ 23 | name: 'last_name', 24 | }) 25 | lastName: string; 26 | 27 | @Column({ 28 | type: 'enum', 29 | enum: Role, 30 | default: Role.CUSTOMER, 31 | }) 32 | role: Role; 33 | 34 | @BeforeInsert() 35 | async hashPassword() { 36 | if (this.password) { 37 | this.password = await bcrypt.hash(this.password, 10); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/users/dto/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, PartialType } from '@nestjs/swagger'; 2 | import { IsEmail, IsEnum, IsNotEmpty, IsString } from 'class-validator'; 3 | import { Role } from '../../auth/models/roles.model'; 4 | 5 | export class CreateUserDto { 6 | @ApiProperty() 7 | @IsString() 8 | @IsEmail() 9 | readonly email: string; 10 | 11 | @ApiProperty() 12 | @IsString() 13 | @IsNotEmpty() 14 | readonly password: string; 15 | 16 | @ApiProperty() 17 | @IsString() 18 | @IsNotEmpty() 19 | readonly firstName: string; 20 | 21 | @ApiProperty() 22 | @IsString() 23 | @IsNotEmpty() 24 | readonly lastName: string; 25 | } 26 | 27 | export class CreateAdminDto extends CreateUserDto { 28 | @ApiProperty() 29 | @IsEnum(Role) 30 | readonly role: Role; 31 | } 32 | 33 | export class UpdateUserDto extends PartialType(CreateUserDto) {} 34 | 35 | export class DefaultColumnsResponse extends CreateUserDto { 36 | @ApiProperty() 37 | readonly id: number; 38 | 39 | @ApiProperty() 40 | readonly createdAt: Date; 41 | 42 | @ApiProperty() 43 | readonly updatedAt: Date; 44 | 45 | @ApiProperty() 46 | readonly role: Role; 47 | } 48 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | import config from '../config'; 6 | import { UsersModule } from '../users/users.module'; 7 | import { AuthController } from './controllers/auth.controller'; 8 | import { AuthService } from './services/auth.service'; 9 | import { JwtRefreshTokenStrategy } from './strategies/jwt-refresh.strategy'; 10 | import { LocalStrategy } from './strategies/local.strategy'; 11 | 12 | @Module({ 13 | imports: [ 14 | UsersModule, 15 | PassportModule, 16 | JwtModule.registerAsync({ 17 | inject: [config.KEY], 18 | useFactory: (configService: ConfigType) => { 19 | return { 20 | secret: configService.jwt.jwtSecret, 21 | signOptions: { 22 | expiresIn: configService.jwt.accessTokenExpiration, 23 | }, 24 | }; 25 | }, 26 | }), 27 | ], 28 | controllers: [AuthController], 29 | providers: [AuthService, LocalStrategy, JwtRefreshTokenStrategy], 30 | }) 31 | export class AuthModule {} 32 | -------------------------------------------------------------------------------- /db/migrations/1647339231280-table_user.ts: -------------------------------------------------------------------------------- 1 | import { MigrationInterface, QueryRunner } from 'typeorm'; 2 | 3 | export class tableUser1647339231280 implements MigrationInterface { 4 | name = 'tableUser1647339231280'; 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query( 8 | `CREATE TYPE "public"."users_role_enum" AS ENUM('customer', 'admin')`, 9 | ); 10 | await queryRunner.query( 11 | `CREATE TABLE "users" ("id" SERIAL NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "email" character varying NOT NULL, "password" character varying NOT NULL, "refresh_token" character varying, "first_name" character varying NOT NULL, "last_name" character varying NOT NULL, "role" "public"."users_role_enum" NOT NULL DEFAULT 'customer', CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`, 12 | ); 13 | } 14 | 15 | public async down(queryRunner: QueryRunner): Promise { 16 | await queryRunner.query(`DROP TABLE "users"`); 17 | await queryRunner.query(`DROP TYPE "public"."users_role_enum"`); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt-refresh.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Request } from 'express'; 5 | import { ExtractJwt, Strategy } from 'passport-jwt'; 6 | import config from '../../config'; 7 | import { UsersService } from '../../users/services/users.service'; 8 | import { PayloadToken } from '../models/token.model'; 9 | 10 | @Injectable() 11 | export class JwtRefreshTokenStrategy extends PassportStrategy( 12 | Strategy, 13 | 'jwt-refresh-token', 14 | ) { 15 | constructor( 16 | @Inject(config.KEY) 17 | private configService: ConfigType, 18 | private readonly userService: UsersService, 19 | ) { 20 | super({ 21 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 22 | secretOrKey: configService.jwt.jwtRefreshSecret, 23 | passReqToCallback: true, 24 | }); 25 | } 26 | 27 | async validate(request: Request, payload: PayloadToken) { 28 | const refreshToken = request.headers.authorization.split(' ')[1]; 29 | 30 | return this.userService.getUserIfRefreshTokenMatches( 31 | refreshToken, 32 | payload.id, 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExecutionContext, 3 | HttpException, 4 | HttpStatus, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { AuthGuard } from '@nestjs/passport'; 9 | import * as jwt from 'jsonwebtoken'; 10 | import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; 11 | 12 | const HTTP_STATUS_TOKEN_EXPIRED = 498; 13 | 14 | @Injectable() 15 | export class JwtAuthGuard extends AuthGuard('jwt') { 16 | constructor(private reflector: Reflector) { 17 | super(); 18 | } 19 | 20 | canActivate(context: ExecutionContext) { 21 | const isPublic = this.reflector.get(IS_PUBLIC_KEY, context.getHandler()); 22 | if (isPublic) { 23 | return true; 24 | } 25 | return super.canActivate(context); 26 | } 27 | 28 | handleRequest(err, user, info) { 29 | if (info instanceof jwt.TokenExpiredError) { 30 | throw new HttpException('Token expired', HTTP_STATUS_TOKEN_EXPIRED); 31 | } 32 | 33 | if (err || !user) { 34 | throw new HttpException( 35 | { 36 | status: HttpStatus.UNAUTHORIZED, 37 | error: 'Unauthorized user', 38 | }, 39 | HttpStatus.UNAUTHORIZED, 40 | ); 41 | } 42 | 43 | return user; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/auth/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | HttpException, 5 | HttpStatus, 6 | Injectable, 7 | UnauthorizedException, 8 | } from '@nestjs/common'; 9 | import { Reflector } from '@nestjs/core'; 10 | import { Observable } from 'rxjs'; 11 | import { ROLE_KEY } from '../decorators/roles.decorator'; 12 | import { Role } from '../models/roles.model'; 13 | import { PayloadToken } from '../models/token.model'; 14 | 15 | @Injectable() 16 | export class RolesGuard implements CanActivate { 17 | constructor(private reflector: Reflector) {} 18 | 19 | canActivate( 20 | context: ExecutionContext, 21 | ): boolean | Promise | Observable { 22 | const roles = this.reflector.get(ROLE_KEY, context.getHandler()); 23 | 24 | if (!roles) { 25 | return true; 26 | } 27 | 28 | const request = context.switchToHttp().getRequest(); 29 | const user = request.user as PayloadToken; 30 | 31 | const isAuth = roles.some((role) => role === user.role); 32 | if (!isAuth) { 33 | throw new UnauthorizedException('Invalid role'); 34 | } 35 | return isAuth; 36 | } 37 | 38 | handleRequest(err, user) { 39 | if (err || !user) { 40 | throw new HttpException( 41 | { 42 | status: HttpStatus.UNAUTHORIZED, 43 | error: 'This user does not have the required permissions', 44 | }, 45 | HttpStatus.UNAUTHORIZED, 46 | ); 47 | } 48 | 49 | return user; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigType } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | import * as Joi from 'joi'; 5 | import { AuthModule } from './auth/auth.module'; 6 | import config from './config'; 7 | import { enviroments } from './environments'; 8 | import { UsersModule } from './users/users.module'; 9 | 10 | @Module({ 11 | imports: [ 12 | ConfigModule.forRoot({ 13 | envFilePath: enviroments[process.env.NODE_ENV] || '.env', 14 | load: [config], 15 | isGlobal: true, 16 | validationSchema: Joi.object({ 17 | JWT_SECRET: Joi.string().required(), 18 | JWT_REFRESH_SECRET: Joi.string().required(), 19 | ACCESS_TOKEN_EXPIRATION: Joi.string().required(), 20 | REFRESH_TOKEN_EXPIRATION: Joi.string().required(), 21 | }), 22 | validationOptions: { 23 | abortEarly: true, //when true, stops validation on the first error, otherwise returns all the errors found. Defaults to true. 24 | }, 25 | }), 26 | TypeOrmModule.forRootAsync({ 27 | inject: [config.KEY], 28 | useFactory: (configService: ConfigType) => { 29 | return { 30 | type: 'postgres', 31 | host: configService.postgres.host, 32 | port: configService.postgres.port, 33 | database: configService.postgres.name, 34 | username: configService.postgres.user, 35 | password: configService.postgres.password, 36 | autoLoadEntities: true, 37 | keepConnectionAlive: true, 38 | }; 39 | }, 40 | }), 41 | UsersModule, 42 | AuthModule, 43 | ], 44 | controllers: [], 45 | providers: [], 46 | }) 47 | export class AppModule {} 48 | -------------------------------------------------------------------------------- /src/auth/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | HttpCode, 5 | Post, 6 | Req, 7 | Request, 8 | UseGuards, 9 | } from '@nestjs/common'; 10 | import { ApiBearerAuth, ApiBody, ApiResponse, ApiTags } from '@nestjs/swagger'; 11 | import { 12 | GetRefreshResponse, 13 | LoginDto, 14 | PostLoginResponse, 15 | } from '../dto/login.dto'; 16 | import { JwtAuthGuard } from '../guards/jwt-auth.guard'; 17 | import JwtRefreshGuard from '../guards/jwt-refresh.guard'; 18 | import { LocalAuthGuard } from '../guards/local-auth.guard'; 19 | import { PayloadToken } from '../models/token.model'; 20 | import { AuthService } from '../services/auth.service'; 21 | 22 | type AuthorizedRequest = Express.Request & { 23 | headers: { authorization: string }; 24 | user: PayloadToken; 25 | }; 26 | 27 | @ApiTags('auth') 28 | @Controller('auth') 29 | export class AuthController { 30 | constructor(private authService: AuthService) {} 31 | 32 | @ApiBody({ type: LoginDto }) 33 | @ApiResponse({ type: PostLoginResponse, status: 200 }) 34 | @UseGuards(LocalAuthGuard) 35 | @HttpCode(200) 36 | @Post('login') 37 | login(@Request() req: { user: PayloadToken }) { 38 | const user = req.user; 39 | return this.authService.login(user); 40 | } 41 | 42 | @ApiResponse({ status: 200 }) 43 | @ApiBearerAuth('access-token') 44 | @UseGuards(JwtAuthGuard) 45 | @Get('logout') 46 | async logOut(@Request() req: { user: PayloadToken }) { 47 | await this.authService.logout(req.user); 48 | } 49 | 50 | @ApiResponse({ status: 200, type: GetRefreshResponse }) 51 | @ApiBearerAuth('refresh-token') 52 | @UseGuards(JwtRefreshGuard) 53 | @Get('refresh') 54 | refresh(@Req() req: AuthorizedRequest) { 55 | return this.authService.createAccessTokenFromRefreshToken(req.user); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { NestFactory } from '@nestjs/core'; 4 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 5 | import { AppModule } from './app.module'; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | const configService = app.get(ConfigService); 10 | 11 | const enableCors = configService.get('ENABLE_CORS'); 12 | const port = configService.get('DATABASE_PORT'); 13 | 14 | if (enableCors) { 15 | app.enableCors(); 16 | } 17 | 18 | app.useGlobalPipes( 19 | new ValidationPipe({ 20 | whitelist: true, //If set to true validator will strip validated object of any properties that do not have any decorators. 21 | transform: true, //The ValidationPipe can automatically transform payloads to be objects typed according to their DTO classes. To enable auto-transformation, set transform to true. 22 | forbidNonWhitelisted: true, //If set to true, instead of stripping non-whitelisted properties validator will throw an error 23 | transformOptions: { 24 | enableImplicitConversion: true, //If set to true class-transformer will attempt conversion based on TS reflected type 25 | }, 26 | }), 27 | ); 28 | 29 | const config = new DocumentBuilder() //SWAGGER 30 | .setTitle('API') 31 | .setDescription('API description') 32 | .setVersion('1.0') 33 | .addBearerAuth( 34 | { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 35 | 'access-token', 36 | ) 37 | .addBearerAuth( 38 | { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, 39 | 'refresh-token', 40 | ) 41 | .build(); 42 | 43 | const document = SwaggerModule.createDocument(app, config); 44 | SwaggerModule.setup('docs', app, document); //localhost:3000/docs | localhost:8080/docs to get info of the API 45 | 46 | await app.listen(port || 3000); 47 | } 48 | bootstrap(); 49 | -------------------------------------------------------------------------------- /src/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@nestjs/common'; 2 | import { ConfigType } from '@nestjs/config'; 3 | import { JwtService } from '@nestjs/jwt'; 4 | import * as bcrypt from 'bcrypt'; 5 | import config from '../../config'; 6 | import { UsersService } from '../../users/services/users.service'; 7 | import { PayloadToken } from './../models/token.model'; 8 | 9 | @Injectable() 10 | export class AuthService { 11 | constructor( 12 | private usersService: UsersService, 13 | private jwtService: JwtService, 14 | @Inject(config.KEY) 15 | private configService: ConfigType, 16 | ) {} 17 | 18 | async validateUser(email: string, password: string) { 19 | const user: { 20 | password: string; 21 | id: number; 22 | role: string; 23 | } = await this.usersService.findByEmailAndGetPassword(email); 24 | 25 | if (user) { 26 | const isMatch = await bcrypt.compare(password, user.password); 27 | 28 | if (isMatch) { 29 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 30 | const { password, ...rta } = user; 31 | return rta; 32 | } 33 | } 34 | return null; 35 | } 36 | 37 | async login(user: PayloadToken) { 38 | const { accessToken } = this.jwtToken(user); 39 | const refreshToken = this.jwtRefreshToken(user); 40 | await this.usersService.setCurrentRefreshToken(refreshToken, user.id); 41 | 42 | return { 43 | accessToken, 44 | refreshToken, 45 | }; 46 | } 47 | 48 | jwtToken(user: PayloadToken) { 49 | const payload: PayloadToken = { role: user.role, id: user.id }; 50 | return { 51 | accessToken: this.jwtService.sign(payload), 52 | }; 53 | } 54 | 55 | jwtRefreshToken(user: PayloadToken) { 56 | const payload = { role: user.role, id: user.id }; 57 | 58 | const refreshToken = this.jwtService.sign(payload, { 59 | secret: this.configService.jwt.jwtRefreshSecret, 60 | expiresIn: `${this.configService.jwt.refreshTokenExpiration}`, 61 | }); 62 | 63 | return refreshToken; 64 | } 65 | 66 | async logout(user: PayloadToken) { 67 | return await this.usersService.removeRefreshToken(user.id); 68 | } 69 | 70 | async createAccessTokenFromRefreshToken(user: PayloadToken) { 71 | return this.jwtToken(user); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/users/controllers/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Patch, 8 | Post, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { 12 | ApiBearerAuth, 13 | ApiOperation, 14 | ApiResponse, 15 | ApiTags, 16 | } from '@nestjs/swagger'; 17 | import { Public } from '../../auth/decorators/public.decorator'; 18 | import { Roles } from '../../auth/decorators/roles.decorator'; 19 | import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; 20 | import { RolesGuard } from '../../auth/guards/roles.guard'; 21 | import { Role } from '../../auth/models/roles.model'; 22 | import { 23 | CreateAdminDto, 24 | CreateUserDto, 25 | DefaultColumnsResponse, 26 | UpdateUserDto, 27 | } from '../dto/create-user.dto'; 28 | import { UsersService } from '../services/users.service'; 29 | 30 | @ApiTags('users') // put the name of the controller in swagger 31 | @Controller('users') 32 | @UseGuards(JwtAuthGuard, RolesGuard) // makes the all routs as private by default 33 | export class UsersController { 34 | constructor(private readonly usersService: UsersService) {} 35 | 36 | @ApiOperation({ summary: 'create a user with customer role' }) 37 | @ApiResponse({ 38 | status: 201, 39 | type: DefaultColumnsResponse, 40 | }) 41 | @Public() // makes the endpoint accessible to all 42 | @Post() 43 | create(@Body() createUserDto: CreateUserDto) { 44 | return this.usersService.create(createUserDto); 45 | } 46 | 47 | @ApiOperation({ summary: 'create a user with admin role' }) 48 | @ApiResponse({ 49 | status: 201, 50 | type: DefaultColumnsResponse, 51 | }) 52 | @ApiBearerAuth('access-token') // in the swagger documentation, a bearer token is required to access this endpoint 53 | @Roles(Role.ADMIN) // makes the endpoint accessible only by the admin 54 | @Post('admin') 55 | createAdmin(@Body() creatAdminDto: CreateAdminDto) { 56 | return this.usersService.create(creatAdminDto); 57 | } 58 | 59 | @ApiResponse({ 60 | status: 200, 61 | isArray: true, 62 | type: DefaultColumnsResponse, 63 | }) 64 | @ApiBearerAuth('access-token') 65 | @Roles(Role.ADMIN) 66 | @Get() 67 | findAll() { 68 | return this.usersService.findAll(); 69 | } 70 | 71 | @ApiBearerAuth('access-token') 72 | @ApiResponse({ 73 | status: 200, 74 | type: DefaultColumnsResponse, 75 | }) 76 | @Roles(Role.ADMIN) 77 | @Get(':id') 78 | findOne(@Param('id') id: string) { 79 | return this.usersService.findOne(+id); 80 | } 81 | 82 | @ApiBearerAuth('access-token') 83 | @Roles(Role.ADMIN) 84 | @Patch(':id') 85 | update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) { 86 | return this.usersService.update(+id, updateUserDto); 87 | } 88 | 89 | @ApiBearerAuth('access-token') 90 | @Roles(Role.ADMIN) 91 | @Delete(':id') 92 | remove(@Param('id') id: string) { 93 | return this.usersService.remove(+id); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/users/services/users.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | } from '@nestjs/common'; 6 | import { InjectRepository } from '@nestjs/typeorm'; 7 | import * as bcrypt from 'bcrypt'; 8 | import { createHash } from 'crypto'; 9 | import { Repository } from 'typeorm'; 10 | import { 11 | CreateAdminDto, 12 | CreateUserDto, 13 | UpdateUserDto, 14 | } from '../dto/create-user.dto'; 15 | import { User } from '../entities/user.entity'; 16 | 17 | @Injectable() 18 | export class UsersService { 19 | constructor( 20 | @InjectRepository(User) 21 | private userRepository: Repository, 22 | ) {} 23 | 24 | async create(createUserDto: CreateUserDto | CreateAdminDto) { 25 | const user = await this.userRepository.findOne({ 26 | email: createUserDto.email, 27 | }); 28 | 29 | if (user) { 30 | throw new BadRequestException(); 31 | } 32 | 33 | const createdUser = await this.userRepository.create(createUserDto); 34 | const saveUser = await this.userRepository.save(createdUser); 35 | delete saveUser.password; 36 | delete saveUser.refreshToken; 37 | return saveUser; 38 | } 39 | 40 | async findAll() { 41 | return this.userRepository.find(); 42 | } 43 | 44 | async findByEmailAndGetPassword(email: string) { 45 | return await this.userRepository.findOne({ 46 | select: ['id', 'password', 'role'], 47 | where: { email }, 48 | }); 49 | } 50 | 51 | async findOne(id: number) { 52 | return await this.userRepository.findOne(id); 53 | } 54 | 55 | async findById(userId: number) { 56 | return await this.userRepository.findOneOrFail(userId); 57 | } 58 | 59 | async findByEmail(email: string) { 60 | return await this.userRepository.findOneOrFail({ 61 | where: { email }, 62 | }); 63 | } 64 | 65 | async update(id: number, updateUserDto: UpdateUserDto) { 66 | const user = await this.userRepository.preload({ 67 | id, 68 | ...updateUserDto, 69 | }); 70 | if (!user) { 71 | throw new NotFoundException(`User with id ${id} does not exist`); 72 | } 73 | return this.userRepository.save(user); 74 | } 75 | 76 | async remove(id: number) { 77 | const user = await this.userRepository.findOne(id); 78 | 79 | if (!user) { 80 | throw new NotFoundException(`User with id ${id} does not exist`); 81 | } 82 | 83 | return this.userRepository.remove(user); 84 | } 85 | 86 | async setCurrentRefreshToken(refreshToken: string, userId: number) { 87 | //crypto is a node module, and bcrypt the maximum length of the hash is 60 characters, and token is longer than that, so we need to hash it 88 | const hash = createHash('sha256').update(refreshToken).digest('hex'); 89 | 90 | const currentHashedRefreshToken = await bcrypt.hashSync(hash, 10); 91 | return await this.userRepository.update(userId, { 92 | refreshToken: currentHashedRefreshToken, 93 | }); 94 | } 95 | 96 | async removeRefreshToken(userId: number) { 97 | await this.findById(userId); 98 | 99 | return this.userRepository.update( 100 | { id: userId }, 101 | { 102 | refreshToken: null, 103 | }, 104 | ); 105 | } 106 | 107 | async getUserIfRefreshTokenMatches(refreshToken: string, userId: number) { 108 | const user = await this.userRepository.findOne({ 109 | select: ['id', 'refreshToken', 'role'], 110 | where: { id: userId }, 111 | }); 112 | 113 | const hash = createHash('sha256').update(refreshToken).digest('hex'); 114 | const isRefreshTokenMatching = await bcrypt.compare( 115 | hash, 116 | user.refreshToken, 117 | ); 118 | 119 | if (isRefreshTokenMatching) { 120 | return { id: user.id, role: user.role }; 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-typeorm-template", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "infra:up": "docker-compose up -d postgres", 10 | "infra:down": "docker-compose stop postgres", 11 | "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", 12 | "migration:run": "npm run build && npx typeorm migration:run", 13 | "migration:generate": "npm run build && npm run typeorm -- migration:generate -n", 14 | "migration:revert": "npm run build && npx typeorm migration:revert", 15 | "seed:run": "ts-node ./node_modules/typeorm-seeding/dist/cli.js seed", 16 | "seed:one": "ts-node ./node_modules/typeorm-seeding/dist/cli.js seed -s", 17 | "prebuild": "rimraf dist", 18 | "build": "nest build", 19 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 20 | "start": "nest start", 21 | "start:dev": "nest start --watch", 22 | "start:debug": "nest start --debug --watch", 23 | "start:prod": "node dist/main", 24 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 25 | "test": "jest", 26 | "test:watch": "jest --watch", 27 | "test:cov": "jest --coverage", 28 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 29 | "test:e2e": "jest --config ./test/jest-e2e.json", 30 | "pretest:e2e": "docker-compose up -d postgresTest", 31 | "posttest:e2e": "docker-compose stop postgresTest && docker-compose rm -f postgresTest", 32 | "prepare": "husky install", 33 | "reinstall": "docker-compose stop postgres && docker-compose rm -f postgres && npm run infra:up && npm run migration:run && npm run seed:run " 34 | }, 35 | "dependencies": { 36 | "@nestjs/common": "^8.0.0", 37 | "@nestjs/config": "^1.2.0", 38 | "@nestjs/core": "^8.0.0", 39 | "@nestjs/jwt": "^8.0.0", 40 | "@nestjs/mapped-types": "^1.0.1", 41 | "@nestjs/passport": "^8.2.1", 42 | "@nestjs/platform-express": "^8.0.0", 43 | "@nestjs/swagger": "^5.2.0", 44 | "@nestjs/typeorm": "^8.0.3", 45 | "bcrypt": "^5.0.1", 46 | "class-transformer": "^0.5.1", 47 | "class-validator": "^0.13.2", 48 | "joi": "^17.6.0", 49 | "passport-jwt": "^4.0.0", 50 | "passport-local": "^1.0.0", 51 | "pg": "^8.7.3", 52 | "reflect-metadata": "^0.1.13", 53 | "rimraf": "^3.0.2", 54 | "rxjs": "^7.2.0", 55 | "swagger-ui-express": "^4.3.0", 56 | "typeorm": "^0.2.45" 57 | }, 58 | "devDependencies": { 59 | "@nestjs/cli": "^8.0.0", 60 | "@nestjs/schematics": "^8.0.0", 61 | "@nestjs/testing": "^8.0.0", 62 | "@types/express": "^4.17.13", 63 | "@types/jest": "27.0.2", 64 | "@types/node": "^16.0.0", 65 | "@types/supertest": "^2.0.11", 66 | "@typescript-eslint/eslint-plugin": "^5.0.0", 67 | "@typescript-eslint/parser": "^5.0.0", 68 | "eslint": "^8.0.1", 69 | "eslint-config-prettier": "^8.3.0", 70 | "eslint-plugin-prettier": "^4.0.0", 71 | "husky": "^7.0.4", 72 | "jest": "^27.2.5", 73 | "lint-staged": "^12.3.5", 74 | "prettier": "^2.3.2", 75 | "source-map-support": "^0.5.20", 76 | "supertest": "^6.1.3", 77 | "ts-jest": "^27.0.3", 78 | "ts-loader": "^9.2.3", 79 | "ts-node": "^10.0.0", 80 | "tsconfig-paths": "^3.10.1", 81 | "typescript": "^4.3.5" 82 | }, 83 | "jest": { 84 | "moduleFileExtensions": [ 85 | "js", 86 | "json", 87 | "ts" 88 | ], 89 | "rootDir": "src", 90 | "testRegex": ".*\\.spec\\.ts$", 91 | "transform": { 92 | "^.+\\.(t|j)s$": "ts-jest" 93 | }, 94 | "collectCoverageFrom": [ 95 | "**/*.(t|j)s" 96 | ], 97 | "coverageDirectory": "../coverage", 98 | "testEnvironment": "node" 99 | }, 100 | "lint-staged": { 101 | "*.{ts,tsx}": "eslint --fix --max-warnings 0" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /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 | ## Description 9 | 10 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 11 | 12 | ## Installation 13 | 14 | 1. Clone the repository: 15 | 16 | `git clone git@github.com:jordicher/nestjs-typeorm-auth-template.git` 17 | 18 | 2. Open a terminal in the repository API folder: 19 | 20 | `cd nestjs-typeorm-auth-template` 21 | 22 | 3. Install dependencies: 23 | 24 | `npm install` 25 | 26 | ## Project configuration 27 | 28 | 1. Copy the `.env.example` file to `.env` in the same root folder: 29 | 30 | `cp .env.example .env` 31 | 32 | 2. As it is, it should work, but you can change these parameters: 33 | 34 | - `ACCESS_TOKEN_EXPIRATION`: expiration time of the JWT access token 35 | - `REFRESH_TOKEN_EXPIRATION`: expiration time of the JWT refresh token 36 | - `JWT_SECRET`: secret key used by JWT to encode access token 37 | - `JWT_REFRESH_SECRET`: secret key used by JWT to encode refresh token 38 | - `DATABASE_PORT`: port used by the API 39 | 40 | ## Database configuration 41 | 42 | 1. In the root of the API project, edit the file `.env` and configure these parameters using your Postgres configuration. 43 | 44 | ``` 45 | POSTGRES_NAME=template 46 | POSTGRES_PORT=5432 47 | POSTGRES_PASSWORD=templateUserPass 48 | POSTGRES_USER=templateUser 49 | POSTGRES_HOST=localhost 50 | ``` 51 | 52 | 2. Start the database with docker 53 | 54 | ``` 55 | $ npm run infra:up 56 | ``` 57 | 58 | ## Running the app 59 | 60 | ```bash 61 | # watch mode 62 | $ npm run start:dev 63 | 64 | # production mode 65 | $ npm run start:prod 66 | ``` 67 | 68 | ## Test 69 | 70 | ```bash 71 | # e2e tests 72 | $ npm run test:e2e 73 | 74 | ``` 75 | 76 | ### Migrations. 77 | 78 | To create a migration and implement changes in the db. 79 | 80 | //**run old migrations, this project by default has a user migration** 81 | 82 | ``` 83 | $ npm run migration:run 84 | ``` 85 | 86 | //generate a migration 87 | 88 | ``` 89 | $ npm run migration:generate name_new_migration 90 | ``` 91 | 92 | //run the migration 93 | 94 | ``` 95 | $ npm run migration:run 96 | ``` 97 | 98 | ## Documentation 99 | 100 | This template uses swagger for documentation. 101 | To see swagger, if you are using port 8080 for the api, it would be for example => localhost:8080/docs 102 | 103 | ![imagen](https://user-images.githubusercontent.com/56872592/162640131-e28b39fc-a778-4718-b5aa-93fa62ec1daf.png) 104 | 105 | ## Endpoint security 106 | 107 | This template uses jwt tokens and refresh tokens. 108 | 109 | To make a route public for everyone you have to add the @Public decorator above the endpoint. Example, users.controller.ts / endpoint post, /users. 110 | 111 | We can put three types of validations on the endpoints. 112 | 113 | - That it has a valid token, access-token. 114 | - That it has a valid token and is role x, example delete user can only be done by the admin role, Roles decorator. 115 | - That the refresh token is valid. 116 | 117 | ## How refresh tokens work 118 | 119 | The access token has to have a short lifetime, while the refresh token has to have a longer lifetime. (you can modify the duration by modifying the project variables). 120 | 121 | When logging in, it returns the two tokens. 122 | The refresh token is encrypted in the database, and is reset every time the user logs in or out. 123 | 124 | When an access token expires, the endpoint will return a custom error. 125 | httpStatus = 498 126 | message = Token expired 127 | 128 | In this case, a request must be made to auth/refresh-token that contains the refresh token in the header. This will return a valid access token. 129 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 2 | import { Test, TestingModule } from '@nestjs/testing'; 3 | import * as request from 'supertest'; 4 | import { Connection } from 'typeorm'; 5 | import { AppModule } from './../src/app.module'; 6 | import { userAdmin, userCustomer, userLogin } from './utils'; 7 | 8 | describe('AppController (e2e)', () => { 9 | let app: INestApplication; 10 | let adminJwtToken: string; 11 | 12 | beforeAll(async () => { 13 | const moduleFixture: TestingModule = await Test.createTestingModule({ 14 | imports: [AppModule], 15 | }).compile(); 16 | 17 | app = moduleFixture.createNestApplication(); 18 | app.useGlobalPipes( 19 | new ValidationPipe({ 20 | whitelist: true, 21 | transform: true, 22 | forbidNonWhitelisted: true, 23 | transformOptions: { 24 | enableImplicitConversion: true, 25 | }, 26 | }), 27 | ); 28 | await app.init(); 29 | 30 | const connection = app.get(Connection); 31 | await connection.synchronize(true); 32 | 33 | await connection 34 | .createQueryBuilder() 35 | .insert() 36 | .into('users') 37 | .values([userAdmin]) 38 | .execute(); 39 | }); 40 | 41 | afterAll(() => { 42 | app.close(); 43 | }); 44 | 45 | describe('Authentication', () => { 46 | //some tests are of => https://gist.github.com/mjclemente/e13995c29376f0924eb2eacf98eaa5a6 47 | 48 | it('authenticates user with valid credentials and provides a jwt token', async () => { 49 | const response = await request(app.getHttpServer()) 50 | .post('/auth/login') 51 | .send({ email: userLogin.email, password: userLogin.password }) 52 | .expect(200); 53 | 54 | adminJwtToken = response.body.accessToken; 55 | expect(adminJwtToken).toMatch( 56 | /^[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*$/, 57 | ); 58 | }); 59 | 60 | it('fails to authenticate user with an incorrect password', async () => { 61 | const response = await request(app.getHttpServer()) 62 | .post('/auth/login') 63 | .send({ email: userLogin.email, password: 'wrong' }) 64 | .expect(401); 65 | 66 | expect(response.body.accessToken).not.toBeDefined(); 67 | }); 68 | 69 | it('fails to authenticate user that does not exist', async () => { 70 | const response = await request(app.getHttpServer()) 71 | .post('/auth/login') 72 | .send({ email: 'nobody@example.com', password: 'test' }) 73 | .expect(401); 74 | 75 | expect(response.body.accessToken).not.toBeDefined(); 76 | }); 77 | }); 78 | 79 | describe('Users', () => { 80 | let customerId: number; 81 | it('should create a customer user', async () => { 82 | const response = await request(app.getHttpServer()) 83 | .post('/users') 84 | .send({ 85 | email: userCustomer.email, 86 | password: userCustomer.password, 87 | firstName: userCustomer.firstName, 88 | lastName: userCustomer.lastName, 89 | }) 90 | .expect(201); 91 | 92 | customerId = response.body.id; 93 | expect(response.body.email).toBe(userCustomer.email); 94 | expect(response.body.firstName).toBe(userCustomer.firstName); 95 | expect(response.body.lastName).toBe(userCustomer.lastName); 96 | }); 97 | 98 | it('should not create a customer user with an invalid email', async () => { 99 | await request(app.getHttpServer()) 100 | .post('/users') 101 | .send({ 102 | email: 'invalid', 103 | password: userCustomer.password, 104 | firstName: userCustomer.firstName, 105 | lastName: userCustomer.lastName, 106 | }) 107 | .expect(400); 108 | }); 109 | 110 | it('should get a user', async () => { 111 | const response = await request(app.getHttpServer()) 112 | .get(`/users/${customerId}`) 113 | .set('Authorization', `Bearer ${adminJwtToken}`) 114 | .expect(200); 115 | 116 | expect(response.body.email).toBe(userCustomer.email); 117 | }); 118 | 119 | it('should list all users', async () => { 120 | const response = await request(app.getHttpServer()) 121 | .get('/users') 122 | .set('Authorization', `Bearer ${adminJwtToken}`) 123 | .expect(200); 124 | 125 | expect(response.body.length).toBe(2); 126 | }); 127 | 128 | it('should update a customer user', async () => { 129 | const response = await request(app.getHttpServer()) 130 | .patch(`/users/${customerId}`) 131 | .set('Authorization', `Bearer ${adminJwtToken}`) 132 | .send({ 133 | email: userCustomer.email, 134 | password: userCustomer.password, 135 | firstName: 'Jordi', 136 | lastName: 'test', 137 | }) 138 | .expect(200); 139 | 140 | expect(response.body.email).toBe(userCustomer.email); 141 | expect(response.body.firstName).toBe('Jordi'); 142 | expect(response.body.lastName).toBe('test'); 143 | }); 144 | 145 | it('should delete a customer user', async () => { 146 | await request(app.getHttpServer()) 147 | .delete(`/users/${customerId}`) 148 | .set('Authorization', `Bearer ${adminJwtToken}`) 149 | .expect(200); 150 | }); 151 | 152 | it('should not delete a customer user with an invalid id', async () => { 153 | await request(app.getHttpServer()) 154 | .delete('/users/0') 155 | .set('Authorization', `Bearer ${adminJwtToken}`) 156 | .expect(404); 157 | }); 158 | }); 159 | }); 160 | --------------------------------------------------------------------------------