diff --git a/doc.md b/doc.md new file mode 100644 index 0000000..15fa9c3 --- /dev/null +++ b/doc.md @@ -0,0 +1,130 @@ +# Encryption des données +## SHA-256 +- Tokens/sum +## Argon2id +- Mots de passe +- Tokens +## Symétrique avec clé encryption API +- Secret utilisateur +- Banks/name +## Symétrique secret utilisateur +- Secret 2FA +- Usernames +- Emails +- Wordings +- Accounts/amount +- Todos/name + + + +# For future +## Auth +### GET /auth/2fa +Take JWT. Generate the 2fa secret and send the link+qrcode. +### POST /auth/2fa +Take JWT and 2fa code and verify it's well configured by the user. +### DELETE /auth/2fa +Take JWT. Remove the 2fa from the account. +### POST /auth/2fa/recover +Take JWT and one 2fa recovery code. Disable the 2fa if code is correct. +▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬ +# Naming +- AT: Access token (classic JWT, have user id, token type) +- RT: Refresh token (JWT, have user id, token type) +- CT: Code token (JWT, have user id, token type, and keep logged value) + +# Routes + +## Version +### GET /version +Return the current backend version. + +## Auth +### POST /auth/login?keep +Take username, password. Return AT and RT if keep param is present. If verification code present, return CT. +### POST /auth/logout +Take AT (and RT if existing) and add them to token blacklist. +### POST /auth/logout/all +Take AT, add all existing tokens to blacklist +### POST /auth/register +Take username, email and password. Return CT and send email with confirmation code. +### POST /auth/refresh +Take old AT and RT, add both to token blacklist and re-send new AT and RT. +### POST /auth/register/confirm +Take CT and confirmation code. Return AT. +### POST /auth/register/confirm/resend +Take CT. Re-create and re-send a new confirmation code by email. Return new CT. + +## Users +### GET /users/me +Take JWT and return username and email. +### PATCH /users/me/username +Take JWT and new username. +### PATCH /users/me/password +Take JWT and new password. +### DELETE /users/me +Take JWT. + +## Transactions +### GET /transactions +Take JWT, return list of user transactions. +### POST /transactions +Take JWT, transaction type, amount and wording. +### PATCH /transactions/:ulid/wording +Take JWT and new wording. +### POST /transactions/rectification +Take JWT, transaction ulid, new amount. Return updated transaction. +### GET /transactions/categories +Take JWT, return default and users categories. +### POST /transactions/categories +Take JWT, name, icon and color. Return created category. +### PUT /transactions/categories/:id +Take JWT, name, icon and color. Return updated category. + +## Recurring transactions +### GET /recurring +Take JWT, return user recurring transactions. +### POST /recurring +Take JWT, wording, type and amount. Return created recurring transaction. +### PUT /recurring/:id +Take JWT, wording and amount. Return updated recurring transaction. +### DELETE /recurring/:id +Take JWT. + +## Tips +### GET /tips/tod +Return tips of the day. + +## Accounts +### GET /accounts +Take JWT, return all user accounts. +### POST /accounts +Take JWT, name, bank id and current amount, return created account. +### PATCH /accounts/:id/name +Take JWT and name, return updated account. +### DELETE /accounts/:id +Take JWT. + +## Banks +### GET /banks +Take JWT, return user banks and default banks. +### POST /banks +Take JWT and bank name, return created bank. +### PATCH /banks/:id/name +Take JWT and name, return updated bank. +### DELETE /banks/:id +Take JWT. + +## Todos +### GET /todos +Take JWT. Return all user todos +### POST /todos +Take JWT, name, deadline, parent id, icon, color and recurring. Return created todo. +### PATCH /todos/:id/parent +Take JWT and new parent id. Return updated todo. +### PATCH /todos/:id/completed +Take JWT and new completed value. Return updated todo. +### PUT /todos/:id +Take JWT, name, deadline, icon, color, completed value and frequency. Return updated todo. +### DELETE /todos/:id +Take JWT. diff --git a/prisma/migrations/20240104022007_init/migration.sql b/prisma/migrations/20240105225104_init/migration.sql similarity index 99% rename from prisma/migrations/20240104022007_init/migration.sql rename to prisma/migrations/20240105225104_init/migration.sql index 2a27083..d5a36f5 100644 --- a/prisma/migrations/20240104022007_init/migration.sql +++ b/prisma/migrations/20240105225104_init/migration.sql @@ -183,7 +183,7 @@ CREATE UNIQUE INDEX "tips_tips_key" ON "tips"("tips"); CREATE UNIQUE INDEX "tips_order_key" ON "tips"("order"); -- CreateIndex -CREATE UNIQUE INDEX "banks_name_key" ON "banks"("name"); +CREATE UNIQUE INDEX "banks_name_user_id_key" ON "banks"("name", "user_id"); -- CreateIndex CREATE UNIQUE INDEX "accounts_name_key" ON "accounts"("name"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bce63fa..c42377e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -82,11 +82,12 @@ model Tips { model Banks { id Int @id @default(autoincrement()) - name String @unique + name String user_id Int? user User? @relation(fields: [user_id], references: [id]) account Accounts[] + @@unique([name, user_id]) @@map("banks") } diff --git a/prisma/seed.ts b/prisma/seed.ts index 9186c60..71f984a 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -12,6 +12,7 @@ const encryptionService = new EncryptionService(); async function main(){ const userSecret = encryptionService.generateSecret(); + const encryptionKey = process.env.SYMMETRIC_ENCRYPTION_KEY; const testUser = await prisma.user.upsert({ where: {id: 1}, update: {}, @@ -20,7 +21,7 @@ async function main(){ username: encryptionService.encryptSymmetric("test", userSecret), email: "test@exemple.org", password: await encryptionService.hash("password"), - secret: encryptionService.encryptSymmetric(userSecret, process.env.SYMMETRIC_ENCRYPTION_KEY), + secret: encryptionService.encryptSymmetric(userSecret, encryptionKey), verification_code_id: null, created_at: new Date(), updated_at: new Date(), @@ -79,7 +80,7 @@ async function main(){ update: {}, create: { id: 1, - name: "Default bank", + name: encryptionService.encryptSymmetric("Default bank", encryptionKey), }, }); @@ -89,7 +90,7 @@ async function main(){ create: { id: 2, user_id: 1, - name: "User bank", + name: encryptionService.encryptSymmetric("User bank", encryptionKey), }, }); diff --git a/src/app.module.ts b/src/app.module.ts index 1b63fc5..bfe022a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,8 +7,9 @@ import {VerificationCodesModule} from "./verification-codes/verification-codes.m import {TodosModule} from "./todos/todos.module"; import {TipsModule} from "./tips/tips.module"; import {MaintenanceModule} from "./maintenance/maintenance.module"; +import {BanksModule} from "./banks/banks.module"; @Module({ - imports: [AuthModule, VersionModule, ConfigModule.forRoot({isGlobal: true}), UsersModule, VerificationCodesModule, TodosModule, TipsModule, MaintenanceModule], + imports: [AuthModule, VersionModule, ConfigModule.forRoot({isGlobal: true}), UsersModule, VerificationCodesModule, TodosModule, TipsModule, MaintenanceModule, BanksModule], }) export class AppModule{} diff --git a/src/banks/banks.controller.ts b/src/banks/banks.controller.ts new file mode 100644 index 0000000..25d1d6f --- /dev/null +++ b/src/banks/banks.controller.ts @@ -0,0 +1,57 @@ +import {Body, Controller, Delete, Get, HttpStatus, Param, Patch, Post, Req, UseGuards} from "@nestjs/common"; +import {MaintenanceGuard} from "../maintenance/guards/maintenance.guard"; +import {ApiBearerAuth, ApiResponse} from "@nestjs/swagger"; +import {AtGuard} from "../auth/guards/at.guard"; +import {BanksService} from "./banks.service"; +import {BankEntity} from "./models/entities/bank.entity"; +import {BankNameDto} from "./models/dto/bank-name.dto"; +import {IdDto} from "../models/dto/id.dto"; + +@Controller("banks") +@UseGuards(MaintenanceGuard) +export class BanksController{ + constructor( + private readonly banksService: BanksService + ){} + + @Get() + @UseGuards(AtGuard) + @ApiBearerAuth() + @ApiResponse({status: HttpStatus.OK, description: "User and default banks returned", type: BankEntity, isArray: true}) + @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) + @ApiResponse({status: HttpStatus.NOT_FOUND, description: "User not found"}) + async getBanks(@Req() req: any): Promise{ + return this.banksService.getBanks(req.user.id); + } + + @Post() + @UseGuards(AtGuard) + @ApiBearerAuth() + @ApiResponse({status: HttpStatus.CREATED, description: "Bank added", type: BankEntity}) + @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) + @ApiResponse({status: HttpStatus.NOT_FOUND, description: "User not found"}) + @ApiResponse({status: HttpStatus.CONFLICT, description: "Bank already exists"}) + async addBank(@Req() req: any, @Body() bankNameDto: BankNameDto): Promise{ + return this.banksService.addBank(req.user.id, bankNameDto.name); + } + + @Patch(":id/name") + @UseGuards(AtGuard) + @ApiBearerAuth() + @ApiResponse({status: HttpStatus.OK, description: "Bank name updated", type: BankEntity}) + @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) + @ApiResponse({status: HttpStatus.NOT_FOUND, description: "User or bank not found"}) + async updateBankName(@Req() req: any, @Param() idDto: IdDto, @Body() bankNameDto: BankNameDto): Promise{ + return this.banksService.updateBankName(req.user.id, idDto.id, bankNameDto.name); + } + + @Delete(":id") + @UseGuards(AtGuard) + @ApiBearerAuth() + @ApiResponse({status: HttpStatus.OK, description: "Bank deleted", type: BankEntity}) + @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) + @ApiResponse({status: HttpStatus.NOT_FOUND, description: "User or bank not found"}) + async deleteBank(@Req() req: any, @Param() idDto: IdDto): Promise{ + return this.banksService.deleteBank(req.user.id, idDto.id); + } +} diff --git a/src/banks/banks.module.ts b/src/banks/banks.module.ts new file mode 100644 index 0000000..538cb31 --- /dev/null +++ b/src/banks/banks.module.ts @@ -0,0 +1,12 @@ +import {Module} from "@nestjs/common"; +import {BanksService} from "./banks.service"; +import {BanksController} from "./banks.controller"; +import {ServicesModule} from "../services/services.module"; +import {UsersModule} from "../users/users.module"; + +@Module({ + controllers: [BanksController], + providers: [BanksService], + imports: [ServicesModule, UsersModule], +}) +export class BanksModule{} diff --git a/src/banks/banks.service.ts b/src/banks/banks.service.ts new file mode 100644 index 0000000..78b39e3 --- /dev/null +++ b/src/banks/banks.service.ts @@ -0,0 +1,97 @@ +import {Injectable, NotFoundException} from "@nestjs/common"; +import {BankEntity} from "./models/entities/bank.entity"; +import {PrismaService} from "../services/prisma.service"; +import {ConfigService} from "@nestjs/config"; +import {EncryptionService} from "../services/encryption.service"; +import {UsersService} from "../users/users.service"; + +@Injectable() +export class BanksService{ + + constructor( + private readonly prismaService: PrismaService, + private readonly configService: ConfigService, + private readonly encryptionService: EncryptionService, + private readonly usersService: UsersService, + ){} + + async getBanks(userId: number): Promise{ + if(!await this.usersService.isUserExists(userId)) + throw new NotFoundException("User not found"); + const banks: BankEntity[] = await this.prismaService.banks.findMany({ + where: { + OR: [ + {user_id: userId}, + {user_id: null}, + ], + } + }); + for(const bank of banks) + bank.name = this.encryptionService.decryptSymmetric(bank.name, this.configService.get("SYMMETRIC_ENCRYPTION_KEY")); + return banks; + } + + async addBank(userId: number, bankName: string): Promise{ + if(!await this.usersService.isUserExists(userId)) + throw new NotFoundException("User not found"); + const banks = await this.getBanks(userId); + for(const bank of banks) + if(bank.name === bankName) + throw new NotFoundException("Bank already exists"); + const bank: BankEntity = await this.prismaService.banks.create({ + data: { + user_id: userId, + name: this.encryptionService.encryptSymmetric(bankName, this.configService.get("SYMMETRIC_ENCRYPTION_KEY")), + } + }); + bank.name = bankName; + return bank; + } + + async updateBankName(userId: number, bankId: number, name: string): Promise{ + if(!await this.usersService.isUserExists(userId)) + throw new NotFoundException("User not found"); + const bank: BankEntity = await this.prismaService.banks.findUnique({ + where: { + id: bankId, + user_id: userId, + } + }); + if(!bank) + throw new NotFoundException("User bank not found"); + const banks = await this.getBanks(userId); + for(const bank of banks) + if(bank.name === name) + throw new NotFoundException("Bank already exists with this name"); + await this.prismaService.banks.update({ + where: { + id: bankId, + }, + data: { + name: this.encryptionService.encryptSymmetric(name, this.configService.get("SYMMETRIC_ENCRYPTION_KEY")), + } + }); + bank.name = name; + return bank; + } + + async deleteBank(userId: number, bankId: number): Promise{ + if(!await this.usersService.isUserExists(userId)) + throw new NotFoundException("User not found"); + const bank: BankEntity = await this.prismaService.banks.findUnique({ + where: { + id: bankId, + user_id: userId, + } + }); + if(!bank) + throw new NotFoundException("User bank not found"); + await this.prismaService.banks.delete({ + where: { + id: bankId, + } + }); + bank.name = this.encryptionService.decryptSymmetric(bank.name, this.configService.get("SYMMETRIC_ENCRYPTION_KEY")); + return bank; + } +} diff --git a/src/banks/models/dto/bank-name.dto.ts b/src/banks/models/dto/bank-name.dto.ts new file mode 100644 index 0000000..f1df38a --- /dev/null +++ b/src/banks/models/dto/bank-name.dto.ts @@ -0,0 +1,9 @@ +import {ApiProperty} from "@nestjs/swagger"; +import {IsNotEmpty, IsString} from "class-validator"; + +export class BankNameDto{ + @ApiProperty() + @IsString() + @IsNotEmpty() + name: string; +} diff --git a/src/banks/models/entities/bank.entity.ts b/src/banks/models/entities/bank.entity.ts new file mode 100644 index 0000000..ee30da8 --- /dev/null +++ b/src/banks/models/entities/bank.entity.ts @@ -0,0 +1,11 @@ +import {Banks} from "@prisma/client"; +import {ApiProperty} from "@nestjs/swagger"; + +export class BankEntity implements Banks{ + @ApiProperty() + id: number; + @ApiProperty() + name: string; + @ApiProperty() + user_id: number; +} diff --git a/src/middlewares/logger.middleware.ts b/src/middlewares/logger.middleware.ts index 11c88d6..8e48732 100644 --- a/src/middlewares/logger.middleware.ts +++ b/src/middlewares/logger.middleware.ts @@ -20,7 +20,7 @@ export class LoggerMiddleware implements NestMiddleware{ const path = req.url; const statusCode = res.statusCode; const duration = Date.now() - startTime; - const resSize = res.getHeader("Content-Length"); + const resSize = res.getHeader("Content-Length") || "N/A"; console.log(`${currentTime} ${httpOrHttps} ${method} ${path} ${statusCode} ${duration}ms ${resSize}`); }); next(); diff --git a/src/todos/models/dto/todo-id.dto.ts b/src/models/dto/id.dto.ts similarity index 89% rename from src/todos/models/dto/todo-id.dto.ts rename to src/models/dto/id.dto.ts index afd10ff..db02b88 100644 --- a/src/todos/models/dto/todo-id.dto.ts +++ b/src/models/dto/id.dto.ts @@ -2,7 +2,7 @@ import {ApiProperty} from "@nestjs/swagger"; import {IsNotEmpty} from "class-validator"; import {Type} from "class-transformer"; -export class TodoIdDto{ +export class IdDto{ @ApiProperty() @IsNotEmpty() @Type(() => Number) diff --git a/src/todos/todos.controller.ts b/src/todos/todos.controller.ts index 36f9094..97d0296 100644 --- a/src/todos/todos.controller.ts +++ b/src/todos/todos.controller.ts @@ -5,7 +5,7 @@ import {UpdateParentDto} from "./models/dto/update-parent.dto"; import {CreateTodoDto} from "./models/dto/create-todo.dto"; import {UpdateTodoDto} from "./models/dto/update-todo.dto"; import {TodoEntity} from "./models/entities/todo.entity"; -import {TodoIdDto} from "./models/dto/todo-id.dto"; +import {IdDto} from "../models/dto/id.dto"; import {AtGuard} from "../auth/guards/at.guard"; import {TodosService} from "./todos.service"; import {MaintenanceGuard} from "../maintenance/guards/maintenance.guard"; @@ -50,7 +50,7 @@ export class TodosController{ @ApiResponse({status: HttpStatus.OK, description: "Set todo parent", type: TodoEntity}) @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) @ApiResponse({status: HttpStatus.NOT_FOUND, description: "Todo not found"}) - async setTodoParent(@Req() req: any, @Param() todoIdDto: TodoIdDto, @Body() updateParentDto: UpdateParentDto): Promise{ + async setTodoParent(@Req() req: any, @Param() todoIdDto: IdDto, @Body() updateParentDto: UpdateParentDto): Promise{ return await this.todosService.setTodoParent(req.user.id, todoIdDto.id, updateParentDto.parent_id); } @@ -60,7 +60,7 @@ export class TodosController{ @ApiResponse({status: HttpStatus.OK, description: "Set todo completed", type: TodoEntity}) @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) @ApiResponse({status: HttpStatus.NOT_FOUND, description: "Todo not found"}) - async setTodoCompleted(@Req() req: any, @Param() todoIdDto: TodoIdDto, @Body() updateCompletedDto: UpdateCompletedDto): Promise{ + async setTodoCompleted(@Req() req: any, @Param() todoIdDto: IdDto, @Body() updateCompletedDto: UpdateCompletedDto): Promise{ return await this.todosService.setTodoCompleted(req.user.id, todoIdDto.id, updateCompletedDto.completed); } @@ -70,7 +70,7 @@ export class TodosController{ @ApiResponse({status: HttpStatus.OK, description: "Update a todo", type: TodoEntity}) @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) @ApiResponse({status: HttpStatus.NOT_FOUND, description: "Todo not found"}) - async updateTodo(@Req() req: any, @Param() todoIdDto: TodoIdDto, @Body() updateTodoDto: UpdateTodoDto): Promise{ + async updateTodo(@Req() req: any, @Param() todoIdDto: IdDto, @Body() updateTodoDto: UpdateTodoDto): Promise{ return await this.todosService.updateTodo( req.user.id, todoIdDto.id, @@ -89,7 +89,7 @@ export class TodosController{ @ApiResponse({status: HttpStatus.OK, description: "Delete a todo", type: TodoEntity}) @ApiResponse({status: HttpStatus.UNAUTHORIZED, description: "Invalid or missing access token"}) @ApiResponse({status: HttpStatus.NOT_FOUND, description: "Todo not found"}) - async deleteTodo(@Req() req: any, @Param() todoIdDto: TodoIdDto): Promise{ + async deleteTodo(@Req() req: any, @Param() todoIdDto: IdDto): Promise{ return await this.todosService.deleteTodo(req.user.id, todoIdDto.id); } } diff --git a/src/users/models/entities/user.entity.ts b/src/users/models/entities/user.entity.ts index aab0627..ae9190e 100644 --- a/src/users/models/entities/user.entity.ts +++ b/src/users/models/entities/user.entity.ts @@ -1,12 +1,21 @@ import {User} from "@prisma/client"; +import {ApiProperty} from "@nestjs/swagger"; export class UserEntity implements User{ - id: number; - username: string; - email: string; - password: string; - secret: string; - verification_code_id: number; - created_at: Date; - updated_at: Date; + @ApiProperty() + id: number; + @ApiProperty() + username: string; + @ApiProperty() + email: string; + @ApiProperty() + password: string; + @ApiProperty() + secret: string; + @ApiProperty() + verification_code_id: number; + @ApiProperty() + created_at: Date; + @ApiProperty() + updated_at: Date; }