[Nest.js] Refresh Token으로 로그인/로그아웃 보안 개선 (+🍪)

Seung Hyeon ·2023년 12월 16일
1

백엔드

목록 보기
11/19
post-thumbnail

Refresh Token이란

✔ Access-Token을 재발급할 수 있는 토큰이다.

Refresh Token은 왜 필요할까

유저가 로그인했을 때 Access Token만을 이용해서 권한을 인증하게 된다면, 공격자가 Access Token을 탈취한 경우 토큰을 즉시 무효화 시키지 못하는 취약점을 가지고 있다.
→ 이를 방지하고자 토큰의 유효시간을 짧게 설정할 수 있다. 그러나 이 방법은 유저가 수시로 재로그인을 해야하기 때문에 불편함이 있다.

비상태 저장 방식은 토큰의 보안 취약점을 보강하고 사용자 편의성을 유지하기 위해 슬라이딩 세션을 사용한다. 슬라이딩 세션은 로그인 정보를 다시 입력하지 않고 현재 가지고 있는 토큰을 새로운 토큰으로 재발급하는 방식을 말한다.
그렇다면 사용자가 로그인하는 과정을 대신해줄 무언가가 필요한데, Refresh Token이라는 것을 생성하여 로그인할 때 Access Token과 더불어 Refresh Token도 하나 생성한다. Refresh Token은 Access Token과 마찬가지로 JWT를 사용할 수 있고 일반적으로 Access Token에 비해 만료 시간을 더 길게 잡는다.

Flow 설명(간단 ver)

※ AT : Access Token , RT : Refresh Token

Access Token만을 이용해 인가처리할 경우, 로그인 api호출 시 응답으로 Access_token을 클라이언트단에 전달해준다.

Access Token + Refresh Token을 이용해 인가처리할 경우, 처음 사용자가 로그인할 때 Access Token과 함께 Refresh Token을 발급하고, Refresh Token을 해시화해서 DB에 저장해야한다. 그 이유는 DB가 노출되거나 레인보우 테이블 공격을 받더라도 안전할 수 있기 때문이다.

※ DB에 저장하는 이유: Refresh Token이 탈취된다면, Access Token보다 더 오랜기간 보안에 구멍이 생기기 때문에 반드시 안전한 공간에 저장해야 한다.

만약 Access Token 만료로 401에러가 발생한 경우, 클라이언트는 DB에 저장되어있는 Refresh Token을 이용하여 새로운 Access Token을 발급해달라는 요청을 한다. 이 때 Refresh Token은 변경되지 않는다. (로그인 유지)

※ 현재 들어온 Refresh token이 유저 db에 저장된 Refresh token과 똑같은 지 비교하는 과정을 거치는데, 그 이유는 token이 갈취 혹은 변조되거나 동일한 사용자가 동시에 접속하는 문제를 해결하기 위해서이다.

async getUserIfRefreshTokenMatches(refreshToken: string, userId: string) {
  try {
    const user = await this.usersRepository.findById(userId);

    if (!user || !user.hashedRefreshToken) {
      throw new UnauthorizedException('엑세스가 거부되었습니다.');
    }

    const isRefreshTokenMatching = await bcrypt.compare(refreshToken, user.hashedRefreshToken);

    if (isRefreshTokenMatching) {
      return user;
    } else {
      throw new UnauthorizedException('Refresh 토큰이 사용자 것과 일치하지 않습니다');
    }
  } catch (e) {
    console.error(e);
    if (e instanceof UnauthorizedException) {
      throw e;
    }
  }
}

Flow 설명 (Detailed ver)

  • 평소의 API사용의 인가 처리에는 Access Token을 사용하지만, 만료되었을 떄의 재발급은 Access Token이 아닌 Refresh Token을 이용해서 Access Token을 재발급하게 한다.

Refresh Token이 만료되었을 때

2가지 방법이 있다.

  1. Refresh 토큰이 만료되었을 경우, 재로그인하라는 에러 메시지를 응답으로 보낸다.

    if (err instanceof TokenExpiredError) {
      throw new UnauthorizedException('Refresh Token is expired. Please re-login');
    }
  2. Access토큰은 유효하지만 refresh가 만료되었을 경우 : Refresh Token이 만료되기 n시간 전, Refresh 재발급

 

✅ 최종 선택 : 첫번째 방법

→ refresh token을 제공하는 목적 자체가 액세스 토큰을 재발급 받기 위해 사용되는 것이므로 refresh token 자체가 만료된다면 다시 로그인하여 재검증하는 것이 옳은 방법이다.

✅ Access token이 유효하지만 Refresh token이 만료된 상황이라면 별다른 처리가 필요 없다. 그냥 추가 재발급이 불가능한 상황일 뿐…

로그아웃

DB에 저장된 유저의 Refresh Token을 없앤다.

보안 강화 - 로그인

백엔드에서 토큰을 응답으로 보낼 때 쿠키에 토큰을 저장한 후 함께 보낸다

→ 생성된 토큰을 Cookie 정보와 함께 반환한다
→ 쿠키 정보에는 설정한 만료시간을 넣어준다.

참고: https://prolog.techcourse.co.kr/studylogs/2272

 

※ 프론트에서 토큰을 직접 저장할 경우 : document.cookie를 사용

  • Javascript으로 사용되는 document.cookie 명령어는 사이트에서 쿠기 값을 활용하고 조작할 수 있지만, 공격자들이 CSS 공격으로 쿠키 값을 탈취할 수 있다.
  • 따라서 백엔드에서 httpOnly: true 옵션을 통해 토큰을 쿠키에 저장하고 쿠키를 반환함으로써 Javascript에서 쿠키에 접근할 수 없도록 한다.

쿠키 형식

const result = {
  accessToken: token,
  domain: 'localhost',
  path: '/',
  httpOnly: true,
  maxAge: +this.configService.get('JWT_ACCESS_EXPIRATION_TIME') * 1000,
};

res.cookie('Access', accessToken, 나머지);

보안 강화 - 로그아웃

로그아웃을 백엔드 단에서 모두 처리한다.

로그아웃 시 쿠키에 저장되어있는 엑세스 토큰 및 리프레쉬 토큰을 삭제하고 만료시간도 모두 삭제

user.service.ts

async logout(id: string) {
  try {
    await this.removeRefreshToken(id);
    const result = await this.getCookiesForLogout();
    return result;
  } catch (e) {
    console.error(e);
    throw new InternalServerErrorException('알 수 없는 오류로 로그아웃하지 못했습니다.');
  }
}

private async getCookiesForLogout() {
  return {
    accessOption: {
      domain: 'localhost',
      path: '/',
      httpOnly: true,
      maxAge: 0,   // 만료시간을 0으로
    },
    refreshOption: {
      domain: 'localhost',
      path: '/',
      httpOnly: true,
      maxAge: 0,    // 만료시간을 0으로
    },
  };
}

Response 객체 예시

1. 로그인 성공

res.cookie로 access, refresh 토큰을 쿠키에 저장 ↓

res.cookie('Access', accessToken, accessOption);
res.cookie('Refresh', refreshToken, refreshOption);

2. Refresh Token으로 Access Token 갱신

res.cookie로 갱신된 access 토큰을 쿠키에 저장 ↓

res.cookie('Access', accessToken, accessOption);

3. 로그아웃

res.cookie로 access,refresh 토큰을 쿠키에서 삭제 ↓

const { accessOption, refreshOption } = await this.authService.logout(req.user);
res.cookie('Access', '', accessOption);
res.cookie('Refresh', '', refreshOption);

 

※ 참고
NestJs로 배우는 백엔드 프로그래밍(도서)

profile
안되어도 될 때까지

0개의 댓글