본문 바로가기
프로젝트

JWT 로그인 구현 (With Nest, React)

by 아마도개발자 2023. 9. 24.

 아마도 로그인 기능을 구현하는 방법은 여러가지가 있을 것이다. 쿠키, 세션, JWT토큰 등의 방법이 있는데 나는 JWT토큰을 사용하여 기능을 구현하려 한다. 우선 위 방법들의 차이가 무엇인지 간단하게 살펴 보자.

 

1. 세션

  • 사용자 정보 파일을 브라우저에 저장하는 쿠키와 달리 세션은 서버 측에서 관리
  • 사용자가 많아질수록 서버 메모리를 많이 차지
  • 서버는 클라이언트를 구분하기 위해 세션 ID를 부여하며  브라우저를 종료할 때까지 인증상태를 유지
  • 보안 면에서 쿠키보다 우수

세션 동작 방식

https://hazelcast.com/glossary/web-session/

1. 유저가 로그인 요청

2. 서버에서 세션 생성 후, 세션 아이디 반환

4. 유저가 API요청 시, 세션ID를 함께 전송

5. 세션 ID가 유효할 시, 응답을 보냄

 

 

2. 쿠키

 

  • 인증 유효시간 설정가능, 유효 시간이 정해지면 브라우저가 종료되어도 인증이 유지
  • Response Header 속성에서 클라이언트에 쿠키를 만들 수 있음
  • 쿠키는 사용자가 따로 요청하지 않아도 브라우저가 Request시에 Request Header를 넣어서 자동으로 서버에 전송

쿠키 동작 방식

https://devopedia.org/http-cookie

 

1. 클라이언트가 페이지를 요청
2. 웹 서버는 쿠키를 생성
3. 생성한 쿠키에 정보를 담아 클라이언트에게 반환
4. 넘겨받은 쿠키는 클라이언트가 다시 서버에 요청할 때 요청과 함께 쿠키를 전송
5. 동일 사이트 재방문 시 클라이언트의 PC에 해당 쿠키가 있는 경우, 요청 페이지와 함께 쿠키를 전송

 

3. JWT 토큰

 

  • 토큰에 인가 정보를 저장할 수 있음 (별도의 저장소가 필요하지 않다)
  • 다른 로그인 시스템에 접근 및 권한 공유가 가능 (쿠키와의 차이)

 

 

JWT 토큰 동작 방식

https://medium.com/@0xAggelos/building-a-secure-authentication-system-with-nestjs-jwt-and-postgresql-e1b4833b6b4e

1. 인증(로그인) 요청

2. 서버에서 accessToken, refreshToken 발급

3. API요청 시 accessToken을 헤더에 포함하여 요청

4. accessToken이 유효할 때 서버에서 응답

5. accessToken이 유효하지 않을 때, refreshToken을 확인

6. refreshToken이 유효할 때, accessToken 재발급

 

 

 

 

JWT토큰 방식을 선택한 이유

 

1. 세션을 선택할 경우 여러 대의 서버를 사용하게 되면, 서버 간 인가 정보가 공유되지 않는다. 결국 DB에 저장한 정보를 참조하여야 하는데, DB에 데이터가 쌓일 경우 이에 소비되는 IO 비용이 높아진다는 문제가 발생한다. 또한 노드의 장점인 MSA에 적합하지 않다고 생각했다.

 

2. 토큰에 인가 정보가 저장되어 있기 때문에 사용자 요청시에 토큰만 확인하면 인가를 수월하게 진행할 수 있다. 또한 토큰 방식의 가장 큰 보안 위협인 탈취에 대해서도, refreshToken을 도입함으로써 보완할 수 있기 때문에 활용도가 높다고 생각했다.

 

 

 

코드 예제

 

1) 인증(로그인) 요청

// 로그인 요청
const result = await axios.post(false,"auth/login",{
        userId: loginFormDataState.id,
        password: loginFormDataState.password
      })
// 서버 인증 (controller)
@HttpCode(HttpStatus.OK)
  @Post('login')
  async signIn(@Body() signInDto: SignInDto, @Request() req: Request, @Res({ passthrough: true }) res: Response) {

    const {status, message, accessToken, refreshToken} = await this.authService.login(signInDto);
  
    return {accessToken,refreshToken,message,status};
  }

2) 로그인 정보가 DB와 일치할 경우 accessToken, refreshToken을 발급한다.

// 인증 요청 (service)
async login(signInDto: SignInDto) {
    const {userId,password} = signInDto;

    if (!(await this.validateUser(signInDto))) {
      return {
        status: 1,
        message: "잘못된 사용자 정보입니다"
      }
    }

    const {accessToken} = await this.generateAccessToken(userId)
    const {refreshToken} = await this.generateRefreshToken(userId)

    await this.setRefreshToken(refreshToken,userId)

    return {
      status: 0,
      message: "로그인 성공",
      accessToken,
      refreshToken,
      
    }
  }

  async validateUser(signInDto: SignInDto): Promise<boolean> {
    const {userId,password} = signInDto;
    const [user] = await this.usersRepository.find({where:{userId:userId}});

    if (!user) {
      console.log("!user")
      return false
    }

    if (user && !(await bcrypt.compare(password,user.password))) {
      console.log("!password")
      return false
    }

    // const {password, ...result} = user
    return true
  }

  async generateAccessToken(userId: string) {
    const accessTokenExpired = new Date(new Date().getTime() + Number(process.env.JWT_ACCESS_TOKEN_EXPIRED));
    const payload = { userId: userId, accessTokenExpired:accessTokenExpired };
    return {
      accessToken: await this.jwtService.signAsync(payload, {
        secret: jwtConstants.accessTokenKey,
        expiresIn:jwtConstants.accessTokenExpired
      }),
    };
  }

  async generateRefreshToken(userId: string) {
    const payload = { userId: userId };
    console.log(jwtConstants.refreshTokenKey)
    return {
      refreshToken: await this.jwtService.signAsync(payload, {
        secret: jwtConstants.refreshTokenKey,
        expiresIn: jwtConstants.refreshTokenExpired
      }),
    };
  }

3) accessToken을 localstorage에 저장하고, API요청 시 accessToken을 헤더에 포함하여 전달

// axios instance
import axios from "axios";

const instance = axios.create({
  baseURL: 'http://localhost:3000/',
  params: {

  },
  timeout: 3000,
  headers: {
    'Content-Type': 'application/json',
    'Authorization': `${localStorage.getItem("accessToken")}`,
  },
  withCredentials:true,
});


export default instance;

4) accessToken이 유효할 때 서버에서 응답 ( 유효하지 않을 시 401에러 => 클라이언트가 refreshToken 확인 요청 )

// jwt-access.guard.ts

import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

@Injectable()
export class JwtAccessAuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
  ) {}
  async canActivate(
    context: ExecutionContext,
  ): Promise<any> {
    try {
      const request = context.switchToHttp().getRequest();
      const accessToken = request.headers["authorization"]
      const user = await this.jwtService.verify(accessToken);
      request.user = user;
      return user;

    } catch(err) {
      const response = context.switchToHttp().getResponse();
      response.status(200).json({ 
        status: 401,
        message: 'Invalid AccessToken' 
      });
      return false; 
      
    }
  }
}
// ServerApi.ts
  try {
    
    const response = await axios.post(path, body);
    console.log(response.data);
    if (response.status === 200) {
      if (response.data.status == 0) {
        return {
          isSuccess: true,
          data: response.data
        }
      }

      else if (isRefreshAccessTknWhenFalse && response.data.status === 401) {
        // refresh 토큰 재 호출
        const response = await axios.post(
          "auth/refresh",
          {
            refreshToken: localStorage.getItem('refreshToken')
          }
        );

        if (response.status === 201) {
          if (localStorage.getItem('accessToken')) {
            localStorage.removeItem('accessToken')
          }
          localStorage.setItem("accessToken",response.data);

          return {
            isSuccess: true,
            data: response.data
          }
        }
        else {
          return {
            isSuccess: false,
            data: response.data
          }
        }
      }

      else {
        return {
          isSuccess: false,
          data: response.data
        }
      }
      
    } 
	
    ...

새로운 accessToken 발행

// auth.controller.ts
@Post('refresh')
  async refresh(@Body() refreshTokenDto: RefreshTokenDto, @Res({ passthrough: true }) res: Response) {
    try {
      
      const newAccessToken = (await this.authService.refresh(refreshTokenDto)).accessToken;
      if (!newAccessToken) {
        res.status(200).json({
          message: "Expired RefreshToken"
        })
      }
      console.log(`new AccessToken : ${newAccessToken}`)
      res.setHeader('Authorization', newAccessToken);
      res.send({newAccessToken});
    } catch(err) {
      console.log(err)
      throw new UnauthorizedException('Invalid refresh-token');
    }
  }

 

 

 

 

 

위와 같은 흐름으로 로그인을 구현할 수 있다. 실제 nest와 react로 구현할 때, nest에서 guard 개념이 꽤나 헷갈렸다.. 개인적으로 인터넷에 나와있는 코드 전문을 참조하기 보다는 흐름만 생각하면서 본인이 구현해보는 방법이 좋은 것 같다고 생각한다. 아마도.

'프로젝트' 카테고리의 다른 글

나는 왜 사이드 프로젝트를 실패하였나  (0) 2024.02.27