From d8ac8a1db082805d5fca17e23b950b7c3c1c163f Mon Sep 17 00:00:00 2001 From: Ruslan Gonzalez Date: Sun, 30 Aug 2020 21:18:13 +0100 Subject: [PATCH] user roles done --- package.json | 1 + src/app.module.ts | 11 ++-- src/app.roles.ts | 29 +++++++++ src/auth/auth.controller.ts | 8 ++- src/auth/dtos/login.dto.ts | 6 ++ src/common/decorators/auth.decorator.ts | 6 +- src/config/constants.ts | 4 +- src/config/default-user.ts | 23 ++++++++ src/main.ts | 2 + src/post/entities/post.entity.ts | 7 +++ src/post/post.controller.ts | 78 ++++++++++++++++++++++--- src/post/post.service.ts | 24 ++++---- src/user/dtos/create-user.dto.ts | 11 +++- src/user/dtos/index.ts | 3 +- src/user/dtos/user-registration.dto.ts | 4 ++ src/user/entities/user.entity.ts | 8 +++ src/user/user.controller.ts | 72 +++++++++++++++++++++-- src/user/user.service.ts | 17 +++--- yarn.lock | 19 ++++++ 19 files changed, 288 insertions(+), 45 deletions(-) create mode 100644 src/app.roles.ts create mode 100644 src/auth/dtos/login.dto.ts create mode 100644 src/config/default-user.ts create mode 100644 src/user/dtos/user-registration.dto.ts diff --git a/package.json b/package.json index 7492d2b..5a3b5e2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "class-transformer": "^0.3.1", "class-validator": "^0.12.2", "mysql": "^2.18.1", + "nest-access-control": "^2.0.2", "passport": "^0.4.1", "passport-jwt": "^4.0.0", "passport-local": "^1.0.0", diff --git a/src/app.module.ts b/src/app.module.ts index cc43ba9..5987541 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,17 +1,18 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config' +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AccessControlModule } from 'nest-access-control'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { PostModule } from './post/post.module'; -import { TypeOrmModule } from '@nestjs/typeorm'; import { UserModule } from './user/user.module'; -import { DATABASE_HOST, DATABASE_PORT, DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_NAME } from './config/constants'; import { AuthModule } from './auth/auth.module'; +import { DATABASE_HOST, DATABASE_PORT, DATABASE_USERNAME, DATABASE_PASSWORD, DATABASE_NAME } from './config/constants'; +import { roles } from './app.roles'; @Module({ imports: [ - PostModule, TypeOrmModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ @@ -32,8 +33,10 @@ import { AuthModule } from './auth/auth.module'; isGlobal: true, envFilePath: '.env' }), - UserModule, + AccessControlModule.forRoles(roles), AuthModule, + UserModule, + PostModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/app.roles.ts b/src/app.roles.ts new file mode 100644 index 0000000..ec63e6a --- /dev/null +++ b/src/app.roles.ts @@ -0,0 +1,29 @@ +import { RolesBuilder } from "nest-access-control"; + +export enum AppRoles { + AUTHOR = 'AUTHOR', + ADMIN = 'ADMIN' +} + +export enum AppResource { + USER = 'USER', + POST = 'POST' +} + +export const roles: RolesBuilder = new RolesBuilder(); + +roles + // AUTHOR ROLES + .grant(AppRoles.AUTHOR) + .updateOwn([AppResource.USER]) + .deleteOwn([AppResource.USER]) + .createOwn([AppResource.POST]) + .updateOwn([AppResource.POST]) + .deleteOwn([AppResource.POST]) + // ADMIN ROLES + .grant(AppRoles.ADMIN) + .extend(AppRoles.AUTHOR) + .createAny([AppResource.USER]) + .updateAny([AppResource.POST, AppResource.USER]) + .deleteAny([AppResource.POST, AppResource.USER]) + diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 80c8413..cf43044 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,9 +1,10 @@ -import { Controller, Post, Get, UseGuards } from '@nestjs/common'; -import { LocalAuthGuard, JwtAuthGuard } from './guards'; +import { Controller, Post, Get, UseGuards, Body } from '@nestjs/common'; +import { LocalAuthGuard } from './guards'; import { User, Auth } from 'src/common/decorators'; import { User as UserEntity } from 'src/user/entities'; import { AuthService } from './auth.service'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags } from '@nestjs/swagger'; +import { LoginDto } from './dtos/login.dto'; @ApiTags('Auth routes') @Controller('auth') @@ -16,6 +17,7 @@ export class AuthController { @UseGuards(LocalAuthGuard) @Post('login') async login( + @Body() loginDto: LoginDto, @User() user: UserEntity ) { const data = await this.authService.login(user); diff --git a/src/auth/dtos/login.dto.ts b/src/auth/dtos/login.dto.ts new file mode 100644 index 0000000..7ca6973 --- /dev/null +++ b/src/auth/dtos/login.dto.ts @@ -0,0 +1,6 @@ + + +export class LoginDto { + email: string; + password: string; +} \ No newline at end of file diff --git a/src/common/decorators/auth.decorator.ts b/src/common/decorators/auth.decorator.ts index 3ed5779..7668f20 100644 --- a/src/common/decorators/auth.decorator.ts +++ b/src/common/decorators/auth.decorator.ts @@ -1,11 +1,13 @@ import { applyDecorators, UseGuards } from "@nestjs/common"; import { ApiBearerAuth } from "@nestjs/swagger"; import { JwtAuthGuard } from "src/auth/guards"; +import { ACGuard, Role, UseRoles } from "nest-access-control"; -export function Auth() { +export function Auth(...roles: Role[]) { return applyDecorators( - UseGuards(JwtAuthGuard), + UseGuards(JwtAuthGuard, ACGuard), + UseRoles(...roles), ApiBearerAuth() ) } \ No newline at end of file diff --git a/src/config/constants.ts b/src/config/constants.ts index 4a4d569..4be99a3 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -4,4 +4,6 @@ export const DATABASE_HOST = 'DATABASE_HOST'; export const DATABASE_PORT = 'DATABASE_PORT'; export const DATABASE_USERNAME = 'DATABASE_USERNAME'; export const DATABASE_PASSWORD = 'DATABASE_PASSWORD'; -export const DATABASE_NAME = 'DATABASE_NAME'; \ No newline at end of file +export const DATABASE_NAME = 'DATABASE_NAME'; +export const DEFAULT_USER_EMAIL = 'DEFAULT_USER_EMAIL'; +export const DEFAULT_USER_PASSWORD = 'DEFAULT_USER_PASSWORD'; \ No newline at end of file diff --git a/src/config/default-user.ts b/src/config/default-user.ts new file mode 100644 index 0000000..5cdcd18 --- /dev/null +++ b/src/config/default-user.ts @@ -0,0 +1,23 @@ +import { getRepository } from 'typeorm' +import { ConfigService } from '@nestjs/config' +import { DEFAULT_USER_EMAIL, DEFAULT_USER_PASSWORD } from './constants' +import { User } from 'src/user/entities' + +export const setDefaultUser = async (config: ConfigService) => { + const userRepository = getRepository(User) + + const defaultUser = await userRepository + .createQueryBuilder() + .where('email = :email', { email: config.get('DEFAULT_USER_EMAIL') }) + .getOne() + + if (!defaultUser) { + const adminUser = userRepository.create({ + email: config.get(DEFAULT_USER_EMAIL), + password: config.get(DEFAULT_USER_PASSWORD), + roles: ['ADMIN'] + }) + + return await userRepository.save(adminUser) + } +} diff --git a/src/main.ts b/src/main.ts index ad1854c..30d764b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,6 +5,7 @@ import { AppModule } from './app.module'; import { initSwagger } from './app.swagger'; import { ConfigService } from '@nestjs/config'; import { SERVER_PORT } from './config/constants'; +import { setDefaultUser } from './config/default-user'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -13,6 +14,7 @@ async function bootstrap() { const port = parseInt(config.get(SERVER_PORT), 10) || 3000; initSwagger(app); + setDefaultUser(config); app.useGlobalPipes( new ValidationPipe({ diff --git a/src/post/entities/post.entity.ts b/src/post/entities/post.entity.ts index 6cc6530..861118d 100644 --- a/src/post/entities/post.entity.ts +++ b/src/post/entities/post.entity.ts @@ -3,7 +3,10 @@ import { PrimaryGeneratedColumn, Column, CreateDateColumn, + ManyToOne, + JoinColumn, } from 'typeorm'; +import { User } from 'src/user/entities'; @Entity('posts') export class Post { @@ -33,4 +36,8 @@ export class Post { @CreateDateColumn({ type: 'timestamp' }) createdAt: Date; + + @ManyToOne(_ => User, (user) => user.posts, { eager: true }) + @JoinColumn({ name: 'author' }) + author: User; } diff --git a/src/post/post.controller.ts b/src/post/post.controller.ts index 757d4e3..8602bcd 100644 --- a/src/post/post.controller.ts +++ b/src/post/post.controller.ts @@ -9,15 +9,22 @@ import { ParseIntPipe, } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; - +import { InjectRolesBuilder, RolesBuilder } from 'nest-access-control'; +import { AppResource } from 'src/app.roles'; import { PostService } from './post.service'; import { CreatePostDto, EditPostDto } from './dtos'; +import { User, Auth } from 'src/common/decorators'; +import { User as UserEntity } from 'src/user/entities'; @ApiTags('Posts') @Controller('post') export class PostController { - constructor(private readonly postService: PostService) {} - + constructor( + private readonly postService: PostService, + @InjectRolesBuilder() + private readonly roleBuilder: RolesBuilder, + ) {} + @Get() async getMany() { const data = await this.postService.getMany(); @@ -30,21 +37,74 @@ export class PostController { return { data }; } + @Auth({ + resource: AppResource.POST, + action: 'create', + possession: 'own' + }) @Post() - async createPost(@Body() dto: CreatePostDto) { - const data = await this.postService.createOne(dto); + async createPost( + @Body() dto: CreatePostDto, + @User() author: UserEntity + ) { + const data = await this.postService.createOne(dto, author); return { message: 'Post created', data }; } + @Auth({ + resource: AppResource.POST, + action: 'update', + possession: 'own' + }) @Put(':id') - async editOne(@Param('id') id: number, @Body() dto: EditPostDto) { - const data = await this.postService.editOne(id, dto); + async editOne( + @Param('id') id: number, @Body() dto: EditPostDto, + @User() author: UserEntity + ) { + + let data; + + if ( + this.roleBuilder + .can(author.roles) + .updateAny(AppResource.POST) + .granted + ) { + + // Puede editar cualquier POST... + data = await this.postService.editOne(id, dto); + + } else { + + // Puede editar solo los propios... + data = await this.postService.editOne(id, dto, author); + } + return { message: 'Post edited', data }; } + @Auth({ + resource: AppResource.POST, + action: 'delete', + possession: 'own' + }) @Delete(':id') - async deleteOne(@Param('id') id: number) { - const data = await this.postService.deleteOne(id); + async deleteOne( + @Param('id') id: number, + @User() author: UserEntity + ) { + + let data; + + if (this.roleBuilder + .can(author.roles) + .deleteAny(AppResource.POST) + .granted + ) { + data = await this.postService.deleteOne(id); + } else { + data = await this.postService.deleteOne(id, author); + } return { message: 'Post deleted', data }; } } diff --git a/src/post/post.service.ts b/src/post/post.service.ts index 1be2c55..ae6a552 100644 --- a/src/post/post.service.ts +++ b/src/post/post.service.ts @@ -4,6 +4,7 @@ import { Repository } from 'typeorm'; import { Post } from './entities'; import { CreatePostDto, EditPostDto } from './dtos'; +import { User } from 'src/user/entities'; @Injectable() export class PostService { @@ -16,27 +17,26 @@ export class PostService { return await this.postRepository.find(); } - async getById(id: number) { - const post = await this.postRepository.findOne(id); - if (!post) throw new NotFoundException('Post does not exist'); + async getById(id: number, author?: User) { + const post = await this.postRepository.findOne(id) + .then(p => !author ? p : !!p && author.id === p.author.id ? p : null) + if (!post) throw new NotFoundException('Post does not exist or unauthorized'); return post; } - async createOne(dto: CreatePostDto) { - const post = this.postRepository.create(dto); + async createOne(dto: CreatePostDto, author: User) { + const post = this.postRepository.create({...dto, author}); return await this.postRepository.save(post); } - async editOne(id: number, dto: EditPostDto) { - const post = await this.postRepository.findOne(id); - - if (!post) throw new NotFoundException('Post does not exist'); - + async editOne(id: number, dto: EditPostDto, author?: User) { + const post = await this.getById(id, author); const editedPost = Object.assign(post, dto); return await this.postRepository.save(editedPost); } - async deleteOne(id: number) { - return await this.postRepository.delete(id); + async deleteOne(id: number, author?: User) { + const post = await this.getById(id, author); + return await this.postRepository.remove(post); } } diff --git a/src/user/dtos/create-user.dto.ts b/src/user/dtos/create-user.dto.ts index 48f8c6f..ed2960c 100644 --- a/src/user/dtos/create-user.dto.ts +++ b/src/user/dtos/create-user.dto.ts @@ -1,4 +1,6 @@ -import { IsString, IsEmail, MinLength, MaxLength, IsOptional } from "class-validator"; +import { IsString, IsEmail, MinLength, MaxLength, IsOptional, IsArray, IsEnum } from "class-validator"; +import { AppRoles } from "src/app.roles"; +import { EnumToString } from "src/common/helpers/enumToString"; export class CreateUserDto { @IsOptional() @@ -19,4 +21,11 @@ export class CreateUserDto { @MaxLength(128) password: string; + @IsArray() + @IsEnum(AppRoles, { + each: true, + message: `must be a valid role value, ${ EnumToString(AppRoles)}` + }) + roles: string[]; + } diff --git a/src/user/dtos/index.ts b/src/user/dtos/index.ts index 16005df..ed7b214 100644 --- a/src/user/dtos/index.ts +++ b/src/user/dtos/index.ts @@ -1,2 +1,3 @@ export * from './create-user.dto'; -export * from './edit-user.dto'; \ No newline at end of file +export * from './edit-user.dto'; +export * from './user-registration.dto'; \ No newline at end of file diff --git a/src/user/dtos/user-registration.dto.ts b/src/user/dtos/user-registration.dto.ts new file mode 100644 index 0000000..4a3c5fe --- /dev/null +++ b/src/user/dtos/user-registration.dto.ts @@ -0,0 +1,4 @@ +import { OmitType } from "@nestjs/mapped-types"; +import { CreateUserDto } from "./create-user.dto"; + +export class UserRegistrationDto extends OmitType(CreateUserDto, ['roles'] as const) {} diff --git a/src/user/entities/user.entity.ts b/src/user/entities/user.entity.ts index 1f6d3c7..f98c5c9 100644 --- a/src/user/entities/user.entity.ts +++ b/src/user/entities/user.entity.ts @@ -5,8 +5,10 @@ import { BeforeInsert, BeforeUpdate, Entity, + OneToOne, } from 'typeorm'; import { hash } from 'bcryptjs'; +import { Post } from 'src/post/entities'; @Entity('users') export class User { @@ -24,6 +26,9 @@ export class User { @Column({ type: 'varchar', length: 128, nullable: false, select: false }) password: string; + + @Column({ type: 'simple-array' }) + roles: string[]; @Column({ type: 'bool', default: true }) status: boolean; @@ -39,4 +44,7 @@ export class User { } this.password = await hash(this.password, 10); } + + @OneToOne(_ => Post, post => post.author, { cascade: true } ) + posts: Post; } diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 95be0dd..f55ed39 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -1,12 +1,20 @@ import { Controller, Get, Post, Put, Delete, Param, Body } from '@nestjs/common'; import { UserService } from './user.service'; -import { CreateUserDto, EditUserDto } from './dtos'; +import { CreateUserDto, EditUserDto, UserRegistrationDto } from './dtos'; +import { Auth, User } from 'src/common/decorators'; +import { ApiTags } from '@nestjs/swagger'; +import { RolesBuilder, InjectRolesBuilder } from 'nest-access-control'; +import { AppResource, AppRoles } from 'src/app.roles'; +import { User as UserEntity } from './entities'; +@ApiTags('Users routes') @Controller('user') export class UserController { constructor( - private readonly userService: UserService + private readonly userService: UserService, + @InjectRolesBuilder() + private readonly rolesBuilder: RolesBuilder ) {} @Get() @@ -15,6 +23,16 @@ export class UserController { return { data } } + @Post('register') + async publicRegistration( + @Body() dto: UserRegistrationDto + ) { + const data = await this.userService.createOne({ + ...dto, roles: [AppRoles.AUTHOR] + }); + return { message: 'User registered', data } + } + @Get(':id') async getOne( @Param('id') id: number, @@ -23,6 +41,11 @@ export class UserController { return { data } } + @Auth({ + possession: 'any', + action: 'create', + resource: AppResource.USER + }) @Post() async createOne( @Body() dto: CreateUserDto @@ -31,20 +54,59 @@ export class UserController { return { message: 'User created', data } } + @Auth({ + possession: 'own', + action: 'update', + resource: AppResource.USER + }) @Put(':id') async editOne( @Param('id') id: number, - @Body() dto: EditUserDto + @Body() dto: EditUserDto, + @User() user: UserEntity ) { - const data = await this.userService.editOne(id, dto) + + let data; + + if(this.rolesBuilder + .can(user.roles) + .updateAny(AppResource.USER) + .granted + ) { + // esto es un admin + data = await this.userService.editOne(id, dto) + } else { + // esto es un author + const { roles, ...rest } = dto; + data = await this.userService.editOne(id, rest, user) + } return { message: 'User edited', data } } + @Auth({ + action: 'delete', + possession: 'own', + resource: AppResource.USER + }) @Delete(':id') async deleteOne( @Param('id') id: number, + @User() user: UserEntity ) { - const data = await this.userService.deleteOne(id) + + let data; + + if(this.rolesBuilder + .can(user.roles) + .updateAny(AppResource.USER) + .granted + ) { + // esto es un admin + data = await this.userService.deleteOne(id) + } else { + // esto es un author + data = await this.userService.deleteOne(id, user) + } return { message: 'User deleted', data } } diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 053497d..5b66b20 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -21,9 +21,11 @@ export class UserService { return await this.userRepository.find() } - async getOne(id: number) { - const user = await this.userRepository.findOne(id); - if (!user) throw new NotFoundException('User does not exists') + async getOne(id: number, userEntity?: User) { + const user = await this.userRepository.findOne(id) + .then(u => !userEntity ? u : !!u && userEntity.id === u.id ? u : null) + + if (!user) throw new NotFoundException('User does not exists or unauthorized') return user; } @@ -39,14 +41,15 @@ export class UserService { return user; } - async editOne(id: number, dto: EditUserDto) { - const user = await this.getOne(id) + async editOne(id: number, dto: EditUserDto, userEntity?: User) { + console.log(dto); + const user = await this.getOne(id, userEntity) const editedUser = Object.assign(user, dto); return await this.userRepository.save(editedUser); } - async deleteOne(id: number) { - const user = await this.getOne(id); + async deleteOne(id: number, userEntity?: User) { + const user = await this.getOne(id, userEntity); return await this.userRepository.remove(user); } diff --git a/yarn.lock b/yarn.lock index be0b7fc..777477e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1168,6 +1168,13 @@ accepts@~1.3.7: mime-types "~2.1.24" negotiator "0.6.2" +accesscontrol@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/accesscontrol/-/accesscontrol-2.2.1.tgz#c942c48e330841c619682309a8c3aeec6bec66eb" + integrity sha512-52EvFk/J9EF+w4mYQoKnOTkEMj01R1U5n2fc1dai6x1xkgOks3DGkx01qQL2cKFxGmE4Tn1krAU3jJA9L1NMkg== + dependencies: + notation "^1.3.6" + acorn-globals@^4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/acorn-globals/-/acorn-globals-4.3.4.tgz#9fa1926addc11c97308c4e66d7add0d40c3272e7" @@ -5072,6 +5079,13 @@ neo-async@^2.5.0, neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +nest-access-control@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/nest-access-control/-/nest-access-control-2.0.2.tgz#6006204352306e33f576bbce749379b3043b5df8" + integrity sha512-PBDygolc0ThNdCDAP/0ipp21itGDO3yxbAMh/egb1k+bRPxl7/Iuz65jnTKwNhmEBRMupkaX+zY7vxTAcsbsuQ== + dependencies: + accesscontrol "^2.2.1" + next-tick@1: version "1.1.0" resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" @@ -5171,6 +5185,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +notation@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/notation/-/notation-1.3.6.tgz#bc87b88d1f1159e2931e7f9317a3020313790321" + integrity sha512-DIuJmrP/Gg1DcXKaApsqcjsJD6jEccqKSfmU3BUx/f1GHsMiTJh70cERwYc64tOmTRTARCeMwkqNNzjh3AHhiw== + npm-run-path@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f"