Authentication

onyoo·2023년 1월 19일
0

NestJs

목록 보기
8/8
post-thumbnail

Overview

  1. 클라이언트에서 가입 페이지를 통해 데이터를 서버로 보낸다
  2. 이메일이 이미 사용중인지 확인하고 만약, 사용중이라면 에러를 리턴한다
  3. 비밀번호를 암호화 하여 데이터베이스에 저장한다
  4. 가입요청을 한 사람에게 다시 응답을 보낸다,새로 만들어진 레코드에 연결된 ID를 포함하는 쿠키로 말이다.
  5. 브라우저에서 쿠키를 자동으로 관리한다. 만약, 브라우저에서 후속요청을 보내면 요청에 첨부된 쿠키 내부에 해당 데이터를 자동으로 포함한다.

여기에서 서버가 처리해야할 내용은 바로 내부에 저장된 정보를 살펴보는 것이다.쿠키안에 있는 정보등의 것들이 조작되지 않았음을 확인해야한다.확인을 마쳤다면 요청한 사람이 누구인지 확인해야하고, 쿠키에 적힌 ID의 사용자가 누구인지 확인해야한다 우리가 가진 데이터베이스를 이용해서 말이다.

이러한 인증 흐름은 NestJs 뿐만 아니라 다른 프레임워크에서 또한 똑같기 때문에 잘 알아두어야한다.

이제 인증흐름에 대한 대략적인 이야기를 마쳣으니 우리가 작성해야할 코드가 어떻게 구성될 것인지에 대해서 의논해보자.

User Service

첫번째로 이야기할 부분은 바로 유저서비스와 관련된 부분이다.

유저서비스에 더 많은 데이터를 저장해야한다, 해야할 일은 다음과 같다.

  • email을 사용하고있는지 중복을 확인하는 것
  • 유저의 패스워드를 암호화하는 것
  • 가입한 유저의 데이터를 저장하는 것
  • 유저아이디를 포함한 쿠키를 사용자에게 되돌려보내는 것

이러한 인증관련 항목을 구현하는데에 있어서 두가지 방향으로 구현할 수 있다.

첫번째, 기존 유저 서비스에 새로운 메서드를 추가하는 방향이다.

두번째, 인증서비스라는 새로운 서비스를 만들어 로그인을 구현한다. 단, User 데이터를 가지고 구현하는 논리들은 이미 user 서비스에 구현되어있기 때문에 user service를 상속받아 auth를 구현할 예정이다.

두 방법 모두 장단점이 뚜렷하다. 어플리케이션의 규모가 작다면 첫번째 옵션이 나쁘지 않은 선택일 것이다. 그러나 어플리케이션의 규모가 커진다면 두번째 옵션을 사용하는 것이 적절하다. 그 이유에 대해서 얘기해보자.

어느 시점에서 사용자와 관련된 기능을 더 추가하고 싶다고 상상해보자. 이를테면 비밀번호 재설정과 같은 기능을 구현하고 싶다고 가정해보자. 그럼 그와 관련된 기능을 구현하기 위해서 구현해야하는 함수가 더욱 많아질 것이고 사용자와 직접적으로 연관된 것이 아닌 사용자와 간접적으로 연관된 기능이 많아질 것이다. 물론, 어플리케이션의 규모가 커질때에만 이러한 문제가 발생하는 것이지만 우리는 규모가 큰 어플리케이션을 만든다는 가정하에 후자의 옵션으로 인증 서비스를 구현하고자한다.

Auth Service 구현하기

User Module의 계층구조

Auth service를 작성하기 전 우리는 UserModule의 구조에 대해서 간략하게 알아볼 필요가 있다.

Users Controller 는 사용자 서비스에 대한 액세스 권한을 얻어야 한다. 사용자를 제거하고, 사용자를 가져오고, 사용자를 삭제하고,사용자를 업데이트 하는 등등 모든 종류의 것들을 시도하기 때문이다. 여기에 덧붙여서 Users Controller 는 가입과 등록을 위해 새로운 Auth Service 를 사용해야한다. 이렇게 되면, Auth Service 는 사용자를 생성하고 쿼리해야하는데,그러기 위해서 Users Service 를 사용해야한다 ! 이렇게 구성된 Auth ServiceUsers Repository 에 직접 접근하지 않을 것이다. 그러면 어떤것을 이용하여 접근하느냐 ? 바로 Users Service 를 이용하여 접근 할 것이다.

Auth Service 를 코드로 작성해보자.

import { Injectable } from '@nestjs/common';
import { UsersService } from './users.service';

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

Auth Service를 구현해볼것이다. 일단, User 폴더에 auth.service.ts 라는 파일을 생성한다. 해당 서비스를 컨트롤러에서 가져다 쓸 것이기 때문에 종속성 주입 가능이라는 Injectable 데코레이터를 넣어준다. 그리고 마지막으로 생성자를 이용하여 유저 서비스를 사용할것임을 표현해준다.

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { AuthService } from './auth.service';

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

이렇게 작성된 코드는 User 과 관련되어있기 때문에 User Module에 Import 해주어야한다. providers에 Auth Service를 추가해서 다른 코드에서 해당 코드를 종속성 주입할 수 있도록 해준다.

이제 본격적으로 Auth Service에 들어갈 메소드를 작성해볼것이다.

첫번째로 가입하는 로직을 구현할 것이다. 가입하는 로직의 경우 다음과 같은 순서를 거쳐 진행된다.

  1. 같은 이메일을 이용하여 가입한 사용자가 있는지 확인한다
  2. 유저 비밀번호를 암호화여 저장한다
  3. 새로운 유저를 생성하고 저장한다
  4. 생성된 유저를 반환한다

일단, 첫번째 단계의 코드를 작성할 것이다. 우리는 같은 이메일을 이용하는 사용자가 있는지 확인하는 메서드를 이미 UserService에 구현해두었다. 바로 find 메서드이다. 해당 메서드의 경우 email을 이용하여 사용자를 찾고 사용자가 여럿일 경우 여러명의 사용자를 리턴한다.

find(email: string) {
    return this.repo.find({ where: { email } });
    //해당하는 모든 레코드
}

이 메서드를 이용하여 기능을 구현할 것이다.

import { BadRequestException, Injectable } from '@nestjs/common';
import { UsersService } from './users.service';

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

  async signup(email: string, password: number) {
    //같은 이메일을 이용하여 가입한 사용자가 있는지 확인한다
    **const users = await this.usersService.find(email);
    if (users.length){
        throw new BadRequestException('email in use');
    }**
    // 유저 비밀번호를 암호화여 저장한다
    

    //새로운 유저를 생성하고 저장한다

    //생성된 유저를 반환한다
  }
  //가입
  signin() {}
}

보면, usersService에 선언되어있는 함수를 이용하여 유저 데이터를 받는다. 만약 받은 유저 데이터의 길이가 1일경우. 이미 데이터가 존재하기 때문에 email을 이미 사용하고 있다는 에러를 리턴한다.

비밀번호 Hashing 과정 이해하기

해싱함수는 사용자 암호를 처리하는 모든 작업에 있어서 가장 중요한 부분이다! 해싱함수는 암호저장에만 사용되는 것이 아니라 다양한 보안에 사용된다. 우리는 해싱함수를 구현하는 것이 아니라 노드에 구현된 해싱 함수 라이브러리를 사용할 것이다.

우리의 경우 일반 문자열인 비밀번호를 일반 Input 값으로 해시함수에 넣어서 일종의 디지털 지문인 결과값을 저장할것이다.

여기에서 우리가 가지게 되는 해싱함수서 나온 결괏값은 다음과 같은 특징을 가진다.

  1. 입력을 변경하면 완전히 되돌릴 수 없다. 즉, 동일한 문자열을 정확하게 입력해야만 동일한 출력이 반환된다.
  2. 암호화된 문자열만 봐서는 원본 문자열이 무엇인지 알 수 없다

우리가 작성할 일련의 회원가입 절차는 해시함수를 이용하여 다음과 같이 진행될 것이다.

이러한 방법으로 이메일은 암호화 과정을 거치지 않고 데이터베이스에 저장하나, 비밀번호의 경우 암호화 과정을 거쳐 데이터베이스에 저장한다. 그러나 이러한 방법이 진짜 안전하다고 할 수는 없다.

레인보우 테이블 공격에는 취약할 수 밖에 없다.

공격자는 자주 사용되는 문자열에 대해서 해시의 출력값을 데이터베이스에 저장한다. 그리고 사용자에게서 비밀번호 데이터를 갈취했을 때 이미 암호화된 데이터를 갈취한 데이터와 비교한다. 이렇게 되면 이미 암호화 된 값들을 가지고 있기 때문에 단순 비교를 통해 같은 값이면 비밀번호의 원본 문자열을 획득할 수 있다.

이러한 공격을 받을 수 있기 때문에 우리는 가입절차와 서명절차를 조금 더 복잡하게 만들것이다.

우리는 salt란 값을 새로 추가하여 해싱함수를 거친 값에 넣어 줄 것이다. salt를 넣는게 레인보우 어택과 어떤 연관이 있는지 알아보자.

우리는 원문의 비밀번호 값에 salt값을 넣어 해시함수에 입력값으로 줄 것 이다. 앞에서 말했던 해시함수의 특징 중 하나인 “문자열의 하나만 달라져도 모든 값이 완전히 달라진다” 라는 이것이 salt값을 추가하는 이유이다.

만약 데이터베이스의 암호화된 값이 털렸다고 해도, salt값의 하나의 문자열만 변경 해도 원본 문자열을 유추할 수 없다. 즉, 원본 문자열의 변화 없이 salt값을 변경하는 것 만으로도 공격에 방어할 수 있다. 레인보우 테이블 공격의 경우 모든 경우의 수를 데이터베이스에 저장한다고 하지 않았는가 ? 그러면 다시 생각해보자, 공격자가 salt값이 바뀔때마다 모든 데이터를 다시 데이터베이스에 새로 저장할것같은가 ? 아마 아닐것이다. salt값이 변경될때마다 아예 다른 값을 가지기때문에 공격에 어려움을 가질것이다.

이러한 연유로 우리는 salt를 추가한 해시함수를 이용하여 비밀번호를 인증할 것이다.

참고자료

암호화 로직 구현하기

암호화 로직을 구현하기 위해서 auth.service.ts 로 다시 돌아가보자.

import { BadRequestException, Injectable } from '@nestjs/common';
import { UsersService } from './users.service';
**import { randomBytes, scrypt as _scrypt } from 'crypto';
//암호화를 위한 모듈 추가 순서대로, salt를 위한 랜덤생성 , 암호화를 위한 메서드
import { promisify } from 'util';
//scrypt 함수는 비동기 방식으로 이루어지기 때문에 콜백을 사용하지 않기 위해 promisify로 래핑함

const scrypt = promisify(_scrypt);
//promisify 래핑, 처음에 _scrypt로 선언한 것은 래핑하고 나서 scyrpt로 사용하고 싶기 때문**

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

  async signup(email: string, password: string) {
    //같은 이메일을 이용하여 가입한 사용자가 있는지 확인한다
    const users = await this.usersService.find(email);
    if (users.length) {
      throw new BadRequestException('email in use');
    }
    // 유저 비밀번호를 암호화여 저장한다
    // salt 생성
    const salt = randomBytes(8).toString('hex');

    // salt와 비밀번호를 해싱한다
    const hash = (await scrypt(password, salt, 32)) as Buffer;

    // 해싱된 비밀번호와 salt를 결합한다.
    const result = salt + '.' + hash.toString('hex');

    //새로운 유저를 생성하고 저장한다
    const user = await this.usersService.create(email, result);

    //생성된 유저를 반환한다
    return user;
  }
  //가입
  signin() {}
}

콜백으로 코드를 짜는 것을 피하기 위해 promisify 를 사용하여 래핑을 해주어서 해시함수를 사용한다.

랜덤 8바이트로 이루어진 salt 값을 생성한뒤, 해당 salt 값과 비밀번호를 같이 해싱한다. 여기에서 조금 주의해야할 점은 hashing 된 값이 어떤 값인지 typescript가 모르기 때문에 buffer 값이라고 알려준다.

그렇게 해싱된 값에 salt값을 이어붙인다. 해당 값을 비밀번호로 저장하여 유저를 반환하면 가입 로직 작성이 완료된다.

users.controller.ts

export class UsersController {
  constructor(
    private usersService: UsersService,
    private authService: AuthService, // 새로운 서비스 추가
  ) {}
  @Post('/signup')
  createUser(@Body() body: CreateUserDto) {
    return this.authService.signup(body.email, body.password);
  }
}

아래는 그렇게 해서 저장한 이메일주소와 비밀번호값이다.

Sign in Service 구현하기

로그인을 구현하는 것은 위에서 구현했던 회원가입과 거의 유사하다고 보면 된다. 코드를 하나씩 살펴보자.

async signin(email: string, password: string) {
    const [user] = await this.usersService.find(email);
		// 한명의 유저를 찾았다고 가정한다
    if (!user) {
      throw new NotFoundException('user not found!');
    }
    const [salt, stored_hash] = user.password.split('.');
    const hash = (await scrypt(password, salt, 32)) as Buffer;

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

유저를 이메일을 이용하여 찾은 뒤, 유저를 찾지 못할경우는 에러를 발생시키고 종료하지만. 유저를 찾았을 경우 password에 저장된 hash 값과 salt값을 분리하여 저장되어있는 암호화된 비밀번호값과 입력된 비밀번호를 salt값과 해싱하여 두 값이 일치하는지 확인한다.

@Post('/signin')
  signin(@Body() body: CreateUserDto) {
    return this.authService.signin(body.email, body.password);
}

이렇게 작성한 로그인 코드는 컨트롤러에서 다음과 같이 사용하면 된다.

쿠키를 이용하여 세션 만들기

  1. 쿠키 세션 라이브러리를 이용하여 Cookie 헤더를 조회한다.
  2. 쿠키 세션은 문자열을 디코딩하여 객체를 만든다.
  3. 요청 핸들러에 있는 데코레이터를 사용하여 세션객체에 접근한다.
  4. 세션 오브젝트에 있는 요소를 추가,삭제,변경 한다.
  5. 쿠키 세션은 업데이트된 세션을 보고 암호화 된 문자열로 변환한다.
  6. 문자열은 응답객체안에 Set Cookie 헤더에 보내진다.

이 일련의 과정을 코드로 나열해보고 어떻게 응답이 가고 어떻게 응답이 돌아오는지 확인해보자.

@Get('/colors/:color')
setColor(@Param('color') color: string, @Session() session: any) {
    session.color = color;
  }

세션과 함께 색상을 설정한다, 이때 쿠키를 이용하여 색상을 암호화한다.

요청을 할때 돌아오는 응답은 다음과 같다.

@Get('/colors')
  getColor(@Session() session: any) {
    return session.color;
 }

위의 요청을 보낸 다음 , 이 요청을 보내면 쿠키와 함께 암호화되었던 원본값이 돌아온다!

지금까지 간단한 컨트롤러를 만들어서 쿠키를 사용하여 세션이 어떻게 형성되는지 확인해보았다.

그러면 이제 유저 로그인을 구현해보자.

가입,로그인 기능 구현하기

일단, 가입과 로그인에 대한 컨트롤러를 만들 것이다. 앞에서 auth.service.ts 에 작성했던 서비스를 바탕으로 가입기능과 로그인기능을 작성할 것이다.

 @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;
  }

  @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;
  }

앞에서 작성했던 예시코드와 비슷하게, 세션을 데코레이터로 선언한 다음 auth service에서 받은 유저 정보를 세션에 넣어줄 것이다. 아직 완성이 된 것은 아니지만 요청을 보내서 어떻게 답변이 나오는지 확인해보자.

### Create a new user
POST http://localhost:3000/auth/signup
content-type: application/json

{
  "email": "test@test.com",
  "password": "12345"
}

쿠키에 session id가 함께 날라온다!

### Sign in as an existing user
POST http://localhost:3000/auth/signin
content-type: application/json

{
  "email": "test@test.com",
  "password": "12345"
}

만약에, 여기서 새로운 세션을 생성해서 보내지 않는 한, 아까 받았던 쿠키의 값이 변경되지 않을 것이다.

즉, 새로운 데이터로 로그인할 경우가 아니라면 쿠키에는 우리가 아까 생성했던 쿠기의 값이 유지된다는 소리다.

이제 우리의 다음 목표로 더 나아가보자, 여기서 나아가 우리는 누가 로그인 했는지 알아내는 것이 최종 목표이다.

즉, 누가 이 쿠키를 제출했는지 데이터베이스에서 알아내야한다.

현재 사용하고있는 세션의 주인이 누구인지 알아보기 위해 우리는 whoami라는 컨트롤러를 만들것이다.

코드는 다음과 같다.

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

세션에 있는 객체값을 이용하여 해당 아이디가 데이터베이스에 있는지 확인한다.

우리가 로그인하고 있는 유저의 아이디가 다음과 같다는 것을 알려주는 응답이다.

로그아웃기능 구현하기

로그아웃 기능은 단순합니다. 현재 저장하고 있는 쿠키값을 비워주면 된다.

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

다만 추가적으로 수정해야할 부분이 있다. 우리가 로그아웃을 테스트한 뒤, 현재 세션에 있는 값이 비어있는지 확인을 하기 위해 whoami로 조회를 하면 우리가 원하는데로 작동하지 않는다. 우리가 원하는 값은 null 값을 가지고 있는 것이지만, 아까 로그아웃하기전 가지고 있던 세션아이디도 아니고 데이터베이스에 있는 데이터 하나를 조회한다. 이는 whoami 라우터가 가져다 쓰고있는 user service 코드가 잘못되어있음을 의미한다.

findOne(id: number) {
    return this.repo.findOneBy({ id });
    //해당하는 단 하나의 레코드
  }

우리가 사용한 findone 함수의 경우 단 하나의 레코드를 리턴하도록 구현되어있지만 id가 없는 경우의 예외처리를 해주지 않았다. 이럴경우 가장 앞에있는 레코드 하나를 조회하여 가져오기때문에 우리가 원하는 결과값을 반환하지 못했던 것이다. 그래서 우리는 null 값에 대한 예외처리를 하기 위해 다음과 같이 예외 처리 코드를 추가했다.

findOne(id: number) {
    if (!id) {
      return null;
    }
    return this.repo.findOneBy({ id });
    //해당하는 단 하나의 레코드
  }

즉,null값의 아이디가 들어오면 null 값을 리턴한다는 조건문을 추가해서 쿠키가 초기화되었을 경우를 고려할 수 있게 되었다.

이렇게 되면 로그아웃 기능에 대해서는 마무리가 된다 (물론 완벽한 마무리는 아니고,돌아오는 응답값에 대한 상태를 변경한다든가.. 그런 수정이 가능한 상태이긴 하다)

중간정리

우리가 지금까지 구현한 기능은

회원가입

로그인

로그아웃

현재 로그인한 사용자가 누구인지 조회

이 네가지 기능은 제법 Auth 기능으로서 훌륭하다! 그러나 여기서 두가지가 더 추가될 것이다. 프로젝트 내부의 다른곳에서 사용할 수 있도록 돕는 도구의 역할을 하는 두가지를 살펴보자.

  1. 사용자가 로그인하지 않은 경우 특정 핸들러에 대한 요청을 거부하는 것
  2. 현재 로그인해있는 유저가 누구인지 자동으로 알려주는 핸들러,즉 현재 서명된 최종 사용자가 누구인지(본질적으로 요청을 하는 사람이 누구인지)

위의 두가지 기능은 함수 혹은 인터셉터, 데코레이터 등으로 구현할 것이다.우리는 이것들을 핸들러 내에서 사용하여 자동화하기만 하면 된다.

첫번째로 우리가 사용할 기능은 Guard 라는 것이다. 이름에서 보이는것과 같은 역할을 정확하게 수행한다. 조건이 충족되지 않은 사용자에 대해서 경로를 보호하고 경로에 대한 액세스를 금지하는 역할을 한다. 즉 Guard 는 특정 경로에 대한 요청을 하는 사람이 서명이 되어있는 사람인지 확인하는 기능을 역할을 한다.

두번째로 우리가 사용할 기능은 Interceptor와 Decorator를 이용하여 구현할 기능인데,이는 현재 로그인하고 있는 사용자가 누구인지 핸들러에게 자동으로 알려주는 역할을 할 것이다.

위 두가지 중 가장 복잡한 두번째 Interceptor와 Decorator 구현을 먼저해보려고 한다.

현재 로그인한 사용자가 누구인지 자동으로 알려주기

데코레이터의 작동방식

공식문서

우리가 작성할 커스텀 데코레이터는 다음과 같은 코드로 작성해 사용 할 수 있는 직관적인 데코레이터이다.

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

이 데코레이터를 사용하면 어떤 데이터를 받을 수 있는지 직관적으로다가 말이다.

그럼 간단한 커스텀 데코레이터를 만들어서 테스트를 해보자.

우리가 만들 커스텀 데코레이터는 현재 유저를 알려주는 기능을 하고 있다.

user 폴더에 decorator 폴더를 생성하고 다음과 같은 코드를 작성할것이다

current-user.decorator.ts

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

export const CurrentUser = createParamDecorator(
  (data: any, context: ExecutionContext) => {
    return 'hi there';
  },
);

여기에서 우리는 CurrentUser라는 데코레이터를 만들어 param으로 들어오는 데이터를 받고 hi there이라는 문자열을 출력하도록 했다.

그럼 이것을 직접 적용해보도록하자

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

요청을 받으면 다음과 같이 우리가 지정했던 값을 리턴한다.

커스텀 데코레이터는 이러한 식으로 간단하게 작성할 수 있다.

공식문서에서 설명한 것을 예시로 들면, 우리가 유저라는 객체를 받아서 모든 라우터에서 동일한 처리를 해주어야한다. 그때 그 일련의 처리의 경우 각각의 라우터마다 동일하기 때문에 재사용이 가능하다. 이럴때 커스텀 데코레이터를 사용해주면, 코드의 직관성이 매우 높아지기 때문에 이럴때 사용하는 것이 좋다.

그러나 우리가 구현해볼 내용은 앞에서 작성했던 코드 처럼 단순하게 동작하지는 않기 때문에(앞에서 설명할때 단순하진 않았다,,무엇이 껴있었다) 우리는 커스텀 데코레이터와 인터셉터를 합쳐 해당 동작을 구현해낼 것이다.

첫번째로 우리가 해야할 일은 바로 세션 객체를 불러와서 세션 객체에 담겨있는 유저 아이디를 끄집어내는 것이다.

그것을 코드로 작성해보자

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

export const CurrentUser = createParamDecorator(
  (data: **never**, context: ExecutionContext) => {
    //never로 타입을 설정한 이유 -> data 값을 사용하지 않을 것이기 때문에, data 값을 변경하는 즉시 어떤 방식으로든 사용되거나 액세스 되지않음
   
    **const request = context.switchToHttp().getRequest();**
   
    **console.log(request.session.userId);**
   
    return 'hi there';
  },
);

변경된 부분을 보자,우리는 데코레이터의 인자로 어떠한 데이터가 들어오길 원하지 않는다. 그러한 동작을 위해 들어오는 인자 중 data를 never 타입으로 선언해주었다. 이 never 타입을 지정해주면, never 가 아닌 다른 타입은 절대로 들어올 수 없다. 즉, 어떤 데이터도 이 data로는 들어올 수 없다는 것을 보장한다.

다음은 context를 이용하여 핸들러에 들어온 요청을 처리하는 부분이다. SwitchToHTTP 는 http와 관련된 기능의 추상화를 불러오는 메서드로서 이를 이용하여 요청으로 들어온 데이터를 처리할 수 있다.

SwitchToHTTP 참고자료

아래의 로그를 통해 우리가 세션 객체를 불러왔다는 것을 확인 할 수 있다.

두번째로 우리가 해야할 일은 가져온 유저 아이디를 이용하여 데이터베이스에 있는 해당 유저아이디를 가진 데이터가 있는지 확인하고 그것이 누구인지 확인해야하는 것이다.

그리고 그러한 작업은 대부분 유저 서비스에서 처리해왔기 때문에, 우리는 이 작업을 위해서 유저서비스를 컨테이너에서 가져오려고 할 것이다. 그러나 데코레이터로 종속성 주입을 사용할 수는 없다. 왜냐하면 데코레이터는 종속성 주입 시스템의 외부에 존재하기 때문이다. 그래서 우리는 유저 서비스 인스턴스를 데코레이터로 바로 가져올 수는 없다. 우리는 이러한 문제를 인터셉터를 만들어서 해결해보고자 한다!

참고

의존성 주입의 관점에서, 모듈 외부에서 등록된 전역 인터셉터 (위의 예에서와 같이 useGlobalInterceptors()는 의존성이 주입될 수 없습니다.

이는 모듈의 컨텍스트 외부에서 수행되기 때문입니다. 이 문제를 해결하기 위해 다음 구성을 사용하여 모든 모듈에서 직접 인터셉터를 설정할 수 있습니다.

즉,모듈 내에 인터셉터를 선언해야 의존성을 주입할 수 있기 때문에 모듈 내에 인터셉터를 선언 할 것입니다.

우리가 만들 인터셉터는 DI System 안에서 작동하며 우리에게 UserService 에서 받은 데이터를 넘겨줄 것이다.

본격적으로 어떻게 작동하는지 코드를 작성해보도록하자.

import {
  NestInterceptor,
  ExecutionContext,
  CallHandler,
  Injectable,
} from '@nestjs/common';

import { UsersService } from '../users.service';

@Injectable()
export class CurrentUserInterceptor implements NestInterceptor {
  constructor(private usersService: UsersService) {}

  async intercept(context: ExecutionContext, handler: CallHandler) {
    const request = context.switchToHttp().getRequest();
    const { userId } = request.session; // {};

    if (userId) {
      const user = await this.usersService.findOne(userId);
      **request.currentUser = user**; // 데코레이터가 읽을 수 있도록 request에 넣어준다
    }
    return handler.handle();
  }
}
  • @InJectable , 인터셉트가 유저 컨트롤러에 주입될 것이기 때문에 선언함
  • 생성자를 이용하여 유저서비스를 컨테이너로부터 받아온다
  • 인터셉터 함수에서 userId데이터를 받아온다. 여기에서 우리는 발상의 전환을 해볼 것이다. 인터셉터로 가로챈 데이터를 직접적으로 핸들러에 보내지는 않을 것이며,핸들러에 보내더라도 데코레이터에 데이터를 보내야하기 때문에 의미가 없다(데코레이터에는 인자를 받지 않겠다고 앞에서 이야기했다) 그렇다면 여기에서 데코레이터로 데이터를 보내기 위해서는 어떻게 해야할까? 바로, 요청에다가 해당 데이터를 넣어주는 것이다. 데코레이터에서는 sessionId를 알아내기 위해 request 값을 가져온다. 이때 우리가 userid를 이용하여 미리 데이터베이스에 있는 데이터를 조회해서 user 객체 값을 넣어두면 데코레이터에서 데이터를 조회 할 수 있다. 그렇기 때문에 userId가 존재할 경우 request값에 추가되는 형태를 가지게 된 것이다!

이제 우리는 이 인터셉터를 컨트롤러에 연결해줄 것이다. 이 인터셉터를 사용할 모듈은 user 모듈이기 때문에 user module에 provider에 등록을 하도록하자.

import { Module } from '@nestjs/common';
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { AuthService } from './auth.service';
import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';

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

등록된 인터셉터는 DI Container에서 관리할 것이다. 그럼 이제 사용해보도록하자.

import {
  Body,
  Controller,
  Post,
  Param,
  Query,
  Patch,
  Get,
  Delete,
  NotFoundException,
  Session,
  **UseInterceptors**,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
import { Serialize } from 'src/interceptors/serialize.interceptor';
import { UserDto } from './dto/user.dto';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorator/current-user.decorator';
**import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';
import { User } from './user.entity';**

@Controller('auth')
@Serialize(UserDto)
**@UseInterceptors(CurrentUserInterceptor)**
export class UsersController {
  constructor(
    private usersService: UsersService,
    private authService: AuthService,
  ) {}

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

  //... 생략
}

user service에 Interceptor를 추가한 모습이다.

그러나 하나의 인터셉터를 추가하기 위한 과정이 너무 길다. UseInterceptors를 데코레이터를 선언해서 CurrentUserInterceptor를 인자로 보내 이 컨트롤러에 사용될 인터셉터가 무엇인지 알려주어야 하고,, 여기에서 import되는 코드만 세줄이 늘어난다.

이것에 대한 보완을 하기위해 인터셉터를 컨트롤러 단위가 아닌 전역으로 선언해 하나의 인터셉터 인스턴스가 각기 다른 컨트롤러 요청에 적용되도록 구현할것이다.

user module로 이동을 해서, 다음과 같이 코드를 수정하자

import { Module } from '@nestjs/common';
**import { APP_INTERCEPTOR } from '@nestjs/core';**
import { UsersController } from './users.controller';
import { UsersService } from './users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
import { AuthService } from './auth.service';
**import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';**

@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UsersController],
  providers: [
    UsersService,
    AuthService,
    **{ provide: APP_INTERCEPTOR, useClass: CurrentUserInterceptor },**
  ],
})
export class UsersModule {}

유저 모듈에 전역모듈을 제공하겠다고 선언한 뒤, 그곳에 현재 유저를 알려주는 인터셉터를 넣어주었다.

아까 유저 서비스에 작성되었던 코드를 다시보자.

import {
  Body,
  Controller,
  Post,
  Param,
  Query,
  Patch,
  Get,
  Delete,
  NotFoundException,
  Session,
  ~~UseInterceptors,~~
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';
import { Serialize } from 'src/interceptors/serialize.interceptor';
import { UserDto } from './dto/user.dto';
import { AuthService } from './auth.service';
import { CurrentUser } from './decorator/current-user.decorator';
~~import { CurrentUserInterceptor } from './interceptors/current-user.interceptor';~~
import { User } from './user.entity';

@Controller('auth')
@Serialize(UserDto)
~~@UseInterceptors(CurrentUserInterceptor)~~
export class UsersController {
  constructor(
    private usersService: UsersService,
    private authService: AuthService,
  ) {}

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

  //... 생략
}

이제 이 세줄의 코드를 삭제할 수 있다!

물론 이렇게 전역으로 인터셉터를 선언하면 코드를 줄일 수 있다는 장점과 동시에 단점도 존재한다.


바로, 현재유저가 누구인지 궁금하지 않은 컨트롤러 또한 이 인터셉터를 사용하게 된다는 것이다. 그렇다면 글로벌로 선언된 인터셉터가 필요없는 컨트롤러의 경우 어떻게 인터셉터를 처리하는 지.. 등등에 대한 내용은 추후 조사해서 내용을 추가하도록 하겠다 (본인도 궁금함)

로그인하지 않은 사용자가 요청을 할수없도록 막기

해당 기능은 특정 컨트롤러에 대한 액세스를 방지하는 것을 목표로 한다 ! 더불어 사용자의 로그인 여부에 따라 핸들러를 라우팅한다.

우리는 여기에서 라우터에 유저가 접근한게 맞다면 true를 리턴할 것이고 아니라면 false를 리턴할 것이다.

가드는 다양한 위치에 배치할 수 있다. 모든 요청에 대한 가드, 각 컨트롤러에 대한 가드, 핸들러에 대한 가드 세가지 위치로 나누어 배치할 수 있다.

우리는 whoami 핸들러에 가드를 설정해주어 로그인하지 않은 사용자의 요청은 막을 생각이다.

그럼 코드를 작성해보자.

src 디렉토리에 guard 디렉토리를 만들고 그곳에 auth.guard.ts 를 만든다

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

export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();
    return request.session.userId;
    //return 하는 값이 존재하면 true, 그렇지 않고 다른 값이 오면 false
  }
}

CanActivate 함수는 Guard 함수를 만들기 위해 반드시 구현되어야 하는 함수로서, 이 요청이 실행될수있는지 없는지 여부를 확인 할 수있는 boolean 값을 리턴해야한다.이 경우 세션아이디값이 존재할 경우 true, 존재하지 않거나 정의되지 않은 경우 false가 된다.

이러한 가드를 핸들러에 적용한 코드는 다음과 같다.

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

이제, 해당 핸들러를 호출하기 위해서는 세션아이디가 있어야한다! 요청을 날리고 결과를 확인해보자.

로그인한 상태에서 요청

로그아웃 한 상태에서의 요청

마무리를 하며,

길고 길었던 Auth 기능에 대한 정리가 마무리되었다. 물론 배운것을 토대로 내가 새로 알아보아야 할 것들이 넘쳐나지만 이를테면 jwt 토큰등의 다른 인증 방법들,, 어찌되었던 간략하게나마 인증에 대해서 상세하게 공부할 수 있었다.

해당 포스팅은 다음 강의를 수강하며 작성한 내용들입니다.

NestJS: The Complete Developer's Guide

잘못된 내용이 있다면 코멘트로 알려주세요.

profile
반갑습니다 ! 백엔드 개발 공부를 하고있습니다.

0개의 댓글