NestJS/Typescript : JWT를 통한 인증과 인가 과정

NestJS/Typescript : JWT를 통한 인증과 인가 과정

인증과 인가 과정

  • 인증(Authentication) : 식별 가능한 정보를 입력하여 자신의 신원을 시스템에 증명
    • 아이디와 패스워드를 입력해 인증서버를 통해 로그인
  • 인가(Authorization) : 인증된 사용자에 대하여 자원 접근 권한을 확인
    • 로그인한 사용자가 자원서버에서의 글 쓰기와 같은 서비스를 이용할 수 있는지 확인

JWT(Json Web Token)

  • 로그인 이후 인증과 관련한 정보들을인코딩한 상태로 담은 토큰
  • 토큰은 Header.Payload.Signature로 세 파트의 JSON 쌍이 인코딩(base64)된 구조
    • Header(헤더) : 토큰의 종류(typ)와 해시 알고리즘 방식(alg)
    • Payload(정보) : 토큰에 대한 정보
      • 등록된 클레임 : 토큰에 대한 정보들을 담기 위해 이름이 이미 정해진 클레임(sub, lat)
      • 공개 클레임 : 그 외 다른 정보들로 충돌이 방지된 이름을 가지고 있어야 함
      • 비공개 클레임 : 클라이언트-서버 간 서비스를 공유하기 위한 정보를 담은 클레임
    • Signature(서명) : 토큰의 무결성을 위한 암호화 코드
      • Header의 인코딩 값과 Payload의 인코딩 값, 그리고 secret 키를 해쉬 함수로 생성
      • 헤더에서 지정한 알고리즘을 통해 암호화
      • 복호화도 가능
  • CookieSession의 단점을 보완할 수 있다.
    • Cookie
      • 요청 보낼 때 값을 그대로 보내기 때문에, 중간에 유출 및 조작 당할 수 있음
      • 용량 제한이 있기 때문에 많은 정보를 담을 수 없으며, 쿠키 사이즈가 클 수록 네트워크 부하가 심해짐
      • 웹 브라우저 간 쿠키 지원 형태가 다르기 때문에, 브라우저 간 공유가 불가능
    • Session
      • 쿠키에 저장된 세션을 탈취하여, 공격자가 사용자인 척 위장할 수 있음(세션 하이재킹)
      • 세션을 저장하기 위한 저장소를 사용하기 때문에 요청이 많아질 경우 서버 부하가 심해짐
      • 확장성이 낮음
    • JWT
      • Header와 Payload를 가지고 Signature을 생성하므로 데이터 위변조를 막을 수 있음
      • 인증 정보에 대한 별도의 저장소가 필요 없음
      • 서버가 클라이언트 상태를 저장할 필요가 없어, 서버를 무상태(Stateless)로 만들 수 있으며, 확장성(Scalability)이 좋음
      • 다른 서비스에 권공유(소셜 계정 로그인)하는 등, 로그인 정보의 확장성(Extensibility)이 좋음

Access Token & Refresh Token

  • JWT는 발급 시 사용자의 인증과 인가를 무효화할 수 없기 때문에, 토큰 탈취 위험을 막기 위해 만료 시간을 짧게 줌
    • 토큰 만료 시간을 짧게 주면 매번 다시 인증 과정을 거쳐야 하는 문제가 있기 때문에 도입된 개념

인증

  1. IDPassword 정보와 함께 로그인 요청
  2. 데이터베이스에서 ID와 Password 대조
    • 일치할 경우, Access TokenRefresh Token 응답
    • 클라이언트에서 두 토큰을 저장

인가

  1. HeaderAccess Token을 포함시켜 API 요청
  2. 토큰의 유효성 검증
    • Access Token 만료가 되지 않고 토큰 유효성 검증 완료 시 API 요청을 처리하고 응답

재발급

  1. Access Token만료될 경우, 만료가 되었음을 응답
  2. HeaderRefresh Token을 포함시켜 Access Token 재발급 요청
  3. Refresh Token의 유효성 검증하고, 새로운 Access Token을 응답
    • 클라이언트에서 Access Token 저장

NestJS에서 JWT 사용

프로젝트에서는 Access Token 발급하고 사용하는 과정만 진행하였습니다!

  • JWT 모듈 설치
npm install @nestjs/passport passport passport-local # 인증 미들웨어 관련 모듈
npm install @nestjs/jwt passport-jwt # JWT 관련 모듈
npm install cookie-parser # 쿠키 관련한 모듈
npm install -D @types/passport-local @types/passport-jwt # Typescript 타입 정의 모듈
  • 쿠키를 사용하기 위해 cookie-parser Import
// main.ts
import { NestFactory } from '@nestjs/core';
import * as cookieParser from 'cookie-parser';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.use(cookieParser()); // cookieParser() 사용
    
  // ...
}
bootstrap();
  • 인증 모듈에 JWT 모듈 Import
// auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';

@Module({
  imports: [
    JwtModule.registerAsync({
      useFactory: (configService: ConfigService) => {
        return {
          // JWT 비밀키 값  (추후에 .env 파일에 저장하여 가져와주는 것이 좋다.)
          secret: 'secret',
          // JWT 서명 옵션  
          signOptions: {
            // 토큰 만료 시간, 단위가 없으면 단위 ms(ex: 24h, 120)
            expiresIn: '24h',
            // algorithm: 사용할 알고리즘(default: HS256)
            // notBefore: 토큰 발급 후 사용을 허용하는 시간, 단위가 없으면 단위 ms
            // audience: 토큰 발급 대상
            // issuer: 토큰 발급자
            // jwtid: jwt 식별자
            // subject: 토큰의 주제
            // noTimestamp: 토큰에 타임스탬프 포함 여부
            // header: JWT 헤더 설정
            // keyid: 키 식별자 설정
          },
        };
      },
      inject: [ConfigService],
    }),
  ],
  // ...
})
export class AuthModule {
}
  • 로그인 시 JWT Access Token 발급 및 인증
// auth/auth.controller.ts
@Controller('auth')
export class AuthController {
  // ...

  /** 사용자 로그인
   * @Body signInUserDto 로그인 정보 */
  @Post('/login')
  @HttpCode(HttpStatus.OK)
  async signIn(@Body() signInUserDto: SignInUserDto, @Res() res: Response) {
    // 동록된 사용자 확인  
    const verifiedUser = await this.authService.verifyUser(signInUserDto);

    // JWT Token 발급  
    const payload = { id: verifiedUser.id, username: verifiedUser.username };
    const accessToken = await this.authService.getAccessToken(payload);

    // Set-Cookie 헤더로 JWT 토큰 & 응답 body로 사용자 정보 반환  
    return res.cookie('accessToken', accessToken, { httpOnly: true }).json({
      message: SuccessType.USER_SIGN_IN,
      data: payload,
    });
  }
}
// auth/auth.service.ts
@Injectable()
export class AuthService {
    /**
   * 사용자 검증
   * @return 검증된 User 객체
   * @param signInUserDto LoginDto
   */
  async verifyUser(signInUserDto: SignInUserDto): Promise<User> {
    const user = await this.userRepository.findOneBy({
      username: signInUserDto.username,
    });
    if (!user) {
      throw new UnauthorizedException(ErrorType.USER_NOT_EXIST);
    }

    const isMatch = await this.comparePassword(
      signInUserDto.password,
      user.password,
    );
    if (!isMatch) {
      throw new UnauthorizedException(ErrorType.PASSWORD_MISMATCH);
    }

    return user;
  }
    
  // ...

  /** JWT Access Token 발급
   * @param payload payload 요소 */
  async getAccessToken(payload: any) {
    return await this.jwtService.signAsync(payload);
  }
}
  • JWT 인가 과정
@UseGuards(JwtAuthGuard) // 전역으로 사용할 수 있고, 메소드에 따로 사용할 수 있음
@Controller('users')
export class UserController {
  // ...
}
// auth/guard/jwtAuth.guard.ts
@Injectable()
// AuthGuard(타입명)을 상속받는 것으로, 타입명을 설정해주면 그에 맞는 strategy를 따라감
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest(err: any, user: any) {
    if (err || !user) {
      throw err || new UnauthorizedException('유효하지 않은 토큰입니다.');
    }
    return user;
  }
}
// auth/strategy/jwt.strategy.ts
@Injectable()
// PassportStrategy(Strategy, '타입명, (default: jwt)')을 상속 받는 것으로 타입명에 그에 대한 strategy
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private readonly userLib: UserLib,
    private readonly configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([
        (req: Request) => {
          return req?.cookies?.accessToken;
        },
      ]),
      ignoreExpiration: false,
      secretOrKey: 'secret',
    });
  }

  // 사용자에 대한 유효성 확인
  async validate(payload: { id: number; username: string }) {
    const user = await this.userLib.getUserById(payload.id);

    if (!user) {
      throw new UnauthorizedException('유효하지 않는 사용자입니다.');
    }

    // req.user에 사용자 정보를 담음
    return {
      id: user.id,
      username: user.username,
    };
  }
}

관련 Repository

참고 자료

이 글이 도움이 되었나요?

신고하기
0분 전
작성된 댓글이 없습니다. 첫 댓글을 달아보세요!
    댓글을 작성하려면 로그인이 필요합니다.