반응형
반응형

📝Nest.js 라이브러리

  • date-fns
    • 날짜 및 시간 처리를 쉽게 도와주는 라이브러리입니다.
  • loadsh
    • 배열안이나 Hash로 구성된 값들을 합치거나 등에 복잡한 연산들을 함수 하나로 해결해줍니다. → JavaScript의 코드를 줄여주고, 빠른 작업에 도움을 줍니다.
  • prettier
    • 다양한 언어에서 코드의 형식을 자동으로 정리해주는 도구로 일관된 코드를 유지시켜줍니다.
  • joi
    • 데이터 유효성 검사 라이브러리입니다. (최소 3글자 등...)
  • winston
    • 로깅 라이브러리입니다.
  • jwt
    • jwt 토큰을 생성하고 인증하는데 사용하는 라이브러리입니다.
  • bcrypt
    • bdcrpyt로 비밀번호를 해시합니다.
  • cookie-parser
    • cookie를 파싱할 수 있게 도와주는 라이브러리입니다.
  • class-validator
    • 데코레이터를 사용해 객체에 대한 속성 유효성 검사를 합니다 (@IsString(), @IsEmail 등..) 
  • prisma (선택 사항)
    • orm입니다.
  • graphql (선택 사항)
    • API 인터페이스 입니다.

 

📝Nest.js 네이밍 컨벤션

  • 파일명
    • user-info.ts
  • enum
    • user-status.enum.ts
  • boolean
    • is prefix (isDailyLimit)

 

 

📝Nest.js 프로젝트 구조

  • common
    • decorator
      • custom common decorator
    • constant
      • 상수값
    • dto
      • common dto
    • enum
      • enum 파일
    • interceptor
      • common nest.js interceptor
    • logger
      • winston
    • guards
      • common nest.js guards
    • filter
      • common nest.js filter
    • module
      • common module
    • strategy
      • passport.js 사용 전략
    • uitls
      • common util
    • middleware
      • nest.js middleware
  • user-info
    • user-info.resolver
    • user-info.module
    • user-info.service
    • response-dto
      • response dto (user-info.response.ts)
    • request-dto
      • request dto (user-info.input.ts)
    • decorator
      • 해당 엔드포인트 custom decorator
    • interceptor
      • 해당 엔드포인트 interceptor

 

 

📝Nest.js [IDE 설정]

https://mondaymonday2.tistory.com/988

 

[IDE] [WebStorm] 환경 설정 (Prettier, eslint, Auto Save Prettier)

📝Prettier 설치https://www.npmjs.com/package/prettier prettierPrettier is an opinionated code formatter. Latest version: 3.3.3, last published: 12 days ago. Start using prettier in your project by running `npm i prettier`. There are 17948 other projec

mondaymonday2.tistory.com

 

반응형
반응형

📝Next.js 라이브러리

  • loadsh
    • 배열안이나 Hash로 구성된 값들을 합치거나 등에 복잡한 연산들을 함수 하나로 해결해줍니다. → JavaScript의 코드를 줄여주고, 빠른 작업에 도움
  • prettier
    • 다양한 언어에서 코드의 형식을 자동으로 정리해주는 도구로 일관된 코드를 유지시켜줍니다.
  • axios
    • HTTP 클라이언트 라이브러리입니다. API 요청시 사용됩니다.
  • react query
    • 서버 상태 관리를 효율적으로 처리할 수 있는 라이브러리입니다. 주로 API 호출을 관리하고, 데이터 패칭, 캐싱, 동기화 등을 자동화합니다
  • redux toolkit
    • 전역으로 상태를 관리해주는 역할을 합니다.
  • shadcn-ui
    • Tailwind CSS를 기반으로 하는 UI 컴포넌트 라이브러리입니다.
  • date-fns
    • 날짜 및 시간 처리를 쉽게 도와주는 라이브러리입니다.
  • react-hook-form
    • 폼 데이터를 처리하고 유효성 검사를 쉽게 도와주는 라이브러리입니다.
  • react-icons
    • 아이콘들을 제공합니다.
  • tanstack-table
    • 테이블을 쉽게 만들 수 있게 해주는 라이브러리입니다.
  • graphql-codegen (선택사항)
    • 서버가 graphql 기반으로 되어있을 때 자동적으로 React Query 기반으로 패치를 만들어줍니다.

 

📝Next.js 네이밍 컨벤션

  • 폴더명
    • category-item
  • 파일명
    • page-size-constant
  • constant
    • PAGE_SIZE
  • enum
    • Status or Statuses Suffix (CodeStatus) 
  • boolean
    • is prefix (isDone)

 

📝tsx 작성 순서

/** ──── 상태관리 ──── **/
...
/** ──── Query & Mutation ──── **/
...
/** ──── 상태관리 함수 ──── **/
... 함수 설명 → /* 사용자 정보 지우는 함수 */
/** ──── UseEffect ────**/
...
/** ──── TSX ────**/
... tsx에서도 분리하고 싶은 경우 → {/* ──── 상품번호 ──── */} 이런식으로 주석처리


위를 기본으로 하고 추가할 사항이 있으면 추가한다 → 예) 유효성 검사
  1. 상태관리 (useState or useSelector)
  2. Query & Mutation 또는 Axios, Fetcher 사용
  3. 상태관리 함수
  4. UseEffect
  5. TSX 작성

 

 

📝Next.js 프로젝트 구조

  • app
    • (auth) → jwt 검증
  • components
    • 각 페이지별 컴포넌트 (faq ...)
    • 공통 컴포넌트 (common 폴더)
  • constant
    • constant 파일
    • constant suffix (page-size-constant)
  • enum
    • enum 파일
  • gql (선택 사항)
    • query.graphql
    • mutations.graphql
  • hooks
    • use prefix 커스텀 훅 작성 (use-single-file)
  • lib
    • 유틸함수 utils.ts
  • redux
    • store.ts
    • reducer.ts
    • slice 폴더안에 페이지별 폴더 (slice → user-profile/user-profile-slice.ts)
  • interface (고민중)

 

 

 

📝Next.js [IDE 설정]

https://mondaymonday2.tistory.com/988

 

[IDE] [WebStorm] 환경 설정 (Prettier, eslint, Auto Save Prettier)

📝Prettier 설치https://www.npmjs.com/package/prettier prettierPrettier is an opinionated code formatter. Latest version: 3.3.3, last published: 12 days ago. Start using prettier in your project by running `npm i prettier`. There are 17948 other projec

mondaymonday2.tistory.com

 

 

반응형
반응형

저번에 Nest.js에서는 백엔드를 구현했으니 이번엔 클라이언트에서 처리를 어떻게 해야할지에 대해서 알아봅시다. 이것도 제가 그냥 뭐뭐가 필요할 거 같다라고 해서 정리한 거라 참고만 바랍니다.

 

https://mondaymonday2.tistory.com/996

 

[Nest.js] JWT 회원가입 / 로그인 / 로그아웃 / 인증 / 인가 구현 (Refresh Token, Access Token)

https://docs.nestjs.com/recipes/passport#enable-authentication-globally공식문서 기반으로 만들어야 정상이지만 저는 다른 방법을 사용했습니다. 일단 공식문서 기반으로 설명 드리겠습니다. 기본적으로 passport.j

mondaymonday2.tistory.com

 

 

최상위 layout.tsx

"use client";

import { Noto_Sans } from "next/font/google";
import "./globals.css";
import React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { Toaster } from "@/components/ui/toaster";
import { store } from "@/redux/store";
import { Provider } from "react-redux";
import { CookiesProvider } from "react-cookie";
import { LoginCheckWrapper } from "@/app/(auth)/login-check";

const notoSans = Noto_Sans({
  subsets: ["latin"], // 필요에 따라 다른 서브셋 추가
  weight: ["400", "700"], // 필요에 따라 다른 굵기 추가
});

const queryClient = new QueryClient();

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <Provider store={store}>
      <QueryClientProvider client={queryClient}>
        <html lang="en">
          <body className={notoSans.className}>
            <CookiesProvider>
              <LoginCheckWrapper>{children}</LoginCheckWrapper>
              <Toaster />
              <ReactQueryDevtools initialIsOpen={true} />
            </CookiesProvider>
          </body>
        </html>
      </QueryClientProvider>
    </Provider>
  );
}

LoginCheckWrapper라는 Component를 만들어서 인증에 대한 처리를 하게 했습니다.

 

page.tsx (로그인 페이지)

"use client";

import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import Image from "next/image";
import loginImg from "../../public/images/login.jpg";
import googleLogo from "../../public/images/google.png";
import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation";
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { KeyboardEventHandler, useEffect, useState } from "react";
import { toast } from "@/components/ui/use-toast";
import { fetcher } from "@/common/fetcher";
import { useCookies } from "react-cookie";

interface IUser {
  username: string;
  password: string;
}

/** ─── Handler ─── **/
async function signIn({ username, password }: IUser) {
  return await fetcher.post(
    process.env.NEXT_PUBLIC_API_URL + "/signin",
    {
      username: username,
      password: password,
    },
    { withCredentials: true },
  );
}

export default function Home() {
  /** ─── Hooks ─── **/
  const router = useRouter();

  /** ─── State ─── **/
  const [username, setUsername] = useState("");
  const [password, setPassword] = useState("");
  const [rememberMe, setRememberMe] = useState(false);
  const [cookies, setCookie] = useCookies();

  /** ─── Fetch ─── **/
  const { mutate: signInMutate } = useMutation({
    mutationKey: ["signin"],
    mutationFn: signIn,
    onSuccess: (data) => {
      toast({
        title: "login!!",
      });
      router.push("/main");
    },
    // ToDo Error Handle 정형화
    onError: (e: AxiosError<any>) => {
      toast({
        title: e.response!.data.message,
      });
    },
  });

  /** ─── 이벤트 핸들러 ─── **/
  const checkRememberMe = (isChecked: boolean) => {
    setCookie("REMEMBER", isChecked, { expires: new Date(9999, 12, 31) });
    setRememberMe(isChecked); // React Cookie는 State 관리가 아니라서 따로 필요
  };

  const login = () => {
    signInMutate({ username: username, password: password });
    setCookie("LOGIN", true);
  };

  const onSubmitSearch = (e: React.KeyboardEvent) => {
    if (e.key === "Enter") {
      login();
    }
  };

  const googleLogin = () => {
    router.replace(process.env.NEXT_PUBLIC_API_URL + "/auth/google");
    setCookie("LOGIN", true);
  };

  /** ─── useEffect ─── **/
  useEffect(() => {
    setRememberMe(cookies.REMEMBER);
  }, [cookies.REMEMBER]);

  useEffect(() => {
    setCookie("LOGIN", false);
  }, []);

  /** ─── TSX ─── **/
  return (
    <main>
      <div className="flex m-7">
        <div className="basis-1/6">
          <div className="flex justify-between">
            <div className="flex-grow text-xl font-semibold text-brand w-48">
              PRISM WEAVER
            </div>
          </div>
        </div>
        <div className="basis-1/4">
          <div className="mx-10 mt-40 ">
            <div className="font-semibold text-4xl">Sign In</div>

            <div className="grid w-full items-center gap-1.5 mt-7">
              <Label htmlFor="username">Username</Label>
              <Input
                type="text"
                id="username"
                placeholder="Username"
                onChange={(event) => {
                  setUsername(event.target.value);
                }}
              />
            </div>
            <div className="grid w-full items-center gap-1.5 mt-7">
              <Label htmlFor="password">Password</Label>
              <Input
                type="password"
                id="password"
                placeholder="Password"
                onChange={(event) => {
                  setPassword(event.target.value);
                }}
                onKeyDown={(event) => onSubmitSearch(event)}
              />
            </div>

            <div className="flex mt-2 items-end">
              <Switch
                className="mt-2"
                onCheckedChange={(checked) => {
                  checkRememberMe(checked);
                }}
                checked={rememberMe}
              />
              <span className="mx-2 min-w-40">Remember Me</span>
            </div>
            <Button
              className="mt-12 text-base w-full bg-brand-disabled"
              onClick={() => {
                login();
              }}
            >
              Login
            </Button>
            <div>
              <Button
                className="mt-5 text-base w-full bg-brand-disabled"
                onClick={() => {
                  googleLogin();
                }}
              >
                <Image
                  className="mx-2"
                  src={googleLogo}
                  alt={"google logo"}
                  width={20}
                  height={20}
                />
                Sign in with Google
              </Button>
            </div>

            <div className="flex mt-4 justify-center">
              <div className="mx-3">
                <span className="min-w-20 text-gray-400 text-sm">
                  forgot ID
                </span>
              </div>
              <Separator orientation="vertical" className="py-3" />
              <div className="mx-3" onClick={() => router.push("forgot")}>
                <span className="min-w-20 text-gray-400 text-sm hover:cursor-pointer">
                  forgot PW
                </span>
              </div>
              <Separator orientation="vertical" className="py-3" />
              <div className="mx-3" onClick={() => router.push("signup")}>
                <span className="min-w-20 text-gray-400 text-sm hover:cursor-pointer">
                  Sign Up
                </span>
              </div>
            </div>
          </div>
        </div>
        <div className="basis-3/6">
          <Image
            className="object-fill mt-32 rounded-2xl min-w-fit"
            src={loginImg}
            alt="login"
          />
        </div>
      </div>
    </main>
  );
}

자동로그인을 위해 REMEMBER를 Cookie에 담아서 기록하게 했습니다.

로그인했을 때는 LOGIN 여부를 Cookie에 담아서 기록하게 했습니다.

 

이 두개를 사용한 이유는 밑에 나오게 됩니다.

 

fethcer.tsx

import axios from "axios";
import { store } from "@/redux/store";
import { authSlice } from "@/redux/auth/auth-slice";
import { ROLE } from "@/common/enum";

// Axios 인스턴스 생성
export const fetcher = axios.create({ withCredentials: true });

// Request 인터셉터 추가
fetcher.interceptors.request.use(
  (config) => {
    const token = store.getState().auth.accessToken;
    if (token) {
      config.headers["Authorization"] = `Bearer ${token}`;
    }
    return config;
  },
  (error) => {
    return Promise.reject(error);
  },
);

// Response 인터셉터 추가
fetcher.interceptors.response.use((response) => {
  const accessToken = response.headers["access-token"];
  const isRefreshTokenInvalid = response.headers["refresh-token-invalid"];
  const role = response.headers["role"];

  // store.dispatch(authSlice.actions.setIsRefreshTokenValid(false));

  /** Refresh Token 만료시 핸들링 **/
  if (isRefreshTokenInvalid) {
    store.dispatch(authSlice.actions.setAccessToken(""));
    // store.dispatch(authSlice.actions.setIsRefreshTokenValid(true));
    store.dispatch(authSlice.actions.setRole(ROLE.VISITOR));
  }

  /** Access Token 만료시 핸들링 **/
  if (accessToken) {
    store.dispatch(authSlice.actions.setAccessToken(accessToken));
    store.dispatch(authSlice.actions.setRole(role));
  }

  // 응답 데이터 반환
  return response.data;
});

// fetch custom
// https://stackoverflow.com/questions/44820568/set-default-header-for-every-fetch-request

api 통신에 필요한 axios를 사용했으며 백엔드에서 access token과 refresh token 검증을 위해 front에서 통신할 때마다 보내야하는데 모든 axios에 하나하나씩 달면 유지보수도 힘들기 때문에 Request를 보낼때(모든 api통신) 인터셉터를 통해 Redux(메모리)에 저장한 access token을 Header값에 붙여서 보냅니다.

 

서버로부터 받은 응답의 경우 header값에 access token과 refresh token 유효여부, 인가에 따른 페이지 접근권한을 위한 role정보를 받아서 refresh token이 유효하지 않는 경우 메모리에 있는 access token을 초기화시킵니다.

 

access token이 유효한 경우 access token을 메모리에 저장합니다. 메모리에 저장하는 이유는 탈취에 위험이 전혀 없기 때문입니다. [CSRF, XSS 방지] (쿠키에 httpOnly와 samesite 처리하는 것도 또다른 방법이라고도 합니다)

 

 

login-check.tsx (jwt 검증)

"use client";
import { store } from "@/redux/store";
import React, { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { EXCEPT_ROUTE } from "@/common/constants";
import { fetcher } from "@/common/fetcher";
import { useQuery } from "@tanstack/react-query";
import { useCookies } from "react-cookie";

const getAccessToken = async (): Promise<any> => {
  return await fetcher.post<any>(
    process.env.NEXT_PUBLIC_API_URL + "/getAccessToken",
  );
};

export const LoginCheckWrapper = ({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) => {
  const router = useRouter();
  const pathname = usePathname();
  const [cookies, setCookie] = useCookies();
  const [isTokenChecked, setIsTokenChecked] = useState(false); // 액세스 토큰 재발급 요청이 끝났다는 표시
  const [redirectStep, setRedirectStep] = useState(""); // 리다이렉트 문제 해결을 위해 넣은 단계 (토큰 없이 /main 이동시 / 리다이렉트할 때 /main 페이지 내용이 잠깐 보이는 현상 방지)

  /** 액세스 토큰 재발급 요청 **/
  const { isFetching: isAccessTokenFetching } = useQuery({
    queryKey: ["getAccessToken"],
    queryFn: getAccessToken,
  });

  /** 특정 페이지로 바로 접근하는 경우 (새로고침, 직접 주소 입력) **/
  useEffect(() => {
    /* 엑세스 토큰 발급 요청 했을 때 **/
    if (!isAccessTokenFetching) {
      const token = store.getState().auth.accessToken;
      const isRememberMe = cookies.REMEMBER;
      const isLogin = cookies.LOGIN;

      /* 리프레시 토큰이 만료 됐을 때 로그아웃 (엑세스 토큰 비정상 발급 + 토큰이 필요한 서버 주소인 경우) **/
      if (token === "" && !EXCEPT_ROUTE.includes(pathname)) {
        router.replace("/");
        setRedirectStep("replace");
      }
      // 자동로그인 O, 엑세스토큰 정상발급(정상 리프레시토큰), 로그인페이지로 접근시 로그인 되었기 때문에 main 페이지로 이동
      else if (isRememberMe === true && token !== "" && pathname === "/") {
        router.replace("/main");
        setCookie("LOGIN", true);
        setRedirectStep("replace");
      }
      // 로그아웃 안 하고 나갔을 때 (토큰 값은 있어도 Login 상태가 아닌 경우)
      else if (token !== "" && !EXCEPT_ROUTE.includes(pathname) && !isLogin) {
        router.replace("/");
        setRedirectStep("replace");
      }
      // 로그인을 한 상태의 경우 메인페이지 접속시 리다이렉트
      else if (isLogin && pathname === "/" && token !== "") {
        router.replace("/main");
        setRedirectStep("replace");
      }

      setIsTokenChecked(true);
    }

    if (isTokenChecked && redirectStep === "replace") {
      setRedirectStep("done");
    }
  }, [pathname, isAccessTokenFetching]);

  /** 토큰 유효 체크가 안 됐을 때 **/
  if (!isTokenChecked) {
    return <></>;
  }

  /** 리다이렉트시 처리 **/
  if (isTokenChecked && redirectStep === "done") {
    return <>{children}</>;
  }

  /** 일반 페이지 접근 (리다이렉트 아닐 때) **/
  if (isTokenChecked && redirectStep === "") {
    return <>{children}</>;
  }
};

경우의 수에 따라 처리 방법을 분기 시켜놨다.

 

  1. 리프레시 토큰이 만료시 처리
    • access token 요청처리하면서 fetcher를 통해 refresh token이 유효하지 않으면 access token에 값이 없기 때문에 분기 처리하고 그 이후에 로그인 페이지로 라우팅시킨다.
  2. 자동 로그인했을 때 처리 (자동로그인 설정, 리프레시 토큰 정상일 때 로그인페이지로 이동시)
    • 메인페이지로 이동
  3. 로그아웃 안 하고 브라우저 끌 때 (자동로그인 미설정, 리프레시 토큰 정상일 때)
    • 자동로그인 설정 안 해도 다시 브라우저 다시 키면 로그인한 상태인데 로그인할 때 쿠키에 login 했다는 표시를 세션단위로 남겨서 브라우저가 닫히면 사라지게 한다. 그 이후 로그인 페이지로 리다이렉트
  4. 로그인 한 상태로 로그인 페이지 접속
    • 로그인을 한 상태이기 때문에 로그인페이지로 가면 메인페이지로 보내버린다

 

 

비동기로 처리하기 때문에 토큰 유효 체크가 안 됐을 땐 아무런 화면이 보이면 안 된다. 또한 리다이렉트가 완전이 끝나야 페이지가 보여야하는데 setRedirect로 redirect가 실행되었고 끝났다는 걸 따로 체크 안 하면 리다이렉트 하기 전 페이지가 잠깐 보이게 된다. (/main 접속시 0.5초정도 /main페이지 보였다가 /로 리다이렉트) 이걸 방지하기 위해서 따로 처리를 분리했다. 리다이렉트가 아닌 경우에도 처리를 해야하기 때문에 마지막 처럼 처리했다.

 

 

F5를 누르면 전체가 다시 렌더링 되는데 이 때 메모리에 있는 값도 날라가기 때문에 새로고침하면 위에 컴포넌트가 다시 렌더링 되면서 access token값을 다시 요청하게 된다.

반응형
반응형

 

 

공식문서 기반으로 만들어야 정상이지만 저는 다른 방법을 사용했습니다. 일단 공식문서 기반으로 설명 드리겠습니다. 기본적으로 passport.js 기반으로 만들어져있습니다.

https://docs.nestjs.com/recipes/passport#enable-authentication-globally

 

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

 

참고로 아래 프로세스 기반으로 구성했습니다.

https://mondaymonday2.tistory.com/992

 

[CS 지식] JWT 구현 프로세스 (로그인, 자동로그인, API 통신, 권한에 따른 API 및 페이지 접근, Access T

JWT를 이용해 인증, 인가를 구현하는 방법에 대해 설명한다. 로그인로그인하기클라이언트에서 아이디, 패스워드 전달서버에서 패스워드 암호화 후 DB에서 아이디, 패스워드 검증서버에서 Access T

mondaymonday2.tistory.com

 

 

 

 

📝인증 (공식문서)

jwt.guard.ts

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

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

UseGuard랑 Strategy은 세트입니다. Guard에서 통과한 이후에 정상인 경우 JwtStrategy의 super 검증을 거친 이후 validate를 통과해 controller를 실행시킵니다. validate return값에는 request 객체의 user 객체 안에 값이 들어가게 되기 때문에 필요한 정보가 있는 경우 return 해주면 됩니다.

 

 

auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    PassportModule,
    JwtModule.register({
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' },
    }),
  ],
  providers: [AuthService, LocalStrategy, JwtStrategy],
  exports: [AuthService],
})
export class AuthModule {}

여기에서 jwtModule을 선언해야 jwtService를 이용한 verify작업이나 jwt 토큰 생성을 할 수 있습니다.

 

 

jwt.strategy.ts

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy,'jwt') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: jwtConstants.secret,
    });
  }

  async validate(payload: any) {
    return { userId: payload.sub, username: payload.username };
  }
}
  1. jwtFromRequest를 통해 Request정보를 어떻게 파싱할지에 대해 선언합니다. 여기에서는 Header에서 Bearer를 통해 보낸 경우에 대해 파싱하는 메소드를 기본적으로 사용합니다.
  2. secretOrKey의 경우 jwt에서 사용되는 secret 키 입니다.
  3. validate의 경우 위에 파싱해서 secret 키로 검증을 성공하면 동작하게 됩니다. return값은 controller에 request.user에 담아 보낼 값을 의미합니다.

 


저는 토큰 정보 등을 Header를 통해 보냈습니다. 왜냐하면 return type이 무조건 application/json가 아닐 수도 있다고 생각을 해서 그렇게 만들었습니다만 json으로 response에 담아서 보내도 충분할 것 같습니다.

 

📝회원가입

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { SALT_OR_ROUNDS } from '../common/constants';
import { SignInDto } from './user.dto';

@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) {}

  async signUp(signInDto: SignInDto) {
    const hashPassword = await bcrypt.hash(signInDto.password, SALT_OR_ROUNDS);

    try {
      await this.prisma.member.create({
        data: {
          username: signInDto.username,
          password: hashPassword,
          refresh_token: '',
        },
      });
    } catch (e) {
      throw new HttpException(
        'username already is existed',
        HttpStatus.INTERNAL_SERVER_ERROR,
      );
    }
  }
}

아이디랑 패스워드를 보내 패스워드는 bcrypt로 해싱했습니다.

 

📝로그인

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { SignInDto } from '../user/user.dto';
import { ConfigService } from '@nestjs/config';
import {
  ACCESS_TOKEN_EXPIRE,
  ACCESS_TOKEN_SECRET,
  REFRESH_TOKEN_EXPIRE,
  REFRESH_TOKEN_SECRET,
} from '../common/constants';

@Injectable()
export class AuthService {
  constructor(
    private readonly prisma: PrismaService,
    private readonly jwtService: JwtService,
    private readonly configService: ConfigService,
    private refreshJwtService: JwtService,
  ) {}

  async signIn(signInDto: SignInDto) {
    /** Username Validate **/
    const user = await this.prisma.member.findUnique({
      where: { username: signInDto.username },
    });

    if (!user) {
      throw new HttpException(
        'username or password is wrong',
        HttpStatus.UNAUTHORIZED,
      );
    }

    /** Password Validate **/
    const isMatch = await bcrypt.compare(signInDto.password, user.password);
    if (!isMatch) {
      throw new HttpException(
        'username or password is wrong',
        HttpStatus.UNAUTHORIZED,
      );
    }

    /** Jwt Create **/
    const payload = { username: user.username, role: user.role };
    const accessToken = await this.jwtService.signAsync(payload, {
      secret: this.configService.get<string>(ACCESS_TOKEN_SECRET),
      expiresIn: this.configService.get<string>(ACCESS_TOKEN_EXPIRE),
    });
    const refreshToken = await this.refreshJwtService.signAsync(payload, {
      secret: this.configService.get<string>(REFRESH_TOKEN_SECRET),
      expiresIn: this.configService.get<string>(REFRESH_TOKEN_EXPIRE),
    });

    await this.prisma.member.update({
      data: {
        refresh_token: refreshToken,
      },
      where: { username: user.username },
    });

    return {
      access_token: accessToken,
      refresh_token: refreshToken,
    };
  }
}

로그인 정보 확인해서 DB값하고 비교한 이후에 정상인 경우 Access Token하고 Refresh Token을 생성해서 Refresh Token의 경우는 cookie에 보안을 위해 httpOnly로 보냅니다. Access Token의 경우는 Header에 담아서 보냅니다. Refresh Token의 경우는 Access Token을 재발급하는 용도이기 때문에 사용자가 가지고 요청마다 같이 보내야 Access Token이 만료 됐을 때 Refresh Token을 검증해서 Access Token을 재발급해줍니다.

 

📝로그아웃

@Public()
@Post('logout')
async logout(@Res() response: Response) {
    response.clearCookie(REFRESH_TOKEN).send();
    return { response: 'logout' };
}

로그아웃하는 경우 가지고 있는 Refresh Token쿠키를 삭제합니다.

 

📝인증 / 인가

auth.module

//node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"

import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthService } from './auth.service';
import { ConfigService } from '@nestjs/config';
import { ACCESS_TOKEN_EXPIRE, ACCESS_TOKEN_SECRET } from '../common/constants';
import { PassportModule } from '@nestjs/passport';
import { GoogleStrategy } from '../common/strategies/google.strategy';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';

@Module({
  imports: [
    PassportModule,
    JwtModule.registerAsync({
      global: true,
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>(ACCESS_TOKEN_SECRET),
        signOptions: {
          expiresIn: configService.get<string>(ACCESS_TOKEN_EXPIRE),
        },
      }),
      inject: [ConfigService],
    }),

    PrismaModule,
  ],
  providers: [
    AuthService,
    GoogleStrategy,
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
  exports: [AuthService, JwtModule],
})
export class AuthModule {}
  1. module에는 JwtService를 사용하기 위해 JwtModule을 import합니다.
  2. useClass를 이용해 UseGuards를 따로 선언 안 해도 전역으로 해당 JwtGuard를 검증하게 합니다

secret key의 경우는 node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" 명령어로 랜덤 값으로 만듭니다.

 

auth.guard

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

import { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import {
  ACCESS_TOKEN_SECRET,
  REFRESH_TOKEN,
  REFRESH_TOKEN_SECRET,
} from '../common/constants';
import { ROLE } from '../common/enum';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService,
    private prismaService: PrismaService,
    private reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    /** Public Decorator Ignore Token **/
    const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    /** ─── JWT Validate ─── **/
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse();

    const refreshToken = request.cookies.REFRESH_TOKEN;
    const accessToken = this.extractAccessTokenFromHeader(request);

    let payload: { username: string; role: ROLE };

    /** Refresh Token Validate (검증 실패 → Logout) **/
    try {
      payload = await this.jwtService.verifyAsync(refreshToken, {
        secret: this.configService.get<string>(REFRESH_TOKEN_SECRET),
      });
    } catch (e) {
      response
        .clearCookie(REFRESH_TOKEN)
        .setHeader('Refresh-Token-Invalid', true)
        .send();
      throw new UnauthorizedException();
    }

    /** Refresh Token값 DB Refresh Token이랑 비교해 서버에서 강제 로그아웃 **/
    const dbRefreshToken = await this.prismaService.member.findUnique({
      where: { username: payload.username },
    });
    if (dbRefreshToken.refresh_token !== refreshToken) {
      response
        .clearCookie(REFRESH_TOKEN)
        .setHeader('Refresh-Token-Invalid', true)
        .send();
      throw new UnauthorizedException();
    }

    /** Access Token Validate (검증 실패 → 재발급) **/
    try {
      await this.jwtService.verifyAsync(accessToken, {
        secret: this.configService.get<string>(ACCESS_TOKEN_SECRET),
      });
    } catch (e) {
      const accessToken = await this.jwtService.signAsync({
        username: payload.username,
        role: payload.role,
      });
      response
        .setHeader('Access-Token', accessToken)
        .setHeader('Role', payload.role);
    }

    return true;
  }

  private extractAccessTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}
  1. isPublic를 사용한 이유는 @isPublic() 어노테이션을 달면 jwtGuard가 따로 필요없는 메소드라고 선언하는 것입니다. (전역으로 jwtGuard를 사용하게끔 했기 때문에 회원가입이나 로그인 등에는 필요)
  2. Refresh Token를 시크릿키로 복호화했을 때 유효하면 넘어가고 유효하지 않으면 유효하지 않다는 값을 Header로 보내 클라이언트에서는 로그아웃시킨다.
  3. Refresh Token값하고 DB Refresh Token값하고 비교하는데 DB Refresh Token값을 따로 가지는 이유는 JWT의 경우 사용자에게 인증을 위임하기 때문에 서버에서는 강제로 로그아웃 시킬 수가 없다. 하지만 이 방법을 이용하면 강제 로그아웃을 시킬 수 있다. 다르면 Refresh Token이 유효하지 않다는 문구를 Header에 담아 보낸다. (이 방법을 쓰면 서버에서 세션관리하는 부담을 JWT의 장점인 사용자에게 부담을 넘기는 걸 다시 서버가 어느정도 안고 가게 된다. Trade Off라 어쩔 수 없음)
  4. Access Token을 검증해 정상적이지 않으면 Refresh Token을 이용해 재발급해서 사용자에게 보낸다 Role의 경우는 어떤 페이지는 사용자가 접근할 수 있지만 어떤 페이지는 관리자만 접근할 수 있게끔 하기 위해 따로 담았다. (Refresh Token은 이미 위에서 검증해서 내려온 거이기 때문에 정상이라 이렇게 재발급하면 된다.)

 

main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import helmet from 'helmet';
import * as dotenv from 'dotenv';
import * as cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config';
import { FRONT_URL, PORT } from './common/constants';

dotenv.config();

// prettier-ignore
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const configService= app.get(ConfigService);
  /** server setting **/
  app.use(helmet());                                                                 // 보안 적용
  app.use(cookieParser());                                                           // 쿠키 라이브러리 사용
  app.enableCors({                                                                   // cors 허용
    origin: configService.get(FRONT_URL),
    credentials: true,
    exposedHeaders: ['Access-Token', 'Refresh-Token-Invalid', "Role"]               // token header allow (header에서 parse하려면 필요)
  });
  await app.listen(configService.get(PORT));                                        // 포트 적용
}

bootstrap();

 

반응형
반응형

.env 파일은 API 서버 주소와 같은 환경변수들을 관리하는 파일이다. 다양한 .env 파일이 존재한다.

 

next dev 사용시 env 파일 적용 순서는 아래와 같다. (.env.development.local이 우선순위 가장 높음)

  1. .env.development.local
  2. .env.local
  3. .env.development
  4. .env

 

next build 사용시 env 파일 적용 순서는 아래와 같다. (.env.production.local이 우선순위 가장 높음)

  1. .env.production.local
  2. .env.local
  3. .env.production
  4. .env

 

내가 원하는 건 다양한 환경에 따른 환경변수 실행인데 next dev의 경우 기본적으로 development가 들어가고 next build 및 start의 경우는 produciton으로 자동 세팅이 된다. 환경 파일을 강제로 주입하려고 했는데 프레임워크에 반하는 것이기 때문에 하지 않기로 했다. 로컬에서 개발할 땐 env.local 파일을 사용하고 개발서버의 경우 env.development파일을 운영서버의 경우 env.production 파일이 injection 되었으면 좋겠는데 흠... 굳이 위에 있는 파일을 다 만들지 않는 게 좋을 거 같고 개발의 경우 .env.development를 운영의 경우는 env.production 즉, 2가지만 만들어서 혼동이 없게 하는게 맞는 거 같다.

 

 

infisical이라는 걸 통해 서버에서 env파일을 관리하고 injection해서 사용하게 할 수 있다.

https://infisical.com/

 

Infisical is an open-source end-to-end platform to manage secrets and configuration across your team and infrastructure.

Secret Rotation and Dynamic Secrets

infisical.com

 

 

 

🔗 참고 및 출처

https://jjunn93.com/entry/React-env-%ED%99%98%EA%B2%BD-%EA%B5%AC%EB%B6%84-prod-dev-local

반응형
반응형

📝withCredentials

서로 다른 도메인에 요청을 보낼때 요청에 Credentials 정보를 담아서 보낼지에 대한 여부입니다.

기본적으로 브라우저가 제공하는 요청 API 들은 별도의 옵션 없이 브라우저의 쿠키와 같은 인증과 관련된 데이터를 함부로 요청 데이터에 담지 않도록 되어있다. 이는 응답을 받을때도 마찬가지이다.  따라서 요청과 응답에 쿠키를 허용하고 싶을 경우, 이를 해결하기 위한 옵션이 바로 withCredentials 옵션을 넣어야한다

 

credential 정보가 포함된 요청은 아래와 같습니다.

  1. 쿠키를 첨부해서 보내는 요청
  2. 헤더에 Authorization 항목이 있는 요청

 

📝HttpOnly

자바스크립트로부터 쿠키 접근을 차단하여 XSS 공격 방지를 해준다

 

📝Secure

HTTPS를 통해서만 쿠키를 전송하여 MITM 공격 방지할 수 있다

 

📝SameSite

쿠키가 서로 다른 사이트에서 전송되지 않도록 제한 하는 속성으로 CSRF 공격을 방지하는데 사용됩니다.

 

  • Strict
    • 쿠키가 같은 사이트 내에서만 전송됩니다. 외부 사이트에서 링크 클릭하여 들어오는 경우 쿠키가 전송되지 않습니다.
  • Lax
    • 쿠키가 같은 사이트 내에서만 전송됩니다. 외부 사이트에서 gET 요청이 발생한 경우에는 쿠키가 전송 될 수 있습니다. → 예를 들면 다른 곳에서 쿠팡 링크로 들어왔을 때 어떤 검색어를 통해서 들어왔는지에 대한 쿠키에 대한 정보를 파악하기 위해 필요하다
  • None
    • 쿠키가 모든 사이트 간 전송될 수 있습니다. 이 옵션을 사용할 때는 Secure 속성을 함께 사용해야합니다.

 

 

 

 

 

반응형
반응형

JWT를 이용해 인증, 인가를 구현하는 방법에 대해 설명한다.

 

로그인

  1. 로그인하기
    • 클라이언트에서 아이디, 패스워드 전달
  2. 서버에서 패스워드 암호화 후 DB에서 아이디, 패스워드 검증
  3. 서버에서 Access Token, Refresh Token 토큰 발행
    • Access Secret Key, Refresh Secret Key 각각 다르게 생성
  4. 서버에서 Refresh Token DB 저장
    • 추후 서버에서 강제 로그아웃하기 위해 필요
  5. 서버에서 Response 쿠키에 HttpOnly 사용상태로 리프레시 토큰 저장 후 클라이언트로 보내기
  6. 서버에서 클라이언트로 Access Token 값 보낸후 클라이언트에서 메모리(Redux) 저장

 

권한 필요한 API 통신

일반적으로 토큰은 요청 헤더의 Authorization 필드에 담겨서 보내지게 되는데 JWT에 해당하는 type은 Bearer입니다 → Authorization: <type> <credentials>

Global Guard에 Guards를 적용시키고 필요하지 않은 건 Public Decorator로 Pass하게 한다 (로그인, 회원가입 기능 등…)

Admin만 호출할 수 있는 게 있으면 Role에 담아서 보낸다

  1. 클라이언트에서 요청시 헤더에 Authorization: Bearer ${Access Token}을 붙여서 Access Token과 함께 API 요청을 하고 쿠키의 Refresh Token 내용을 같이 보낸다.
  2. 서버에서 Access Token을 검증하며 정상일 땐 그대로 처리한다 비정상일 땐 Access Token 만료 시나리오대로 진행한다.
  3. Refresh Token이 만료된 경우는 Refresh Token 만료 시나리오대로 한다.

 

권한에 따른 페이지 접근 처리

  1. Jwt의 payload 데이터에 해당 사용자의 역할 정보를 넣어서 암호화 시킨다.
  2. Access Token 발급 받을 때 Header에 Role 정보를 보낸다
  3. Role정보에 따라 페이지를 달리 처리한다
    • Next.js의 경우 admin이라는 라우팅하나 만들고 layout안에 검증식을 넣어놨다

 

Access Token 만료

잘못된 토큰 요청인지 만료되었는지를 클라이언트에게 보여줄 필욘 없기 때문에 유효하지만 않는지 체크만 하면 된다.

  1. 클라이언트에서 서버에 HTTP 요청을 보낸다
    • 모든 요청에는 Access Token은 Bearer에 담아서 보내고 Refresh Token은 쿠키에 담아 보낸다
  2. Access Token가 유효하지 않을 때 쿠키에 담아서 같이 보낸 Refresh Token 값으로 검증 후 정상일 경우 Access Token을 재발급하고 요청한 HTTP통신을 처리하며 Access Token값을 클라이언트에 Response Header로 보낸다
    • 조회 버튼 눌렀는데 발급만 해서 보내면 다시 또 조회버튼을 눌러야하니 사용자가 불편할 수도 있으니 이렇게 처리한다. 또한 HTTPS통신을 사용하면 Response Header값이 암호화 되어서 중간자 탈취에도 안전하다
  3. 클라이언트에서는 Fetcher의 Response에 Access Token키의 값이 왔을 때 Redux에 저장한다

 

Refresh Token 만료

Refresh Token이 만료된 경우 일반적으로 보안상의 이유로 사용자에게 재로그인을 요청하는 것이 일반적인 접근입니다.

재로그인을 통해 새로운 Access Token과 Refresh Token을 발급받는 것이 안전합니다.

  1. 클라이언트에서 Access Token이 만료되었을 때 쿠키에 담아서 보낸 Refresh Token에 대한 검증을 한다. 정상일 경우 Access Token을 재발급하지만 비정상인 경우 다시 로그인 하게끔 해야한다.
  2. 서버에서는 Cookie에 담긴 Refresh Token을 지운다.
  3. HTTPS통신으로 Header에 Refresh Token이 만료 되었다는 값을 담아서 보낸다.
    • HTTPS통신이라 암호화가 되어서 중간 탈취에 안전하다.
  4. 클라이언트는 Fetcher를 통해 Header에 만료되었다는 값이 들어오면 체크해서 Redux에 있는 Access Token을 초기화시키고 Login페이지로 이동시킨다.

 

자동 로그인

  1. 로그인 여부가 true이면 아래 내용 진행 (쿠키 저장을 브라우저 영역으로 저장한다)
  2. 페이지 처음 들어오면 Refresh Token이 존재하는 경우 같이 보낸다
  3. 서버에서 Refresh Token이 정상인 경우 Access Token 만료 시나리오를 따라간
  4. 서버에서 Refresh Token이 비정상인 경우 Refresh Token 만료 시나리오를 따라간다

 

새로고침시 로그인 유효 / 로그인 후 사용 가능한 페이지 접근

  1. 로그인 여부가 true이면 아래 내용 진행 (자동 로그인이 아니면 로그인시 세션 영역으로 만들어짐)
  2. 페이지 처음 들어오면 Refresh Token이 존재하는 경우 같이 보낸다
  3. 서버에서 Refresh Token이 정상인 경우 Access Token 만료 시나리오를 따라간다
  4. 서버에서 Refresh Token이 비정상인 경우 Refresh Token 만료 시나리오를 따라간다

 

 

로그아웃 (버튼)

  1. 서버에서 Refresh Token값이 있는 쿠키를 삭제한다.
  2. 클라이언트에서 메모리에 저장된 Access Token 삭제한다.
  3. 로그인 페이지로 이동시킨다.

 

강제 로그아웃

  1. 클라이언트는 항상 Refresh Token값이 정상인지랑 DB값하고 같은지 체크해야한다
  2. 블랙리스트 방식도 존재하지만 시간이 지나면 쓸모 없는 데이터(어차피 유효기간 지나서 인증 안 되는 JWT 등…)을 주기적으로 삭제해야하는 번거로움이 있기 때문에 선택 안 함
  3. 인메모리를 사용하면 더 효율적이긴하다
  • Token으로 클라이언트에게 주체가 넘어간 경우 서버에서 토큰을 폐기시킬 수 있는 방법은 쿠키로 보낸 Refresh Token값하고 DB에 저장된 Refresh Token값하고 같은지 체크해야한다
  • 로그인시 Redis 같은 인메모리DB에 token 정보를 저장하고, 로그아웃시 Redis에서 token 정보를 지움으로써 로그아웃 여부를 확인할 수 있다

 

소셜로그인

passport.js로 구현한다

  1. passport docs에 있는대로 처리하고 eamil정보를 받아서 따로 회원가입을 진행시키던가 강제로 회원가입 시켜서 회원정보정도는 관리해야한다
  2. 소셜로그인을 클릭한 이후에 동작은 로그인 프로세스와 동일하게 한다
    • 서버에서 Access Token, Refresh Token 토큰 발행
    • 서버에서 Refresh Token DB 저장
    • 서버에서 Response 쿠키에 Http Only 사용상태로 리프레시 토큰 저장 후 클라이언트로 보내기
    • 서버에서 클라이언트로 Access Token 값 보낸후 클라이언트에서 메모리(Redux) 저장

 

Access Token 탈취

  • 메모리에 저장하기 때문에 사용자가 직접 주지 않는 이상 탈취되진 않는다.
  • 기간을 짧게 잡아서 탈취되더라도 못 쓰게한다
  • 메모리에 담기 때문에 XSS나 CSRF 방지가 가능하다
  • HTTPS를 통해 패킷 탈취되어도 암호화가 되어서 안 보이게 된다

 

Refresh Token 탈취

  • 사용자는 리프레시 토큰을 쿠키에 저장해서 보낸다. (Http only, Secure 보안 사항 등록)
  • 리프레시 토큰은 DB에 저장한다
    • JWT 장점은 클라이언트가 인증 권한을 관리해서 서버에서 로그아웃 시킬 수 있는 방법이 없는데 DB나 Redis에 Refresh Token값을 저장하고 Refresh Token 검증시 저장값 하고 다르면 로그아웃시켜버리게 한다

 

XSS 방어로 쿠키 탈취 막기

  • XSS 방어 관련된 라이브러리 (Express의 Helmet 등…)
  • 웹 방화벽
  • Cookie의 Http Only 사용

 

CSRF 막기

  • SameSite설정 → 브라우저 호환 문제 발생으로 사용하기 힘듬
  • 쿠키에 인가 정보 넣지 않기 → 메모리 보관 (Private 변수, Redux)

 

 

🔗 참고 및 출처

https://inpa.tistory.com/entry/AXIOS-📚-CORS-쿠키-전송withCredentials-옵션

https://velog.io/@cada/토근-기반-인증에서-bearer는-무엇일까

https://velog.io/@hahan/JWT란-무엇인가

https://sokdak-sokdak.tistory.com/11

반응형
반응형

📝평문 저장

  • 비밀번호를 그대로 저장
  • 구현이 간단하지만 보안에 매우 취약하다

 

📝단순 해싱

  • 해시 함수를 이용해 해시값으로 변환하여 저장
  • 평문에 비해 보안이 향상되지만 무차별 대입 공격에 취약하며 동일한 비밀번호가 동일 해시값을 가짐으로 레인보우 테이블 공격에 취약하다

 

📝솔팅

  • 비밀번호에 무작위 값(Salt)을 추가한 후 해시 함수로 변환
  • 동일 비밀번호도 서로 다른 해시 값을 가져 레인보우 테이블 공격 방지 가능하지만 빠른 해시 함수는 무차별 대입 공격에 취약하다

 

📝키 스트레칭

  • 비밀번호에 무작위 값을 추가한 이후 해시 함수를 여러번 실행시킨다
  • 주로 CPU 사용량을 증가시켜 해시 계산 속도를 늦추는 데 중점을 둡니다
  • 최근 일반적 장비로는 1초에 50억개 이상의 해시값을 비교할 수 있지만 스트레칭을 적용하면 장비에서 1초에 5번정도만 비교가 가능하다. 즉, 무차별 대입 공격에 강하지만 나도 저장해야하기 때문에 서버에 부담이 된다.
    • PBKDF2 (Password-Based Key Derivation Function 2)
      • 비밀번호와 솔트를 결합한 후 여러 번 반복하여 해시 값을 생성합니다.
    • bcrypt
      • 키 스트레칭을 구현하며, 비교적 느리게 설계되어 공격자가 비밀번호를 추측하는 속도를 늦춥니다.

📝메모리 집약적 함수

  • 메모리 집약적 함수는 해시 계산 과정에서 많은 메모리 및 CPU를 사용하도록 설계되어, 하드웨어 가속 공격에 대한 저항력을 높입니다.
    • Argon2
      • 비밀번호 해시를 생성하는 최신 알고리즘으로, 메모리와 연산 시간을 조절할 수 있으며, 다양한 공격에 대해 강력한 보안을 제공합니다.
    • scrypt
      • bcrypt와 유사하지만, 메모리 사용량을 증가시켜 더욱 강력한 보안을 제공합니다.

 

반응형
반응형

📝OAuth 2.0이란

Authentication 사용자의 인증입니다
Authorization 사용자의 인가로 어떤 자원에 접근할 권한을 부여하는 것입니다
Access Token 리소스 서버에게서 리소스 소유자의 보호된 자원을 획득할 때 사용되는 만료 기간이 있는 Token입니다
Refresh Token Access Token 만료시 이를 갱신하기 위한 용도로 사용하는 Token입니다

 

📝OAuth 2.0의 4가지 역할

Resource Owner 사용자입니다.
Client 보호된 자원을 사용하려고 접근 요청을 하는 애플리케이션입니다.
예) 쇼핑몰 홈페이지
Resource Server 사용자의 보호된 자원을 호스팅하는 서버입니다.
예) 구글 사용자 정보 관리 서버
Authorization Server 권한 서버. 인증/인가를 수행하는 서버로 클라이언트의 접근 자격을 확인하고 Access Token을 발급하여 권한을 부여하는 역할을 수행합니다.
예) 사용자 서버

 

 

📝 OAuth 2.0 추가 및 보완된 기능

  • 1.0에 비해 구현이 쉬워졌습니다
  • 웹, 앱 데스크톱 등 다양한 환경에서 지원이 가능해집낟
  • 리프레시 토큰 개념이 추가 되었습니다
  • HTTPS를 통해 암호화를 강화시켰습니다

 

 

📝 OAuth 2.0 프로토콜

Authorization Code Grant

가장 많이 사용하고 기본이 되는 방식입니다. 로그인한 이후에 권한 부여 승인 코드를 전달 받습니다. 해당 코드는 일회용이며 해당 코드로 Access Token을 요청한 이후에 받아서 원하는 자원에 접근하게끔 사용합니다.

 

 

Implicit Grant 

권한 부여 승인 코드 없이 바로 Access Token을 발급합니다. 비교적 간소화 되기 때문에 빠른 응답이 있지만 Refresh Token이 사용 불가능하며(?) 안전하게 저장하기 힘든 클라이언트(JS 사용 브라우저)에 최적화된 방식입니다. → 서버가 없다는 거 자체가 이해가 안 된다.

 

Resource Owner Password Credentials Grant

username, password를 전달해 Access Token을 받습니다. 또한 Refresh Token이 사용이 가능합니다. 이 방식은 권한서버, 리소스 서버, 클라이언트가 모두 같은 시스템에 속해 있을 때 사용되어야하는 방식입니다. → Third Part를 사용 안 하고 내가 직접 인증서버를 관리할 때를 의미하는 거 같다

 

Client Credentials Grant

검증된 클라이언트 서버(예를 들면 쇼핑몰 웹서버)에서 자기는 안전하다고 판단한 경우에 사용됩니다. 이 경우에는 Resource Server는 사용자의 정보가 들어있는 곳보다는 모든 로그가 쌓여있고 필터링이 따로 필요 없는 서버를 접근하기 위한 것이고 이미 요청한 서버는 안전하다고 판단하기 때문에 Access Token을 요청하면 바로 해당 서버의 Acces Token을 바로 발급해주는 것입니다. Refresh Token은 사용할 수 없습니다.

 

📝 OIDC (Open ID Connect)

OAuth 2.0의 확장으로 Access Token으로 접근권한 뿐만 아니라 사용자 정보도 ID토큰으로 받아와 사용할 수 있습니다.

 

📝 개인적인 생각

위 프로토콜들이 이해가 안 되는게 많다 일단 로그인 처리가 되고 난 이후에 권한 부여 승인 코드를 따로 받는 것 그 이후에 그걸 가지고 Access Token을 굳이 또 요청해야하는 작업이 필요한 것일까? 로그인이 완료 되었으면 다 된 거 같은데 또한 Refresh Token을 못 쓴다는데 Refresh Token을 Access Token하고 같이 주면 되는 거 아닌가 싶다

 

 

🔗 참고 및 출처

https://blog.naver.com/mds_datasecurity/222182943542

반응형