기초적인 승인 시스템

제출된 보고서 중 무의미한 데이터를 방지하기 위해 ‘관리자’와 관리자의 승인 개념을 도입한다. 우선은 관리자는 배제한체 승인에 관해서만 구현한다.

우선 report.entity 파일에 속성을 추가하자

1
2
3
// report.entity.ts
  @Column({ default: false })
  approved: boolean;

이후 컨트롤러에 관련 메서드를 추가한다.

1
2
3
// reports.controller.ts
 @Patch('/:id')
  approveReport(@Param('id') id: string, @Body() body: ApprovedReportDto) {}

reports의 dtos 디렉토리에 새로운 dto를 추가하자. report dto 에도 approved 속성을 추가해야한다.

1
2
3
4
5
6
7
8
9
10
11
12
// approve-report.dto.ts
import { IsBoolean } from 'class-validator';

export class ApproveReportDto {
  @IsBoolean()
  approved: boolean;
}

// report.dto.ts
 @Expose()
  approved: boolean;

다시 컨트롤러로 돌아가서 마무리한다.

1
2
3
4
5
// reports.controller.ts
@Patch('/:id')
  approveReport(@Param('id') id: string, @Body() body: ApproveReportDto) {
    return this.reportsService.changeApproval(id, body.approved);
  }

서비스에 changeApproval도 추가해보자

1
2
3
4
5
6
7
8
9
10
// reports.service.ts
async changeApproval(id: string, approved: boolean) {
    const report = await this.repo.findOne({ where: { id: parseInt(id) } });
    if (!report) {
      throw new NotFoundException('report not found');
    }

    report.approved = approved;
    return this.repo.save(report);
  }

이제 API 테스트를 진행해본다. approved 가 false로 추가되어있으면 성공이다. id를 기억해두자

새로운 테스트도 추가하자. report 뒤에 id를 제공한다.

1
2
3
requests.http
PATCH http://localhost:3000/reports/3
content

approved가 true로 바뀌었는지 확인해보자.

Authentication VS Authorization

Authentication : 누가 요청했는가 파악하기

Authorization : 요청한 사람이 권한이 있는지 확인하기

기존 users 디렉토리 내 인터셉터는 쿠키를 통해 요청한 사람이 누구인지 확인하였다.

guards는 로그인 한사람이 누구인지 파악하는 기능이 있다.

따라서 우리는 AdminGuard를 만들어서 request.currentUser가 관리자인지 확인하도록 middleware를 추가할 계획이다.

권한 가드 추가하기

user entity에 관리자 속성을 추가하자

1
2
3
// user.entity.ts
@Column({ default: true })
  admin: boolean;

테스트를 위해 true로 설정해놓았다.

이제 src 폴더 내 guards 폴더에 admin.guard.ts 를 추가하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// admin.guard.ts
import { CanActivate, ExecutionContext } from '@nestjs/common';

export class AdminGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    if (!request.currentUser) {
      return false;
    }

    return request.currentUser.admin;
  }
}

컨트롤러에 연결한다.

1
2
3
4
5
6
7
8
// reports.controller.ts
import { AdminGuard } from '../guards/admin.guard';

@Patch('/:id')
  @UseGuards(AdminGuard)
  approveReport(@Param('id') id: string, @Body() body: ApproveReportDto) {
    return this.reportsService.changeApproval(id, body.approved);
  }

서버를 켜서 로그인하고 report를 보낸 후 승인하는 요청을 보내면 403 Forbidden 에러가 발생한다.

Guard가 제대로 작동하지 않는 이유

현재 요청의 Flow를 살펴보자

  1. 요청
  2. 쿠키-세션 미들웨어
  3. AdminGuard
  4. Request Handler
  5. Response

3-4, 4-5 사이에 CurrentUser Interceptor가 개입한다.

인터셉터를 middleware로 변경하여 AdminGuard 이전으로 순서를 변경해야 한다.

인터셉터는 미들웨어와 guard 이후에 실행된다. 따라서 미들웨어와 guard는 인터셉터가 생성하는 유저 정보나 인스턴스를 참조할 수 없다.

Interceptor 미들웨어로 바꾸기

user 폴더에 middlewares 폴더를 만들고 내부에 current-user.middleware.ts를 만들자. 주석처리부분은 일단 냅둬보

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// current-user,middleware.ts
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { UsersService } from ' ../users.service';

@Injectable()
export class CurrentUserMiddleware implements NestMiddleware {
  constructor(private usersService: UsersService) {}

  async use(req: Request, res: Response, next: NextFunction) {
    const { userId } = req.session || {};

    if (userId) {
      const user = await this.usersService.findOne(userId);
      // @ts-ignore
      req.currentUser = user;
    }

    next();
  }
}

user 모듈을 수정한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// users.module.ts
import { Module, MiddlewareConsumer } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { AuthService } from './auth.service';
import { User } from './user.entity';
import { CurrentUserMiddleware } from './middlewares/current-user.middleware';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService, AuthService],
})
export class UsersModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(CurrentUserMiddleware).forRoutes('*');
  }
}

API Client 테스트를 해보자. 정상적으로 보내진다.

Type 정의 에러 수정하기

새로 만든 미들웨어 파일에서 주석처리한 부분을 지우면 currentUser에 오류가 발생한다. currentUser 속성이 없기 떄문이다. 이를 해결하기 위해 요청 인터페이스를 수정한다.

1
2
3
4
5
6
7
8
9
10
11
// current-user.middleware.ts
import { User } from'../user.entity';

declare global {
    namespace Express {
        interface Request {
            currentUser?: User;
        }
    }
}

이렇게 하면 주석을 지워도 더이상 오류가 발생하지 않는다.

쿼리 문자열이 가져오는 정보의 유효성 검사하기

report의 dtos 디렉토리에 get-estimate.dot.ts를 만들자. create-report dto파일을 복사 붙여넣어서 불필요한 속성은 지워준다. price만 지워버린다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// get-estimate.dto.ts
import {
  IsString,
  IsNumber,
  Min,
  Max,
  IsLongitude,
  IsLatitude,
} from 'class-validator';

export class GetEstimateDto { // 이름 잘 바꿔주자
  @IsString()
  make: string;

  @IsString()
  model: string;

  @IsNumber()
  @Min(1930)
  @Max(2050)
  year: number;

  @IsNumber()
  @Min(0)
  @Max(1000000)
  mileage: number;

  @IsLongitude()
  lng: number;

  @IsLatitude()
  lat: number;
}

컨트롤러에 추가하고 API 테스트 코드도 추가한다.

1
2
3
// reports.controller.ts
 @Get()
 getEstimate(@Query() query: GetEstimateDto) {}
1
2
3
4
// requests.http

### Get an estimate for an existing vehicle
GET http://localhost:3000/reports?make=toyota&model=corolla&lng=0&lat=0&mileage=10000&year=1980

요청윈 위에 테스트 코드에 있는 속성을 넣는다. 서버를 키고 요청을 보내보면 오류가 발생한다.

우리가 숫자를 보내고 있지만 서버에선 이 숫자들을 문자열로 받아들이고 있기 떄문이다.

쿼리 문자열 데이터 변환하기

dto 파일을 수정하자

1
2
3
4
5
6
// get-estimate.dto.ts
 @Transform(({ value }) => parseInt(value))
 @IsNumber()
 @Min(1930)
 @Max(2050)
  year: number;

위처럼 문자열을 숫자로 받기 위한 처리가 필요하다. 모두 수정해주자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// get-estimate.dto.ts
import {
  IsString,
  IsNumber,
  Min,
  Max,
  IsLongitude,
  IsLatitude,
} from 'class-validator';
import { Transform } from 'class-transformer';

export class GetEstimateDto {
  @IsString()
  make: string;

  @IsString()
  model: string;

  @Transform(({ value }) => parseInt(value))
  @IsNumber()
  @Min(1930)
  @Max(2050)
  year: number;

  @Transform(({ value }) => parseInt(value))
  @IsNumber()
  @Min(0)
  @Max(1000000)
  mileage: number;

  @Transform(({ value }) => parseFloat(value))
  @IsLongitude()
  lng: number;

  @Transform(({ value }) => parseFloat(value))
  @IsLatitude()
  lat: number;
}

올바르게 받는지 확인하기 위해 컨트롤러의 getEstimate 메서드에 console.log(query);를 입력하고

API 테스트를 돌려 콘솔창을 확인한다. 숫자가 알맞게 들어온 것을 확인할 수 있다.

추정 가격 생성

기준을 세워 데이터에서 비슷한걸 찾자.

  1. 같은 제조사, 모델
  2. 경도 위도 차이가 5도 미만
  3. 연식 차이가 3년 이내
  4. 주행거리가 가장 가까운 레코드

기준의 근접한 보고서 3개를 찾아 해당 가격의 평균값을 반