NestJS Cookie를 활용한 인증・인가 처리

임동혁 Ldhbenecia·2023년 12월 10일
0

NestJS

목록 보기
4/4
post-thumbnail

NestJS 로그인 인증 인가 처리

현재 네이버 커넥트 재단 부스트캠프에서 모락(Morak)이라는 서비스를 개발 중입니다. 개발을 진행하며 인증, 인가 처리 방식에 대해 정리합니다.

무엇이 문제인가?

로그인 인증,인가 처리를 진행하면서 우선 제한된 시간 속에서 구현을 마치기 위해서 보안적으로는 신경을 거의 쓰지 못해 탐탁치가 않았습니다.

그리고 현재 저희 모락 서비스는 프론트엔드분들께서 할 일이 굉장히 많으시고, 백엔드는 서비스 API 작업이 대부분 끝난 상태이기 때문에 많은 고통을 받았던 인증, 인가에 대해서 좀 더 공부하고 어떻게 하면 프론트엔드분들이 더 쉽게 더 좋은 방법으로 이 문제를 같이 해결할 수 있을지에 대해 생각하게 되었습니다.

현재 상황

현재 우리 모락 서비스는 로그인을 하면 액세스 토큰이랑 리프레쉬 토큰을 쿠키로 심어주고 있습니다.

여기서 이제 유저들이 그룹에 가입하거나 모각코를 개설한다는 등 해당 요청을 보낼 때는 
그 회원을 알아보기 위해서 UseGuard로 인가를 처리하고 있습니다.

이러기 위해서 클라이언트에서는 사용자의 정보가 필요한 api요청을 보낼때 헤더에 bearer token으로 액세스 토큰을 심어서 보내주게 되어 해당 사용자에 대한 인가처리를 하고 있습니다.

그런데 이때 액세스토큰에 httponly를 걸면 프론트분들이 bearer token으로 액세스토큰을 줄 수가 없습니다.
이유는 프론트엔드단에서 httponly를 걸어서 주면 해당 쿠키를 js로 접근할 수가 없기 때문에 추출해서 함께 헤더에 포함해서 요청을 할 수 없는 상황이 발생합니다.

1, 쿠키를 httponly를 걸어서 주면 클라이언트에서 요청처리를 어떻게 하는지?

  1. Secure을 걸면 https가 아닌 로컬호스트 환경에서는 어떻게 진행하는지?

슬랙에서 캠퍼분에게 물어보다.

이곳 저곳 금요일 프로젝트 현황 발표 때도 유심히 듣고 부스트캠프 진행 기간동안 다른 캠퍼분들의 많은 코드를 염탐하였습니다.
그 중 보안 쪽에서 J053_박재하님이 굉장히 잘 아시는 것을 보게 되어 바로 물어보았습니다.

여기서 정말 간과했던 사실은 access_token이 만료될 시 만료기한이 더 긴 refresh_token을 사용해서 refresh API 를 통해 새로운 액세스 토큰을 발급하는 API는 이미 짜여져있습니다.

이 API를 짤 때는 프론트엔드단에서 헤더에 bearer_token도 받고 그 뿐만 아니라 현재 브라우저에 심어져 있는 쿠키를 서버에서 추출해내어서 검증하고 Redis에 저장되어있는 refresh_token 과 일치할 시 새로운 액세스 토큰을 발급하여 보내주는 식으로 동작하도록 코드를 작성하였습니다.

이 방법의 경우 굳이 프론트엔드단에서 헤더에 bearer_token을 보내줄 필요가 없이 서버에서 알아서 추출해내서 사용할 수 있다는 점이 있습니다. 굳이 불필요한 bearer_token을 받을 필요가 없는 것입니다.

NestJS의 UseGaurd를 통해 인가에 대해서 처리하려고 하여서 불필요한 작업까지 진행한 것으로 보입니다.

1번에 대해서 해결책을 얻었고 추가적으로 질문을 하였습니다.

해결 방법

재하님의 방법대로 진행하는 방법에 대해 이해를 하고 설명을 드렸습니다.

도움을 주신 J053_박재하님 정말 감사합니다!!!!

해결 과정

프론트단에서 헤더에 bearer_token을 아예 안보내줘도 되고 그냥 요청만 하면 서버에서 처리를 하게 하고 싶었습니다.

이러면 프론트단에서도 편할 것으로 예상 됩니다.
프론트에서는 헤더에서 bearer_token을 빼기만 하면 끝나기 때문입니다.

이럴 시 프론트엔드단에서 쿠키에 직접 접근할 필요가 없으므로 httponly=true, secure 등
보안에 대한 처리 또한 가능해집니다.
현재는 httponly도 false이고 아무런 처리도 되어있지 않습니다.

  • 야기되는 문제점

    • 일단 한번에 잘 진행이 될 일이 없으니 디버깅을 하고 테스트를 하는데 비용이 좀 들을 수도 있음
      포스트맨으로 귀찮긴 하지만 혼자 할 수 있음
  • 장점
    - 현재 Useguard로 Bearer_token으로 프론트에서 액세스토큰을 받아와서 인가를 처리하고 있는데
    가드 코드를 수정하면 bearer_token으로 보내주지않아도 서버에서 알아서 쿠키에 심어져있는거 추출해옴

  • 이럴 시 프론트엔드에서 쿠키에 직접 접근할 필요가 사라짐

import { Injectable, ExecutionContext } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class AtGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // ExecutionContext에서 현재 요청을 추출
    const request = context.switchToHttp().getRequest();

    // 쿠키에서 액세스 토큰을 추출
    const accessToken = request.cookies.access_token;

    // request.user에 액세스 토큰을 넣어줌. Passport는 이를 사용하여 사용자 인증
    request.user = { accessToken };

    // AuthGuard의 원래 canActivate 메서드를 호출
    return super.canActivate(context);
  }
}
  • 실험 (member/me) 헤더에 토큰 안넣어도 반환됨
    • 쿠키에서 따옴

결과 bearer Token을 넣어주지않아도 해당 사용자에 대한 요청을 반환합니다.

토큰에 대한 검증

import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { getSecret } from '@morak/vault';

@Injectable()
export class AtGuard extends AuthGuard('jwt') {
  constructor(private jwtService: JwtService) {
    super();
  }

  canActivate(context: ExecutionContext) {
    const request = context.switchToHttp().getRequest();

    if (!request.cookies) {
      throw new UnauthorizedException('Unauthorized');
    }

    const accessToken = request.cookies.access_token;

    try {
      const decodedToken = this.jwtService.verify(accessToken, {
        secret: getSecret('JWT_ACCESS_SECRET'),
      });

      const { providerId, socialType, email, profilePicture, nickname } = decodedToken;

      request.user = { providerId, socialType, email, profilePicture, nickname };

      return true;
    } catch (error) {
      throw new UnauthorizedException('Invalid access token');
    }
  }
}
  • 토큰이 이상할 시

문제점 발생

  • 게시글을 작성하려고 하자 500 에러가 발생함

원래 Atguard를 바꾸기 이전 bearer_token을 받아서 유저의 정보를 추출하면

member: {
  id: 1n,
  providerId: '117187214221556274884',
  email: 'bcwm.morak@gmail.com',
  nickname: 'morak morak',
  profilePicture: 'https://lh3.googleusercontent.com/a/ACg8ocKej7-OYc7vwz5Xu8Tss37yWKC5vEVMet-1YFh8PcB7Xg=s96-c',
  socialType: 'google',
  createdAt: 2023-12-06T06:17:44.810Z
}

이렇게 나왔었는데 현재는 아래 처럼 나와서 id값이 없다는 오류가 발생합니다.

액세스토큰으로 유저를 받았을 때 id, createdAt은 없습니다.
그래서 처음에 providerId로 식별자를 사용하기로 하였습니다.

→ 아 이 방법 뭔가 좀 아닌 것 같았습니다.
왜냐하면 이럴 시 다른 모든 로직에서 member.id로 쓰던 값을 전부 변경해줘야한다는 점에서 마음에 들지 않았습니다.

생각해낸 방법

AtGuard에서 id 값을 추가해주자.
이 방법을 사용하면 id 값을 그대로 쓰면 됩니다.
지금 id값이 없으니 넣으면 된다. 단순하게 생각하였습니다.

  • 이렇게 하기 위한 방법
    providerId 같은 값을 가지고 디비에 접근해서 id 값을 가져와서 포함해서 반환해주면 된다.

  • 문제점

    • 이러면 AtGuard가 필요한 요청 때마다 계속 DB에서 id 값을 찾아서 반환할 것임
  • 해결 방법

    • 최초 로그인 시에 한번만 처리하면 됨
    • 최초 로그인 시 액세스 토큰을 생성해서 발급할 때 이 찾은 id 값을 넣어서 만들면 됨
async getUserIdFromToken(providerId: string): Promise<bigint> {
    const user = await this.prisma.member.findUnique({
      where: {
        providerId,
      },
      select: {
        id: true,
      },
    });

    return user.id;
  }

다음과 같이 providerId를 통해 userId를 추출합니다.

async signIn(userDto: CreateUserDto): Promise<Tokens | null> {
    const { providerId, socialType, email, profilePicture, nickname } = userDto;
    const userId = await this.getUserIdFromToken(providerId);

    const token = this.generateJwt({
      userId,
      providerId,
      socialType,
      email,
      profilePicture,
      nickname,
    });

그리고 로그인을 할 때 저 id값을 따와서 id를 포함해서 액세스 토큰을 생성합니다.

이렇게 하면 DB에 접근할 때 id값을 지금까지 해온 것처럼 그대로 적용할 수 있으며, 로그인을 할때만 적용되기 때문에 매번 DB에 접근하는 조회 비용도 줄일 수 있게됩니다.

try {
      const decodedToken = this.jwtService.verify(accessToken, {
        secret: getSecret('JWT_ACCESS_SECRET'),
      });

      const { userId, providerId, socialType, email, profilePicture, nickname } = decodedToken;

      const userIdBigInt = BigInt(userId);

      request.user = { id: userIdBigInt, providerId, socialType, email, profilePicture, nickname };

      console.log(request.user);

      return true;
    } catch (error) {
      throw new UnauthorizedException('Invalid access token');
    }

여기서 주의할 점은 DB에 저희는 현재 id값을 bigint 타입으로 넣고 있습니다.

JWT를 복호화해서 출력을 해보면 bigint로 반환을 해도 string 타입으로 출력이 되기 때문에 bigint로 형변환을 한번 더 해주어야 합니다.

그리고 request.user에 id 값으로 저 bigint로 형변환한 값을 넣어주게 되면?

깔끔하게 처리가 된 모습입니다.

처음에는 id 값이 없었기 때문에 게시글을 작성할 때 missing id와 같은 오류가 계속해서 발생했는데
유저 정보를 반환할 때 id 값을 넣어줌으로써 잘 해결된 모습입니다.

이외 다른 모든 테스트도 문제 없이 돌아가게 되어
최종적으로 로그인 인가처리를 마치게 되었습니다.

profile
지극히 평범한 공대생

0개의 댓글

관련 채용 정보