티스토리 뷰

2023년 10월, 원티드 프리온보딩 프론트엔드 챌린지 요약

 

토큰

토큰 기반 인증이란 사용자가 자신의 아이덴티티를 확인하고 고유한 액세스 토큰을 받을 수 있는 프로토콜이다.

쉽게 말해, 기술적인 사용으로 "신원 증명"이다.

 

그렇다면, 로그인에 토큰을 사용하는 이유는 무엇일까?

우리가 서비스를 이용할 때, 회원의 신원이 필요할 때가 있다. 예를 들면, 배달의 주문에서 배달을 주문한 회원이 배달 상황에 대해 문의를 하게 되면, 그 회원의 신원이 있어야 답을 줄 수 있을 것이다. 즉, 누구인지 알아야 문의에 대답을 해줄 수 있다는 것이다.

 

토큰 전체 흐름

클라이언트 → 로그인 요청 → 서비스에서 토큰 발급 및 전달 → 토큰 보관 → 토큰을 사용해 서비스에 요청

 

실제 서비스에서는 어떤 기능이 필요할까?

  • 로그인 후 로그인 상태 유지
  • 자동 로그인

 

만약, 토큰이 있다면 전체적인 흐름은 어떻게 될까?

유저 진입 → 토큰 있음  → 토큰 유효(토큰 유효성 검증 진행) → 토큰 사용(서버)

 

반대로 토큰이 없다면

유저 진입 → 토큰 없음 → 로그인 진행(인증정보 확인 및 토큰 발급) → 토큰 사용(서버)

 

JWT

토큰을 만드는 기술은 "JWT(JSON Web Token)"이다.

 

JWT 내부에는 다음과 같이 구성되어 있다.

HEADER.PAYLOAD.SIGNATURE

 

HEADER

  • 암호화 규칙
  • 토큰 타입
{
	"alg": "HS256",
    "typ": "JWT"
}

 

PAYLOAD

  • 데이터(클레임)
{
	"sub": "1234567890",
    "name": "John Doe",
    "iat": 1516239022
}

 

SIGNATURE

  • 암호화를 위한 데이터
HMACSHA256(
	base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    MY_SECRET_KEY_1234!@#
) secret base64 encoded

 

해싱(Hashing)

이는 텍스트로 되어진 문자를 암호화하는 것으로 해싱이 완료되면 무작위의 암호문을 만들 수 있다.

 

실제로 JWT 동작이 일어나는 곳은 어디일까?

위의 유저가 진입해 토큰의 유무를 확인하는 과정에서 보자면, 토큰이 유효한지 확인하는 단계에서 "복호화 및 내용확인"을 진행한다. 그리고 로그인을 진행하는 과정에서 "규칙에 맞게 신규 토큰 생성"을 진행한다.

 

그러나 토큰을 사용할 때는 유의해야 할 것이 있다. 잘못된다면 보안 사고가 발생할 수 있다.

  1. 시크릿 키 노출
  2. 데이터 복호화로 인한 정보 유출

 

Stateless Token

왜 토큰에 중요한 정보를 넣어둘까?

토큰 자체가 스스로의 유효성을 검증하는 완결성을 가지기 때문이다.

 

서버에서는 토큰 내용만 확인해 유저 정보가 올바른지, 시그니쳐가 올바른지 확인한다.

 

이 때, "토큰 탈취" 등의 문제로 보안 문제가 발생할 수 있으니 유의해야 한다.

 

실제로 코드 작성해보기

export const loginWithToken = async (
  args: LoginRequest
): Promise<LoginResultWithToken> => {
  const loginRes = await fetch(`${BASE_URL}/auth/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(args),
  });

  if (loginRes.ok) {
    const loginResponseData = await loginRes.json();
    return {
      result: "success",
      access_token: loginResponseData.access_token,
    };
  }

  return {
    result: "fail",
    access_token: null,
  };
};

로그인이 진행되면 토큰을 반환하도록 짜여진 코드이다. 로그인 API로 호출하여 토큰이 있는지, 토큰이 유효한지 확인 후, 있다면 "success"와 함께 토큰을 넘기고 없다면 "fail"과 함께 null 값을 넘긴다.

 

const JWTLogin = () => {
  const [userInfo, setUserInfo] = useState<UserInfo | null>(null);

  const loginSubmitHandler = async (
    event: React.FormEvent<HTMLFormElement>
  ) => {
    event.preventDefault();

    const formData = new FormData(event.currentTarget);

    const loginResult = await loginWithToken({
      username: formData.get("username") as string,
      password: formData.get("password") as string,
    });

    if (loginResult.result === "fail") return;
    
    const userInfo = await getCurrentUserInfoWithToken(
      loginResult.access_token
    );

    if (userInfo === null) return;

    setUserInfo(userInfo);
  };

 위에서 토큰 유효 함수를 사용해 로그인을 요청하게 되면, 로그인에 실패하거나 토큰이 없거나 유효하지 않아 회원 정보가 없다면 로그인을 진행하지 않는다. 반대로 값이 있다면 해당 값을 변수에 저장한다.

 

이 과정에서 서버는 어떤 일이 일어날까? 정말 간단하게 각 API의 엔드포인트 중점으로 설명하자면,

"auth/login"으로 POST 요청이 들어올 때 "@UseGards(LocalAuthGuard)" 애노테이션을 사용하여 보안을 설정한다.

그리고 로그인을 요청했을 때, 받은 값으로 회원 정보가 유효한지 확인한다.

async validate(username: string, password: string): Promise<any> {
	const user = await this.authService.validateUser(username, password)
    
    if (!user) throw new UnauthorizedException()
    
    return user
}

async validateUser(username: string, password: string): Promise<any> {
	const user = await this.usersService.findOne(username)
    
    if (user && user.password === password) {
    	const { password, ...userInfo } = user
        return userInfo
    }
    
    return null
}

그리고 "usersService"에서는 데이터에 저장되어 있는 "username""password"의 값을 비교해서 데이터가 있다면 해당 회원의 정보를 넘겨준다.

 

그리고 로그인 과정이 진행되고 나서 마지막으로 "access_token"에 페이로드를 넘겨준다.

 

그렇다면 자동 로그인은 어떻게 구현할 수 있을까?

토큰을 반환하는 대신 로컬 스토리지에 저장하는 방법을 사용할 수 있겠다. 실제로 기업이 이런 방법을 사용하고 있지는 않다고 하니 동작 과정만 알아가도록 하자.

 

export const login = async (args: LoginRequest): Promise<LoginResult> => {
  const loginRes = await fetch(`${BASE_URL}/auth/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(args),
  });

  if (loginRes.ok) {
    const loginResponseData = await loginRes.json();
    saveAccessTokenToLocalStorage(loginResponseData.access_token);
    return "success";
  }

  return "fail";
};

여기서 로그인 API를 호출한다. 만약, "access_token" 발급에 성공했다면, 이 토큰을 로컬 스토리지에 저장하는 함수를 호출하여 저장하고 성공 메시지를 전달한다.

 

const AutoLogin = () => {
  const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
  const isDataFetched = useRef(false);

  const getUserInfo = useCallback(async () => {
    const userInfo = await getCurrentUserInfo();

    if (userInfo === null) return;

    setUserInfo(userInfo);

    isDataFetched.current = true;
  }, []);

  useEffect(() => {
    if (isDataFetched.current) return;
    getUserInfo();
  }, []);
  
ㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡㅡ  

export const getCurrentUserInfo = async (): Promise<UserInfo | null> => {
  const userInfoRes = await fetchClient(`${BASE_URL}/profile`, {
    method: "GET",
  });

  if (userInfoRes.ok) {
    return userInfoRes.json() as Promise<UserInfo>;
  }

  return null;
};

export const fetchClient = async (
  url: string,
  options: RequestInit
): Promise<Response> => {
  const accessToken = getAccessTokenFromLocalStorage();
  const newOptions = {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${accessToken}`,
    },
  };
  return fetch(url, newOptions);
};

이제 로컬 스토리지에 저장되어 있는 토큰을 가져와서 자동 로그인을 시도한다. 만약에, 토큰을 불러올 수 없어 "fail" 메시지를 전달받았다면 로그인이 진행되지 않는다. 반대로 토큰을 불러왔다면 그 정보를 변수에 저장한다.

 

이 때, 다른 페이지에서도 로컬 스토리지에 저장되어 있는 토큰을 사용해서 회원 유무 판단을 할 수 있다.

 

JWT 보관 방식과 보안

JWT의 보안에서 취약한 점이 있다. 바로 "토큰 탈취"이다.

이 때, "access_token"(런타임) 메모리에 저장하게 되면 재접근을 할 수 없도록 할 수 있다.

 

만약, 로컬 스토리지나 쿠키에 저장하면 "XSS(Cross Site Scripting)"이라는 자바스크립트로 접근해 해킹하는 공격을 당할 수 있다. 그러나 메모리에 저장했다면 이 XSS 공격을 방어할 수 있다.

여기서 메모리에 저장한다는 것은 변수에 저장한다고 생각하면 된다.

 

쿠키에 저장한다면, "CSRF(Cross-site Requeset Forgery)" 공격에 당할 수 있다.

이 공격은 사용자가 서비스에 인증을 할 때, 서버에서 쿠키에 JWT를 저장한다. 이 때, 스팸메일 등을 통해 같은 브라우저를 이용해 방문하게 되면 브라우저가 쿠키를 자동으로 전송하여 악성 사이트에 정보가 해킹되는 것이다.

그러니, 스팸메일과 같은 잘못된 방식으로 사이트에 접속하는 일은 하지 말아야 한다.

 

프론트에서 어떻게 해야 합니까?

만약 이러한 질문을 받는다면, 솔직하게 "우리는 할 수 있는 게 없어요"라고 답해도 된다.

우리가 단독으로 할 수 있는 일은 사실상 없다.

대부분 서버에서 처리하기 때문에 프론트엔드에서는 받아서 처리하는 일만 할 뿐이다.

 

시스템 설계로 해결 - Refresh Token & Access Token

페이지 애플리케이션에서 "Refresh Token"을 요청을 하게 되면 "Authorization Server"에서 "Access_Token"을 전달한다. 그리고 이 토큰으로 실제 서버에 요청을 하게되면 보호된 리소스를 전달한다.

 

만약, "Refresh Token"없이 "Access_Token"만 사용한다면, XSS 등을 통해 탈취될 수 있다. 그러나 "Refresh Token"를 같이 사용하게 되면, 탈취를 해도 사용할 수 없게 된다.

 

사용자가 서비스에 인증을 시도하면 짧은 시간동안 "Access_Token"은 메모리에 발급되고 "Refresh Token""HttpOnly Cookie"로 발급된다. 이 때, HttpOnly는 자바스크립트로 접근을 할 수 없다.

악성사이트에서 해당 쿠키 값을 훔쳐갔어도 그 값을 이용해 바로 쓰거나 읽도록 할 수 없기 때문에 해킹이 불가능한 것이다.

다시 사용자가 서비스에 요청을 할 때, "Access_Token"이 만료가 되었다면 "Refresh" 요청을 하게되고 새로운 "Access_Token"을 발급한다. 그리고 새로 발급받은 토큰으로 서비스에 다시 요청하고 응답받아 동작이 성공하게 된다.

 

"Refresh Token""httpOnly"가 필수이기 때문에 당연히 XSS 공격이 통하지 않는다.

다시 말하지만, "httpOnly"는 유저가 자바스크립트에서 세팅을 할 수 없다. 만약, 토큰 값을 복사해 가더라도 직접 엑세스 인증을 하거나 할 수 없다. 다른 방법으로 탈취하더라도 추가 인증이 필요하다.

"CSRF" 공격으로 "Refresh Token"의 재사용을 시도한다면 기존의 "Access_Token"가 이미 살아있기 때문에 모든 토큰을 무효화시킨다. 이 때, "Refresh Token"은 Refresh Token Rotation으로 일회용이다.

 

이를 요약하자면, 보안을 위해 조치할 수 있는 것들이 늘어나고 매 번 로그인하지 않고도 탈취에 의한 사고 위험도 줄일 수 있다는 것이다.

 

그럼에도 불구하고 하드웨어 자체가 탈취되어 리프레시 토큰 자체가 탈취된다면 어쩔 수 없는...

 

정리

  1. 로그인 요청
  2. Response 값: 액세스, 리프레쉬
  3. 메모리에 저장된 액세스 토큰으로 요청
  4. 액세스 토큰이 만료
  5. 리프레쉬 토큰으로 다시 액세스 토큰 재요청
  6. 메모리에 저장된 재요청한 액세스 토큰을 헤더에 담아 정보 요청

 

인증이 필요한 페이지에서 새로고침 및 상태로 들고있던 "Access_Token"이 리셋되면, 이 때마다 "Refresh Token"으로 매번 요청을 보낸다.

그리고 페이지 이동 시에 계속 "Refresh Token"으로 정보를 받아오진 않을테니, 역시 받은 유저의 정보도 상태로 가지고 있다.

즉, 유저 정보 X → "Access_Token" 확인 없으면 "Refresh Token"으로 정보 요청 → "Access_Token"과 유저 정보를 "State"로 저장

 

"Access_Token"을 메모리에 저장하면 새로고침하거나 다른 탭 띄울 때 마다 토큰 재발급 API 호출을 해야 한다.

 

HTTP Request 살펴보기

HTTP Request - Hyper Text Transfer Protocol

HTTP Request의 구조 → Requests / Responses

인증을 위해 우리가 구현한 부분 → Requests에 담겨져 있다

Bearer가 무엇일까? → 다음 시간에.

 

서버에서 전달하는 "cURL" 

"cURL"은 명령줄이나 스크립트에서 데이터를 전송하는 데 사용된다. "cURL"은 자동차, 텔레비전 세트, 라우터, 프린터, 오디오 장비, 휴대폰, 태블릿, 셋톱 박스, 미디어 플레이어에도 사용되며, 100억 개가 넘는 설치에서 수천 개의 소프트웨어 애플리케이션을 위한 인터넷 전송 엔진이다.

 

cURL 주요 옵션 → 인터넷에서 찾아볼 것

  • -k
  • -L
  • -v
  • -o
  • -d
  • -u
  • -H

 

정답이 있는 문제와 정답이 없는 문제 무언가를 질문하거나 결정하는 방법

문제 정의하고 구분하기

  • 정답이 있는 경우들
    • 리액트와 jQuery를 같이 쓰고 싶다
    • 요소를 가운데 정렬하고 싶다
    • useState와 useRef중 무엇을 쓸까?
  • 정답이 없는 경우
    • 보안을 얼마나 엄격하게 챙겨야 할까?
    • 어떤 UI를 선택하는게 좋을까?
    • 어떤 테스트 코드를 짜는게 좋을까?

 

비용과 가치 판단하기 비용을 투입할 가치가 있는가

레퍼런스에 너무 의존하지 않기 → 다른 사람들이 한 것이 항상 옳은 것은 아니다