Skip to content

JWT 쿠키로 사용하기

Summer Min edited this page Dec 3, 2024 · 1 revision

1. 쿠키의 필요성

@Get('naver/callback')
  @UseGuards(AuthGuard('naver'))
  async naverCallback(@Req() req) {
    // 네이버 인증 후 사용자 정보 반환
    const user = req.user;
    // TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
    const payload = { sub: user.id, provider: user.provider };
    const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
    const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
    return {
      message: '네이버 로그인 성공',
      user,
      accessToken,
      refreshToken,
    };
  }
  • 현재 백엔드에서는 생성한 access, refresh token을 response의 Body에 담아서 알아서 보관하고, 매 요청때마다 자동으로 헤더에 담아서 request를 보내게 구현할 예정이었다
  • 그런데 클라이언트의 입장에서 해당 방법으로 구현할 경우, 로그인 성공 후
  1. 리디렉션을 할 수 없는 문제 (CORS)
  2. 보안상 토큰 탈취의 가능성

이 두 가지의 이유로 HttpOnly 쿠키 형태로 두가지 토큰을 담아, 전달하는 것으로 인증 방식을 변경하기로 하였다

2. 쿠키 변경 과정

1. oAuth 인증 후 토큰 response 형식 수정

 async naverCallback(@Req() req, @Res() res: Response) {
    // 네이버 인증 후 사용자 정보 반환
    const user = req.user;
    // TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
    const payload = { sub: user.id, provider: user.provider };
    const accessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
    const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });

    // 토큰을 쿠키에 담아서 메인 페이지로 리디렉션
    res.cookie('accessToken', accessToken, { httpOnly: true, maxAge: HOUR });
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      maxAge: WEEK,
    });
    res.redirect(302, '/');
  }
  • oAuth 인증 후 서버는 토큰을 생성한 후, response에 쿠키로 첨부하여 302 상태코드를 전송한다
  • 일단은 jwt 토큰의 유효기간과 쿠키의 유효기간을 똑같이 맞춘다

2. 쿠키 관리방식, 쿠키 & jwt 토큰의 유효기간 결정

  • 문제는 쿠키는 유효시간이 제대로 설정되지 않는다면 브라우저 종료시, 바로 소멸될 가능성이 있다는 것이다.
  • 가장 중요한 것은 브라우저 별로 다르지만, 쿠키의 maxAgeexpire을 둘 다 설정해야 해단 기간동안 쿠키가 소멸되지않고 안전히 보관된다는 것이다
  • 또한, 쿠키가 존재하는 기간이 토큰의 유효기간보다 길게 하여 서버에서 해당 토큰을 직접 체크하고 관리하기로 하였다. HttpOnly 쿠키이기 때문에 클라이언트 코드는 해당 쿠키에 접근할 수 없기 때문이다. 앞으로 서버에서 유효하지않은 토큰을 확인하고 해당 쿠키를 직접 없애면서 로그인, 로그아웃 상태를 관리할 것이다.
  • Access Token(JWT)의 유효기간: 1시간
  • Refresh Token(JWT)의 유효기간: 5개월
  • Access Token, Refresh Token을 담은 쿠키의 수명: 6개월

3. JWT 발급하는 주체 Token Service로 분리

const HOUR = 60 * 60;
const DAY = 24 * 60 * 60;
const FIVE_MONTHS = 5 * 30 * 24 * 60 * 60;
const MS_HALF_YEAR = 6 * 30 * 24 * 60 * 60 * 1000;

@Injectable()
export class TokenService {
  constructor(
    private readonly jwtService: JwtService,
    private readonly userRepository: UserRepository,
  ) {}

  generateAccessToken(userId: number): string {
    const payload = { sub: userId };
    return this.jwtService.sign(payload, {
      expiresIn: HOUR,
    });
  }

  async generateRefreshToken(userId: number): Promise<string> {
    const payload = { sub: userId};
    const refreshToken = this.jwtService.sign(payload, {
      expiresIn: FIVE_MONTHS,
    });

    await this.updateRefreshToken(userId, refreshToken);

    return refreshToken;
  }

  setAccessTokenCookie(response: Response, accessToken: string): void {
    response.cookie('accessToken', accessToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: MS_HALF_YEAR,
      expires: new Date(Date.now() + MS_HALF_YEAR),
    });
  }

  setRefreshTokenCookie(response: Response, refreshToken: string): void {
    response.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: MS_HALF_YEAR,
      expires: new Date(Date.now() + MS_HALF_YEAR),
    });
  }
  • 이제 토큰, 쿠키 관련 로직은 이제 token.service.ts에서 관리하기에 authController에서 token module을 import해서 사용하면 된다

4. access token 검증 후 만료 시, refresh token 검증 ⇒ 서버로 역할 위임

  • 이제 클라이언트에서 토큰에 접근을 할 수 없기에,

    • (1) access token이 만료의 이유로 유효하지 않을 시
    • (2) refresh token을 검증
    • (3) access token을 해당 사용자 정보를 바탕으로 새로 발급

    ⇒ 해당 로직을 서버에서 직접 실행해야한다

    마찬가지로 tokenService에서 해당 역할을 하는 함수를 만든다

...
  async refreshAccessToken(refreshToken: string): Promise<string> {
    // refreshToken 1차 검증
    const decoded = this.jwtService.verify(refreshToken, {
      secret: process.env.JWT_SECRET,
    });

    // 검증된 토큰에서 사용자 ID 추출
    const userId = decoded.sub;

    // refreshToken 2차 검증
    // DB에 저장된 refreshToken과 비교
    const isValid = await this.compareStoredRefreshToken(userId, refreshToken);
    if (!isValid) {
      throw new InvalidTokenException();
    }

    // 새로운 accessToken을 발급한다
    return this.generateAccessToken(decoded.sub);
  }

3. 쿠키 삭제 케이스 정리

  • 그렇다면 쿠키는 어떤 케이스에서 삭제해야할까? jwt-auth guard(로그인을 요구로하는 api에서 데코레이터 형태로 사용할 Guard),에서 로그인한 사용자의 인증을 검사하고 인가할 때
  1. access token, refresh token 둘 다 있지 않을 경우
  2. 둘의 검증 과정 중
    1. access token이 만료 외의 이유로 유효하지 않을 경우
    2. refresh token이 어떠한 이유로든 유효하지 않은 경우

⇒ 모두 request에 담겨있는 쿠키를 삭제하고 (정확히 같은 특성의 쿠키를 삭제하도록 코드를 써야 해당 쿠키를 인식하고 삭제한다) 다시 로그인 절차를 진행하도록 클라이언트 측에서 안내한다

  clearCookies(response: Response) {
    response.clearCookie('accessToken', {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
    });

    response.clearCookie('refreshToken', {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
    });
  }
  • token service에 쿠키를 모두 삭제하는 함수를 추가하고
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse<Response>();

    const cookies = request.cookies; // 쿠키에서 가져오기

    // 쿠키가 아예 없는 경우는 로그인 안 된 상태로 간주
    if (!cookies || !cookies.accessToken || !cookies.refreshToken) {
      // 관련된 쿠키 비워주기
      this.tokenService.clearCookies(response);
      throw new LoginRequiredException();
    }

    const { accessToken, refreshToken } = cookies;

    try {
      // JWT 검증
      const decodedToken = this.jwtService.verify(accessToken, {
        secret: process.env.JWT_SECRET,
      });

      // 유효한 토큰이면 요청 객체에 사용자 정보를 추가
      request.user = decodedToken;
      return true;
    } catch (error) {
      // accessToken이 만료된 경우
      if (error instanceof TokenExpiredError) {
        try {
          // 새로운 accessToken 발급받기
          const newAccessToken =
            await this.tokenService.refreshAccessToken(refreshToken);

          // 쿠키 업데이트
          this.tokenService.setAccessTokenCookie(response, newAccessToken);

          // 요청 객체에 사용자 정보 추가
          const decodedNewToken = this.jwtService.verify(newAccessToken, {
            secret: process.env.JWT_SECRET,
          });
          request.user = decodedNewToken;

          return true;
        } catch (refreshError) {
          // refreshToken 디코딩 실패 시 처리 쿠키 비워줌
          this.tokenService.clearCookies(response);
          throw new InvalidTokenException();
        }
      } else {
        // accessToken 디코딩(만료가 아닌 이유로) 실패 시 처리 쿠키 비워줌
        this.tokenService.clearCookies(response);
        throw new InvalidTokenException();
      }
     }
  • 위의 auth-guard 코드에서 token 서비스 계층의 함수들을 사용하여 쿠키를 관리한다

4. 이걸로 뭘 할 수 있나요?

⇒ 이 구현 후 흐름을 정리하자면

  1. 로그인을 필요로 하며 + 로그인 한 사용자의 정보(Id)를 요구하는 api에서는
  2. auth-guard를 사용하면
  3. api 도달 전 auth-guard에서 request의 쿠키를 검증하여
  4. 토큰을 검증한다
    1. access token이 유효할 시 바로 사용자 정보를 얻어서 전달한다
    2. access token이 만료되었지만 refresh token은 유효할 시, 해당 토큰에서 얻은 사용자 정보로 access token을 다시 발급하고, 사용자 정보를 전달한다
    3. 그 밖의 경우는 사용자에게 다시 로그인하도록 redirection으로 안내한다
  5. 이제 api 코드에서는 request안에서 사용자 정보를 얻을 수 있다!!
 async createWorkspace(
    @Request() req,
    @Body() createWorkspaceDto: CreateWorkspaceDto,
  ) {
    const userId = req.user.sub; // 인증된 사용자의 ID

→ 이런 식으로!

개발 문서

⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
🚧 트러블슈팅

팀 문화

🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략

그룹 기록

📢 발표 자료
🌤️ 데일리 스크럼
📑 회의록
🏖️ 그룹 회고
🚸 멘토링 일지
Clone this wiki locally