[Nest.js V9] 정리 노트 - 9

세션 인증과 사용자 검증 인터셉터

Intro


시작


사용자 인증을 추가해봅시다. 기본적인 흐름은 다음과 같습니다.

  1. 클라이언트는 회원가입 요청(POST)을 서버측에 요청합니다. 이메일과 비밀번호가 요청 데이터로 전송되겠죠.
  2. 서버클라이언트가 전송한 이메일이 이미 존재하는지 검사합니다. 있다면 에러를 보냅니다.
  3. 패스워드를 단방향 암호화 합니다. 유저 레코드를 DB에 저장합니다. 쿠키를 응답데이터에 추가합니다. 단 이때 쿠키 값에는 사용자의 id값이 저장됩니다.
  4. 클라이언트는 브라우저, 모바일 기기를 이용하겠죠. 쿠키는 자동으로 관리됩니다.
  5. 이후 클라이언트의 후속 요청에는 쿠키가 담겨 서버로 발송됩니다.
  6. 서버클라이언트의 요청 데이터에서 쿠키를 꺼내 변조 여부를 검사하고 DB에 존재하는 사용자 인지 검증하고 요청을 처리합니다.

AuthService를 새로 추가합니다. UsersService의 일부 메서드를 필요로 하므로 서비스 생성 후 의존성을 주입하도록 합니다.

AuthService 생성하기


의존 관계를 다시 정리합니다.

  1. UsersControllerUsersService, AuthService를 의존합니다.
  2. UsersServiceUsersRepository를 의존합니다.
  3. AuthServiceUsersService를 의존합니다.

다음과 같이 AuthService를 생성하고 users.module.ts모듈내 providers배열에 추가합니다.

ts
// users/auth.service.ts
import { Injectable } from "@nestjs/common";
import { UsersService } from "./users.service";

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}
}

이메일 검증하기


ts
async signup(email: string, password: string) {
  // 이메일이 존재하는지 검증
  const users = await this.usersService.find(email);

  if(users.length) {
    throw new BadRequestException('email is use');
  }
}

회원가입 구현


패스워드를 문자열 그대로 저장해선 안됩니다. 해싱 함수를 이용해 암호화된 데이터로 저장해야 합니다.

문자열을 인자로 하고 해싱함수를 호출하면 완전히 다른 해시 문자가 생성됩니다. 이때 인자로 발송되는 문자열의 해시값은 항상 동일합니다. 다만 입력값이 변경된다면 반환되는 해시 문자는 완전히 달라집니다.

입력이 조금이라도 다르면 다른 결과를 나타내는게 첫번째 특징입니다. 두번째 특징은 해시한 이후 원래의 문자열로 되돌아갈 수 없다는 것 입니다.

가입과정에서 문자열을 인자로 보내 해싱 함수를 호출하고 해싱 문자를 DB에 저장합니다. 후속으로 로그인 할 경우 인자로 전달되는 문자열이 이전과 다른 경우 해시는 일치하지 않습니다.

다만 이 해시 또한 약점이 존재합니다. 사용자는 다른 웹사이트에서도 거의 동일한 비밀번호를 사용한다는 것입니다.

기본적인 대처 방법은 우리쪽 DB가 유출되더라도 해시 함수로 통해 만들어진 해싱 문자의 원문을 숨기기 위해 salt가 추가 되어야 합니다.

더 좋은 보안책은 이전 엑세스 IP가 다른 경우 추가 인증이 없다면 로그인을 방지하는 등의 조치가 필요하겠죠.

다음과 같이 구현합니다.

ts
// users/auth.service.ts
import { randomBytes, scrypt as _scrypt } from "crypto";
import { promisify } from "util";

// _scrypt는 콜백 기반의 함수이므로, promisify를 사용하여 Promise 기반으로 변환합니다.
// 이를 통해 async/await 문법을 사용해 비동기적으로 처리할 수 있게 됩니다.
const scrypt = promisify(_scrypt);

@Injectable()
export class AuthService {
  constructor(private usersService: UsersService) {}

  /**
   * @desc 회원가입
   */ 
  async signup(email: string, password: string) {
    // 이메일이 존재하는지 검증
    const users = await this.usersService.find(email);

    if(users.length) {
      throw new BadRequestException('email is use');
    }

    // 비밀번호 암호화

    // 8바이트 크기의 무작위 데이터를 생성하고 이를 16진수 문자열로 변환합니다. 문자열은 16자 생성
    const salt = randomBytes(8).toString('hex');

    // 평문과 salt 문자열을 섞은 32바이트 생성
    // typescript의 경우 scrypt의 반환 타입은 unknown으로 인식하기 때문에 Buffer 타입으로 명시
    const hash = (await scrypt(password, salt, 32)) as Buffer;

    // 32바이트를 16진수로 변환하고 salt.해시 문자열 변환
    const result = salt + '.' + hash.toString('hex');

    // 유저 레코드 저장
    const user = await this.usersService.create(email, result);

    // 생성된 유저 데이터 반환
    return user;
  }
}

로그인 구현


로그인에는 저장된 salt.hash와 일치하는지 검증하기 위한 로직이 추가됩니다.

ts
// users/auth.service.ts
async signin(email: string, password: string) {
  // 인증
  const [user] = await this.usersService.find(email);

  if(!user) {
    throw new NotFoundException('user not found');
  }

  const [salt, storedHash] = user.password.split('.');

  const hash = await (scrypt(password, salt, 32)) as Buffer;

  if (storedHash !== hash.toString('hex')){
    throw new BadRequestException('bad password');
  }
  
  return user;
}

세션 쿠키 설정하기


로그인에 성공한 경우 클라이언트에게 전달할 쿠키에 세션을 설정합니다.

관련 라이브러리를 설치합니다.

bash
yarn add cookie-session @types/cookie-session

main.ts 에서 cookieSession 을 사용하도록 수정합니다.

ts
// main.ts
const cookieSession = require('cookie-session');

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieSession({
    // 쿠키에 저장된 세션 데이터를 암호화하는 데 사용되는 키입니다.
    // 배열로 여러 키를 제공할 수 있으며, 이를 통해 키 회전을 구현할 수 있습니다.
    // 여기서는 'dsaf4382d1'이라는 문자열을 암호화 키로 사용합니다.
    keys: ['dsaf4382d1']
  }))
  // ...
}

기존 회원가입, 로그인 라우팅을 수정합니다. 로그아웃 API도 추가합니다.

ts
// users.controller.ts

// users/auth
@Post('/signup')
async createUser(@Body() body: CreateUserDto, @Session() session: any) {
  const user = await this.authService.signup(body.email, body.password);
  session.userId = user.id;
  return user;
}

// users/auth
@Post('/signin')
async signin(@Body() body: CreateUserDto, @Session() session: any) {
  const user = await this.authService.signin(body.email, body.password);
  session.userId = user.id;
  return user;
}

@Post('signout')
signOut(@Session() session: any) {
  session.userId = null;
}

회원가입, 로그인을 수행하면 클라이언트는 세션 쿠키를 소유하게 됩니다. 이 상태에서 서버로 다시 요청을 보내는 예제입니다.

로그아웃 이후에는 기존에 얻고자 했던 응답은 얻지 못합니다.

ts
// users.controller.ts
@Get('/whoami')
whoAmI(@Session() session: any) {
  return this.usersService.findOne(session.userId);
}

// users.service.ts
findOne(id: number) {

  if(!id) {
    return null;
  }

  return this.repo.findOne({
    where: {id}
  })
}

인터셉터를 활용해 사용자 검증하기


데코레이터와 인터셉터를 결합해 사용자 검증을 구현합니다.

데코레이터 코드는 다음과 같습니다.

ts
// src/users/decorators/current-user-decorator.ts
import {
  createParamDecorator,
  ExecutionContext
} from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  // CurrentUser 데코레이터는 인수를 받지 않을 것이므로 인자를 받는 첫번째 인자에 never타입을 지정합니다.
  (data: never, context: ExecutionContext) => {
    // context HTTP 요청을 가져옵니다.
    const request = context.switchToHttp().getRequest();

    request.session.userId

    return 'hi there!'; // 현재 이 데코레이터는 요청을 처리하지 않고 단순히 'hi there!' 문자열을 반환합니다.
  }
);

여기서 문제가 있습니다. request.session.userId로 유저 ID까지 가져오는데 성공합니다만. 실제 사용자가 존재하느지 검증하기 위해 usersService를 의존해야만 합니다. 다만 데코레이터는 의존성을 주입할 수 없습니다.

때문에 데코레이터 뿐만 아니라 인터셉터를 추가해 의존성을 주입해야 합니다.

ts
// src/users/decorators/current-user-decorator.ts

import {
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Injectable
} from '@nestjs/common'
import { UsersService } from '../users.service'
import { Observable } from 'rxjs';

@Injectable()
export class CurrentUserInterceptor implements NestInterceptor {
  // UsersService를 주입받아 사용하기 위해 생성자를 통해 주입합니다.
  constructor(private uesrsService: UsersService) {}

  // 인터셉터는 요청이 컨트롤러로 전달되기 전에 실행됩니다.
  async intercept(context: ExecutionContext, handler: CallHandler<any>): Promise<Observable<any>> {
    const request = context.switchToHttp().getRequest()
    const { userId } = request.session || {};

    if (userId) {
      const user = await this.uesrsService.findOne(userId);
      request.currentUser = user;
    }

    return handler.handle();
  }
}

추가로 UsersService를 주입하여 사용하기 위해 모듈내 providers 배열에 추가해야합니다.

ts
// src/users/users.module.ts
import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [UsersService, AuthService, CurrentUserInterceptor]
})

데코레이터 또한 수정합니다. 인터셉터 이후 실행되므로 request에서 currentUser를 반환합니다.

ts
import {
  createParamDecorator,
  ExecutionContext
} from '@nestjs/common';

export const CurrentUser = createParamDecorator(
  // CurrentUser 데코레이터는 인수를 받지 않을 것이므로 인자를 받는 첫번째 인자에 never타입을 지정합니다.
  (data: never, context: ExecutionContext) => {
    // context HTTP 요청을 가져옵니다.
    const request = context.switchToHttp().getRequest();
    return request.currentUser;
  }
);

이제 컨트롤러를 수행하여 CurrentInterceptor 인터셉터를 실행하여 사용자가 존재하는지 검증한 뒤 CurrentUser 데코레이터를 이용해 사용자 정보를 가져오는 API를 완성합니다.

ts
@Controller('users')
@Serialize(UserDto)
@UseInterceptors(CurrentUserInterceptor)
export class UsersController {
  // ...

  @Get('/whoami')
  whoAmI(@CurrentUser() user: User) {
    return user;
  }
}

사용자 검증에 대한 기능상으로는 모든 요건을 충족했지만 추가 컨트롤러가 생성될 경우 매번 인터셉터를 설정해야 합니다. 전역의 컨트롤러에서 작동시키기 위해 방법을 바꿔봅시다.

글로벌 스코프 인터셉터


users.controlle.ts에서 @UseInterceptors(CurrentUserInterceptor) 구문을 제거합니다.

그리고 users.module.ts 내용을 다음과 같이 수정해 모든 요청이 CurrentUserInterceptor 를 거친 후 컨트롤러에 도달하도록 수정합니다.

ts
// src/users/users.module.ts
import { UsersService } from './users.service';
import { AuthService } from './auth.service';
import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';

@Module({
  // ...
  providers: [
    UsersService, 
    AuthService, 
    {
      provide: APP_INTERCEPTOR,
      useClass: CurrentUserInterceptor
    }
  ]
})
export class UsersModule {}

이제 서버에서 받는 모든 요청은 CurrentUserInterceptor를 거치게 됩니다. 여기에 더해 이제 사용자 엑세스 권한을 요구하는 컨트롤러에 검증된 사용자가 아니려먼 403 에러를 자동으로 응답하는 Guard라는 데코레이터를 추가해봅시다.

Guard 사용하기


Nest 에서는 컨트롤러에 도달하기전 요청에 대한 검증을 수행하는 Guard라는 데코레이터를 지원합니다. 특정 컨트롤러에만 적용하는것이 가능하기 때문에 위 전역 스코프 인터셉터의 문제를 보완할 수 있습니다.

우선 Guard 내부에는 canActivate 라는 메서드가 존재합니다.

  • canActivate() : 요청이 들어오면 자동으로 호출되며 요청을 검증해 참, 거짓을 반환합니다. 참인 경우 검증에 성공, 거짓(null, 0, '', false 등)인 경우 요청은 자동으로 거부됩니다.

Guard는 또한 인터셉터와 매우 유사하게 작동합니다. 다만 NestInterceptor를 구현하지 않고 CanActivate를 구현합니다.

ts
// src/
import { CanActivate, ExecutionContext } from "@nestjs/common";
import { Observable } from "rxjs";

export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    const request = context.switchToHttp().getRequest();

    return request.session.userId;
  }
}

이 과정을 통해 모든 사용자 요청에서 쿠키내 존재하는 세션 데이터 중 userId를 가져옴과 동시에 존재하는 사용자인 경우 인터셉터 내에서 request 에 currnetUser 라는 사용자 정보를 추가합니다. 그리고 @CurrentUser 데코레이터로 유저 정보를 가져올 수 있죠. 그리고 사용자 엑세스 권한을 요구하는 컨트롤러의 경우 Guard를 적용하여 엑세스 권한을 검증합니다.