diff --git a/src/app.module.ts b/src/app.module.ts index 0dbd5d2..6c2ed4d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; import { UserModule } from './user/user.module'; import { AuthModule } from './auth/auth.module'; import { User } from './entity/user'; +import { UserGroup } from './entity/user-group'; +import { UserRole } from './entity/user-role'; const moduleTypeOrm = TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -14,24 +16,19 @@ const moduleTypeOrm = TypeOrmModule.forRootAsync({ return { type: 'postgres', host: configService.get('DATABASE_HOST'), - port: parseInt(configService.get('DATABASE_PORT') as string,10), + port: parseInt(configService.get('DATABASE_PORT') as string, 10), username: configService.get('DATABASE_USER'), password: configService.get('DATABASE_PASS'), database: configService.get('DATABASE_NAME'), - entities: [User], + entities: [User, UserGroup, UserRole], + logging: true, // synchronize: true, }; }, - }); @Module({ - imports: [ - ConfigModule.forRoot(), - moduleTypeOrm, - UserModule, - AuthModule, - ], + imports: [ConfigModule.forRoot(), moduleTypeOrm, UserModule, AuthModule], controllers: [AppController], providers: [AppService], }) diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 2712002..98fd47b 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,21 +1,13 @@ -import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; +import { Controller, Post, Body, ValidationPipe } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { User } from '../entity/user'; +import { LoginRequestDto } from './dto/login-request.dto'; @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} @Post('login') - async login(@Body() body: { username: string; password: string }) { - console.info('login', body); - const user: User | null = await this.authService.validateUser( - body.username, - body.password, - ); - if (!user) { - throw new UnauthorizedException(); - } - return this.authService.login(user); + async login(@Body(new ValidationPipe()) body: LoginRequestDto) { + return await this.authService.login(body); } } diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 94bbc53..c5d76df 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,8 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { UserService } from '../user/user.service'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { User } from '../entity/user'; +import { LoginRequest, LoginResponse } from '../types'; @Injectable() export class AuthService { @@ -12,15 +13,38 @@ export class AuthService { ) {} async validateUser(username: string, pass: string): Promise { - const user = await this.userService.findByUsername(username); + const user = await this.userService.findByUsername(username, { + groups: { + roles: true, + }, + }); if (user && (await bcrypt.compare(pass, user.password))) { return user; } return null; } - login(user: { username: string; id: string | number }) { - const payload = { username: user.username, sub: user.id }; + async login(loginData: LoginRequest): Promise { + const user: User | null = await this.validateUser( + loginData.username, + loginData.password, + ); + + if (!user) { + throw new UnauthorizedException(); + } + + const roles: Set = new Set(); + for (const group of user.groups ?? []) { + for (const role of group.roles ?? []) { + roles.add(role.name); + } + } + const payload = { + username: user.username, + sub: user.id, + roles: Array.from(roles), + }; return { access_token: this.jwtService.sign(payload), }; diff --git a/src/auth/dto/login-request.dto.ts b/src/auth/dto/login-request.dto.ts new file mode 100644 index 0000000..11a2ee2 --- /dev/null +++ b/src/auth/dto/login-request.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class LoginRequestDto { + @IsString() + username: string; + + @IsString() + password: string; +} diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index d9720d5..af3b548 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -2,10 +2,11 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { Role } from './role.enum'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(private configService: ConfigService) { + constructor(configService: ConfigService) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, @@ -13,7 +14,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: { sub: string; username: string }) { - return { userId: payload.sub, username: payload.username }; + validate(payload: { sub: string; username: string; roles: Role[] }) { + return { + userId: payload.sub, + username: payload.username, + roles: payload.roles, + }; } } diff --git a/src/auth/role.enum.ts b/src/auth/role.enum.ts new file mode 100644 index 0000000..285b9c5 --- /dev/null +++ b/src/auth/role.enum.ts @@ -0,0 +1,4 @@ +export enum Role { + User = 'user', + Admin = 'admin', +} diff --git a/src/auth/roles.decorator.ts b/src/auth/roles.decorator.ts new file mode 100644 index 0000000..b687140 --- /dev/null +++ b/src/auth/roles.decorator.ts @@ -0,0 +1,5 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from './role.enum'; + +export const ROLES_KEY = 'roles'; +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/auth/roles.guard.ts b/src/auth/roles.guard.ts new file mode 100644 index 0000000..a6abc07 --- /dev/null +++ b/src/auth/roles.guard.ts @@ -0,0 +1,21 @@ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Role } from './role.enum'; +import { ROLES_KEY } from './roles.decorator'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles) { + return true; + } + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} diff --git a/src/data-source.ts b/src/data-source.ts index e7e8db2..b2fa416 100644 --- a/src/data-source.ts +++ b/src/data-source.ts @@ -3,6 +3,8 @@ import { DataSource } from 'typeorm'; import { User } from './entity/user'; import * as dotenv from 'dotenv'; +import { UserGroup } from './entity/user-group'; +import { UserRole } from './entity/user-role'; dotenv.config(); @@ -15,7 +17,7 @@ export const AppDataSource = new DataSource({ database: process.env.DATABASE_NAME, synchronize: false, logging: false, - entities: [User], + entities: [User, UserGroup, UserRole], migrations: [ 'src/migration/**/*.ts' ], diff --git a/src/entity/user-group.ts b/src/entity/user-group.ts new file mode 100644 index 0000000..3993e01 --- /dev/null +++ b/src/entity/user-group.ts @@ -0,0 +1,21 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { UserRole } from './user-role'; + +@Entity() +export class UserGroup { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true }) + name: string; + + @ManyToMany(() => UserRole) + @JoinTable() + roles: UserRole[]; +} diff --git a/src/entity/user-role.ts b/src/entity/user-role.ts new file mode 100644 index 0000000..928ab11 --- /dev/null +++ b/src/entity/user-role.ts @@ -0,0 +1,10 @@ +import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; + +@Entity() +export class UserRole { + @PrimaryGeneratedColumn() + id: number; + + @Column({ unique: true }) + name: string; +} diff --git a/src/entity/user.ts b/src/entity/user.ts index 4d8d289..7fdead2 100644 --- a/src/entity/user.ts +++ b/src/entity/user.ts @@ -1,4 +1,11 @@ -import { Entity, Column, PrimaryGeneratedColumn } from "typeorm" +import { + Entity, + Column, + PrimaryGeneratedColumn, + ManyToMany, + JoinTable, +} from 'typeorm'; +import { UserGroup } from './user-group'; @Entity() export class User { @@ -13,4 +20,8 @@ export class User { @Column() password: string; -} \ No newline at end of file + + @ManyToMany(() => UserGroup) + @JoinTable() + groups: UserGroup[]; +} diff --git a/src/migration/1761597406101-add_rbac.ts b/src/migration/1761597406101-add_rbac.ts new file mode 100644 index 0000000..829abd4 --- /dev/null +++ b/src/migration/1761597406101-add_rbac.ts @@ -0,0 +1,75 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRbac1761597406101 implements MigrationInterface { + name = 'AddRbac1761597406101'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_role" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, CONSTRAINT "UQ_31f96f2013b7ac833d7682bf021" UNIQUE ("name"), CONSTRAINT "PK_fb2e442d14add3cefbdf33c4561" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "user_group" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, CONSTRAINT "UQ_11b85d8d72220e3ca816d3e907a" UNIQUE ("name"), CONSTRAINT "PK_3c29fba6fe013ec8724378ce7c9" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "user_group_roles_user_role" ("userGroupId" integer NOT NULL, "userRoleId" integer NOT NULL, CONSTRAINT "PK_ebde92504ad1d97331b6b64a6df" PRIMARY KEY ("userGroupId", "userRoleId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_411df6d2b8a7e01aacc3c8a6ea" ON "user_group_roles_user_role" ("userGroupId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9172c01a353d2db376e54ee91b" ON "user_group_roles_user_role" ("userRoleId") `, + ); + await queryRunner.query( + `CREATE TABLE "user_groups_user_group" ("userId" integer NOT NULL, "userGroupId" integer NOT NULL, CONSTRAINT "PK_a4c39cf055515d3478562577ce4" PRIMARY KEY ("userId", "userGroupId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_372e638c75644389a7832a604e" ON "user_groups_user_group" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_235ca434168087abe1c665fd37" ON "user_groups_user_group" ("userGroupId") `, + ); + await queryRunner.query( + `ALTER TABLE "user_group_roles_user_role" ADD CONSTRAINT "FK_411df6d2b8a7e01aacc3c8a6eae" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_roles_user_role" ADD CONSTRAINT "FK_9172c01a353d2db376e54ee91bb" FOREIGN KEY ("userRoleId") REFERENCES "user_role"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "user_groups_user_group" ADD CONSTRAINT "FK_372e638c75644389a7832a604ed" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "user_groups_user_group" ADD CONSTRAINT "FK_235ca434168087abe1c665fd375" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_groups_user_group" DROP CONSTRAINT "FK_235ca434168087abe1c665fd375"`, + ); + await queryRunner.query( + `ALTER TABLE "user_groups_user_group" DROP CONSTRAINT "FK_372e638c75644389a7832a604ed"`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_roles_user_role" DROP CONSTRAINT "FK_9172c01a353d2db376e54ee91bb"`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_roles_user_role" DROP CONSTRAINT "FK_411df6d2b8a7e01aacc3c8a6eae"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_235ca434168087abe1c665fd37"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_372e638c75644389a7832a604e"`, + ); + await queryRunner.query(`DROP TABLE "user_groups_user_group"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_9172c01a353d2db376e54ee91b"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_411df6d2b8a7e01aacc3c8a6ea"`, + ); + await queryRunner.query(`DROP TABLE "user_group_roles_user_role"`); + await queryRunner.query(`DROP TABLE "user_group"`); + await queryRunner.query(`DROP TABLE "user_role"`); + } +} diff --git a/src/migration/1761597689677-add_rbac_to_admin.ts b/src/migration/1761597689677-add_rbac_to_admin.ts new file mode 100644 index 0000000..010fa53 --- /dev/null +++ b/src/migration/1761597689677-add_rbac_to_admin.ts @@ -0,0 +1,63 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRbacToAdmin1761597689677 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const idRoleAdmin = await this.doInsert(queryRunner, 'user_role', { + name: 'admin', + }); + console.info('idRoleAdmin', idRoleAdmin); + const idRoleUserManagement = await this.doInsert(queryRunner, 'user_role', { + name: 'user_manager', + }); + console.info('idRoleUserManagement', idRoleUserManagement); + const idGroupAdmin = await this.doInsert(queryRunner, 'user_group', { + name: 'admin', + }); + console.info('idGroupAdmin', idGroupAdmin); + + + await this.doInsert(queryRunner, 'user_group_roles_user_role', { + userGroupId: idGroupAdmin, + userRoleId: idRoleAdmin, + }); + + await this.doInsert(queryRunner, 'user_group_roles_user_role', { + userGroupId: idGroupAdmin, + userRoleId: idRoleUserManagement, + }); + + const users: object[] = (await queryRunner.query( + `SELECT * FROM "user" WHERE "username" = $1`, + ['admin'], + )) as object[]; + let userId: number | undefined = undefined; + if (users?.length > 0) { + const user = users[0] as { id: number }; + userId = user.id; + } + await this.doInsert(queryRunner, 'user_groups_user_group', { + userGroupId: idGroupAdmin, + userId: userId, + }); + } + + public async down(_queryRunner: QueryRunner): Promise { + // await queryRunner.query( + } + + private async doInsert( + queryRunner: QueryRunner, + table: string, + values: Record, + ): Promise { + console.info("inserting values",values); + const insertResult = await queryRunner.manager + .createQueryBuilder() + .insert() + .into(table) + .values(values) + .execute(); + + return insertResult.raw[0]?.id as string; + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..6c4a74a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,11 @@ + +export interface LoginRequest{ + username: string; + password: string; +} + +export interface LoginResponse{ + access_token: string; +} + + diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index 475e838..7413471 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -14,9 +14,13 @@ import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { User } from '../entity/user'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { Roles } from '../auth/roles.decorator'; +import { Role } from '../auth/role.enum'; +import { RolesGuard } from '../auth/roles.guard'; @Controller('users') -@UseGuards(JwtAuthGuard) +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.Admin) export class UserController { constructor(private readonly userService: UserService) {} @@ -45,6 +49,7 @@ export class UserController { return this.userService.update(+id, updateUserDto); } + @Roles(Role.Admin) @Delete(':id') remove(@Param('id') id: string): Promise { return this.userService.remove(+id); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index f1eed15..b8a0bba 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from '../entity/user'; import * as bcrypt from 'bcrypt'; +import { FindOptionsRelations } from 'typeorm/find-options/FindOptionsRelations'; @Injectable() export class UserService { @@ -19,8 +20,11 @@ export class UserService { return this.usersRepository.findOneBy({ id }); } - findByUsername(username: string): Promise { - return this.usersRepository.findOne({ where: { username } }); + findByUsername( + username: string, + relations: FindOptionsRelations, + ): Promise { + return this.usersRepository.findOne({ where: { username }, relations }); } async create(user: Partial): Promise {