diff --git a/.eslintrc b/.eslintrc index 16094ccb..957731e8 100644 --- a/.eslintrc +++ b/.eslintrc @@ -3,13 +3,9 @@ "plugins": ["@typescript-eslint", "import"], "extends": ["airbnb"], - "ignorePatterns": [ - "node_modules", - "dist", - "packages/api/types.ts" - ], + "ignorePatterns": ["node_modules", "dist", "packages/api/types.ts"], - "env":{ + "env": { "browser": true, "node": true, "jasmine": true @@ -34,11 +30,14 @@ "import/no-extraneous-dependencies": 0, "import/extensions": 0, "import-name": 0, - "no-unused-vars": ["error", { - "argsIgnorePattern": "^_", - "args": "after-used", - "ignoreRestSiblings": true - }], + "no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "args": "after-used", + "ignoreRestSiblings": true + } + ], "@typescript-eslint/no-unused-vars": ["error"], "no-unused-expressions": "off", @@ -56,10 +55,13 @@ "jsx-a11y/no-static-element-interactions": 0, "linebreak-style": 0, "max-classes-per-file": 0, // Leads to too many files - "no-empty-function": ["error", {"allow": ["constructors"]}], // Needed by NestJS dep injection - "no-empty": [2, { - "allowEmptyCatch": true - }], + "no-empty-function": ["error", { "allow": ["constructors"] }], // Needed by NestJS dep injection + "no-empty": [ + 2, + { + "allowEmptyCatch": true + } + ], "no-underscore-dangle": 0, "no-useless-constructor": 0, // Needed by NestJS dep injection "object-curly-newline": 0, diff --git a/packages/api/src/App.module.ts b/packages/api/src/App.module.ts index 05a505fe..a97482ea 100644 --- a/packages/api/src/App.module.ts +++ b/packages/api/src/App.module.ts @@ -1,9 +1,8 @@ import { Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; import path from 'path'; - -import { AssignmentFileModule } from './AssignmentFile'; import { AssignmentModule } from './Assignment'; +import { AssignmentFileModule } from './AssignmentFile'; import { AuthModule } from './Auth'; import { CharacterModule } from './Character'; import { CMSModule } from './CMS'; @@ -16,10 +15,11 @@ import { PathModule } from './Path'; import { PathUserModule } from './PathUser'; import { QuestionModule } from './Question'; import { StorySectionModule } from './StorySection'; -import { UserConceptModule } from './UserConcepts'; import { UserModule } from './User'; +import { UserConceptModule } from './UserConcepts'; import { UserModuleModule } from './UserModule'; + /** * Export these dependencies so they can be used in testing */ diff --git a/packages/api/src/Auth/index.ts b/packages/api/src/Auth/index.ts index 2e265376..96fb5533 100644 --- a/packages/api/src/Auth/index.ts +++ b/packages/api/src/Auth/index.ts @@ -8,17 +8,18 @@ import { AuthService } from './Auth.service'; import { JWTStrategy } from './strategies/jwt.strategy'; import { LocalStrategy } from './strategies/local.strategy'; - @Module({ imports: [ UserModule, - PassportModule, + PassportModule.register({ + defaultStrategy: 'jwt' + }), JwtModule.register({ secret: config.get('auth.jwtSecret'), signOptions: { expiresIn: '1h' } }) ], providers: [AuthResolver, AuthService, LocalStrategy, JWTStrategy], - exports: [AuthService] + exports: [AuthService, PassportModule] }) export class AuthModule {} diff --git a/packages/api/src/Character/Character.resolver.ts b/packages/api/src/Character/Character.resolver.ts index 1b9869c9..ed0a2b67 100644 --- a/packages/api/src/Character/Character.resolver.ts +++ b/packages/api/src/Character/Character.resolver.ts @@ -1,10 +1,10 @@ import { UseGuards } from '@nestjs/common'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; - import { GQLAuthGuard } from '../Auth/GQLAuth.guard'; - -import { CharacterService } from './Character.service'; +import { Roles } from '../Role/Role.guard'; import { Character, CreateCharacterInput, UpdateCharacterInput } from './Character.entity'; +import { CharacterService } from './Character.service'; + @Resolver('Character') export class CharacterResolver { @@ -16,19 +16,19 @@ export class CharacterResolver { return this.characterService.findAll(); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Character) createCharacter(@Args('character') character: CreateCharacterInput) { return this.characterService.create(character); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Character) async updateCharacter(@Args('character') character: UpdateCharacterInput) { return this.characterService.update(character); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Boolean) async deleteCharacter(@Args('id') id: string) { return this.characterService.delete(id); diff --git a/packages/api/src/Concept/Concept.resolver.ts b/packages/api/src/Concept/Concept.resolver.ts index f62da35c..c514f05c 100644 --- a/packages/api/src/Concept/Concept.resolver.ts +++ b/packages/api/src/Concept/Concept.resolver.ts @@ -1,15 +1,16 @@ -import { Resolver, Query, Args, Mutation, ResolveField, Parent } from '@nestjs/graphql'; import { UseGuards } from '@nestjs/common'; - -import { ConceptService } from './Concept.service'; -import { Concept, CreateConceptInput, UpdateConceptInput } from './Concept.entity'; +import { Args, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { GQLAuthGuard } from '../Auth/GQLAuth.guard'; +import { CMS } from '../CMS/CMS'; +import { LessonModule } from '../Lesson'; +import { Roles } from '../Role/Role.guard'; import { CurrentUser } from '../User/CurrentUser.decorator'; import { User } from '../User/User.entity'; -import { UserConceptService } from '../UserConcepts/UserConcept.service'; import { UserConcept } from '../UserConcepts/UserConcept.entity'; -import { LessonModule } from '../Lesson'; -import { CMS } from '../CMS/CMS'; +import { UserConceptService } from '../UserConcepts/UserConcept.service'; +import { Concept, CreateConceptInput, UpdateConceptInput } from './Concept.entity'; +import { ConceptService } from './Concept.service'; + @Resolver(Concept) export class ConceptResolver { @@ -37,19 +38,19 @@ export class ConceptResolver { return this.userConceptService.findByUser(user.id); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Concept) createConcept(@Args('concept') concept: CreateConceptInput) { return this.conceptService.create(concept); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Concept) updateConcept(@Args('concept') concept: UpdateConceptInput) { return this.conceptService.update(concept); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Boolean) deleteConcept(@Args('conceptId') conceptId: string) { return this.conceptService.delete(conceptId); diff --git a/packages/api/src/Database/index.ts b/packages/api/src/Database/index.ts index 586a300a..03e1e668 100644 --- a/packages/api/src/Database/index.ts +++ b/packages/api/src/Database/index.ts @@ -29,7 +29,6 @@ import { SeederService } from './seeders/Seeders.service'; import { TypeORMModule } from './TypeORM.module'; import { CMS } from '../CMS/CMS'; - /** * Main Database Module, used in App.module and testing */ @@ -69,4 +68,4 @@ import { CMS } from '../CMS/CMS'; ], exports: [DatabaseService, SeederService] }) -export class DatabaseModule { } +export class DatabaseModule {} diff --git a/packages/api/src/Database/seeders/Seeders.service.ts b/packages/api/src/Database/seeders/Seeders.service.ts index 2ad4afa1..741589f8 100644 --- a/packages/api/src/Database/seeders/Seeders.service.ts +++ b/packages/api/src/Database/seeders/Seeders.service.ts @@ -1,12 +1,12 @@ import { Inject, Injectable } from '@nestjs/common'; import Listr from 'listr'; import { Connection, Repository } from 'typeorm'; - import { Assignment } from '../../Assignment/Assignment.entity'; import { AssignmentService } from '../../Assignment/Assignment.service'; import { AssignmentFileService } from '../../AssignmentFile/AssignmentFile.service'; import { Character } from '../../Character/Character.entity'; import { CharacterService } from '../../Character/Character.service'; +import { CMS } from '../../CMS/CMS'; import { ConceptService } from '../../Concept/Concept.service'; import { FriendStatus } from '../../Friend/Friend.entity'; import { FriendService } from '../../Friend/Friend.service'; @@ -19,7 +19,7 @@ import { UserService } from '../../User/User.service'; import { UserPreferencesService } from '../../UserPreferences/UserPreferences.service'; import { DatabaseService } from '../Database.service'; import * as random from './random'; -import { CMS } from '../../CMS/CMS'; +// import { RoleType } from '../../Role/RoleType.enum'; interface CTX { @@ -32,7 +32,6 @@ interface CTX { @Injectable() export class SeederService { - /** * Initializes the database service * @param connection The connection, which gets injected @@ -49,7 +48,7 @@ export class SeederService { public conceptService: ConceptService, public friendService: FriendService, public characterService: CharacterService - ) { } + ) {} db = new DatabaseService(this.connection); @@ -66,71 +65,85 @@ export class SeederService { * @param num Number of users you want to create */ async seedUsers(num: number = 4): Promise { - return Promise.all(Array(num).fill(undefined).map(async (_, i) => { - const user = await this.userService.create( - random.userInput({ email: `user${i}@test.com` }) - ); - - if (i % 2 === 0) { - await this.userPreferencesService.update( - user.id, - random.userPreferenceInput() - ); - } - return user; - })); + return Promise.all( + Array(num) + .fill(undefined) + .map(async (_, i) => { + const input = random.userInput({ email: `user${i}@test.com` }); + if (i === 0) { + input.email = 'admin@test.com'; + // (input as UserWithPassword).role = RoleType.admin; + } + const user = await this.userService.create(input); + + if (i % 2 === 0) { + await this.userPreferencesService.update( + user.id, + random.userPreferenceInput() + ); + } + return user; + }) + ); } async seedCharacters(num: number = 3): Promise { - return Promise.all(Array(num).fill(undefined).map(async () => this.characterService.create( - random.characterInput() - ))); + return Promise.all( + Array(num) + .fill(undefined) + .map(async () => this.characterService.create(random.characterInput())) + ); } - - async seedPaths(users: UserWithPassword[] = [], characters: Character[] = []) { + async seedPaths( + users: UserWithPassword[] = [], + characters: Character[] = [] + ) { const paths = [ { id: 'js', name: 'Javascript', icon: 'js' }, { id: 'css', name: 'CSS', icon: 'css' }, { id: 'html', name: 'HTML', icon: 'html' } ]; - return Promise.all(paths.map(async (path, i) => { - let newPath = new Path(); + return Promise.all( + paths.map(async (path, i) => { + let newPath = new Path(); - if ((i % 2 === 0) && (i < characters.length)) { - newPath = await this.pathService.create( - random.pathInput({ ...path, characterId: characters[i].id }) - ); - } else { - newPath = await this.pathService.create( - random.pathInput(path) - ); - } - if (i === 0 && users.length) { - await this.pathService.addUserToPath(users[0].id, newPath.id); - } - return newPath; - })); + if (i % 2 === 0 && i < characters.length) { + newPath = await this.pathService.create( + random.pathInput({ ...path, characterId: characters[i].id }) + ); + } else { + newPath = await this.pathService.create(random.pathInput(path)); + } + if (i === 0 && users.length) { + await this.pathService.addUserToPath(users[0].id, newPath.id); + } + return newPath; + }) + ); } - async seedConcept( numConcept: number = 3, numModule: number = 2, users: UserWithPassword[] ) { const modules = Object.values(this.cms.modules); - const numMod = (numModule < modules.length) ? numModule : modules.length; - return Promise.all(Array(numConcept).fill(undefined).map(async (_, i) => { - const concept = await this.conceptService.create( - random.conceptInput(modules[i % numMod].id) - ); - - if (i === 0) { - await this.conceptService.addUserConcept(concept.id, users[0].id); - } - })); + const numMod = numModule < modules.length ? numModule : modules.length; + return Promise.all( + Array(numConcept) + .fill(undefined) + .map(async (_, i) => { + const concept = await this.conceptService.create( + random.conceptInput(modules[i % numMod].id) + ); + + if (i === 0) { + await this.conceptService.addUserConcept(concept.id, users[0].id); + } + }) + ); } async seedFriend(users: UserWithPassword[]) { diff --git a/packages/api/src/Path/Path.resolver.ts b/packages/api/src/Path/Path.resolver.ts index 958263f5..ae778f11 100644 --- a/packages/api/src/Path/Path.resolver.ts +++ b/packages/api/src/Path/Path.resolver.ts @@ -9,6 +9,7 @@ import { CurrentUser } from '../User/CurrentUser.decorator'; import { User } from '../User/User.entity'; import { Path, PathInput, UpdatePathInput } from './Path.entity'; import { PathService } from './Path.service'; +import { Roles } from '../Role/Role.guard'; @Resolver(() => Path) export class PathResolver { @@ -43,7 +44,7 @@ export class PathResolver { return this.pathService.findById(id); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Path) createPath(@Args('path') path: PathInput) { return this.pathService.create(path); @@ -67,7 +68,7 @@ export class PathResolver { return Boolean(await this.pathService.addUserToPath(user.id, paths)); } - @UseGuards(GQLAuthGuard) + @Roles('admin') @Mutation(() => Path) async updatePath( @Args('path') path: UpdatePathInput diff --git a/packages/api/src/Role/Role.guard.ts b/packages/api/src/Role/Role.guard.ts new file mode 100644 index 00000000..1e9587ac --- /dev/null +++ b/packages/api/src/Role/Role.guard.ts @@ -0,0 +1,49 @@ +import { applyDecorators, CanActivate, ExecutionContext, Injectable, SetMetadata, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { GqlExecutionContext } from '@nestjs/graphql'; +import { GQLAuthGuard } from '../Auth/GQLAuth.guard'; +import { UserWithPassword } from '../User/User.entity'; +import { RolePermissionLevels, RoleType } from './RoleType.enum'; + +@Injectable() +export class RoleGuard implements CanActivate { + constructor(private readonly _reflector: Reflector) { } + + /** + * Looks up user's role, and if any is supplied to `@Role(...)` then compare it + */ + canActivate(context: ExecutionContext): boolean { + // Get the roles passed in from the decorator (array of RoleType) + const roles = this._reflector.get<(keyof typeof RoleType)[]>( + 'roles', context.getHandler()); + + // If no roles supplied, allow anyone + if (!roles.length) return true; + // Map the roles to their numbers, and find the HIGHEST number + const permLevels = roles.map(r => RolePermissionLevels[r]); + const highestAllowed = Math.max(...permLevels); + + // Get the users permmission level + const ctx = GqlExecutionContext.create(context); + const { user } = ctx.getContext().req; + const userLevel = RolePermissionLevels[(user as UserWithPassword).role]; + + // If user's level is too high, block them + if (userLevel > highestAllowed) { + throw new UnauthorizedException( + "You haven't permissions to access this resource" + ); + } + + return true; + } +} + +/** + * Protect a query or mutation by specific roles. Also implemments GQLAuthGuard + * @param roles List of RoleType's to auth a query/mutation/etc + */ +export const Roles = (...roles: (keyof typeof RoleType)[]) => applyDecorators( + SetMetadata('roles', roles), + UseGuards(GQLAuthGuard, RoleGuard) +); diff --git a/packages/api/src/Role/RoleType.enum.ts b/packages/api/src/Role/RoleType.enum.ts new file mode 100644 index 00000000..ce2c5a60 --- /dev/null +++ b/packages/api/src/Role/RoleType.enum.ts @@ -0,0 +1,16 @@ +import { registerEnumType } from '@nestjs/graphql'; + +export enum RoleType { + admin = 'admin', + user = 'user', +} + +export const RolePermissionLevels: { [role in RoleType]: number } = { + admin: 1, + // Gives us room to add other roles if we need + user: 10 +}; + +registerEnumType(RoleType, { + name: 'RoleType' +}); diff --git a/packages/api/src/User/User.entity.ts b/packages/api/src/User/User.entity.ts index e2fa45f1..bd1e820d 100644 --- a/packages/api/src/User/User.entity.ts +++ b/packages/api/src/User/User.entity.ts @@ -1,10 +1,19 @@ import { Field, ID, InputType, ObjectType } from '@nestjs/graphql'; -import { Column, CreateDateColumn, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; - +import { + Column, + CreateDateColumn, + Entity, + OneToMany, + OneToOne, + PrimaryGeneratedColumn, + Unique +} from 'typeorm'; import { CMBaseEntity } from '../lib/Base.entity'; import { PathUser } from '../PathUser/PathUser.entity'; -import { UserPreferences } from '../UserPreferences/UserPreferences.entity'; +import { RoleType } from '../Role/RoleType.enum'; import { UserModule } from '../UserModule/UserModule.entity'; +import { UserPreferences } from '../UserPreferences/UserPreferences.entity'; + @ObjectType() export class User { @@ -23,6 +32,9 @@ export class User { @Field() profileImage: string; + @Field(() => RoleType) + role: RoleType; + @Field(() => UserPreferences, { nullable: true }) userPreferences?: UserPreferences; @@ -60,6 +72,9 @@ export class UserWithPassword extends CMBaseEntity { @OneToMany(() => UserModule, userModules => userModules.user) userModules: UserModule[]; + @Column({ type: 'simple-enum', enum: RoleType, default: RoleType.user }) + role: RoleType; + @OneToOne(() => UserPreferences) userPreferences: UserPreferences; } diff --git a/packages/api/src/User/User.resolver.ts b/packages/api/src/User/User.resolver.ts index 706ed5c2..86ede274 100644 --- a/packages/api/src/User/User.resolver.ts +++ b/packages/api/src/User/User.resolver.ts @@ -1,22 +1,16 @@ import { UseGuards } from '@nestjs/common'; -import { - Args, - Mutation, - Query, - Resolver, - ResolveField, - Parent -} from '@nestjs/graphql'; - +import { Args, Mutation, Parent, Query, ResolveField, Resolver } from '@nestjs/graphql'; import { GQLAuthGuard } from '../Auth/GQLAuth.guard'; -import { User, UserInput } from './User.entity'; -import { UserService } from './User.service'; -import { CurrentUser } from './CurrentUser.decorator'; +import { Roles } from '../Role/Role.guard'; import { UserPreferences, UserPreferencesInput } from '../UserPreferences/UserPreferences.entity'; import { UserPreferencesService } from '../UserPreferences/UserPreferences.service'; +import { CurrentUser } from './CurrentUser.decorator'; +import { User, UserInput } from './User.entity'; +import { UserService } from './User.service'; + @Resolver(() => User) export class UserResolver { @@ -25,7 +19,7 @@ export class UserResolver { private readonly userPreferencesService: UserPreferencesService ) {} - @UseGuards(GQLAuthGuard) + @Roles('admin') @Query(() => [User]) users() { return this.userService.findAll(); @@ -64,8 +58,6 @@ export class UserResolver { @ResolveField(() => UserPreferences) async userPreferences(@Parent() user: User) { - return this.userPreferencesService.findByUser( - user.id - ); + return this.userPreferencesService.findByUser(user.id); } } diff --git a/packages/api/src/User/User.service.ts b/packages/api/src/User/User.service.ts index 298b5645..02164ce4 100644 --- a/packages/api/src/User/User.service.ts +++ b/packages/api/src/User/User.service.ts @@ -3,15 +3,15 @@ import { InjectRepository } from '@nestjs/typeorm'; import bcrypt from 'bcrypt'; import md5 from 'md5'; import { Repository } from 'typeorm'; +import { UserWithPassword } from './User.entity'; -import { UserInput, UserWithPassword } from './User.entity'; @Injectable() export class UserService { constructor( @InjectRepository(UserWithPassword) private readonly userRepository: Repository - ) {} + ) { } async findAll(): Promise { return this.userRepository.find(); @@ -27,13 +27,19 @@ export class UserService { }); } - async create(input: UserInput): Promise { + async create(input: Partial): Promise { const user = this.userRepository.create(input); user.password = await bcrypt.hash(input.password, 10); // When in production add // ?r=pg&d=https:%3A%2F%2Fapi.codementoring.co%2Fstatic%2Fdefault-profile.svg; // to end of profileImage URL. - user.profileImage = `https://www.gravatar.com/avatar/${md5(input.email)}`; + user.profileImage = `https://www.gravatar.com/avatar/${md5(input.email!)}`; return this.userRepository.save(user); } + + async update(id: string, input: Partial): Promise { + const _input = { ...input }; + if (_input.password) _input.password = await bcrypt.hash(input.password, 10); + return this.userRepository.save({ id, ..._input }); + } } diff --git a/packages/api/src/User/index.ts b/packages/api/src/User/index.ts index 37184e88..20fadef8 100644 --- a/packages/api/src/User/index.ts +++ b/packages/api/src/User/index.ts @@ -11,12 +11,14 @@ import { PathUser } from '../PathUser/PathUser.entity'; import { Path } from '../Path/Path.entity'; @Module({ - imports: [TypeOrmModule.forFeature([ - UserWithPassword, - UserPreferences, - Path, - PathUser - ])], + imports: [ + TypeOrmModule.forFeature([ + UserWithPassword, + UserPreferences, + Path, + PathUser + ]) + ], providers: [ UserResolver, UserService, diff --git a/packages/api/src/schema.gql b/packages/api/src/schema.gql index 0ced39d7..049886f5 100644 --- a/packages/api/src/schema.gql +++ b/packages/api/src/schema.gql @@ -105,14 +105,6 @@ A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date """ scalar DateTime -type UserPreferences { - id: String! - userId: String! - practiceGoal: Float! - why: String! - codingAbility: Float! -} - type UserModule { id: String! userId: String! @@ -121,16 +113,30 @@ type UserModule { completedAt: DateTime } +type UserPreferences { + id: String! + userId: String! + practiceGoal: Float! + why: String! + codingAbility: Float! +} + type User { id: ID! firstName: String! lastName: String! email: String! profileImage: String! + role: RoleType! userPreferences: UserPreferences createdAt: DateTime! } +enum RoleType { + admin + user +} + type AssignmentFile { id: String! name: String! @@ -193,10 +199,10 @@ type FriendOutput { } type Query { - assignmentFiles(assignmentId: String!): [AssignmentFile!]! - userAssignmentFiles(authorId: String!): [AssignmentFile!]! assignments: [Assignment!]! moduleAssignments(moduleId: String!): [Assignment!]! + assignmentFiles(assignmentId: String!): [AssignmentFile!]! + userAssignmentFiles(authorId: String!): [AssignmentFile!]! users: [User!]! searchUsers(query: String!): [User!]! me: User!