suyeonme

[Nestjs, Axios] Access Token과 Refresh Token으로 인증 구현하기(클라이언트, 서버) 본문

프로그래밍👩🏻‍💻/기타

[Nestjs, Axios] Access Token과 Refresh Token으로 인증 구현하기(클라이언트, 서버)

suyeonme 2024. 6. 18. 12:32

인증 과정

  1. 서버: 로그인 성공시, 서버에서 refresh token은 httpOnly 헤더에, access token은 클라이언트에 전송합니다.
  2. 클라이언트: access token을 로컬스토리지에 저장합니다. 이후 요청시 헤더에 access token을 추가해서 요청을 보냅니다.
  3. 클라이언트: access token의 기간이 만료되었다면(서버의 응답이 401인 경우) refresh token 갱신 요청을 보냅니다.
  4. 서버: refresh token을 검증한 뒤, access token, refresh token을 갱신합니다. 갱신된 access token을 클라이언트에 전송합니다.
  5. 클라이언트: 갱신된 access token을 로컬스토리지에 저장합니다.

유효기간 및 저장 위치 

  • Access Token: 30분~1시간, 클라이언트에서 헤더에 추가해서 요청
  • Refresh Token: 1주~2주, 서버에서 httpOnly 헤더에 추가

서버 코드

서버는 Next.js로 구성된 프로젝트이다.

 

쿠키를 다루기위해서 먼저 라이브러리를 설치해야한다. (공식문서 참고)

$ pnpm i cookie-parser
$ pnpm i -D @types/cookie-parser

이후 main.ts에 쿠키 라이브러리를 적용한다. 나의 경우 CORS 에러가 발생하지않도록  CORS 관련된 설정도 함께 적용했다. 

app.use(cookieParser());

app.enableCors({
  origin: process.env.CORS_ORIGIN ? [process.env.CORS_ORIGIN] : [],
  methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
  credentials: true, 
  allowedHeaders: 'Content-Type, Accept, Authorization',
});

refresh token은 서버에서 httpOnly 쿠키에 저장하므로 클라이언트에 보내지않아야한다. 따라서 응답 데이터에서 refresh token은 제외하고 access token을 보내도록 코드를 작성했다.

@Controller('auth')
@UseInterceptors(ClassSerializerInterceptor)
export class AuthController {
    constructor(private authService: AuthService) {}
    
    @UseGuards(LocalAuthGuard)
    @Post('/signin')
    async login(
        @Request() _req: Req,
        @Body() signinUserDto: SigninUserDto,
        @Res({ passthrough: true }) res: Response,
    ) {
        const { user, accessToken } = await this.authService.signin(signinUserDto);

        res.cookie('refreshToken', user.refreshToken, {
            httpOnly: true,
            secure: true, // Ensure secure for HTTPS
            sameSite: 'strict', // Prevent CSRF attack
            maxAge: REFRESH_TOKEN_COOKIE_MAX_AGE, // 7 days
        });

        const copied: Omit<User, 'password' | 'refreshToken'> = { ...user };

        /**@description @Exclude() 항목이 인터셉터에서 제거되지않으므로 수동으로 제거  */
        if ('password' in copied && 'refreshToken' in copied) {
            delete copied.password;
            delete copied.refreshToken;
        }

        return { ...copied, accessToken };
    }
}

httpOnly

  • JavaScript로 접근할 수 없는 쿠키로, 클라이언트와 서버 간의 HTTP 요청을 통해서만 전송될 수 있다.
  • 클라이언트측 스크립트는 쿠키에 접근이 불가하므로, XSS(Cross-Site Scripting) 공격으로부터 쿠키를 보호하는 데 유용하다.

sameSite

  • strict: 쿠키는 동일한 사이트에서만 전송된다. 즉, 사용자가 사이트에서 탐색할 때만 쿠키가 전송된다. 다른 사이트에서 요청이 발생하면 쿠키는 전송되지 않는다.
  • 위와 같은 이유로 CSRF(Cross-Site Request Forgery) 공격을 방지하는 데 도움이 된다.

응답데이터에서 refresh token 제거하기

아래와 같이 entity를 바로 반환하는 경우, Controller에 적용한 ClassSerializerInterceptor에 의해서 @Exclude() 필드가 정상적으로 제외된다.

// user.entity.ts
@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    email: string;

    @Column()
    @Exclude()
    password: string;

    @Column()
    username: string;

    @Column({ type: 'varchar', nullable: true })
    @Exclude()
    refreshToken: string | null;
}

하지만 아래와 같이 바로 entity를 반환하지 않는 경우, @Exclude() 필드가 정상적으로 필터링되지 않았다. 따라서 수동으로 제외시켰다.

    @UseGuards(LocalAuthGuard)
    @Post('/signin')
    async login(
        @Request() _req: Req,
        @Body() signinUserDto: SigninUserDto,
        @Res({ passthrough: true }) res: Response,
    ) {
    	// Service에서 Entity를 바로 반환하지 않음
        const { user, accessToken } = await this.authService.signin(signinUserDto);
        ...
        const copied: Omit<User, 'password' | 'refreshToken'> = { ...user };

        /**@description @Exclude() 항목이 인터셉터에서 제거되지않으므로 수동으로 제거  */
        if ('password' in copied && 'refreshToken' in copied) {
            delete copied.password;
            delete copied.refreshToken;
        }

        return { ...copied, accessToken };
    }

클라이언트 코드

React 프로젝트 기준 axios의 인터셉터를 이용해서 인증 로직을 구현하였다.

 

axios/axios.config.ts

axios의 요청, 응답 인터셉터를 다루는 로직을 별도의 모듈로 분리했다.

// axios.config.ts

export const createBaseAPI = (
  baseUrl: string,
  options: AxiosRequestConfig = {}
) => {
  return axios.create({
    baseURL: baseUrl,
    timeout: REQUEST_TIMEOUT,
    withCredentials: true,
    headers: {
      "Content-Type": "application/json",
    },
    ...options,
  });
};

/**
 * @description 요청 인터셉터, 액세스토큰을 헤더에 추가합니다.
 */
export const addRequestInterceptors = (
  instance: ReturnType<typeof axios.create>
) => {
  instance.interceptors.request.use(
    (config) => {
      const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
      if (accessToken) {
        config.headers["Authorization"] = `Bearer ${accessToken}`;
      }
      return config;
    },
    (error) => Promise.reject(error)
  );
};

/**
 * @description
 * - 응답 인터셉터, 액세스토큰이 만료되었을 경우, 리프레시 토큰을 사용하여 재발급합니다.
 * - 에러 발생시 에러 로그를 출력합니다.
 */
export const addResponseInterceptors = (
  instance: ReturnType<typeof axios.create>
) => {
  instance.interceptors.response.use(
    (response) => {
      const accessToken = response.data.data[ACCESS_TOKEN_KEY];
      if (accessToken) {
        localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
      }
      return response;
    },
    async (error) => {
      if (config.nodeEnv === "development") {
        // 에러 로깅
        errorMessageLogger(error);
      }

      // 액세스토큰 만료시 리프레시 토큰 재발급
      const originalRequest = error.config as AxiosRequestConfig & {
        _retry?: boolean;
      };

      if (error?.response?.status === 401 && !originalRequest?._retry) {
        originalRequest._retry = true;
        try {
          const { data } = await instance.get("/auth/refresh");
          const accessToken = data[ACCESS_TOKEN_KEY];

          localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
          axios.defaults.headers.common[
            "Authorization"
          ] = `Bearer ${accessToken}`;

          return instance(originalRequest);
        } catch (refreshError) {
          return Promise.reject(refreshError);
        }
      }

      return Promise.reject(error);
    }
  );
};

axios/baseInstance.ts

/**
 * @description 일반적인 API 요청시 사용하는 axios 인스턴스입니다.
 */
const baseInstance = createBaseAPI(APP_SERVER_BASE_URL);

addRequestInterceptors(baseInstance);
addResponseInterceptors(baseInstance);

export default baseInstance;

인스턴스를 사용하는 곳에서는 다음과 같이 사용하면 된다.

  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
    try {
      const response = await signin(form);
      const { data } = response;

      if (data.statusCode === 201) {
        handleResetForm();
        router.push("/");
      } else {
        console.error(data.message);
      }
    } catch (error) {
      /**@todo 에러 메세지 출력 */
    }
  }
Comments