Lsiron

Nest.js, JWT 전략에서 엑세스 토큰 검증 문제 해결 본문

개발일지/NUDDUCK

Nest.js, JWT 전략에서 엑세스 토큰 검증 문제 해결

Lsiron 2024. 10. 9. 21:43

누떡 프로젝트에서 JWT 가드가 유저의 엑세스 토큰을 검증하지 못하는 문제를 겪었다.

 

처음에는 Authorization 헤더에서 Bearer 토큰을 추출하는 방식으로 JWT 전략을 설정했지만, 실제로는 엑세스 토큰이 쿠키에 저장되도록 했기 때문에 검증이 실패했던 것.

 

아래 코드가 바로 내가 처음에 설정한 토큰 추출 방식이다.

import { AuthService } from '@_auth/auth.service';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: configService.get<string>('JWT_ACCESS_SECRET'),
      ignoreExpiration: false,
    });
  }

  async validate(payload: { sub: string; provider: string }): Promise<{ id: number }> {
    const user = await this.authService.findUserByProvider(payload.provider, payload.sub);
    if (!user) {
      throw new UnauthorizedException();
    }
    return { id: user.id };
  }
}

 

초기에 JWT 전략을 설정할 때 Authorization 헤더를 통한 Bearer 토큰 방식으로 기본 설정을 해 놓았기 때문에,

 

자연스럽게 해당 부분을 놓치게 되었다.. 전혀 다른 곳에서 왜 엑세스토큰 추출을 못하지..?! 하며 머리를 싸맸던..

  @ApiOperation({ summary: '구글 로그인 콜백' })
  @ApiResponse({ status: 200, description: '구글 로그인 성공' })
  @ApiResponse({ status: 401, description: '구글 로그인 실패' })
  @Get('google/callback')
  @UseGuards(AuthGuard('google'))
  async googleCallback(@Req() req: { user: OAuthUser }, @Res() res: Response): Promise<void> {
    const { provider, providerId, email, name } = req.user;

    const userDto: UserDto = {
      provider,
      providerId,
      email,
      name,
    };

    const tokens = await this.authService.getSocialLogin(userDto);

    res.cookie('_a', tokens.accessToken, getAccessCookieOptions());
    res.cookie('__r', tokens.refreshToken, getRefreshCookieOptions());
    res.redirect(this.configService.get<string>('HOME_PAGE')); 
  }


이후 차근차근.. 쿠키에 엑세스토큰과 리프레시 토큰을 저장하고 있으니, 쿠키에서 엑세스 토큰을 추출하도록 추출 방식의 변경이 필요하다는 것을 인지하게 되었고, 이를 반영해 쿠키에서 토큰을 추출하도록 수정한 것!

import { AuthRepository } from '@_modules/auth/auth.repository';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { UserRequest } from 'common/interfaces/user-request.interface';
import { ExtractJwt, Strategy } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly configService: ConfigService,
    private readonly authRepository: AuthRepository,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (request: UserRequest) => {
          const token = request?.cookies?._a;
          if (!token) {
            return null;
          }
          return token;
        },
      ]),
      secretOrKey: configService.get<string>('JWT_ACCESS_SECRET'),
      ignoreExpiration: false,
    });
  }

  async validate(payload: { sub: string; provider: string }): Promise<{ id: number }> {
    const user = await this.authRepository.findUserByProvider(payload.provider, payload.sub);
    if (!user) {
      throw new UnauthorizedException();
    }
    return { id: user.id };
  }
}

 

 

JWT 토큰을 추출하는 커스터마이징 방식의 절차는 아래와 같이 진행한다.

 

1. 요청 객체에서 쿠키를 확인하고, _a라는 이름의 쿠키를 찾는다.

2. 쿠키에 _a가 존재하지 않으면 null을 반환하여 JWT 인증을 하지 않는다.

3. 쿠키에서 _a 값을 성공적으로 찾으면, 그 값을 JWT 토큰으로 사용해 인증 과정을 진행한다.

 

그러면 이제 코드를 하나하나 뜯어보자.

 

1. jwtFromRequest: ExtractJwt.fromExtractors([])

 

ExtractJwt.fromExtractors는 Passport.js에서 제공하는 함수로, JWT 토큰을 추출하는 방식을 커스터마이징할 수 있다. 기본적으로 JWT 토큰은 Authorization 헤더에서 추출되지만, 이 부분에서는 ExtractJwt.fromExtractors를 사용하여 토큰을 직접 커스텀 방식으로 추출하도록 설정한다.

 

여기서 fromExtractors 메서드는 배열을 받는다. 이 배열은 여러 추출기를 포함할 수 있으며, 각 추출기가 차례대로 실행된다.

 

첫 번째 추출기가 null을 반환하면 다음 추출기로 넘어가지만 여기서는 하나의 추출기만 사용했다.

 

2. (request: UserRequest) => {}

 

이 함수는 요청 객체(request)에서 토큰을 추출하는 커스텀 로직이다.

 

request는 요청을 나타내는 객체로, 여기서는 UserRequest 타입을 사용하고 있다. 이 객체에는 요청의 쿠키, 헤더, 본문 등의 데이터가 포함된다.

 

UserRequest 타입

import { User } from '@_modules/user/entity/user.entity';
import { Request } from 'express';

export interface UserRequest extends Request {
  user: User;
  cookies: { [key: string]: string };
}

 

User Entity

import { Comment } from '@_modules/community/entities/comment.entity';
import { Community } from '@_modules/community/entities/community.entity';
import { LifeGraph } from '@_modules/life-graph/entity/life-graph.entity';
import { UserHashtag } from '@_modules/user/entity/hashtag.entity';
import { Column, CreateDateColumn, DeleteDateColumn, Entity, JoinColumn, ManyToOne, OneToMany, PrimaryGeneratedColumn, Unique, UpdateDateColumn } from 'typeorm';

@Entity()
@Unique(['provider', 'providerId'])
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'varchar', length: 255 })
  provider: string;

  @Column({ type: 'varchar', length: 255 })
  providerId: string;

  @Column({ type: 'varchar', length: 255 })
  name: string;

  @Column({ type: 'varchar', length: 255 })
  email: string;

  @Column({ type: 'varchar', length: 255 })
  nickname: string;

  @Column({ type: 'varchar', length: 255, nullable: true })
  refreshToken: string;

  @Column({ type: 'varchar', length: 255, nullable: true })
  imageUrl: string;

  @OneToMany(() => UserHashtag, (userHashtag) => userHashtag.user)
  hashtags: UserHashtag[];

  @ManyToOne(() => LifeGraph)
  @JoinColumn({ name: 'favoriteLifeGraphId' })
  favoriteLifeGraph: LifeGraph;

  @OneToMany(() => LifeGraph, (lifeGraph) => lifeGraph.user)
  lifeGraphs: LifeGraph[];

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt: Date | null;

  @OneToMany(() => Comment, (comment) => comment.user)
  comments: Comment[];

  @OneToMany(() => Community, (community) => community.user)
  communities: Community[];
}

 

3. const token = request?.cookies?._a;

 

이 코드에서는 request.cookies._a를 통해 요청에 포함된 쿠키에서 _a라는 이름의 쿠키 값을 추출하려고 한다. _a는 엑세스 토큰이 저장된 쿠키 이름이다.

 

request?.cookies?._a는 옵셔널 체이닝을 사용한 방식으로, request나 cookies가 존재하지 않으면 undefined를 반환한다. 이렇게 함으로써 request 객체나 cookies가 없는 경우 발생할 수 있는 오류를 방지할 수 있다.

 

4. if (!token) { return null; }

 

만약 _a 쿠키에서 값을 추출하지 못한 경우(즉, 토큰이 없을 때), null을 반환한다.

 

null을 반환하면 Passport.js의 JWT 인증 로직에서 이 요청은 JWT 토큰을 포함하지 않은 것으로 처리된다.

 

5. return token;

 

쿠키에서 _a라는 이름의 쿠키 값을 성공적으로 가져왔다면, 그 값을 반환한다. 이 반환된 값은 JWT 토큰이므로, Passport.js의 JWT 인증 로직에서 이 토큰을 사용하여 유효성을 검증하게 된다.

 

이렇게 수정함으로써, 엑세스토큰을 쿠키로부터 추출하여 검증할 수 있게 되었고, 오류가 더이상 발생하지 않았다!

 

항상 초기설정을 해 놓았다면 이 후, 변경사항이 발생했을때 놓치지 않도록 주의해야겠다.

 

특별히 메모에 별표를 기재 해 놓는것도 좋은 방법이다..!