Skip to content

JWT로 인증 인가 구현

Summer Min edited this page Nov 23, 2024 · 1 revision

1. JWT 인증 인가 개념

async validate(accessToken: string, refreshToken: string, profile: Profile) {
    // 네이버 인증 이후 사용자 정보 처리
    const createUserDto: CreateUserDto = {
      providerId: profile.id,
      provider: 'naver',
      email: profile._json.email,
    };
    let user = await this.authService.findUser(createUserDto);
    if (!user) {
      user = await this.authService.createUser(createUserDto);
    }
    return user; // req.user로 반환
  }

이 코드에서의 accessToken, refreshToken은 네이버, 카카오와 같은 OAuth 제공자가 발급해주는 토큰으로, 사용자가 네이버/카카오에 로그인한 세션을 유지 + 서비스 데이터에 접근할 수 있게 해줌

하지만 이제 우리는 네이버/카카오로 인증을 끝낸 후, 사용자 식별을 위해 로그인 세션을 유지하고 인가된 요청을 처리할 거임.

  1. JWT는 어떤 흐름으로 쓰이고 처리되는가?
  • 클라이언트: JWT를 받아 저장(localStroage / sessionStroage)한 후 모든 요청의 헤더에 이 JWT를 Authorization: Bearer <JWT_TOKEN>형식으로 포함시킬 것

  • 서버: 서버는 클라이언트의 요청이 들어올 때 이 JWT가 (1) 유효한지 (2) 변조되지 않았는지 확인해야하며, 만약 둘을 확인했다면 사용자의 정보를 request에 req.user 형태로 설정해 둘것

    → 어떻게? 이 인증을 할 AuthGaurd를 만들어서!!

  1. JWT의 장점은?

    a. 한번의 인증으로 여러번 요청을 인증된 상태로 보낼 수 있음 → 사용자가 세션을 유지할 수 있음

    b. 사용자가 인증된 후에도 특정 권한을 가지고 있는지 확인할 수 있음

    → 이걸로 워크스페이스 편집 권한 관리해야겠다….

    c. 상태를 서버에 저장하지 않기 때문에, 분산 시스템에서 확장이 유이함

  2. JWT의 구성은?

    • 헤더: 토큰의 타입(JWT)과 사용된 알고리즘(예: HS256)이 포함
    • 페이로드: 사용자의 고유 식별자, 인증 제공자, 그리고 만료 시간과 같은 인증 관련 정보가 포함, 여기서 사용자의 ID제공자(naver 또는 kakao) 정보가 포함
    • 서명: 토큰이 변조되지 않았음을 증명하는 서명 부분, 서버가 가지고 있는 비밀 키 (JWT_SECRET)로 생성

2. JWT 인증 인가 코드 추가

  1. 일단 acessToken, refeshToken으로 인증을 성공하면 서버에서는 사용자에게 JWT를 발급해주도록 하자.
  • auth.controller.ts

    import { Controller, Get, UseGuards, Req } from '@nestjs/common';
    import { AuthGuard } from '@nestjs/passport';
    import { AuthService } from './auth.service';
    import { JwtService } from '@nestjs/jwt';
    // import { JwtAuthGuard } from './guards/jwt-auth.guard';
    
    @Controller('auth')
    export class AuthController {
      constructor(
        private readonly authService: AuthService,
        private readonly jwtService: JwtService,
      ) {}
    
    	...
    
      @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 token = this.jwtService.sign(payload);
        return {
          message: '네이버 로그인 성공',
          user,
          accessToken: token,
        };
      }
    
      ...
    
      @Get('kakao/callback')
      @UseGuards(AuthGuard('kakao'))
      async kakaoCallback(@Req() req) {
        // 카카오 인증 후 사용자 정보 반환
        const user = req.user;
        // TODO: 후에 권한 (workspace 조회, 편집 기능)도 payload에 추가
        const payload = { sub: user.id, provider: user.provider };
        const token = this.jwtService.sign(payload);
        return {
          message: '카카오 로그인 성공',
          user,
          accessToken: token,
        };
      }
  1. 이제 유저의 권한이 필요한 컨트롤러 함수를 하나 만들어보자.
 @Get('profile')
  @UseGuards(JwtAuthGuard) // JWT 인증 검사
  async getProfile(@Req() req) {
    // JWT 토큰을 검증하고 사용자 정보 반환
    return {
      message: '인증된 사용자 정보',
      user: req.user,
    };
  }

→ 컨트롤러에 사용자 정보를 조회할 수 있는 profile 엔드포인트를 만들어보자

→ 로그인한 사용자가 본인의 프로필을 조회할 수 있는 엔드포인트

  // Example: 로그인한 사용자만 접근할 수 있는 엔드포인트
  // auth/profile
  @Get('profile')
  @UseGuards(JwtAuthGuard) // JWT 인증 검사
  async getProfile(@Req() req) {
    // JWT 토큰을 검증하고 사용자 정보 반환
    return {
      message: '인증된 사용자 정보',
      user: req.user,
    };
  }

3. Token을 검증하는 JwtAuthGuard를 만들자

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { LoginRequiredException } from '../../exception/login.exception';
import { InvalidTokenException } from '../../exception/invalid.exception';

@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(private readonly jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const authorizationHeader = request.headers['authorization'];

    // 토크가 있는지 확인
    if (!authorizationHeader) {
      //   console.log('Authorization header missing');
      throw new LoginRequiredException();
    }

    const token = authorizationHeader.split(' ')[1];

    // 유효한 토큰인지 확인
    try {
      const decodedToken = this.jwtService.verify(token, {
        secret: process.env.JWT_SECRET,
      });
      request.user = decodedToken;
      return true;
    } catch (error) {
      //   console.log('Invalid token');
      throw new InvalidTokenException();
    }
  }
}

4. 모듈 세팅

  • auth.module.ts

→ 자 여기서 트러블 슈팅 과정이 있었음!!!

  • 원래 코드
import { Module } from '@nestjs/common';
import { UserRepository } from '../user/user.repository';
import { UserModule } from 'src/user/user.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { NaverStrategy } from './strategies/naver.strategy';
import { KakaoStrategy } from './strategies/kakao.strategy';
import { JwtModule } from '@nestjs/jwt';
import { JwtAuthGuard } from './guards/jwt-auth.guard';

@Module({
  imports: [
    UserModule,
    JwtModule.register({
      secret: process.env.JWT_SECRET,
      signOptions: { expiresIn: '1h' },
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    NaverStrategy,
    KakaoStrategy,
    UserRepository,
    JwtAuthGuard,
  ],
})
export class AuthModule {}

→ 여기서 process.env.JWT_SECRET 를 읽지 못하는 문제가 터짐!!!!

→ why??

환경변수가 애플리케이션이 시작될 때 비동기적으로 로드되었기 때문이다

→ 즉, 모듈에서 등록하는 환경변수는 동적으로 로드되게, 따로 설정을 해야하는 것...

  • useFactory를 사용하여 비동기적으로 코드를 수정하면....
import { Module } from '@nestjs/common';
import { UserRepository } from '../user/user.repository';
import { UserModule } from 'src/user/user.module';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { NaverStrategy } from './strategies/naver.strategy';
import { KakaoStrategy } from './strategies/kakao.strategy';
import { JwtModule } from '@nestjs/jwt';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
  imports: [
    UserModule,
    ConfigModule.forRoot({ isGlobal: true }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { expiresIn: '1h' },
      }),
    }),
  ],
  controllers: [AuthController],
  providers: [
    AuthService,
    NaverStrategy,
    KakaoStrategy,
    UserRepository,
    JwtAuthGuard,
  ],
})
export class AuthModule {}

5. 이제 테스트를 해보장

코드보다는 부여받은 토큰으로 제대로 인증과정이 처리되는지 확인해보겠다

Step 1. 로그인 후 받은 jwt (access) token

image

Step 2. 해당 access token -> postman으로 Authorization 헤더에 실어 사용자 로그인 필요한 엔드포인트로 보내보자

image

→ 여기서 또 헤맴....

Value에 "Bearer " 실어서 보내야됨 !!!!!

Step 3. 엔드포인트에서 에러 없는지 확인

image

6. Refresh Token 추가

  • 현재 access token 주기: 한시간
  • 일단 refresh token 주기 => 일주일로 해두면
  1. controller에서 콜백함수에서 refreshToken을 발급받게 추가해준다
@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,
    };
  }
  1. 모듈 기능에 있던 expire 옵션도 없애준다
signOptions: { expiresIn: '1h' },
  1. refreshToken을 인증하고 인증이 성공적으로 마무리될 시, accessToken을 다시 발급받는 엔드포인트를 만든다
@Post('refresh')
  async refreshAccessToken(@Req() req) {
    const { refreshToken } = req.body;
    const decoded = this.jwtService.verify(refreshToken, {
      secret: process.env.JWT_SECRET,
    });
    const payload = { sub: decoded.sub, provider: decoded.provider };
    const newAccessToken = this.jwtService.sign(payload, { expiresIn: '1h' });
    return {
      message: '새로운 Access Token 발급 성공',
      accessToken: newAccessToken,
    };
  }
  1. 하지만 여기서 refreshToken을 인증하는데 실패할 때는 총 두가지 경우가 있다.

    (a) refreshToken이 조작된 경우, 형식이 잘못된 경우 (토큰을 도입하는 가장 큰 이유)

    (b) refreshToken이 만료돼서 사용자가 재인증 (로그인) 을 거쳐 다시 accessToken, refreshToken을 발급받아야 하는 경우

    이 경우를 나눠서 처리하기 위해 에외처리를 상세하게 해준다

import { TokenExpiredError } from 'jsonwebtoken';
...
catch (error) {
      if (error instanceof TokenExpiredError) {
        // Refresh Token이 만료됨 -> 사용자의 인증 필요
        throw new ExpireException();
      } else {
        // 잘못된 Token임 -> 형식 오류 / 조작된 토큰일 것
        throw new RefreshTokenException();
      }

개발 문서

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

팀 문화

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

그룹 기록

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