From 446210f84abab70f04ec5a00ce2045b67f967a0a Mon Sep 17 00:00:00 2001 From: Felix Costa Date: Sat, 30 May 2020 01:14:00 -0300 Subject: [PATCH 1/4] feat: implementing simple jwt auth module and change config module strategy --- package.json | 12 +- src/app.module.ts | 4 + src/auth/auth.module.ts | 35 +++++ src/auth/auth.resolver.ts | 38 ++++++ src/auth/auth.service.ts | 117 +++++++++++++++++ src/auth/dto/login-result.ts | 11 ++ src/auth/dto/login-user-input.ts | 13 ++ src/auth/guards/admin.guard.ts | 25 ++++ src/auth/guards/jwt-auth.guard.ts | 22 ++++ src/auth/interfaces/jwt-payload.interface.ts | 5 + src/auth/strategies/jwt.strategy.ts | 29 +++++ src/config/config.module.ts | 13 ++ src/config/config.service.spec.ts | 24 ++++ src/config/config.service.ts | 128 +++++++++++++++++++ src/schema.gql | 15 +++ src/users/dto/create-user.dto.ts | 3 + src/users/entities/user.entity.ts | 21 ++- src/users/users.module.ts | 1 + src/users/users.service.ts | 44 +++++++ 19 files changed, 558 insertions(+), 2 deletions(-) create mode 100644 src/auth/auth.module.ts create mode 100644 src/auth/auth.resolver.ts create mode 100644 src/auth/auth.service.ts create mode 100644 src/auth/dto/login-result.ts create mode 100644 src/auth/dto/login-user-input.ts create mode 100644 src/auth/guards/admin.guard.ts create mode 100644 src/auth/guards/jwt-auth.guard.ts create mode 100644 src/auth/interfaces/jwt-payload.interface.ts create mode 100644 src/auth/strategies/jwt.strategy.ts create mode 100644 src/config/config.module.ts create mode 100644 src/config/config.service.spec.ts create mode 100644 src/config/config.service.ts diff --git a/package.json b/package.json index f29a515..4dcb4b8 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,23 @@ "dependencies": { "@nestjs/common": "^6.7.2", "@nestjs/core": "^6.7.2", + "@nestjs/config": "^0.4.1", "@nestjs/graphql": "^6.5.3", - "@nestjs/platform-express": "^6.7.2", + "@nestjs/jwt": "^7.0.0", + "@nestjs/passport": "^7.0.0", + "@nestjs/platform-express": "^7.0.2", "@nestjs/platform-fastify": "^6.10.13", "@nestjs/typeorm": "^6.2.0", "apollo-server-fastify": "^2.9.15", "class-validator": "^0.11.0", + "passport": "^0.4.1", + "passport-jwt": "^4.0.0", "graphql": "^14.5.8", "graphql-tools": "^4.0.6", "helmet": "^3.21.2", "mongodb": "^3.4.1", + "bcrypt": "^4.0.1", + "joi": "^14.3.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.0", "rxjs": "^6.5.3", @@ -54,7 +61,10 @@ "@types/express": "^4.17.1", "@types/jest": "^24.0.18", "@types/node": "^12.7.5", + "@types/joi": "^14.3.4", "@types/supertest": "^2.0.8", + "@types/passport-jwt": "^3.0.3", + "@types/bcrypt": "^3.0.0", "apollo-server-express": "^2.9.15", "easygraphql-tester": "^5.1.6", "faker": "^4.1.0", diff --git a/src/app.module.ts b/src/app.module.ts index 5586ae2..5a668cc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,11 @@ +import { AuthModule } from './auth/auth.module'; import { Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersModule } from './users/users.module'; import { DateScalar } from './common/scalars/date.scalar'; import { EmailScalar } from './common/scalars/email.scalar'; +import { ConfigModule } from './config/config.module'; @Module({ imports: [ @@ -13,6 +15,8 @@ import { EmailScalar } from './common/scalars/email.scalar'; debug: process.env.NODE_ENV === 'development', }), UsersModule, + ConfigModule, + AuthModule, ], providers: [DateScalar, EmailScalar], }) diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 0000000..3f99a61 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,35 @@ +import { ConfigModule } from '../config/config.module'; +import { ConfigService } from '../config/config.service'; +import { Module, forwardRef } from '@nestjs/common'; +import { AuthService } from './auth.service'; +import { PassportModule } from '@nestjs/passport'; +import { JwtModule, JwtModuleOptions } from '@nestjs/jwt'; +import { UsersModule } from '../users/users.module'; +import { JwtStrategy } from './strategies/jwt.strategy'; +import { AuthResolver } from './auth.resolver'; + +@Module({ + imports: [ + PassportModule.register({ defaultStrategy: 'jwt', session: false }), + JwtModule.registerAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => { + const options: JwtModuleOptions = { + secret: configService.jwtSecret, + }; + if (configService.jwtExpiresIn) { + options.signOptions = { + expiresIn: configService.jwtExpiresIn, + }; + } + return options; + }, + inject: [ConfigService], + }), + forwardRef(() => UsersModule), + ConfigModule, + ], + providers: [AuthService, AuthResolver, JwtStrategy], + exports: [AuthService], +}) +export class AuthModule {} diff --git a/src/auth/auth.resolver.ts b/src/auth/auth.resolver.ts new file mode 100644 index 0000000..82b5702 --- /dev/null +++ b/src/auth/auth.resolver.ts @@ -0,0 +1,38 @@ +import { Resolver, Args, Query, Context } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import LoginUserInput from './dto/login-user-input'; +import LoginResult from './dto/login-result'; +import { AuthService } from './auth.service'; +import { AuthenticationError } from 'apollo-server-core'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { UserEntity } from '../users/entities/user.entity'; + +@Resolver('Auth') +export class AuthResolver { + constructor(private authService: AuthService) {} + + @Query(returns => LoginResult) + async login(@Args('user') userInput: LoginUserInput): Promise { + const result = await this.authService.validateUserByPassword(userInput); + if (result) { + return result; + } + + throw new AuthenticationError('Could not log-in with the provided credentials'); + } + + // There is no username guard here because if the person has the token, they can be any user + @Query(returns => String) + @UseGuards(JwtAuthGuard) + async refreshToken(@Context('req') request: any): Promise { + const user: UserEntity = request.user; + if (!user) { + throw new AuthenticationError('Could not log-in with the provided credentials'); + } + const result = await this.authService.createJwt(user); + if (result) { + return result.token; + } + throw new AuthenticationError('Could not log-in with the provided credentials'); + } +} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts new file mode 100644 index 0000000..ea39f9a --- /dev/null +++ b/src/auth/auth.service.ts @@ -0,0 +1,117 @@ +import { Injectable, forwardRef, Inject } from '@nestjs/common'; +import { ConfigService } from '../config/config.service'; +import { JwtService } from '@nestjs/jwt'; +import { UsersService } from '../users/users.service'; +import { JwtPayload } from './interfaces/jwt-payload.interface'; +import LoginUserInput from './dto/login-user-input'; +import LoginResult from './dto/login-result'; +import { UserEntity as UserDocument, UserEntity } from '../users/entities/user.entity'; + +@Injectable() +export class AuthService { + constructor( + @Inject(forwardRef(() => UsersService)) + private usersService: UsersService, + private jwtService: JwtService, + private configService: ConfigService, + ) {} + + /** + * Checks if a user's password is valid + * + * @param {LoginUserInput} loginAttempt Include username or email. If both are provided only + * username will be used. Password must be provided. + * @returns {(Promise)} returns the User and token if successful, undefined if not + * @memberof AuthService + */ + async validateUserByPassword(loginAttempt: LoginUserInput): Promise { + // This will be used for the initial login + let userToAttempt: UserDocument | undefined; + if (loginAttempt.email) { + userToAttempt = await this.usersService.findOneByEmail(loginAttempt.email); + } + + // If the user is not enabled, disable log in - the token wouldn't work anyways + if (userToAttempt && userToAttempt.active === false) { + userToAttempt = undefined; + } + + if (!userToAttempt) { + return undefined; + } + + // Check the supplied password against the hash stored for this email address + let isMatch = false; + try { + isMatch = await userToAttempt.checkPassword(loginAttempt.password); + } catch (error) { + return undefined; + } + + if (isMatch) { + // If there is a successful match, generate a JWT for the user + const token = this.createJwt(userToAttempt!).token; + const result: LoginResult = { + user: userToAttempt!, + token, + }; + userToAttempt.updatedAt = new Date(); + + this.usersService.upsertUser(userToAttempt.id.toString(), userToAttempt); + + return result; + } + + return undefined; + } + + /** + * Verifies that the JWT payload associated with a JWT is valid by making sure the user exists and is enabled + * + * @param {JwtPayload} payload + * @returns {(Promise)} returns undefined if there is no user or the account is not enabled + * @memberof AuthService + */ + async validateJwtPayload(payload: JwtPayload): Promise { + // This will be used when the user has already logged in and has a JWT + const user = await this.usersService.findOneByUsername(payload.name); + + // Ensure the user exists and their account isn't disabled + if (user) { + user.updatedAt = new Date(); + return this.usersService.upsertUser(user.id.toString(), user); + } + + return undefined; + } + + /** + * Creates a JwtPayload for the given User + * + * @param {User} user + * @returns {{ data: JwtPayload; token: string }} The data contains the email, username, and expiration of the + * token depending on the environment variable. Expiration could be undefined if there is none set. token is the + * token created by signing the data. + * @memberof AuthService + */ + createJwt(user: UserEntity): { data: JwtPayload; token: string } { + const expiresIn = this.configService.jwtExpiresIn; + let expiration: Date | undefined; + if (expiresIn) { + expiration = new Date(); + expiration.setTime(expiration.getTime() + expiresIn * 1000); + } + const data: JwtPayload = { + email: user.email, + name: user.name, + expiration, + }; + + const jwt = this.jwtService.sign(data); + + return { + data, + token: jwt, + }; + } +} diff --git a/src/auth/dto/login-result.ts b/src/auth/dto/login-result.ts new file mode 100644 index 0000000..db2f430 --- /dev/null +++ b/src/auth/dto/login-result.ts @@ -0,0 +1,11 @@ +import { Field, ObjectType, Int } from 'type-graphql'; +import { UserEntity as User } from '../../users/entities/user.entity'; + +@ObjectType('LoginResult') +export default class LoginResult { + @Field(type => User) + user: User; + + @Field(type => String) + token: string; +} diff --git a/src/auth/dto/login-user-input.ts b/src/auth/dto/login-user-input.ts new file mode 100644 index 0000000..f73b86f --- /dev/null +++ b/src/auth/dto/login-user-input.ts @@ -0,0 +1,13 @@ +import { IsOptional, Length, MinLength } from 'class-validator'; +import { Field, InputType } from 'type-graphql'; +import { EmailScalar as Email } from '../../common/scalars/email.scalar'; + +@InputType('LoginUserInput') +export default class LoginUserInput { + @Field() + password: string; + + @Field(type => Email) + @Length(30, 500) + email: string; +} diff --git a/src/auth/guards/admin.guard.ts b/src/auth/guards/admin.guard.ts new file mode 100644 index 0000000..b733f82 --- /dev/null +++ b/src/auth/guards/admin.guard.ts @@ -0,0 +1,25 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { UserEntity as User } from '../../users/entities/user.entity'; +import { UsersService } from '../../users/users.service'; +import { AuthenticationError } from 'apollo-server-core'; + +// Check if username in field for query matches authenticated user's username +// or if the user is admin +@Injectable() +export class AdminGuard implements CanActivate { + constructor(private usersService: UsersService) {} + + canActivate(context: ExecutionContext): boolean { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + if (request.user) { + const user = request.user as User; + if (this.usersService.isAdmin(user.permissions)) { + return true; + } + } + throw new AuthenticationError('Could not authenticate with token or user does not have permissions'); + } +} diff --git a/src/auth/guards/jwt-auth.guard.ts b/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 0000000..a379038 --- /dev/null +++ b/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,22 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { AuthenticationError } from 'apollo-server-core'; + +@Injectable() +// In order to use AuthGuard together with GraphQL, you have to extend +// the built-in AuthGuard class and override getRequest() method. +export class JwtAuthGuard extends AuthGuard('jwt') { + getRequest(context: ExecutionContext) { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext().req; + return request; + } + + handleRequest(err: any, user: any, info: any) { + if (err || !user) { + throw err || new AuthenticationError('Could not authenticate with token'); + } + return user; + } +} diff --git a/src/auth/interfaces/jwt-payload.interface.ts b/src/auth/interfaces/jwt-payload.interface.ts new file mode 100644 index 0000000..968481e --- /dev/null +++ b/src/auth/interfaces/jwt-payload.interface.ts @@ -0,0 +1,5 @@ +export interface JwtPayload { + email: string; + name: string; + expiration?: Date; +} diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts new file mode 100644 index 0000000..369c7dc --- /dev/null +++ b/src/auth/strategies/jwt.strategy.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { ConfigService } from '../../config/config.service'; +import { AuthService } from '../auth.service'; +import { PassportStrategy } from '@nestjs/passport'; +import { JwtPayload } from '../interfaces/jwt-payload.interface'; +import { AuthenticationError } from 'apollo-server-core'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private authService: AuthService, configService: ConfigService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: configService.jwtSecret, + }); + } + + // Documentation for this here: https://www.npmjs.com/package/passport-jwt + async validate(payload: JwtPayload) { + // This is called to validate the user in the token exists + const user = await this.authService.validateJwtPayload(payload); + + if (!user) { + throw new AuthenticationError('Could not log-in with the provided credentials'); + } + + return user; + } +} diff --git a/src/config/config.module.ts b/src/config/config.module.ts new file mode 100644 index 0000000..1f79975 --- /dev/null +++ b/src/config/config.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ConfigService } from './config.service'; + +@Module({ + providers: [ + { + provide: ConfigService, + useValue: new ConfigService(`.env`), + }, + ], + exports: [ConfigService], +}) +export class ConfigModule {} diff --git a/src/config/config.service.spec.ts b/src/config/config.service.spec.ts new file mode 100644 index 0000000..aad5eda --- /dev/null +++ b/src/config/config.service.spec.ts @@ -0,0 +1,24 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from './config.service'; + +describe('ConfigService', () => { + let service: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ConfigService, + { + provide: ConfigService, + useValue: new ConfigService(`${process.env.NODE_ENV}.env`), + }, + ], + }).compile(); + + service = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/config/config.service.ts b/src/config/config.service.ts new file mode 100644 index 0000000..e973340 --- /dev/null +++ b/src/config/config.service.ts @@ -0,0 +1,128 @@ +import { Injectable } from '@nestjs/common'; +import * as dotenv from 'dotenv'; +import * as fs from 'fs'; +import * as Joi from 'joi'; + +export interface EnvConfig { + [key: string]: string; +} + +@Injectable() +export class ConfigService { + private readonly envConfig: EnvConfig; + + constructor(filePath: string) { + let file: Buffer | undefined; + try { + file = fs.readFileSync(filePath); + } catch (error) { + file = fs.readFileSync('development.env'); + } + + const config = dotenv.parse(file); + this.envConfig = this.validateInput(config); + } + + private validateInput(envConfig: EnvConfig): EnvConfig { + const envVarsSchema: Joi.ObjectSchema = Joi.object({ + PORT: Joi.number().default(3000), + APP_NAME: Joi.string().required(), + NODE_ENV: Joi.string().required(), + + MONGO_HOST: Joi.string().required(), + MONGO_PORT: Joi.number().required(), + MONGO_DATABASE_NAME: Joi.string().required(), + MONGO_AUTH_ENABLED: Joi.boolean().default(false), + MONGO_USERNAME: Joi.string().when('MONGO_AUTH_ENABLED', { + is: true, + then: Joi.required(), + }), + MONGO_PASSWORD: Joi.string().when('MONGO_AUTH_ENABLED', { + is: true, + then: Joi.required(), + }), + IMAGES_URL: Joi.string().default('http://localhost:3000/images/'), + JWT_SECRET: Joi.string().required(), + JWT_EXPIRES_IN: Joi.number(), + EMAIL_ENABLED: Joi.boolean().default(false), + EMAIL_SERVICE: Joi.string().when('EMAIL_ENABLED', { + is: true, + then: Joi.required(), + }), + EMAIL_USERNAME: Joi.string().when('EMAIL_ENABLED', { + is: true, + then: Joi.required(), + }), + EMAIL_PASSWORD: Joi.string().when('EMAIL_ENABLED', { + is: true, + then: Joi.required(), + }), + EMAIL_FROM: Joi.string().when('EMAIL_ENABLED', { + is: true, + then: Joi.required(), + }), + TEST_EMAIL_TO: Joi.string(), + }); + + const { error, value: validatedEnvConfig } = Joi.validate(envConfig, envVarsSchema); + if (error) { + throw new Error(`Config validation error in your env file: ${error.message}`); + } + return validatedEnvConfig; + } + + get jwtExpiresIn(): number | undefined { + if (this.envConfig.JWT_EXPIRES_IN) { + return +this.envConfig.JWT_EXPIRES_IN; + } + return undefined; + } + + get mongoUri(): string { + return this.envConfig.MONGO_URI; + } + + get jwtSecret(): string { + return this.envConfig.JWT_SECRET; + } + + get imagesUrl(): string { + return this.envConfig.IMAGES_URL; + } + + get emailService(): string | undefined { + return this.envConfig.EMAIL_SERVICE; + } + + get emailUsername(): string | undefined { + return this.envConfig.EMAIL_USERNAME; + } + + get emailPassword(): string | undefined { + return this.envConfig.EMAIL_PASSWORD; + } + + get emailFrom(): string | undefined { + return this.envConfig.EMAIL_FROM; + } + + get testEmailTo(): string | undefined { + return this.envConfig.TEST_EMAIL_TO; + } + + get mongoUser(): string | undefined { + return this.envConfig.MONGO_USER; + } + + get mongoPassword(): string | undefined { + return this.envConfig.MONGO_PASSWORD; + } + + get emailEnabled(): boolean { + return Boolean(this.envConfig.EMAIL_ENABLED).valueOf(); + } + + get mongoAuthEnabled(): boolean { + return Boolean(this.envConfig.MONGO_AUTH_ENABLED).valueOf(); + } +} diff --git a/src/schema.gql b/src/schema.gql index ab9446a..de95059 100644 --- a/src/schema.gql +++ b/src/schema.gql @@ -5,6 +5,7 @@ input CreateUserInput { name: String! + password: String! email: Email! telephone: String birthDate: Date @@ -21,6 +22,16 @@ type ListUsers { total: Int! } +type LoginResult { + user: User! + token: String! +} + +input LoginUserInput { + password: String! + email: Email! +} + type Mutation { saveUser(id: ID, userInput: CreateUserInput!): User! deleteLocation(id: ID!): Boolean! @@ -29,12 +40,16 @@ type Mutation { type Query { user(id: String!): User! users(skip: Int = 0, take: Int = 50, ids: [ID!], name: String, order: String = "DESC", fieldSort: String = "updatedAt"): ListUsers! + login(user: LoginUserInput!): LoginResult! + refreshToken: String! } type User { id: ID! name: String! email: Email! + password: String! + permissions: [String!]! telephone: String birthDate: Date createdAt: Date! diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 14f2d86..6c25ea4 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -8,6 +8,9 @@ export class CreateUserDto { @MinLength(10) name: string; + @Field() + password: string; + @Field(type => Email) @Length(30, 500) email: string; diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index ae12a36..03d426a 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -1,6 +1,7 @@ import { Field, ID, ObjectType } from 'type-graphql'; import { EmailScalar as Email } from '../../common/scalars/email.scalar'; -import { Column, Entity, ObjectID, ObjectIdColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; +import { Column, Entity, ObjectID, ObjectIdColumn, CreateDateColumn, UpdateDateColumn, BeforeInsert } from 'typeorm'; +import * as bcrypt from 'bcrypt'; @Entity('User') @ObjectType('User') @@ -17,6 +18,14 @@ export class UserEntity { @Column() email: string; + @Field(type => String) + @Column() + password: string; + + @Field(type => [String]) + // @Column() + permissions: string[]; + @Field({ nullable: true }) @Column() telephone?: string; @@ -35,4 +44,14 @@ export class UserEntity { @Column() active: boolean; + + // for some reason, not working + @BeforeInsert() + async hashPassword() { + this.password = await bcrypt.hash(this.password, 10); + } + + async checkPassword(attempt: string): Promise { + return await bcrypt.compare(attempt, this.password); + } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 976ca43..442e9a9 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -7,6 +7,7 @@ import { ServiceHelper } from '../common/helpers/service.helper'; @Module({ imports: [TypeOrmModule.forFeature([UserRepository])], + exports: [UsersService], providers: [UsersService, UsersResolver, ServiceHelper], }) export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index e9d80df..880cc63 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,6 +7,7 @@ import { ListUsersEntity } from './entities/list-users.entity'; import { UserRepository } from './repositories/user.repository'; import { ServiceHelper } from '../common/helpers/service.helper'; import { ObjectID } from 'typeorm'; +import * as bcrypt from 'bcrypt'; @Injectable() export class UsersService { @@ -21,6 +22,36 @@ export class UsersService { }); } + /** + * Returns a user by their unique email address or undefined + * + * @param {string} email address of user, not case sensitive + * @returns {(Promise)} + * @memberof UsersService + */ + async findOneByEmail(email: string): Promise { + const user = await this.userRepository.findOne({ email: email.toLowerCase() }); + if (user) { + return user; + } + return undefined; + } + + /** + * Returns a user by their unique username or undefined + * + * @param {string} username of user, not case sensitive + * @returns {(Promise)} + * @memberof UsersService + */ + async findOneByUsername(username: string): Promise { + const user = await this.userRepository.findOne({ name: username.toLowerCase() }); + if (user) { + return user; + } + return undefined; + } + async findUsers(params: FindUsersDto): Promise { return await this.serviceHelper.findAllByNameOrIds(params, this.userRepository); } @@ -40,6 +71,8 @@ export class UsersService { const newUser: UserEntity = await this.serviceHelper.getUpsertData(id, user, this.userRepository); + newUser.password = await bcrypt.hash(newUser.password, 10); + return this.userRepository.save({ ...newUser, active: true }); } @@ -47,4 +80,15 @@ export class UsersService { const user: UserEntity = await this.userRepository.findOne(id); return Boolean(this.userRepository.save({ ...user, active: false })); } + + /** + * Returns if the user has 'admin' set on the permissions array + * + * @param {string[]} permissions permissions property on a User + * @returns {boolean} + * @memberof UsersService + */ + isAdmin(permissions: string[]): boolean { + return permissions.includes('admin'); + } } From 2f2c8f80345ca00b58ce0a908abc866a51c86428 Mon Sep 17 00:00:00 2001 From: Felix Costa Date: Sat, 30 May 2020 01:18:24 -0300 Subject: [PATCH 2/4] test: graphql tests passing --- test/graphql/users.graphql-spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/graphql/users.graphql-spec.ts b/test/graphql/users.graphql-spec.ts index 4ca9767..9611803 100644 --- a/test/graphql/users.graphql-spec.ts +++ b/test/graphql/users.graphql-spec.ts @@ -14,12 +14,14 @@ describe('UsersModule (Queries e Mutations)', () => { it('Should pass if the mutation is valid', done => { const name: string = faker.name.findName(); const email: string = faker.internet.email(); + const password: string = faker.internet.password(); const mutation: string = ` mutation saveUser($user: CreateUserInput!) { saveUser(userInput: $user) { name email + password } } `; @@ -27,6 +29,7 @@ describe('UsersModule (Queries e Mutations)', () => { user: { name, email, + password, }, }); done(); From 9f154844cb5b2cc861a5b84a9163ea238bc3b4c8 Mon Sep 17 00:00:00 2001 From: Felix Costa Date: Sat, 30 May 2020 09:25:10 -0300 Subject: [PATCH 3/4] fix: remove password from result query --- src/schema.gql | 1 - src/users/entities/user.entity.ts | 4 +--- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/schema.gql b/src/schema.gql index de95059..df53aed 100644 --- a/src/schema.gql +++ b/src/schema.gql @@ -48,7 +48,6 @@ type User { id: ID! name: String! email: Email! - password: String! permissions: [String!]! telephone: String birthDate: Date diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index 03d426a..015c7ca 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -18,12 +18,10 @@ export class UserEntity { @Column() email: string; - @Field(type => String) - @Column() + @Column({ select: false }) password: string; @Field(type => [String]) - // @Column() permissions: string[]; @Field({ nullable: true }) From ee16fc39224b8fc3e489210ae9ad96bb5064390c Mon Sep 17 00:00:00 2001 From: Felix Costa Date: Sat, 30 May 2020 15:35:14 -0300 Subject: [PATCH 4/4] fix: refresh token and update user method for auth --- src/app.module.ts | 1 + src/auth/auth.service.ts | 6 +++--- src/users/users.service.ts | 4 ++++ 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app.module.ts b/src/app.module.ts index 5a668cc..6e42b06 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { ConfigModule } from './config/config.module'; GraphQLModule.forRoot({ autoSchemaFile: 'src/schema.gql', debug: process.env.NODE_ENV === 'development', + context: ({ req }) => ({ req }), }), UsersModule, ConfigModule, diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ea39f9a..23e3e35 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -55,9 +55,9 @@ export class AuthService { user: userToAttempt!, token, }; - userToAttempt.updatedAt = new Date(); - this.usersService.upsertUser(userToAttempt.id.toString(), userToAttempt); + userToAttempt.updatedAt = new Date(); + this.usersService.updateUser(userToAttempt); return result; } @@ -79,7 +79,7 @@ export class AuthService { // Ensure the user exists and their account isn't disabled if (user) { user.updatedAt = new Date(); - return this.usersService.upsertUser(user.id.toString(), user); + return this.usersService.updateUser(user); } return undefined; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 880cc63..64a9806 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -76,6 +76,10 @@ export class UsersService { return this.userRepository.save({ ...newUser, active: true }); } + async updateUser(user: UserEntity): Promise { + return this.userRepository.save(user); + } + async deleteUser(id: string): Promise { const user: UserEntity = await this.userRepository.findOne(id); return Boolean(this.userRepository.save({ ...user, active: false }));