From f326a40947b65596d713003b7c73b7943aae0d1f Mon Sep 17 00:00:00 2001 From: "amplication[bot]" Date: Sun, 31 Mar 2024 14:36:51 +0000 Subject: [PATCH 1/2] Amplication build # clufmjno5002x11tfjfdctr2x Build URL: [https://app.amplication-sandbox.com/clu1d2moq00aku4lyym0tti5j/clu1d2n1i00anu4lyzcm4vndj/clu1d9g9j00atu4lyz9ej60pb/builds/clufmjno5002x11tfjfdctr2x](https://app.amplication-sandbox.com/clu1d2moq00aku4lyym0tti5j/clu1d2n1i00anu4lyzcm4vndj/clu1d9g9j00atu4lyz9ej60pb/builds/clufmjno5002x11tfjfdctr2x) --- apps/todo-tasks/.env | 2 + apps/todo-tasks/docker-compose.yml | 2 + apps/todo-tasks/nest-cli.json | 6 +- apps/todo-tasks/package.json | 5 +- apps/todo-tasks/scripts/customSeed.ts | 10 ++ apps/todo-tasks/src/app.module.ts | 5 + apps/todo-tasks/src/auth/Credentials.ts | 21 ++++ apps/todo-tasks/src/auth/IAuthStrategy.ts | 6 + apps/todo-tasks/src/auth/ITokenService.ts | 9 ++ apps/todo-tasks/src/auth/LoginArgs.ts | 12 ++ apps/todo-tasks/src/auth/UserInfo.ts | 14 +++ apps/todo-tasks/src/auth/abac.util.ts | 19 +++ apps/todo-tasks/src/auth/acl.module.ts | 6 + apps/todo-tasks/src/auth/auth.controller.ts | 15 +++ apps/todo-tasks/src/auth/auth.module.ts | 57 +++++++++ apps/todo-tasks/src/auth/auth.resolver.ts | 23 ++++ apps/todo-tasks/src/auth/auth.service.spec.ts | 114 ++++++++++++++++++ apps/todo-tasks/src/auth/auth.service.ts | 49 ++++++++ .../src/auth/base/token.service.base.ts | 25 ++++ apps/todo-tasks/src/auth/constants.ts | 2 + apps/todo-tasks/src/auth/defaultAuth.guard.ts | 27 +++++ apps/todo-tasks/src/auth/gqlAC.guard.ts | 11 ++ .../src/auth/gqlDefaultAuth.guard.ts | 12 ++ .../src/auth/gqlUserRoles.decorator.ts | 19 +++ .../src/auth/jwt/base/jwt.strategy.base.ts | 40 ++++++ apps/todo-tasks/src/auth/jwt/jwt.strategy.ts | 14 +++ apps/todo-tasks/src/auth/jwt/jwtAuth.guard.ts | 3 + .../src/auth/jwt/jwtSecretFactory.ts | 19 +++ .../src/auth/password.service.spec.ts | 69 +++++++++++ apps/todo-tasks/src/auth/password.service.ts | 64 ++++++++++ apps/todo-tasks/src/auth/token.service.ts | 5 + .../todo-tasks/src/auth/userData.decorator.ts | 30 +++++ .../comment/base/comment.controller.base.ts | 55 ++++++++- .../src/comment/base/comment.module.base.ts | 6 +- .../src/comment/base/comment.service.base.ts | 6 +- .../src/comment/comment.controller.ts | 9 +- apps/todo-tasks/src/comment/comment.module.ts | 5 +- apps/todo-tasks/src/constants.ts | 2 + apps/todo-tasks/src/grants.json | 92 ++++++++++++++ .../aclFilterResponse.interceptor.ts | 42 +++++++ .../aclValidateRequest.interceptor.ts | 53 ++++++++ .../providers/secrets/secretsNameKey.enum.ts | 4 +- apps/todo-tasks/src/task/Fff.ts | 21 ++++ .../src/task/base/task.controller.base.ts | 76 +++++++++++- .../src/task/base/task.module.base.ts | 6 +- .../src/task/base/task.service.base.ts | 6 +- apps/todo-tasks/src/task/task.controller.ts | 9 +- apps/todo-tasks/src/task/task.module.ts | 5 +- apps/todo-tasks/src/tests/auth/constants.ts | 19 +++ .../src/tests/auth/jwt/jwt.strategy.spec.ts | 28 +++++ .../src/tests/auth/token.service.spec.ts | 47 ++++++++ .../src/user/base/user.controller.base.ts | 76 +++++++++++- .../src/user/base/user.module.base.ts | 6 +- .../src/user/base/user.service.base.ts | 37 ++++-- apps/todo-tasks/src/user/user.controller.ts | 9 +- apps/todo-tasks/src/user/user.module.ts | 5 +- apps/todo-tasks/src/user/user.service.ts | 8 +- 57 files changed, 1302 insertions(+), 45 deletions(-) create mode 100644 apps/todo-tasks/src/auth/Credentials.ts create mode 100644 apps/todo-tasks/src/auth/IAuthStrategy.ts create mode 100644 apps/todo-tasks/src/auth/ITokenService.ts create mode 100644 apps/todo-tasks/src/auth/LoginArgs.ts create mode 100644 apps/todo-tasks/src/auth/UserInfo.ts create mode 100644 apps/todo-tasks/src/auth/abac.util.ts create mode 100644 apps/todo-tasks/src/auth/acl.module.ts create mode 100644 apps/todo-tasks/src/auth/auth.controller.ts create mode 100644 apps/todo-tasks/src/auth/auth.module.ts create mode 100644 apps/todo-tasks/src/auth/auth.resolver.ts create mode 100644 apps/todo-tasks/src/auth/auth.service.spec.ts create mode 100644 apps/todo-tasks/src/auth/auth.service.ts create mode 100644 apps/todo-tasks/src/auth/base/token.service.base.ts create mode 100644 apps/todo-tasks/src/auth/constants.ts create mode 100644 apps/todo-tasks/src/auth/defaultAuth.guard.ts create mode 100644 apps/todo-tasks/src/auth/gqlAC.guard.ts create mode 100644 apps/todo-tasks/src/auth/gqlDefaultAuth.guard.ts create mode 100644 apps/todo-tasks/src/auth/gqlUserRoles.decorator.ts create mode 100644 apps/todo-tasks/src/auth/jwt/base/jwt.strategy.base.ts create mode 100644 apps/todo-tasks/src/auth/jwt/jwt.strategy.ts create mode 100644 apps/todo-tasks/src/auth/jwt/jwtAuth.guard.ts create mode 100644 apps/todo-tasks/src/auth/jwt/jwtSecretFactory.ts create mode 100644 apps/todo-tasks/src/auth/password.service.spec.ts create mode 100644 apps/todo-tasks/src/auth/password.service.ts create mode 100644 apps/todo-tasks/src/auth/token.service.ts create mode 100644 apps/todo-tasks/src/auth/userData.decorator.ts create mode 100644 apps/todo-tasks/src/constants.ts create mode 100644 apps/todo-tasks/src/grants.json create mode 100644 apps/todo-tasks/src/interceptors/aclFilterResponse.interceptor.ts create mode 100644 apps/todo-tasks/src/interceptors/aclValidateRequest.interceptor.ts create mode 100644 apps/todo-tasks/src/task/Fff.ts create mode 100644 apps/todo-tasks/src/tests/auth/constants.ts create mode 100644 apps/todo-tasks/src/tests/auth/jwt/jwt.strategy.spec.ts create mode 100644 apps/todo-tasks/src/tests/auth/token.service.spec.ts diff --git a/apps/todo-tasks/.env b/apps/todo-tasks/.env index b78da35..85aea5f 100644 --- a/apps/todo-tasks/.env +++ b/apps/todo-tasks/.env @@ -5,4 +5,6 @@ DB_PASSWORD=admin DB_PORT=5432 DB_URL=postgres://admin:admin@localhost:5432/my-db DB_USER=admin +JWT_EXPIRATION=2d +JWT_SECRET_KEY=Change_ME!!! PORT=3000 \ No newline at end of file diff --git a/apps/todo-tasks/docker-compose.yml b/apps/todo-tasks/docker-compose.yml index ef3a7a5..2a466f3 100644 --- a/apps/todo-tasks/docker-compose.yml +++ b/apps/todo-tasks/docker-compose.yml @@ -9,6 +9,8 @@ services: - ${PORT}:3000 environment: BCRYPT_SALT: ${BCRYPT_SALT} + JWT_SECRET_KEY: ${JWT_SECRET_KEY} + JWT_EXPIRATION: ${JWT_EXPIRATION} DB_URL: postgres://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME} depends_on: - migrate diff --git a/apps/todo-tasks/nest-cli.json b/apps/todo-tasks/nest-cli.json index fe51713..b7b60ae 100644 --- a/apps/todo-tasks/nest-cli.json +++ b/apps/todo-tasks/nest-cli.json @@ -1,6 +1,10 @@ { "sourceRoot": "src", "compilerOptions": { - "assets": ["swagger"] + "assets": [ + { + "include": "swagger/**/*" + } + ] } } diff --git a/apps/todo-tasks/package.json b/apps/todo-tasks/package.json index f56aa4b..214c89d 100644 --- a/apps/todo-tasks/package.json +++ b/apps/todo-tasks/package.json @@ -38,12 +38,13 @@ "dotenv": "16.3.1", "graphql": "^16.8.1", "graphql-type-json": "0.3.2", + "nest-access-control": "^3.1.0", "npm-run-all": "4.1.5", "passport": "0.6.0", "passport-http": "0.3.0", "passport-jwt": "4.0.1", "reflect-metadata": "0.1.13", - "ts-node": "10.9.1", + "ts-node": "10.9.2", "type-fest": "2.19.0", "validator": "13.11.0" }, @@ -63,7 +64,7 @@ "prisma": "^5.4.2", "supertest": "^6.3.3", "ts-jest": "^29.1.1", - "typescript": "~5.3.0" + "typescript": "^5.4.3" }, "jest": { "preset": "ts-jest", diff --git a/apps/todo-tasks/scripts/customSeed.ts b/apps/todo-tasks/scripts/customSeed.ts index 26ccaf4..6baf5f6 100644 --- a/apps/todo-tasks/scripts/customSeed.ts +++ b/apps/todo-tasks/scripts/customSeed.ts @@ -2,6 +2,16 @@ import { PrismaClient } from "@prisma/client"; export async function customSeed() { const client = new PrismaClient(); + const username = "admin"; + + //replace this sample code to populate your database + //with data that is required for your service to start + await client.user.update({ + where: { username: username }, + data: { + username, + }, + }); client.$disconnect(); } diff --git a/apps/todo-tasks/src/app.module.ts b/apps/todo-tasks/src/app.module.ts index 17002e4..610ea10 100644 --- a/apps/todo-tasks/src/app.module.ts +++ b/apps/todo-tasks/src/app.module.ts @@ -9,9 +9,14 @@ import { ServeStaticModule } from "@nestjs/serve-static"; import { ServeStaticOptionsService } from "./serveStaticOptions.service"; import { ConfigModule } from "@nestjs/config"; +import { ACLModule } from "./auth/acl.module"; +import { AuthModule } from "./auth/auth.module"; + @Module({ controllers: [], imports: [ + ACLModule, + AuthModule, TaskModule, UserModule, CommentModule, diff --git a/apps/todo-tasks/src/auth/Credentials.ts b/apps/todo-tasks/src/auth/Credentials.ts new file mode 100644 index 0000000..9ac6798 --- /dev/null +++ b/apps/todo-tasks/src/auth/Credentials.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { InputType, Field } from "@nestjs/graphql"; +import { IsString } from "class-validator"; + +@InputType() +export class Credentials { + @ApiProperty({ + required: true, + type: String, + }) + @IsString() + @Field(() => String, { nullable: false }) + username!: string; + @ApiProperty({ + required: true, + type: String, + }) + @IsString() + @Field(() => String, { nullable: false }) + password!: string; +} diff --git a/apps/todo-tasks/src/auth/IAuthStrategy.ts b/apps/todo-tasks/src/auth/IAuthStrategy.ts new file mode 100644 index 0000000..7406267 --- /dev/null +++ b/apps/todo-tasks/src/auth/IAuthStrategy.ts @@ -0,0 +1,6 @@ +import { UserInfo } from "./UserInfo"; + +export interface IAuthStrategy { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + validate: (...any: any) => Promise; +} diff --git a/apps/todo-tasks/src/auth/ITokenService.ts b/apps/todo-tasks/src/auth/ITokenService.ts new file mode 100644 index 0000000..7983189 --- /dev/null +++ b/apps/todo-tasks/src/auth/ITokenService.ts @@ -0,0 +1,9 @@ +export interface ITokenPayload { + id: string; + username: string; + password: string; +} + +export interface ITokenService { + createToken: ({ id, username, password }: ITokenPayload) => Promise; +} diff --git a/apps/todo-tasks/src/auth/LoginArgs.ts b/apps/todo-tasks/src/auth/LoginArgs.ts new file mode 100644 index 0000000..66a0b2f --- /dev/null +++ b/apps/todo-tasks/src/auth/LoginArgs.ts @@ -0,0 +1,12 @@ +import { ArgsType, Field } from "@nestjs/graphql"; +import { ValidateNested } from "class-validator"; +import { Type } from "class-transformer"; +import { Credentials } from "./Credentials"; + +@ArgsType() +export class LoginArgs { + @Field(() => Credentials, { nullable: false }) + @Type(() => Credentials) + @ValidateNested() + credentials!: Credentials; +} diff --git a/apps/todo-tasks/src/auth/UserInfo.ts b/apps/todo-tasks/src/auth/UserInfo.ts new file mode 100644 index 0000000..ef61dc4 --- /dev/null +++ b/apps/todo-tasks/src/auth/UserInfo.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from "@nestjs/graphql"; +import { User } from "../user/base/User"; + +@ObjectType() +export class UserInfo implements Partial { + @Field(() => String) + id!: string; + @Field(() => String) + username!: string; + @Field(() => [String]) + roles!: string[]; + @Field(() => String, { nullable: true }) + accessToken?: string; +} diff --git a/apps/todo-tasks/src/auth/abac.util.ts b/apps/todo-tasks/src/auth/abac.util.ts new file mode 100644 index 0000000..2f0dcab --- /dev/null +++ b/apps/todo-tasks/src/auth/abac.util.ts @@ -0,0 +1,19 @@ +import { Permission } from "accesscontrol"; + +/** + * @returns attributes not allowed to appear on given data according to given + * attributeMatchers + */ +export function getInvalidAttributes( + permission: Permission, + // eslint-disable-next-line @typescript-eslint/ban-types + data: Object +): string[] { + // The structuredClone call is necessary because the + // `Permission.filter` function doesn't consider objects + // with null prototypes. And in graphql requests, the + // object passed here by the request interceptor is an object + // with a null prototype. + const filteredData = permission.filter(structuredClone(data)); + return Object.keys(data).filter((key) => !(key in filteredData)); +} diff --git a/apps/todo-tasks/src/auth/acl.module.ts b/apps/todo-tasks/src/auth/acl.module.ts new file mode 100644 index 0000000..040e7e5 --- /dev/null +++ b/apps/todo-tasks/src/auth/acl.module.ts @@ -0,0 +1,6 @@ +import { AccessControlModule, RolesBuilder } from "nest-access-control"; + +import grants from "../grants.json"; + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const ACLModule = AccessControlModule.forRoles(new RolesBuilder(grants)); diff --git a/apps/todo-tasks/src/auth/auth.controller.ts b/apps/todo-tasks/src/auth/auth.controller.ts new file mode 100644 index 0000000..7ae732f --- /dev/null +++ b/apps/todo-tasks/src/auth/auth.controller.ts @@ -0,0 +1,15 @@ +import { Body, Controller, Post } from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { AuthService } from "./auth.service"; +import { Credentials } from "../auth/Credentials"; +import { UserInfo } from "./UserInfo"; + +@ApiTags("auth") +@Controller() +export class AuthController { + constructor(private readonly authService: AuthService) {} + @Post("login") + async login(@Body() body: Credentials): Promise { + return this.authService.login(body); + } +} diff --git a/apps/todo-tasks/src/auth/auth.module.ts b/apps/todo-tasks/src/auth/auth.module.ts new file mode 100644 index 0000000..d69fd95 --- /dev/null +++ b/apps/todo-tasks/src/auth/auth.module.ts @@ -0,0 +1,57 @@ +import { forwardRef, Module } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { JWT_EXPIRATION } from "../constants"; +import { SecretsManagerModule } from "../providers/secrets/secretsManager.module"; +import { SecretsManagerService } from "../providers/secrets/secretsManager.service"; +import { EnumSecretsNameKey } from "../providers/secrets/secretsNameKey.enum"; +import { AuthController } from "./auth.controller"; +import { AuthResolver } from "./auth.resolver"; +import { AuthService } from "./auth.service"; +import { JwtStrategy } from "./jwt/jwt.strategy"; +import { jwtSecretFactory } from "./jwt/jwtSecretFactory"; +import { PasswordService } from "./password.service"; +import { TokenService } from "./token.service"; +import { UserModule } from "../user/user.module"; +@Module({ + imports: [ + forwardRef(() => UserModule), + PassportModule, + SecretsManagerModule, + JwtModule.registerAsync({ + imports: [SecretsManagerModule], + inject: [SecretsManagerService, ConfigService], + useFactory: async ( + secretsService: SecretsManagerService, + configService: ConfigService + ) => { + const secret = await secretsService.getSecret( + EnumSecretsNameKey.JwtSecretKey + ); + const expiresIn = configService.get(JWT_EXPIRATION); + if (!secret) { + throw new Error("Didn't get a valid jwt secret"); + } + if (!expiresIn) { + throw new Error("Jwt expire in value is not valid"); + } + return { + secret: secret, + signOptions: { expiresIn }, + }; + }, + }), + ], + providers: [ + AuthService, + PasswordService, + AuthResolver, + JwtStrategy, + jwtSecretFactory, + TokenService, + ], + controllers: [AuthController], + exports: [AuthService, PasswordService], +}) +export class AuthModule {} diff --git a/apps/todo-tasks/src/auth/auth.resolver.ts b/apps/todo-tasks/src/auth/auth.resolver.ts new file mode 100644 index 0000000..c186f41 --- /dev/null +++ b/apps/todo-tasks/src/auth/auth.resolver.ts @@ -0,0 +1,23 @@ +import * as common from "@nestjs/common"; +import { Args, Mutation, Query, Resolver } from "@nestjs/graphql"; +import * as gqlACGuard from "../auth/gqlAC.guard"; +import { AuthService } from "./auth.service"; +import { GqlDefaultAuthGuard } from "./gqlDefaultAuth.guard"; +import { UserData } from "./userData.decorator"; +import { LoginArgs } from "./LoginArgs"; +import { UserInfo } from "./UserInfo"; + +@Resolver(UserInfo) +export class AuthResolver { + constructor(private readonly authService: AuthService) {} + @Mutation(() => UserInfo) + async login(@Args() args: LoginArgs): Promise { + return this.authService.login(args.credentials); + } + + @Query(() => UserInfo) + @common.UseGuards(GqlDefaultAuthGuard, gqlACGuard.GqlACGuard) + async userInfo(@UserData() entityInfo: UserInfo): Promise { + return entityInfo; + } +} diff --git a/apps/todo-tasks/src/auth/auth.service.spec.ts b/apps/todo-tasks/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..90b04f4 --- /dev/null +++ b/apps/todo-tasks/src/auth/auth.service.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { AuthService } from "./auth.service"; +import { Credentials } from "./Credentials"; +import { PasswordService } from "./password.service"; +import { TokenService } from "./token.service"; +import { VALID_ID } from "../tests/auth/constants"; +import { UserService } from "../user/user.service"; + +const VALID_CREDENTIALS: Credentials = { + username: "Valid User", + password: "Valid User Password", +}; +const INVALID_CREDENTIALS: Credentials = { + username: "Invalid User", + password: "Invalid User Password", +}; +const USER: any = { + ...VALID_CREDENTIALS, + createdAt: new Date(), + firstName: "ofek", + id: VALID_ID, + lastName: "gabay", + roles: ["admin"], + updatedAt: new Date(), +}; + +const SIGN_TOKEN = "SIGN_TOKEN"; + +const authEntityService = { + user(args: { where: { username: string } }): any | null { + if (args.where.username === VALID_CREDENTIALS.username) { + return USER; + } + return null; + }, +}; + +const passwordService = { + compare(password: string, encrypted: string) { + return true; + }, +}; + +const tokenService = { + createToken(username: string, password: string) { + return SIGN_TOKEN; + }, +}; + +describe("AuthService", () => { + //ARRANGE + let service: AuthService; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: UserService, + useValue: authEntityService, + }, + { + provide: PasswordService, + useValue: passwordService, + }, + { + provide: TokenService, + useValue: tokenService, + }, + AuthService, + ], + }).compile(); + + service = module.get(AuthService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("Testing the authService.validateUser()", () => { + it("should validate a valid user", async () => { + await expect( + service.validateUser( + VALID_CREDENTIALS.username, + VALID_CREDENTIALS.password + ) + ).resolves.toEqual({ + username: USER.username, + roles: USER.roles, + id: USER.id, + }); + }); + + it("should not validate a invalid user", async () => { + await expect( + service.validateUser( + INVALID_CREDENTIALS.username, + INVALID_CREDENTIALS.password + ) + ).resolves.toBe(null); + }); + }); + + describe("Testing the authService.login()", () => { + it("should return userInfo object for correct username and password", async () => { + const loginResult = await service.login(VALID_CREDENTIALS); + expect(loginResult).toEqual({ + username: USER.username, + roles: USER.roles, + accessToken: SIGN_TOKEN, + id: USER.id, + }); + }); + }); +}); diff --git a/apps/todo-tasks/src/auth/auth.service.ts b/apps/todo-tasks/src/auth/auth.service.ts new file mode 100644 index 0000000..9f6add2 --- /dev/null +++ b/apps/todo-tasks/src/auth/auth.service.ts @@ -0,0 +1,49 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { Credentials } from "./Credentials"; +import { PasswordService } from "./password.service"; +import { TokenService } from "./token.service"; +import { UserInfo } from "./UserInfo"; +import { UserService } from "../user/user.service"; + +@Injectable() +export class AuthService { + constructor( + private readonly passwordService: PasswordService, + private readonly tokenService: TokenService, + private readonly userService: UserService + ) {} + + async validateUser( + username: string, + password: string + ): Promise { + const user = await this.userService.user({ + where: { username }, + }); + if (user && (await this.passwordService.compare(password, user.password))) { + const { id, roles } = user; + const roleList = roles as string[]; + return { id, username, roles: roleList }; + } + return null; + } + async login(credentials: Credentials): Promise { + const { username, password } = credentials; + const user = await this.validateUser( + credentials.username, + credentials.password + ); + if (!user) { + throw new UnauthorizedException("The passed credentials are incorrect"); + } + const accessToken = await this.tokenService.createToken({ + id: user.id, + username, + password, + }); + return { + accessToken, + ...user, + }; + } +} diff --git a/apps/todo-tasks/src/auth/base/token.service.base.ts b/apps/todo-tasks/src/auth/base/token.service.base.ts new file mode 100644 index 0000000..f25fdb5 --- /dev/null +++ b/apps/todo-tasks/src/auth/base/token.service.base.ts @@ -0,0 +1,25 @@ +/* eslint-disable import/no-unresolved */ +import { Injectable } from "@nestjs/common"; +import { JwtService } from "@nestjs/jwt"; +import { INVALID_PASSWORD_ERROR, INVALID_USERNAME_ERROR } from "../constants"; +import { ITokenService, ITokenPayload } from "../ITokenService"; +/** + * TokenServiceBase is a jwt bearer implementation of ITokenService + */ +@Injectable() +export class TokenServiceBase implements ITokenService { + constructor(protected readonly jwtService: JwtService) {} + /** + * + * @object { id: String, username: String, password: String} + * @returns a jwt token sign with the username and user id + */ + createToken({ id, username, password }: ITokenPayload): Promise { + if (!username) return Promise.reject(INVALID_USERNAME_ERROR); + if (!password) return Promise.reject(INVALID_PASSWORD_ERROR); + return this.jwtService.signAsync({ + sub: id, + username, + }); + } +} diff --git a/apps/todo-tasks/src/auth/constants.ts b/apps/todo-tasks/src/auth/constants.ts new file mode 100644 index 0000000..59f9f7d --- /dev/null +++ b/apps/todo-tasks/src/auth/constants.ts @@ -0,0 +1,2 @@ +export const INVALID_USERNAME_ERROR = "Invalid username"; +export const INVALID_PASSWORD_ERROR = "Invalid password"; diff --git a/apps/todo-tasks/src/auth/defaultAuth.guard.ts b/apps/todo-tasks/src/auth/defaultAuth.guard.ts new file mode 100644 index 0000000..33a530c --- /dev/null +++ b/apps/todo-tasks/src/auth/defaultAuth.guard.ts @@ -0,0 +1,27 @@ +import { Observable } from "rxjs"; +import { ExecutionContext, Injectable } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { IS_PUBLIC_KEY } from "../decorators/public.decorator"; +import { JwtAuthGuard } from "./jwt/jwtAuth.guard"; + +@Injectable() +export class DefaultAuthGuard extends JwtAuthGuard { + constructor(private readonly reflector: Reflector) { + super(); + } + + canActivate( + context: ExecutionContext + ): boolean | Promise | Observable { + const isPublic = this.reflector.get( + IS_PUBLIC_KEY, + context.getHandler() + ); + + if (isPublic) { + return true; + } + + return super.canActivate(context); + } +} diff --git a/apps/todo-tasks/src/auth/gqlAC.guard.ts b/apps/todo-tasks/src/auth/gqlAC.guard.ts new file mode 100644 index 0000000..dacac55 --- /dev/null +++ b/apps/todo-tasks/src/auth/gqlAC.guard.ts @@ -0,0 +1,11 @@ +import { ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import { ACGuard } from "nest-access-control"; + +export class GqlACGuard extends ACGuard { + async getUser(context: ExecutionContext): Promise { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext<{ req: { user: User } }>().req; + return request.user; + } +} diff --git a/apps/todo-tasks/src/auth/gqlDefaultAuth.guard.ts b/apps/todo-tasks/src/auth/gqlDefaultAuth.guard.ts new file mode 100644 index 0000000..abebd93 --- /dev/null +++ b/apps/todo-tasks/src/auth/gqlDefaultAuth.guard.ts @@ -0,0 +1,12 @@ +import { ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; +import type { Request } from "express"; +import { DefaultAuthGuard } from "./defaultAuth.guard"; + +export class GqlDefaultAuthGuard extends DefaultAuthGuard { + // This method is required for the interface - do not delete it. + getRequest(context: ExecutionContext): Request { + const ctx = GqlExecutionContext.create(context); + return ctx.getContext<{ req: Request }>().req; + } +} diff --git a/apps/todo-tasks/src/auth/gqlUserRoles.decorator.ts b/apps/todo-tasks/src/auth/gqlUserRoles.decorator.ts new file mode 100644 index 0000000..5ea256b --- /dev/null +++ b/apps/todo-tasks/src/auth/gqlUserRoles.decorator.ts @@ -0,0 +1,19 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { GqlExecutionContext } from "@nestjs/graphql"; + +/** + * Access the user roles from the request object i.e `req.user.roles`. + * + * You can pass an optional property key to the decorator to get it from the user object + * e.g `@UserRoles('permissions')` will return the `req.user.permissions` instead. + */ +export const UserRoles = createParamDecorator( + (data: string, context: ExecutionContext) => { + const ctx = GqlExecutionContext.create(context); + const request = ctx.getContext<{ req: { user: any } }>().req; + if (!request.user) { + return null; + } + return data ? request.user[data] : request.user.roles; + } +); diff --git a/apps/todo-tasks/src/auth/jwt/base/jwt.strategy.base.ts b/apps/todo-tasks/src/auth/jwt/base/jwt.strategy.base.ts new file mode 100644 index 0000000..26da6a7 --- /dev/null +++ b/apps/todo-tasks/src/auth/jwt/base/jwt.strategy.base.ts @@ -0,0 +1,40 @@ +import { UnauthorizedException } from "@nestjs/common"; +import { PassportStrategy } from "@nestjs/passport"; +import { ExtractJwt, Strategy } from "passport-jwt"; +import { IAuthStrategy } from "../../IAuthStrategy"; +import { UserInfo } from "../../UserInfo"; +import { UserService } from "../../../user/user.service"; + +export class JwtStrategyBase + extends PassportStrategy(Strategy) + implements IAuthStrategy +{ + constructor( + protected readonly secretOrKey: string, + protected readonly userService: UserService + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey, + }); + } + + async validate(payload: UserInfo): Promise { + const { username } = payload; + const user = await this.userService.user({ + where: { username }, + }); + if (!user) { + throw new UnauthorizedException(); + } + if ( + !Array.isArray(user.roles) || + typeof user.roles !== "object" || + user.roles === null + ) { + throw new Error("User roles is not a valid value"); + } + return { ...user, roles: user.roles as string[] }; + } +} diff --git a/apps/todo-tasks/src/auth/jwt/jwt.strategy.ts b/apps/todo-tasks/src/auth/jwt/jwt.strategy.ts new file mode 100644 index 0000000..13395e5 --- /dev/null +++ b/apps/todo-tasks/src/auth/jwt/jwt.strategy.ts @@ -0,0 +1,14 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { JWT_SECRET_KEY_PROVIDER_NAME } from "../../constants"; +import { JwtStrategyBase } from "./base/jwt.strategy.base"; +import { UserService } from "../../user/user.service"; + +@Injectable() +export class JwtStrategy extends JwtStrategyBase { + constructor( + @Inject(JWT_SECRET_KEY_PROVIDER_NAME) secretOrKey: string, + protected readonly userService: UserService + ) { + super(secretOrKey, userService); + } +} diff --git a/apps/todo-tasks/src/auth/jwt/jwtAuth.guard.ts b/apps/todo-tasks/src/auth/jwt/jwtAuth.guard.ts new file mode 100644 index 0000000..f0c5570 --- /dev/null +++ b/apps/todo-tasks/src/auth/jwt/jwtAuth.guard.ts @@ -0,0 +1,3 @@ +import { AuthGuard } from "@nestjs/passport"; + +export class JwtAuthGuard extends AuthGuard("jwt") {} diff --git a/apps/todo-tasks/src/auth/jwt/jwtSecretFactory.ts b/apps/todo-tasks/src/auth/jwt/jwtSecretFactory.ts new file mode 100644 index 0000000..8ca4d74 --- /dev/null +++ b/apps/todo-tasks/src/auth/jwt/jwtSecretFactory.ts @@ -0,0 +1,19 @@ +import { JWT_SECRET_KEY_PROVIDER_NAME } from "../../constants"; +import { SecretsManagerService } from "../../providers/secrets/secretsManager.service"; +import { EnumSecretsNameKey } from "../../providers/secrets/secretsNameKey.enum"; + +export const jwtSecretFactory = { + provide: JWT_SECRET_KEY_PROVIDER_NAME, + useFactory: async ( + secretsService: SecretsManagerService + ): Promise => { + const secret = await secretsService.getSecret( + EnumSecretsNameKey.JwtSecretKey + ); + if (secret) { + return secret; + } + throw new Error("jwtSecretFactory missing secret"); + }, + inject: [SecretsManagerService], +}; diff --git a/apps/todo-tasks/src/auth/password.service.spec.ts b/apps/todo-tasks/src/auth/password.service.spec.ts new file mode 100644 index 0000000..309c8c0 --- /dev/null +++ b/apps/todo-tasks/src/auth/password.service.spec.ts @@ -0,0 +1,69 @@ +import { ConfigService } from "@nestjs/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import { PasswordService } from "./password.service"; + +const EXAMPLE_PASSWORD = "examplePassword"; +const EXAMPLE_HASHED_PASSWORD = "exampleHashedPassword"; + +const EXAMPLE_SALT_OR_ROUNDS = 1; + +const configServiceGetMock = jest.fn(() => { + return EXAMPLE_SALT_OR_ROUNDS; +}); + +jest.mock("bcrypt", () => ({ + hash: jest.fn(), + compare: jest.fn(), +})); + +const { hash, compare } = jest.requireMock("bcrypt"); + +hash.mockImplementation(async () => EXAMPLE_HASHED_PASSWORD); + +compare.mockImplementation(async () => true); + +describe("PasswordService", () => { + let service: PasswordService; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PasswordService, + { + provide: ConfigService, + useClass: jest.fn(() => ({ + get: configServiceGetMock, + })), + }, + ], + imports: [], + }).compile(); + + service = module.get(PasswordService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + it("should have salt defined", () => { + expect(service.salt).toEqual(EXAMPLE_SALT_OR_ROUNDS); + }); + + it("should compare a password", async () => { + const args = { + password: EXAMPLE_PASSWORD, + hashedPassword: EXAMPLE_HASHED_PASSWORD, + }; + await expect( + service.compare(args.password, args.hashedPassword) + ).resolves.toEqual(true); + }); + + it("should hash a password", async () => { + await expect(service.hash(EXAMPLE_PASSWORD)).resolves.toEqual( + EXAMPLE_HASHED_PASSWORD + ); + }); +}); diff --git a/apps/todo-tasks/src/auth/password.service.ts b/apps/todo-tasks/src/auth/password.service.ts new file mode 100644 index 0000000..377b64b --- /dev/null +++ b/apps/todo-tasks/src/auth/password.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from "@nestjs/common"; +import { hash, compare } from "bcrypt"; +import { ConfigService } from "@nestjs/config"; + +/** Salt or number of rounds to generate a salt */ +export type Salt = string | number; + +const BCRYPT_SALT_VAR = "BCRYPT_SALT"; +const UNDEFINED_SALT_OR_ROUNDS_ERROR = `${BCRYPT_SALT_VAR} is not defined`; +const SALT_OR_ROUNDS_TYPE_ERROR = `${BCRYPT_SALT_VAR} must be a positive integer or text`; + +@Injectable() +export class PasswordService { + /** + * the salt to be used to hash the password. if specified as a number then a + * salt will be generated with the specified number of rounds and used + */ + salt: Salt; + + constructor(private configService: ConfigService) { + const saltOrRounds = this.configService.get(BCRYPT_SALT_VAR); + this.salt = parseSalt(saltOrRounds); + } + + /** + * + * @param password the password to be encrypted. + * @param encrypted the encrypted password to be compared against. + * @returns whether the password match the encrypted password + */ + compare(password: string, encrypted: string): Promise { + return compare(password, encrypted); + } + + /** + * @param password the password to be encrypted + * @return encrypted password + */ + hash(password: string): Promise { + return hash(password, this.salt); + } +} + +/** + * Parses a salt environment variable value. + * If a number string value is given tries to parse it as a number of rounds to generate a salt + * @param value salt environment variable value + * @returns salt or number of rounds to generate a salt + */ +export function parseSalt(value: string | undefined): Salt { + if (value === undefined) { + throw new Error(UNDEFINED_SALT_OR_ROUNDS_ERROR); + } + + const rounds = Number(value); + + if (Number.isNaN(rounds)) { + return value; + } + if (!Number.isInteger(rounds) || rounds < 0) { + throw new Error(SALT_OR_ROUNDS_TYPE_ERROR); + } + return rounds; +} diff --git a/apps/todo-tasks/src/auth/token.service.ts b/apps/todo-tasks/src/auth/token.service.ts new file mode 100644 index 0000000..2ee079c --- /dev/null +++ b/apps/todo-tasks/src/auth/token.service.ts @@ -0,0 +1,5 @@ +import { ITokenService } from "./ITokenService"; + +import { TokenServiceBase } from "./base/token.service.base"; + +export class TokenService extends TokenServiceBase implements ITokenService {} diff --git a/apps/todo-tasks/src/auth/userData.decorator.ts b/apps/todo-tasks/src/auth/userData.decorator.ts new file mode 100644 index 0000000..6a40ad1 --- /dev/null +++ b/apps/todo-tasks/src/auth/userData.decorator.ts @@ -0,0 +1,30 @@ +import { createParamDecorator, ExecutionContext } from "@nestjs/common"; +import { GqlContextType, GqlExecutionContext } from "@nestjs/graphql"; +import { User } from "@prisma/client"; + +/** + * Access the user data from the request object i.e `req.user`. + */ +function userFactory(ctx: ExecutionContext): User { + const contextType = ctx.getType(); + if (contextType === "http") { + // do something that is only important in the context of regular HTTP requests (REST) + const { user } = ctx.switchToHttp().getRequest(); + return user; + } else if (contextType === "rpc") { + // do something that is only important in the context of Microservice requests + throw new Error("Rpc context is not implemented yet"); + } else if (contextType === "ws") { + // do something that is only important in the context of Websockets requests + throw new Error("Websockets context is not implemented yet"); + } else if (ctx.getType() === "graphql") { + // do something that is only important in the context of GraphQL requests + const gqlExecutionContext = GqlExecutionContext.create(ctx); + return gqlExecutionContext.getContext().req.user; + } + throw new Error("Invalid context"); +} + +export const UserData = createParamDecorator( + (data, ctx: ExecutionContext) => userFactory(ctx) +); diff --git a/apps/todo-tasks/src/comment/base/comment.controller.base.ts b/apps/todo-tasks/src/comment/base/comment.controller.base.ts index 64f832b..542b5cb 100644 --- a/apps/todo-tasks/src/comment/base/comment.controller.base.ts +++ b/apps/todo-tasks/src/comment/base/comment.controller.base.ts @@ -16,17 +16,35 @@ import * as errors from "../../errors"; import { Request } from "express"; import { plainToClass } from "class-transformer"; import { ApiNestedQuery } from "../../decorators/api-nested-query.decorator"; +import * as nestAccessControl from "nest-access-control"; +import * as defaultAuthGuard from "../../auth/defaultAuth.guard"; import { CommentService } from "../comment.service"; +import { AclValidateRequestInterceptor } from "../../interceptors/aclValidateRequest.interceptor"; +import { AclFilterResponseInterceptor } from "../../interceptors/aclFilterResponse.interceptor"; import { CommentCreateInput } from "./CommentCreateInput"; import { Comment } from "./Comment"; import { CommentFindManyArgs } from "./CommentFindManyArgs"; import { CommentWhereUniqueInput } from "./CommentWhereUniqueInput"; import { CommentUpdateInput } from "./CommentUpdateInput"; +@swagger.ApiBearerAuth() +@common.UseGuards(defaultAuthGuard.DefaultAuthGuard, nestAccessControl.ACGuard) export class CommentControllerBase { - constructor(protected readonly service: CommentService) {} + constructor( + protected readonly service: CommentService, + protected readonly rolesBuilder: nestAccessControl.RolesBuilder + ) {} + @common.UseInterceptors(AclValidateRequestInterceptor) @common.Post() @swagger.ApiCreatedResponse({ type: Comment }) + @nestAccessControl.UseRoles({ + resource: "Comment", + action: "create", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async createComment( @common.Body() data: CommentCreateInput ): Promise { @@ -68,9 +86,18 @@ export class CommentControllerBase { }); } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get() @swagger.ApiOkResponse({ type: [Comment] }) @ApiNestedQuery(CommentFindManyArgs) + @nestAccessControl.UseRoles({ + resource: "Comment", + action: "read", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async comments(@common.Req() request: Request): Promise { const args = plainToClass(CommentFindManyArgs, request.query); return this.service.comments({ @@ -97,9 +124,18 @@ export class CommentControllerBase { }); } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get("/:id") @swagger.ApiOkResponse({ type: Comment }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "Comment", + action: "read", + possession: "own", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async comment( @common.Param() params: CommentWhereUniqueInput ): Promise { @@ -133,9 +169,18 @@ export class CommentControllerBase { return result; } + @common.UseInterceptors(AclValidateRequestInterceptor) @common.Patch("/:id") @swagger.ApiOkResponse({ type: Comment }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "Comment", + action: "update", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async updateComment( @common.Param() params: CommentWhereUniqueInput, @common.Body() data: CommentUpdateInput @@ -191,6 +236,14 @@ export class CommentControllerBase { @common.Delete("/:id") @swagger.ApiOkResponse({ type: Comment }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "Comment", + action: "delete", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async deleteComment( @common.Param() params: CommentWhereUniqueInput ): Promise { diff --git a/apps/todo-tasks/src/comment/base/comment.module.base.ts b/apps/todo-tasks/src/comment/base/comment.module.base.ts index 7ec373d..2196b54 100644 --- a/apps/todo-tasks/src/comment/base/comment.module.base.ts +++ b/apps/todo-tasks/src/comment/base/comment.module.base.ts @@ -10,9 +10,9 @@ https://docs.amplication.com/how-to/custom-code ------------------------------------------------------------------------------ */ import { Module } from "@nestjs/common"; - +import { ACLModule } from "../../auth/acl.module"; @Module({ - imports: [], - exports: [], + imports: [ACLModule], + exports: [ACLModule], }) export class CommentModuleBase {} diff --git a/apps/todo-tasks/src/comment/base/comment.service.base.ts b/apps/todo-tasks/src/comment/base/comment.service.base.ts index 7a8049b..e9fff07 100644 --- a/apps/todo-tasks/src/comment/base/comment.service.base.ts +++ b/apps/todo-tasks/src/comment/base/comment.service.base.ts @@ -20,16 +20,14 @@ import { export class CommentServiceBase { constructor(protected readonly prisma: PrismaService) {} - async count( - args: Prisma.SelectSubset - ): Promise { + async count(args: Omit): Promise { return this.prisma.comment.count(args); } async comments( args: Prisma.SelectSubset ): Promise { - return this.prisma.comment.findMany(args); + return this.prisma.comment.findMany(args); } async comment( args: Prisma.SelectSubset diff --git a/apps/todo-tasks/src/comment/comment.controller.ts b/apps/todo-tasks/src/comment/comment.controller.ts index 9e01e5f..72dc5a1 100644 --- a/apps/todo-tasks/src/comment/comment.controller.ts +++ b/apps/todo-tasks/src/comment/comment.controller.ts @@ -1,12 +1,17 @@ import * as common from "@nestjs/common"; import * as swagger from "@nestjs/swagger"; +import * as nestAccessControl from "nest-access-control"; import { CommentService } from "./comment.service"; import { CommentControllerBase } from "./base/comment.controller.base"; @swagger.ApiTags("comments") @common.Controller("comments") export class CommentController extends CommentControllerBase { - constructor(protected readonly service: CommentService) { - super(service); + constructor( + protected readonly service: CommentService, + @nestAccessControl.InjectRolesBuilder() + protected readonly rolesBuilder: nestAccessControl.RolesBuilder + ) { + super(service, rolesBuilder); } } diff --git a/apps/todo-tasks/src/comment/comment.module.ts b/apps/todo-tasks/src/comment/comment.module.ts index 4325fb9..bb9d39c 100644 --- a/apps/todo-tasks/src/comment/comment.module.ts +++ b/apps/todo-tasks/src/comment/comment.module.ts @@ -1,10 +1,11 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; import { CommentModuleBase } from "./base/comment.module.base"; import { CommentService } from "./comment.service"; import { CommentController } from "./comment.controller"; @Module({ - imports: [CommentModuleBase], + imports: [CommentModuleBase, forwardRef(() => AuthModule)], controllers: [CommentController], providers: [CommentService], exports: [CommentService], diff --git a/apps/todo-tasks/src/constants.ts b/apps/todo-tasks/src/constants.ts new file mode 100644 index 0000000..e131049 --- /dev/null +++ b/apps/todo-tasks/src/constants.ts @@ -0,0 +1,2 @@ +export const JWT_SECRET_KEY_PROVIDER_NAME = "JWT_SECRET_KEY"; +export const JWT_EXPIRATION = "JWT_EXPIRATION"; diff --git a/apps/todo-tasks/src/grants.json b/apps/todo-tasks/src/grants.json new file mode 100644 index 0000000..f2573bc --- /dev/null +++ b/apps/todo-tasks/src/grants.json @@ -0,0 +1,92 @@ +[ + { + "role": "user", + "resource": "Task", + "action": "read:own", + "attributes": "*" + }, + { + "role": "user", + "resource": "Task", + "action": "create:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "Task", + "action": "update:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "Task", + "action": "delete:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "Task", + "action": "read:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "User", + "action": "read:own", + "attributes": "*" + }, + { + "role": "user", + "resource": "User", + "action": "create:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "User", + "action": "update:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "User", + "action": "delete:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "User", + "action": "read:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "Comment", + "action": "read:own", + "attributes": "*" + }, + { + "role": "user", + "resource": "Comment", + "action": "create:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "Comment", + "action": "update:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "Comment", + "action": "delete:any", + "attributes": "*" + }, + { + "role": "user", + "resource": "Comment", + "action": "read:any", + "attributes": "*" + } +] diff --git a/apps/todo-tasks/src/interceptors/aclFilterResponse.interceptor.ts b/apps/todo-tasks/src/interceptors/aclFilterResponse.interceptor.ts new file mode 100644 index 0000000..5eeba18 --- /dev/null +++ b/apps/todo-tasks/src/interceptors/aclFilterResponse.interceptor.ts @@ -0,0 +1,42 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { InjectRolesBuilder, RolesBuilder } from "nest-access-control"; +import { Reflector } from "@nestjs/core"; + +@Injectable() +export class AclFilterResponseInterceptor implements NestInterceptor { + constructor( + @InjectRolesBuilder() private readonly rolesBuilder: RolesBuilder, + private readonly reflector: Reflector + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const [permissionsRoles]: any = this.reflector.getAllAndMerge( + "roles", + [context.getHandler(), context.getClass()] + ); + + const permission = this.rolesBuilder.permission({ + role: permissionsRoles.role, + action: permissionsRoles.action, + possession: permissionsRoles.possession, + resource: permissionsRoles.resource, + }); + + return next.handle().pipe( + map((data) => { + if (Array.isArray(data)) { + return data.map((results: any) => permission.filter(results)); + } else { + return permission.filter(data); + } + }) + ); + } +} diff --git a/apps/todo-tasks/src/interceptors/aclValidateRequest.interceptor.ts b/apps/todo-tasks/src/interceptors/aclValidateRequest.interceptor.ts new file mode 100644 index 0000000..6d30246 --- /dev/null +++ b/apps/todo-tasks/src/interceptors/aclValidateRequest.interceptor.ts @@ -0,0 +1,53 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { InjectRolesBuilder, RolesBuilder } from "nest-access-control"; +import { Reflector } from "@nestjs/core"; +import * as abacUtil from "../auth/abac.util"; +import { ForbiddenException } from "../errors"; + +@Injectable() +export class AclValidateRequestInterceptor implements NestInterceptor { + constructor( + @InjectRolesBuilder() private readonly rolesBuilder: RolesBuilder, + private readonly reflector: Reflector + ) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const [permissionsRoles]: any = this.reflector.getAllAndMerge( + "roles", + [context.getHandler(), context.getClass()] + ); + + const type = context.getType(); + + const inputDataToValidate = + type === "http" + ? context.switchToHttp().getRequest().body + : context.getArgByIndex(1).data; + + const permission = this.rolesBuilder.permission({ + role: permissionsRoles.role, + action: permissionsRoles.action, + possession: permissionsRoles.possession, + resource: permissionsRoles.resource, + }); + + const invalidAttributes = abacUtil.getInvalidAttributes( + permission, + inputDataToValidate + ); + + if (invalidAttributes.length) { + throw new ForbiddenException( + "Insufficient privileges to complete the operation" + ); + } + + return next.handle(); + } +} diff --git a/apps/todo-tasks/src/providers/secrets/secretsNameKey.enum.ts b/apps/todo-tasks/src/providers/secrets/secretsNameKey.enum.ts index e225d65..52c18af 100644 --- a/apps/todo-tasks/src/providers/secrets/secretsNameKey.enum.ts +++ b/apps/todo-tasks/src/providers/secrets/secretsNameKey.enum.ts @@ -1 +1,3 @@ -export enum EnumSecretsNameKey {} \ No newline at end of file +export enum EnumSecretsNameKey { + JwtSecretKey = "JWT_SECRET_KEY" +} \ No newline at end of file diff --git a/apps/todo-tasks/src/task/Fff.ts b/apps/todo-tasks/src/task/Fff.ts new file mode 100644 index 0000000..a4a4772 --- /dev/null +++ b/apps/todo-tasks/src/task/Fff.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { CommentCreateInput } from "../comment/base/CommentCreateInput"; +import { Type } from "class-transformer"; + +class Fff { + @ApiProperty({ + required: true, + type: () => CommentCreateInput + }) + @Type(() => CommentCreateInput) + dfdf!: CommentCreateInput; + + @ApiProperty({ + required: true, + type: () => String + }) + @Type(() => String) + sdfsdf!: string; +} + +export { Fff as Fff }; \ No newline at end of file diff --git a/apps/todo-tasks/src/task/base/task.controller.base.ts b/apps/todo-tasks/src/task/base/task.controller.base.ts index e0a9fec..badf944 100644 --- a/apps/todo-tasks/src/task/base/task.controller.base.ts +++ b/apps/todo-tasks/src/task/base/task.controller.base.ts @@ -16,7 +16,11 @@ import * as errors from "../../errors"; import { Request } from "express"; import { plainToClass } from "class-transformer"; import { ApiNestedQuery } from "../../decorators/api-nested-query.decorator"; +import * as nestAccessControl from "nest-access-control"; +import * as defaultAuthGuard from "../../auth/defaultAuth.guard"; import { TaskService } from "../task.service"; +import { AclValidateRequestInterceptor } from "../../interceptors/aclValidateRequest.interceptor"; +import { AclFilterResponseInterceptor } from "../../interceptors/aclFilterResponse.interceptor"; import { TaskCreateInput } from "./TaskCreateInput"; import { Task } from "./Task"; import { TaskFindManyArgs } from "./TaskFindManyArgs"; @@ -26,10 +30,24 @@ import { CommentFindManyArgs } from "../../comment/base/CommentFindManyArgs"; import { Comment } from "../../comment/base/Comment"; import { CommentWhereUniqueInput } from "../../comment/base/CommentWhereUniqueInput"; +@swagger.ApiBearerAuth() +@common.UseGuards(defaultAuthGuard.DefaultAuthGuard, nestAccessControl.ACGuard) export class TaskControllerBase { - constructor(protected readonly service: TaskService) {} + constructor( + protected readonly service: TaskService, + protected readonly rolesBuilder: nestAccessControl.RolesBuilder + ) {} + @common.UseInterceptors(AclValidateRequestInterceptor) @common.Post() @swagger.ApiCreatedResponse({ type: Task }) + @nestAccessControl.UseRoles({ + resource: "Task", + action: "create", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async createTask(@common.Body() data: TaskCreateInput): Promise { return await this.service.createTask({ data: data, @@ -45,9 +63,18 @@ export class TaskControllerBase { }); } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get() @swagger.ApiOkResponse({ type: [Task] }) @ApiNestedQuery(TaskFindManyArgs) + @nestAccessControl.UseRoles({ + resource: "Task", + action: "read", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async tasks(@common.Req() request: Request): Promise { const args = plainToClass(TaskFindManyArgs, request.query); return this.service.tasks({ @@ -64,9 +91,18 @@ export class TaskControllerBase { }); } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get("/:id") @swagger.ApiOkResponse({ type: Task }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "Task", + action: "read", + possession: "own", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async task( @common.Param() params: TaskWhereUniqueInput ): Promise { @@ -90,9 +126,18 @@ export class TaskControllerBase { return result; } + @common.UseInterceptors(AclValidateRequestInterceptor) @common.Patch("/:id") @swagger.ApiOkResponse({ type: Task }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "Task", + action: "update", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async updateTask( @common.Param() params: TaskWhereUniqueInput, @common.Body() data: TaskUpdateInput @@ -124,6 +169,14 @@ export class TaskControllerBase { @common.Delete("/:id") @swagger.ApiOkResponse({ type: Task }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "Task", + action: "delete", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async deleteTask( @common.Param() params: TaskWhereUniqueInput ): Promise { @@ -150,8 +203,14 @@ export class TaskControllerBase { } } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get("/:id/comments") @ApiNestedQuery(CommentFindManyArgs) + @nestAccessControl.UseRoles({ + resource: "Comment", + action: "read", + possession: "any", + }) async findComments( @common.Req() request: Request, @common.Param() params: TaskWhereUniqueInput @@ -188,6 +247,11 @@ export class TaskControllerBase { } @common.Post("/:id/comments") + @nestAccessControl.UseRoles({ + resource: "Task", + action: "update", + possession: "any", + }) async connectComments( @common.Param() params: TaskWhereUniqueInput, @common.Body() body: CommentWhereUniqueInput[] @@ -205,6 +269,11 @@ export class TaskControllerBase { } @common.Patch("/:id/comments") + @nestAccessControl.UseRoles({ + resource: "Task", + action: "update", + possession: "any", + }) async updateComments( @common.Param() params: TaskWhereUniqueInput, @common.Body() body: CommentWhereUniqueInput[] @@ -222,6 +291,11 @@ export class TaskControllerBase { } @common.Delete("/:id/comments") + @nestAccessControl.UseRoles({ + resource: "Task", + action: "update", + possession: "any", + }) async disconnectComments( @common.Param() params: TaskWhereUniqueInput, @common.Body() body: CommentWhereUniqueInput[] diff --git a/apps/todo-tasks/src/task/base/task.module.base.ts b/apps/todo-tasks/src/task/base/task.module.base.ts index a08542b..3d40d0d 100644 --- a/apps/todo-tasks/src/task/base/task.module.base.ts +++ b/apps/todo-tasks/src/task/base/task.module.base.ts @@ -10,9 +10,9 @@ https://docs.amplication.com/how-to/custom-code ------------------------------------------------------------------------------ */ import { Module } from "@nestjs/common"; - +import { ACLModule } from "../../auth/acl.module"; @Module({ - imports: [], - exports: [], + imports: [ACLModule], + exports: [ACLModule], }) export class TaskModuleBase {} diff --git a/apps/todo-tasks/src/task/base/task.service.base.ts b/apps/todo-tasks/src/task/base/task.service.base.ts index 7f2d769..4901b87 100644 --- a/apps/todo-tasks/src/task/base/task.service.base.ts +++ b/apps/todo-tasks/src/task/base/task.service.base.ts @@ -21,16 +21,14 @@ import { Task } from "./Task"; export class TaskServiceBase { constructor(protected readonly prisma: PrismaService) {} - async count( - args: Prisma.SelectSubset - ): Promise { + async count(args: Omit): Promise { return this.prisma.task.count(args); } async tasks( args: Prisma.SelectSubset ): Promise { - return this.prisma.task.findMany(args); + return this.prisma.task.findMany(args); } async task( args: Prisma.SelectSubset diff --git a/apps/todo-tasks/src/task/task.controller.ts b/apps/todo-tasks/src/task/task.controller.ts index aa4374d..366cf6e 100644 --- a/apps/todo-tasks/src/task/task.controller.ts +++ b/apps/todo-tasks/src/task/task.controller.ts @@ -1,12 +1,17 @@ import * as common from "@nestjs/common"; import * as swagger from "@nestjs/swagger"; +import * as nestAccessControl from "nest-access-control"; import { TaskService } from "./task.service"; import { TaskControllerBase } from "./base/task.controller.base"; @swagger.ApiTags("tasks") @common.Controller("tasks") export class TaskController extends TaskControllerBase { - constructor(protected readonly service: TaskService) { - super(service); + constructor( + protected readonly service: TaskService, + @nestAccessControl.InjectRolesBuilder() + protected readonly rolesBuilder: nestAccessControl.RolesBuilder + ) { + super(service, rolesBuilder); } } diff --git a/apps/todo-tasks/src/task/task.module.ts b/apps/todo-tasks/src/task/task.module.ts index 8c0f092..114b810 100644 --- a/apps/todo-tasks/src/task/task.module.ts +++ b/apps/todo-tasks/src/task/task.module.ts @@ -1,10 +1,11 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; import { TaskModuleBase } from "./base/task.module.base"; import { TaskService } from "./task.service"; import { TaskController } from "./task.controller"; @Module({ - imports: [TaskModuleBase], + imports: [TaskModuleBase, forwardRef(() => AuthModule)], controllers: [TaskController], providers: [TaskService], exports: [TaskService], diff --git a/apps/todo-tasks/src/tests/auth/constants.ts b/apps/todo-tasks/src/tests/auth/constants.ts new file mode 100644 index 0000000..98e4887 --- /dev/null +++ b/apps/todo-tasks/src/tests/auth/constants.ts @@ -0,0 +1,19 @@ +import { Credentials } from "../../auth/Credentials"; +import { UserInfo } from "../../auth/UserInfo"; + +export const VALID_ID = "1"; + +export const TEST_USER: UserInfo = { + id: "cl7qmjh4h0000tothyjqapgj5", + roles: ["User"], + username: "ofek", +}; +export const SIGN_TOKEN = "SIGN_TOKEN"; +export const VALID_CREDENTIALS: Credentials = { + username: "Valid User", + password: "Valid User Password", +}; +export const INVALID_CREDENTIALS: Credentials = { + username: "Invalid User", + password: "Invalid User Password", +}; diff --git a/apps/todo-tasks/src/tests/auth/jwt/jwt.strategy.spec.ts b/apps/todo-tasks/src/tests/auth/jwt/jwt.strategy.spec.ts new file mode 100644 index 0000000..36bbb24 --- /dev/null +++ b/apps/todo-tasks/src/tests/auth/jwt/jwt.strategy.spec.ts @@ -0,0 +1,28 @@ +import { UnauthorizedException } from "@nestjs/common"; +import { mock } from "jest-mock-extended"; +import { JwtStrategyBase } from "../../../auth/jwt/base/jwt.strategy.base"; +import { TEST_USER } from "../constants"; +import { UserService } from "../../../user/user.service"; +describe("Testing the jwtStrategyBase.validate()", () => { + const userService = mock(); + const jwtStrategy = new JwtStrategyBase("Secrete", userService); + beforeEach(() => { + userService.user.mockClear(); + }); + it("should throw UnauthorizedException where there is no user", async () => { + //ARRANGE + userService.user + .calledWith({ + where: { username: TEST_USER.username }, + }) + .mockReturnValue(Promise.resolve(null)); + //ACT + const result = jwtStrategy.validate({ + id: TEST_USER.id, + username: TEST_USER.username, + roles: TEST_USER.roles, + }); + //ASSERT + return expect(result).rejects.toThrowError(UnauthorizedException); + }); +}); diff --git a/apps/todo-tasks/src/tests/auth/token.service.spec.ts b/apps/todo-tasks/src/tests/auth/token.service.spec.ts new file mode 100644 index 0000000..83b5a51 --- /dev/null +++ b/apps/todo-tasks/src/tests/auth/token.service.spec.ts @@ -0,0 +1,47 @@ +import { JwtService } from "@nestjs/jwt"; +import { mock } from "jest-mock-extended"; +import { TokenServiceBase } from "../../auth/base/token.service.base"; +import { + INVALID_PASSWORD_ERROR, + INVALID_USERNAME_ERROR, +} from "../../auth/constants"; +import { SIGN_TOKEN, VALID_CREDENTIALS, VALID_ID } from "./constants"; + +describe("Testing the TokenServiceBase", () => { + let tokenServiceBase: TokenServiceBase; + const jwtService = mock(); + beforeEach(() => { + tokenServiceBase = new TokenServiceBase(jwtService); + jwtService.signAsync.mockClear(); + }); + describe("Testing the BasicTokenService.createToken()", () => { + it("should create valid token for valid username and password", async () => { + jwtService.signAsync.mockReturnValue(Promise.resolve(SIGN_TOKEN)); + expect( + await tokenServiceBase.createToken({ + id: VALID_ID, + username: VALID_CREDENTIALS.username, + password: VALID_CREDENTIALS.password, + }) + ).toBe(SIGN_TOKEN); + }); + it("should reject when username missing", () => { + const result = tokenServiceBase.createToken({ + id: VALID_ID, + //@ts-ignore + username: null, + password: VALID_CREDENTIALS.password, + }); + return expect(result).rejects.toBe(INVALID_USERNAME_ERROR); + }); + it("should reject when password missing", () => { + const result = tokenServiceBase.createToken({ + id: VALID_ID, + username: VALID_CREDENTIALS.username, + //@ts-ignore + password: null, + }); + return expect(result).rejects.toBe(INVALID_PASSWORD_ERROR); + }); + }); +}); diff --git a/apps/todo-tasks/src/user/base/user.controller.base.ts b/apps/todo-tasks/src/user/base/user.controller.base.ts index a657a99..4a974b4 100644 --- a/apps/todo-tasks/src/user/base/user.controller.base.ts +++ b/apps/todo-tasks/src/user/base/user.controller.base.ts @@ -16,7 +16,11 @@ import * as errors from "../../errors"; import { Request } from "express"; import { plainToClass } from "class-transformer"; import { ApiNestedQuery } from "../../decorators/api-nested-query.decorator"; +import * as nestAccessControl from "nest-access-control"; +import * as defaultAuthGuard from "../../auth/defaultAuth.guard"; import { UserService } from "../user.service"; +import { AclValidateRequestInterceptor } from "../../interceptors/aclValidateRequest.interceptor"; +import { AclFilterResponseInterceptor } from "../../interceptors/aclFilterResponse.interceptor"; import { UserCreateInput } from "./UserCreateInput"; import { User } from "./User"; import { UserFindManyArgs } from "./UserFindManyArgs"; @@ -26,10 +30,24 @@ import { CommentFindManyArgs } from "../../comment/base/CommentFindManyArgs"; import { Comment } from "../../comment/base/Comment"; import { CommentWhereUniqueInput } from "../../comment/base/CommentWhereUniqueInput"; +@swagger.ApiBearerAuth() +@common.UseGuards(defaultAuthGuard.DefaultAuthGuard, nestAccessControl.ACGuard) export class UserControllerBase { - constructor(protected readonly service: UserService) {} + constructor( + protected readonly service: UserService, + protected readonly rolesBuilder: nestAccessControl.RolesBuilder + ) {} + @common.UseInterceptors(AclValidateRequestInterceptor) @common.Post() @swagger.ApiCreatedResponse({ type: User }) + @nestAccessControl.UseRoles({ + resource: "User", + action: "create", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async createUser(@common.Body() data: UserCreateInput): Promise { return await this.service.createUser({ data: data, @@ -46,9 +64,18 @@ export class UserControllerBase { }); } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get() @swagger.ApiOkResponse({ type: [User] }) @ApiNestedQuery(UserFindManyArgs) + @nestAccessControl.UseRoles({ + resource: "User", + action: "read", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async users(@common.Req() request: Request): Promise { const args = plainToClass(UserFindManyArgs, request.query); return this.service.users({ @@ -66,9 +93,18 @@ export class UserControllerBase { }); } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get("/:id") @swagger.ApiOkResponse({ type: User }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "User", + action: "read", + possession: "own", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async user( @common.Param() params: UserWhereUniqueInput ): Promise { @@ -93,9 +129,18 @@ export class UserControllerBase { return result; } + @common.UseInterceptors(AclValidateRequestInterceptor) @common.Patch("/:id") @swagger.ApiOkResponse({ type: User }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "User", + action: "update", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async updateUser( @common.Param() params: UserWhereUniqueInput, @common.Body() data: UserUpdateInput @@ -128,6 +173,14 @@ export class UserControllerBase { @common.Delete("/:id") @swagger.ApiOkResponse({ type: User }) @swagger.ApiNotFoundResponse({ type: errors.NotFoundException }) + @nestAccessControl.UseRoles({ + resource: "User", + action: "delete", + possession: "any", + }) + @swagger.ApiForbiddenResponse({ + type: errors.ForbiddenException, + }) async deleteUser( @common.Param() params: UserWhereUniqueInput ): Promise { @@ -155,8 +208,14 @@ export class UserControllerBase { } } + @common.UseInterceptors(AclFilterResponseInterceptor) @common.Get("/:id/comments") @ApiNestedQuery(CommentFindManyArgs) + @nestAccessControl.UseRoles({ + resource: "Comment", + action: "read", + possession: "any", + }) async findComments( @common.Req() request: Request, @common.Param() params: UserWhereUniqueInput @@ -193,6 +252,11 @@ export class UserControllerBase { } @common.Post("/:id/comments") + @nestAccessControl.UseRoles({ + resource: "User", + action: "update", + possession: "any", + }) async connectComments( @common.Param() params: UserWhereUniqueInput, @common.Body() body: CommentWhereUniqueInput[] @@ -210,6 +274,11 @@ export class UserControllerBase { } @common.Patch("/:id/comments") + @nestAccessControl.UseRoles({ + resource: "User", + action: "update", + possession: "any", + }) async updateComments( @common.Param() params: UserWhereUniqueInput, @common.Body() body: CommentWhereUniqueInput[] @@ -227,6 +296,11 @@ export class UserControllerBase { } @common.Delete("/:id/comments") + @nestAccessControl.UseRoles({ + resource: "User", + action: "update", + possession: "any", + }) async disconnectComments( @common.Param() params: UserWhereUniqueInput, @common.Body() body: CommentWhereUniqueInput[] diff --git a/apps/todo-tasks/src/user/base/user.module.base.ts b/apps/todo-tasks/src/user/base/user.module.base.ts index 87dbcf8..a8b6c68 100644 --- a/apps/todo-tasks/src/user/base/user.module.base.ts +++ b/apps/todo-tasks/src/user/base/user.module.base.ts @@ -10,9 +10,9 @@ https://docs.amplication.com/how-to/custom-code ------------------------------------------------------------------------------ */ import { Module } from "@nestjs/common"; - +import { ACLModule } from "../../auth/acl.module"; @Module({ - imports: [], - exports: [], + imports: [ACLModule], + exports: [ACLModule], }) export class UserModuleBase {} diff --git a/apps/todo-tasks/src/user/base/user.service.base.ts b/apps/todo-tasks/src/user/base/user.service.base.ts index 1d5789e..3d4d199 100644 --- a/apps/todo-tasks/src/user/base/user.service.base.ts +++ b/apps/todo-tasks/src/user/base/user.service.base.ts @@ -15,20 +15,23 @@ import { User as PrismaUser, Comment as PrismaComment, } from "@prisma/client"; +import { PasswordService } from "../../auth/password.service"; +import { transformStringFieldUpdateInput } from "../../prisma.util"; export class UserServiceBase { - constructor(protected readonly prisma: PrismaService) {} + constructor( + protected readonly prisma: PrismaService, + protected readonly passwordService: PasswordService + ) {} - async count( - args: Prisma.SelectSubset - ): Promise { + async count(args: Omit): Promise { return this.prisma.user.count(args); } async users( args: Prisma.SelectSubset ): Promise { - return this.prisma.user.findMany(args); + return this.prisma.user.findMany(args); } async user( args: Prisma.SelectSubset @@ -38,12 +41,32 @@ export class UserServiceBase { async createUser( args: Prisma.SelectSubset ): Promise { - return this.prisma.user.create(args); + return this.prisma.user.create({ + ...args, + + data: { + ...args.data, + password: await this.passwordService.hash(args.data.password), + }, + }); } async updateUser( args: Prisma.SelectSubset ): Promise { - return this.prisma.user.update(args); + return this.prisma.user.update({ + ...args, + + data: { + ...args.data, + + password: + args.data.password && + (await transformStringFieldUpdateInput( + args.data.password, + (password) => this.passwordService.hash(password) + )), + }, + }); } async deleteUser( args: Prisma.SelectSubset diff --git a/apps/todo-tasks/src/user/user.controller.ts b/apps/todo-tasks/src/user/user.controller.ts index b783f3c..21d9583 100644 --- a/apps/todo-tasks/src/user/user.controller.ts +++ b/apps/todo-tasks/src/user/user.controller.ts @@ -1,12 +1,17 @@ import * as common from "@nestjs/common"; import * as swagger from "@nestjs/swagger"; +import * as nestAccessControl from "nest-access-control"; import { UserService } from "./user.service"; import { UserControllerBase } from "./base/user.controller.base"; @swagger.ApiTags("users") @common.Controller("users") export class UserController extends UserControllerBase { - constructor(protected readonly service: UserService) { - super(service); + constructor( + protected readonly service: UserService, + @nestAccessControl.InjectRolesBuilder() + protected readonly rolesBuilder: nestAccessControl.RolesBuilder + ) { + super(service, rolesBuilder); } } diff --git a/apps/todo-tasks/src/user/user.module.ts b/apps/todo-tasks/src/user/user.module.ts index 2f0491d..3ad610a 100644 --- a/apps/todo-tasks/src/user/user.module.ts +++ b/apps/todo-tasks/src/user/user.module.ts @@ -1,10 +1,11 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; import { UserModuleBase } from "./base/user.module.base"; import { UserService } from "./user.service"; import { UserController } from "./user.controller"; @Module({ - imports: [UserModuleBase], + imports: [UserModuleBase, forwardRef(() => AuthModule)], controllers: [UserController], providers: [UserService], exports: [UserService], diff --git a/apps/todo-tasks/src/user/user.service.ts b/apps/todo-tasks/src/user/user.service.ts index cae0388..a8e4508 100644 --- a/apps/todo-tasks/src/user/user.service.ts +++ b/apps/todo-tasks/src/user/user.service.ts @@ -1,10 +1,14 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; +import { PasswordService } from "../auth/password.service"; import { UserServiceBase } from "./base/user.service.base"; @Injectable() export class UserService extends UserServiceBase { - constructor(protected readonly prisma: PrismaService) { - super(prisma); + constructor( + protected readonly prisma: PrismaService, + protected readonly passwordService: PasswordService + ) { + super(prisma, passwordService); } } From 4fd9ce4efe7360dc2a8806012114377b22266f78 Mon Sep 17 00:00:00 2001 From: "amplication[bot]" Date: Sun, 31 Mar 2024 14:45:50 +0000 Subject: [PATCH 2/2] Amplication build # clufmvddr003dq9mhbjc04o1n Build URL: [https://app.amplication-sandbox.com/clu1d2moq00aku4lyym0tti5j/clu1d2n1i00anu4lyzcm4vndj/clu1d9g9j00atu4lyz9ej60pb/builds/clufmvddr003dq9mhbjc04o1n](https://app.amplication-sandbox.com/clu1d2moq00aku4lyym0tti5j/clu1d2n1i00anu4lyzcm4vndj/clu1d9g9j00atu4lyz9ej60pb/builds/clufmvddr003dq9mhbjc04o1n) --- apps/todo-tasks/scripts/seed.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/todo-tasks/scripts/seed.ts b/apps/todo-tasks/scripts/seed.ts index 04cee65..272dddd 100644 --- a/apps/todo-tasks/scripts/seed.ts +++ b/apps/todo-tasks/scripts/seed.ts @@ -1,6 +1,8 @@ import * as dotenv from "dotenv"; import { PrismaClient } from "@prisma/client"; import { customSeed } from "./customSeed"; +import { Salt, parseSalt } from "../src/auth/password.service"; +import { hash } from "bcrypt"; if (require.main === module) { dotenv.config(); @@ -10,12 +12,34 @@ if (require.main === module) { if (!BCRYPT_SALT) { throw new Error("BCRYPT_SALT environment variable must be defined"); } + const salt = parseSalt(BCRYPT_SALT); + + seed(salt).catch((error) => { + console.error(error); + process.exit(1); + }); } -async function seed() { +async function seed(bcryptSalt: Salt) { console.info("Seeding database..."); const client = new PrismaClient(); + + const data = { + username: "admin", + password: await hash("admin", bcryptSalt), + roles: ["user"], + }; + + await client.user.upsert({ + where: { + username: data.username, + }, + + update: {}, + create: data, + }); + void client.$disconnect(); console.info("Seeding database with custom seed...");