From 099b40e77a175af059f26e296b5078e6d2d04d54 Mon Sep 17 00:00:00 2001 From: NorbiPeti Date: Mon, 18 Apr 2022 00:57:34 +0200 Subject: [PATCH] Implement register mutation with validation --- backend/src/application.ts | 15 +- .../src/graphql-resolvers/user-resolver.ts | 184 ++++++++++++++++++ .../input/user-register.input.ts | 37 ++++ backend/src/graphql-types/user.ts | 6 + backend/src/helpers.ts | 6 + backend/src/models/user.model.ts | 10 +- 6 files changed, 255 insertions(+), 3 deletions(-) create mode 100644 backend/src/graphql-resolvers/user-resolver.ts create mode 100644 backend/src/graphql-types/input/user-register.input.ts create mode 100644 backend/src/graphql-types/user.ts create mode 100644 backend/src/helpers.ts diff --git a/backend/src/application.ts b/backend/src/application.ts index b0cef7b..014108d 100644 --- a/backend/src/application.ts +++ b/backend/src/application.ts @@ -6,6 +6,7 @@ import { AuthenticationComponent } from '@loopback/authentication'; import { JWTAuthenticationComponent, UserServiceBindings } from '@loopback/authentication-jwt'; import { SzakdolgozatUserService } from './services'; import { GraphQLServer } from '@loopback/graphql'; +import { UserResolver } from './graphql-resolvers/user-resolver'; export { ApplicationConfig }; @@ -18,8 +19,11 @@ export class SzakdolgozatBackendApplication extends BootMixin( super(options); const server = this.server(GraphQLServer); - this.configure(server.key).to({host: process.env.HOST, port: process.env.PORT}); - this.getServer(GraphQLServer).then(s => this.gqlServer = s); + this.configure(server.key).to({host: process.env.HOST ?? '0.0.0.0', port: process.env.PORT ?? 3000}); + this.getServer(GraphQLServer).then(s => { + this.gqlServer = s; + s.resolver(UserResolver); + }); // Authentication this.component(AuthenticationComponent); @@ -27,5 +31,12 @@ export class SzakdolgozatBackendApplication extends BootMixin( this.service(SzakdolgozatUserService, UserServiceBindings.USER_SERVICE); this.projectRoot = __dirname; + this.bootOptions = { + graphqlResolvers: { + dirs: ['graphql-resolvers'], + extensions: ['js'], + nested: true + } + }; } } diff --git a/backend/src/graphql-resolvers/user-resolver.ts b/backend/src/graphql-resolvers/user-resolver.ts new file mode 100644 index 0000000..872c5ec --- /dev/null +++ b/backend/src/graphql-resolvers/user-resolver.ts @@ -0,0 +1,184 @@ +import { arg, GraphQLBindings, mutation, query, resolver, ResolverData } from '@loopback/graphql'; +import { User } from '../models'; +import { repository } from '@loopback/repository'; +import { UserRepository } from '../repositories'; +import { inject } from '@loopback/core'; +import { SzakdolgozatUserService } from '../services'; +import { TokenServiceBindings, UserServiceBindings } from '@loopback/authentication-jwt'; +import { TokenService } from '@loopback/authentication'; +import { SecurityBindings, UserProfile } from '@loopback/security'; +import { genSalt, hash } from 'bcryptjs'; +import { UserRegisterInput } from '../graphql-types/input/user-register.input'; +import { validated } from '../helpers'; + +@resolver(of => User) +export class UserResolver { + constructor( + @repository('UserRepository') private readonly userRepository: UserRepository, + @inject(UserServiceBindings.USER_SERVICE) private readonly userService: SzakdolgozatUserService, + @inject(GraphQLBindings.RESOLVER_DATA) private readonly resolverData: ResolverData, + @inject(TokenServiceBindings.TOKEN_SERVICE) public jwtService: TokenService, + @inject(SecurityBindings.USER, {optional: true}) public user: UserProfile + ) { + } + + @mutation(returns => User) + async register(@arg('user', validated(UserRegisterInput)) request: UserRegisterInput): Promise> { + const password = await hash(request.password, await genSalt()); + const user = { + email: request.email, + name: request.name, + password, + isAdmin: false + } as User; + return this.userRepository.create(user); + } + + @query(returns => User) + async test(request: User): Promise { + return (await this.userRepository.find())[0]; + } + + /*@mutation(returns => LoginResult) + async login( + @requestBody({ + description: 'The input of login function', + required: true, + content: { + 'application/json': { + schema: getModelSchemaRef(User, {exclude: ['id', 'isAdmin', 'name']}) + }, + } + }) credentials: Pick, + ): Promise<{ token: string, user: Omit }> { + // ensure the user exists, and the password is correct + const user = await this.userService.verifyCredentials(credentials); + // convert a User object into a UserProfile object (reduced set of properties) + const userProfile = this.userService.convertToUserProfile(user); + + // create a JSON Web Token based on the user profile + const token = await this.jwtService.generateToken(userProfile); + return {token, user}; + } + + @post('/users/logout', { + responses: { + '204': { + description: 'Logged out', + }, + }, + }) + @authenticate('jwt') + async logout(@inject(RestBindings.Http.REQUEST) request: Request): Promise { + const split = request.headers.authorization?.split(' '); + if (split && split.length > 1) { + if (this.jwtService.revokeToken) { + await this.jwtService.revokeToken(split[1]); + } else { + console.error('Cannot revoke token'); + } + } + } + + @get('/users/count') + @response(200, { + description: 'User model count', + content: {'application/json': {schema: CountSchema}}, + }) + @authenticate('jwt') + async count( + @param.where(User) where?: Where, + ): Promise { + return this.userRepository.count(where); + } + + @get('/users') + @response(200, { + description: 'Array of User model instances', + content: { + 'application/json': { + schema: { + type: 'array', + items: getModelSchemaRef(User, {includeRelations: true}), + }, + }, + }, + }) + @authenticate('jwt') + async find( + @param.filter(User) filter?: Filter, + ): Promise { + return this.userRepository.find(filter); + } + + @patch('/users') + @response(200, { + description: 'User PATCH success count', + content: {'application/json': {schema: CountSchema}}, + }) + @authenticate('jwt') + async updateAll( + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(User, {partial: true}), + }, + }, + }) + user: User, + @param.where(User) where?: Where, + ): Promise { + return this.userRepository.updateAll(user, where); + } + + @get('/users/{id}') + @response(200, { + description: 'User model instance', + content: { + 'application/json': { + schema: getModelSchemaRef(User, {includeRelations: true}), + }, + }, + }) + @authenticate('jwt') + async findById( + @param.path.number('id') id: number, + @param.filter(User, {exclude: 'where'}) filter?: FilterExcludingWhere + ): Promise { + return this.userRepository.findById(id, filter); + } + + @patch('/users/{id}') + @response(204, { + description: 'User PATCH success', + }) + @authenticate('jwt') + async updateById( + @param.path.number('id') id: number, + @requestBody({ + content: { + 'application/json': { + schema: getModelSchemaRef(User, {partial: true}), + }, + }, + }) + user: User, + ): Promise { + if (id === +this.user.id) { + const loggedInUser = await this.userService.findUserById(this.user.id); + if (user.isAdmin !== undefined && loggedInUser.isAdmin !== user.isAdmin) { + throw new HttpErrors.BadRequest('Cannot change admin status of self'); + } + } + await this.userRepository.updateById(id, user); + } + + @del('/users/{id}') + @response(204, { + description: 'User DELETE success', + }) + @authenticate('jwt') + async deleteById(@param.path.number('id') id: number): Promise { + await this.userRepository.deleteById(id); + }*/ +} diff --git a/backend/src/graphql-types/input/user-register.input.ts b/backend/src/graphql-types/input/user-register.input.ts new file mode 100644 index 0000000..63fafc0 --- /dev/null +++ b/backend/src/graphql-types/input/user-register.input.ts @@ -0,0 +1,37 @@ +import { field, inputType } from '@loopback/graphql'; +import { User } from '../../models'; +import { model, property } from '@loopback/repository'; + +@model() +@inputType() +export class UserRegisterInput implements Pick { + @property({ + type: 'string', + required: true, + jsonSchema: { + pattern: /[A-Za-z0-9.+_-]+@[A-Za-z.-_]*(u-szeged.hu)|(szte.hu)/.source + } + }) + @field() + email: string; + @property({ + type: 'string', + required: true, + jsonSchema: { + pattern: /([A-Za-z-.]+ )+[A-Za-z-.]+/.source + } + }) + @field() + name: string; + @property({ + type: 'string', + required: true, + jsonSchema: { + minLength: 8, + maxLength: 255 + }, + hidden: true + }) + @field() + password: string; +} diff --git a/backend/src/graphql-types/user.ts b/backend/src/graphql-types/user.ts new file mode 100644 index 0000000..5001174 --- /dev/null +++ b/backend/src/graphql-types/user.ts @@ -0,0 +1,6 @@ +import { User } from '../models'; + +export class LoginResult { + token: string; + user: Omit; +} diff --git a/backend/src/helpers.ts b/backend/src/helpers.ts new file mode 100644 index 0000000..5bc7002 --- /dev/null +++ b/backend/src/helpers.ts @@ -0,0 +1,6 @@ +import { jsonToSchemaObject, modelToJsonSchema, validateValueAgainstSchema } from '@loopback/rest'; +import { ClassType } from '@loopback/graphql'; + +export function validated(type: ClassType) { + return {validate: (input: T) => validateValueAgainstSchema(input, jsonToSchemaObject(modelToJsonSchema(type)))}; +} diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts index 7b762cb..f765931 100644 --- a/backend/src/models/user.model.ts +++ b/backend/src/models/user.model.ts @@ -1,14 +1,17 @@ -import { Entity, model, property, hasMany } from '@loopback/repository'; +import { Entity, hasMany, model, property } from '@loopback/repository'; import { Course } from './course.model'; import { CourseUser } from './course-user.model'; +import { field, objectType } from '@loopback/graphql'; @model() +@objectType() export class User extends Entity { @property({ type: 'number', id: true, generated: true, }) + @field() id?: number; @property({ @@ -18,6 +21,7 @@ export class User extends Entity { pattern: /[A-Za-z0-9.+_-]+@[A-Za-z.-_]*(u-szeged.hu)|(szte.hu)/.source } }) + @field() email: string; @property({ @@ -27,6 +31,7 @@ export class User extends Entity { pattern: /([A-Za-z-.]+ )+[A-Za-z-.]+/.source } }) + @field() name: string; @property({ @@ -38,15 +43,18 @@ export class User extends Entity { }, hidden: true }) + @field() password: string; @property({ type: 'boolean', required: true, }) + @field() isAdmin: boolean; @hasMany(() => Course, {through: {model: () => CourseUser}}) + //@field() courses: Course[]; constructor(data?: Partial) {