본문 바로가기

Development Log

[React/NestJS/TypeScript] 로그인 시 JWT Token 발급 및 인증(Token sessionStorage 저장)

일단 만들고 나중에 정리하자.. 고 했지만 결국 정리가 되지 않다 보니까 흐름을 이해하지 못하는 것 같아서 정리하면서 마무리를 해보려고 한다. 회사 다닐 때는 이미 로그인, 회원가입은 전부 구현되어 있었다.. 그렇다.. 그래서 JWT? 토큰? 몰라도 API를 구현할 수 있고 로그인이 되어있는 상태에서 포스트맨을 사용하려면 Header에 Authorization를 추가해서 로그인 시 받은 토큰을 넣어주면서 사용했었다! 개인 프로젝트에서 사용하면서 좀 오랜 시간 막혔던 부분은 토큰 발급 완료! 그럼 이 토큰을 어디에 넣어서 전달해 주고 인증하지? 였던 것 같다. 먼저 궁금증이 생겼던 부분들을 정리하고 사용하는 방법을 정리할 예정이다.

 

JWT란?

처음에는 JWT가 뭔지도 몰랐었는데 당연히 약자가 뭔지는 알아야 하기 때문에 구글 검색을 했었다.

JWT = Json Web Token의 약자이며 웹에서 사용되는 Json 형식의 Token에 대한 규격이라고 생각하면 쉽다! 결국에는 로그인할 때 클라이언트와 서버에서 인증과 인가를 안전하게 주고받고 하기 위해서 사용한다고 생각하면 된다.

당연히 토큰은 한눈에 보고 알아볼 수 있는 문자로 되어있진 않지만 중요한 정보를 넣는다면 사이트를 통해서 알 수 있는 문자기 때문에 사용하더라도 중요 정보는 제외해야 한다. 예를 들어 비밀번호..? 😅 비밀번호는 당연히 제외..😅

 

그래서 JWT를 어디서 사용하는데?

특정 API를 호출했을 때 인증과 인가를 사용하기 위해 사용한다. 조금 더 구체적으로 말하자면 특정 API를 호출하여 JWT Token을 보내서 원하는 서비스에 접근한다고 생각하면 된다. 검색하면서 제일 많이 사용되는 부분은 로그인하여 토큰을 발급받고 해당 토큰으로 로그인 시에만 접근할 수 있는 서비스에 접속하는 부분을 사용하고 있다. 물론 나포함..

 

쿠키도 아닌, 세션도 아닌, 왜 JWT인데?

정말 여러가지 이유들이 있었는데 그중에서 가장 큰 이유는 클라이언트에 인증되었다는 토큰이 유일하게 발급이 되면서 토큰 안에 필요한 정보들이 간결하게 들어가 있고 인증 정보에 대한 별도의 저장소가 필요 없다는 것이었다. 쿠키와 세션보다 안정성이 보장되는데 그만큼 간결성도 가지고 있었기 때문에 개인 프로젝트에 사용하게 되었다. 

 

+ (2024.05.11) JWT를 써야하는 이유에 대한 추가 내용 작성! ⬇️ ⬇️ ⬇️

https://dego.tistory.com/31

 

[Development Log] JWT...왜 써야 하는데?

JWT... 분명 프로젝트에서 기본적으로 많이 사용했고, 과제들에서 요구사항들도 필수 요구사항이라고 할 수 있을 정도로 업에서 JWT를 많이 사용하고 있다는 것을 알 수 있었다. 이 글을 쓰는 이유

dego.tistory.com

 

AccessToken과 RefreshToken 꼭 둘 다 만들어야 해?

사실 처음에 당연히 AccessToken만 생각했었고 RefreshToken까지 고려하지 못했다. 하지만 검색을 계속해보던 도중 RefreshToken까지 구현하는 글들을 많이 보았고 보안적으로 더 안전하게 구현하기 위해서 두 가지 토큰으로 구별해서 만들었던 것 같다. 다만, 남들이 다 한다고 해서 내가 그 방법을 따라야 한다는 것은 아니기 때문에 곰곰이 생각해 봤다. 현재 RefreshToken이 필요한 이유.. AccessToken의 유효기간은 짧게, RefreshToken은 길게.. AccessToken의 유효기간이 끝나면 DB에 저장된 RefreshToken의 유효기간을 확인하여 AccessToken을 다시 재발급.. 지금 내 프로젝트에서는 개인정보가 있는 것도 아니기 때문에 당장 필요하지 않아 보였고 우선은 AccessToken 하나로 구현을 하기로 결심했다. 추후에는 구현해보고 싶기는 해서 해당 프로젝트의 개선사항 또는 다른 프로젝트를 진행할 때 도입해보려고 한다.

// TODO: AccessToken과 RefreshToken 구현하여 보안 강화하기

 

JWT Token은 어디에 저장하는데?

사실 이 물음은 바로 생각이 안 날 수도 있다. 왜냐면 프론트 쪽을 생각해 보지 못해서였던 것 같다. API를 전부 완성하고 토큰을 리턴해준 다음 프론트에서 해당 토큰을 어떻게 사용하지?부터 막혔던 것 같다. 분명 예전에 로그인이 필요했던 서비스면 포스트맨의 헤더에 토큰 값을 넣어줬는데.. 그럼 헤더에 넣어주면 되나?라는 궁금증으로 구글 검색을 시작했다.

정말 여러 가지 방법들이 있었고 아래 3가지 방법을 찾았다.

 

1. Cookie

setCookie("access-token", data.accessToken);

 

2. LocalStorage & SessionStorage

localStorage.setItem("access-token",data.accessToken);
SessionStorage.setItem("access-token",data.accessToken);

 

3. Headers

axios.defaults.headers.common['Authorization'] = 'Bearer ' + res.data;

 

근데 또 여기서 고민이 생겼다.. 실무에서는 어떤 방법을 사용할까? 보안적으로 이슈가 덜 한 방법은 무엇일까? 라는 궁금증이 생겼고 사실 이걸로 몇일 고민했다.. 개인 프로젝트인데 그냥 아무거나 쓰면 안되? 라고 할 수도 있지만 내 성격상 그냥이라는 말이 용납되지 않았고.. 이유를 찾아서 선택하고 싶었다! 그래서 또 열심히 구글링을 했다.

 

일단 쿠키는 사용자가 웹에서 쿠키를 차단해놨다면..? 사용하지 못 한다.. 또한 httpOnly 옵션을 사용한다고 해도 csrf 공격의 위험성이 존재한다. 스토리지는 자바스크립트 파일에 쉽게 접근할 수 있기 때문에 xss 공격의 위험성이 존재한다..😭

 

난 로그인 된 유저의 정보를 유지해야 했고, 항상 로그인 페이지에서 로그인해서 들어오는 사이트로 만들고 있었고, 딱 쓰는 동안에만 유지되었으면 하는 바램도 있었고, 중요한 개인정보는 받지 않아서 크게 보안적인 이슈가 높을 것 같지 않았다. 아무나, 누구나, 자유롭게 들어와서 사용하길 바랬던 마음이 컸다.

 

그래서 내 결정은! 3번은 불가능 했고 1번을 선택했다가 2번으로 방향을 바꿨었다. 사실 1번을 사용하려고 프론트에서 삽질을 너무 많이 해서 시간이 많이 흘러버렸다.. 프론트에서 쿠키를 저장하여 토큰이 필요한 요청의 헤더에 Authorization : Token으로 사용하고 싶었다. 다만 httpOnly 옵션을 사용하지 않으면 성공인데, 사용하게 되면 프론트에서 저장할 수 없다는 사실도 알게 되었다. 결국 서버에서 쿠키 저장까지 해야하는데 프론트에서 사용하는 더 좋은 방법이 있지 않을까라는 생각과 고민에 또 빠졌지만 이 부분은 내가 프론트의 이해도가 높아야 좀 더 고민해볼 만한 주제라고 생각이 되었고 추후에 프론트를 기초부터 차근차근 공부해서 사용해보려고 한다. 

// TODO: Token 저장소를 Cookie로 저장해서 사용하기

 

🌞  이제 궁금증 해결 끝! 본격적으로 JWT Token 백엔드와 프론트 구현 시작!

 

1. Server에서 JWT 관련 라이브러리를 전부 설치

npm install @nestjs/jwt @nestjs/passport passport passport-jwt --save

 

2. JWT Token 생성

위에 라이브러리들을 전부 설치했다면 이제 auth 폴더를 만들어서 관련된 코드들을 전부 넣어줄 예정이다. 우선 아래 이미지처럼 auth 관련 파일들만 전부 만들어주면 된다. 폴더의 위치는 당연히 src 아래이고 난 src > feature > auth 이런식으로 만들어서 넣어줬다. feature 폴더를 중간에 넣은 이유는 user, auth, admin 이런식으로 폴더를 나눴을 때 묶어주기 위함으로 생성했었다. 개개인의 선택이여서 feature 폴더를 중간에 넣지 않아도 된다!

 

 

우선 auth.app.module.ts 먼저 작성해보자!

 

📌 auth.app.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    JwtModule.register({
      secret: process.env.JWT_SECRET_KEY,
      signOptions: { expiresIn: '2 days', algorithm: 'HS512' },
    })
  ],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

 

여기서 JwtModule.register 안에 secret은 키라고 생각하면 된다. 그렇기 때문에 공개되면 안되서 .env에 작성해놓으면 된다! 아래 원하는 키로 작성해놓은 곳에 자신만 알 수 있는 비밀번호?를 작성한다고 생각하면 된다.

 

JWT_SECRET_KEY=원하는 키

 

그 다음엔 auth.controller.ts, auth.service.ts를 작성해보려고 한다.

 

📌 auth.controller.ts

여기서는 this.authService.validateUser(); 이 부분으로 넘어가는 곳만 중요하고 나머지는 일반적으로 로그인을 구현하기 위한 코드라고 생각하면 될 것 같다. 즉, 서비스로 넘어가서 토큰을 생성 후 리턴해준다.

 

import { Body, Controller, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LoginUserDto } from '../user/user.dto';
import { User } from 'src/entity/user.entity';

@Controller({
  path: '/api',
})
export class AuthController {
  constructor(private readonly authService: AuthService) {}

  @Post('/login')
  async loginUser(@Body() loginUserDto: LoginUserDto): Promise<{
    accessToken?: string;
    user?: User;
    userErrorMessageObject: UserErrorMessageObject;
  }> {
   ... 생략
      const validateUser = await this.authService.validateUser(
        loginUserDto,
        user,
      );

      return validateUser;
    }
  }
}

 

📌 auth.service.ts

여기서 payload에 토큰에 넣을 필요한 정보를 작성해준다. 다만 비밀번호등 중요한 정보는 넣지 않는 것이 기본이다! 난 user의 id만 필요했기 때문에 token에 user id만 담아줄 예정이다. 그리고 jwtService는 nestjs/jwt에서 제공하기 때문에 임포트 해서 사용하고 sign 함수를 불러온다면 토큰을 리턴해준다.

 

import { Injectable } from '@nestjs/common';
import { User } from 'src/entity/user.entity';
import { LoginUserDto } from '../user/user.dto';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private readonly jwtService: JwtService,
  ) {}

  async validateUser(
    loginUserDto: LoginUserDto,
    user: User,
  ): Promise<{
    accessToken?: string;
    user?: User;
    userErrorMessageObject: UserErrorMessageObject;
  }> {
    ...생략
        const payload = {
          id: user.id,
        };

        const accessToken = this.jwtService.sign(payload);
        return {
          accessToken: accessToken,
          user,
        };
  }
}

 

여기까지하면 서버에서 토큰 생성은 완료이다. 이제 이 토큰을 인증하는 부분을 만들면 서버쪽은 끝이다.

 

3. JWT Token 인증

이제 아까 설치한 passport-jwt를 사용해서 Token을 인증할 것이다. 여기서 JwtStrategy.ts, JwtAuthGuard.ts 작성과 auth.app.module.ts를 수정해야한다. 일단 JwtStrategy는 PassportStrategy 클래스를 상속하고 있으며, 이를 통해서 passport를 사용한 인증을 구현할 수 있다! 이 부분은 nestJS 공식문서랑 같이 보는게 이해가 빠를 것 같다.

 

+ nestJS 공식문서

https://docs.nestjs.com/security/authentication

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

 

📌 JwtStrategy.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';
import { User } from 'src/entity/user.entity';
import { UserService } from 'src/feature/user/user.service';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(private readonly userService: UserService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET_KEY,
      ignoreExpiration: false,
    });
  }

  async validate(payload: any): Promise<Pick<User, 'id' | 'nickname'>> {
    const user = await this.userService.getUser(payload.id);
    if (user) {
      return user;
    } else {
      throw new UnauthorizedException('User Not Found');
    }
  }
}

 

📌 JwtAuthGuard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}

 

📌 auth.app.module.ts

여기서 수정된 부분은 Passport를 사용하기 위해 PassportModule을 imports에 추가한 것과 JwtStrategy를 사용하기 위해 Providers에 추가한 것이다.

 

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UserModule } from '../user/user.app.module';
import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './strategy/jwt.strategy';
import { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
    }),
    JwtModule.register({
      secret: process.env.JWT_SECRET_KEY,
      signOptions: { expiresIn: '2 days', algorithm: 'HS512' },
    }),
    UserModule,
    PassportModule,
  ],
  providers: [AuthService, JwtStrategy],
  controllers: [AuthController],
})
export class AuthModule {}

 

이제 토큰 인증을 확인하기 위해 필요한 곳에 데코레이터를 사용해서 적용해보면 된다. 아래 코드처럼 @UseGuards(JwtAuthGuard)를 사용하게되면 컨트롤러에 도달하기 전에 토큰을 확인해서 유효하지 않거나 만료된 토큰일 경우에 401 에러로 접근하지 못하게 한다. 만약 사용할 수 있는 토큰이라면 문제없이 접근 가능하다.

 

@UseGuards(JwtAuthGuard)
@Get('/test')
async getProfile(@Req() req: any) {
 const user = req.user;
 return user;
}

 

여기까지하면 서버쪽에서 할 일은 끝났다. 이제 프론트에서 해당 토큰을 받아서 sessionStorage에 저장하여 토큰이 필요한 요청에 헤더에 넣어주기만 하면 되기 때문이다!

 

4. Client에서 sessionStorage에 Token 저장

아래 코드에서 data 요청을 받아서 sessionStorage.setItem("access-token",data.accessToken)에 넣어주면 현재 브라우저 탭의 세션에 저장되고 만약 탭을 끈다면 사라진다. 사실 난 이 부분을 원하기도 했다. 

 

export default function Layout() {
... 생략
	const onSubmit = (data: loginObject) => {
		console.log(data);

		fetch(`/api/login`, {
			method: "POST",
			headers: { "Content-type": "application/json" },
			body: JSON.stringify({
				id: data.id,
				password: data.password,
			}),
		})
			.then((res) => res.json())
			.then((data) => {
				... 생략
					sessionStorage.setItem("access-token",data.accessToken); // 이 부분이 sessionStorage 저장!
					history("/main");
			})
			.catch((error) => {
				console.error("Error:", error);
			});
	};

	return (
		... 생략
	);
}

 

그 다음 sessionStorage에 저장된 토큰이 필요할 경우에는 아래 코드처럼 헤더에 Authorization: `Bearer ${sessionStorage.getItem("access-token")}`로 넣어서 @UseGards() 데코레이터를 통과하는 방식으로 사용했다!

 

import "./accountBook.css";
import { AiFillPlusSquare } from "react-icons/ai";
import { AiFillMinusSquare } from "react-icons/ai";
import { useEffect, useState } from "react";
import Header from "../../components/layout/header";
import { Navigate, useNavigate } from "react-router-dom";
import Footer from "../../components/layout/footer";
import { useForm } from "react-hook-form";
import { getCookie } from "../../components/cookie/cookie";

export default function AccountBook() {
	... 생략
	useEffect(() => {
		fetch("/api/main", {
			method: "GET",
			headers: {
				"Content-type": "application/json",
				Authorization: `Bearer ${sessionStorage.getItem("access-token")}`,
			},
		})
			.then((res) => res.json())
			.then((data) => {
				console.log("제대로 전부 가져오는지 확인 : ", data);
			});
	}, []);

	return (
		... 생략
	);
}

 

 

여기까지 여러번의 삽질과 시간과 배움이 있었던 것 같다. 항상 개인 프로젝트 할때 로그인쪽에서 가장 오래걸렸고 실무에서는 이미 로그인 구현이 되어있어서 서버에서 어떻게 전달되서 프론트에서 구현하는지를 생각해보지 못했던 것 같다. 이 기회에 여러가지의 고민들과 직접 구현해봄으로써 후회는 없던 것 같다! 아직 공부는 멀고도 멀었구나라는 생각과 함께 더 열심히 공부해야겠다!