node_js 9 / 10

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 키를 해쉬 함수로 생성
      • 헤더에서 지정한 알고리즘을 통해 암호화
      • 복호화도 가능
  • Cookie와 Session의 단점을 보완할 수 있다.
    • Cookie
      • 요청 보낼 때 값을 그대로 보내기 때문에, 중간에 유출 및 조작 당할 수 있음
      • 용량 제한이 있기 때문에 많은 정보를 담을 수 없으며, 쿠키 사이즈가 클 수록 네트워크 부하가 심해짐
      • 웹 브라우저 간 쿠키 지원 형태가 다르기 때문에, 브라우저 간 공유가 불가능
    • Session
      • 쿠키에 저장된 세션을 탈취하여, 공격자가 사용자인 척 위장할 수 있음(세션 하이재킹)
      • 세션을 저장하기 위한 저장소를 사용하기 때문에 요청이 많아질 경우 서버 부하가 심해짐
      • 확장성이 낮음
    • JWT
      • Header와 Payload를 가지고 Signature을 생성하므로 데이터 위변조를 막을 수 있음
      • 인증 정보에 대한 별도의 저장소가 필요 없음
      • 서버가 클라이언트 상태를 저장할 필요가 없어, 서버를 무상태(Stateless)로 만들 수 있으며, 확장성(Scalability)이 좋음
      • 다른 서비스에 권공유(소셜 계정 로그인)하는 등, 로그인 정보의 확장성(Extensibility)이 좋음

Access Token & Refresh Token

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

인증

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

인가

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

재발급

  1. Access Token이 만료될 경우, 만료가 되었음을 응답
  2. Header에 Refresh 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

참고 자료

포스트 정보

작성자
laetipark

es laetus🙂

최초 발행
수정일
시리즈9 / 10

node_js