원티드 온보딩: 웹통행증, JWT

윤뿔소·2023년 3월 9일
1

Wanted

목록 보기
2/4

전 시간엔 로그인 기본 개념에 대해서 알았다. 권한을 부여하는 것. 권한을 부여하기위해 웹에선 어떤 걸 통행권으로서 지급할까?

JWT

토큰을 만드는 기술들은 여러가지가 있다. 그 중 웹에서 JWT가 거의 규격처럼 받아들여진다.

JSON Web Token, 웹에서 JSON으로 암호화된 토큰

참고: JWT 사이트여기를 가면 헤더, 페이로드, 시그니처 이렇게 3가지로 나뉘어서 보여준다.

암호화 규칙(alg), 토큰 타입

PAYLOAD

토큰의 핵심 데이터, 클레임

토큰의 내용, 다루려면 조심해야한다. 정보가 다 들어가 있으니!

SIGNATURE

암호화를 위한 데이터

토큰이 올바르게 사용됐는지 시크릿키 및 작성 방법(인코딩), 해싱 방법 등이 담겨져있음

즉, 해시함수(헤더+페이로드+해싱 시크릿키)로 토큰의 값을 정한다는 것이다. 갑자기 나온 해시함수는 무엇일까?

암호화

토큰을 암호화하는 방식은 여러가지가 있다.

Hashing

토큰을 암호화하는 방식, 단방향으로만 암호화된다.

위 헤더에선 HS256으로 해싱 알고리즘을 써서 암호화하고 있다. 그러면 복호화가 안되는 건가? 바로 이때 시크릿 키가 필요하다.

이런 식으로 암호화하고,
이런 식으로 유효성 검사를 하여 올바른 토큰인지 검증한다.

서비스 관점에서의 JWT

위 사진은 서비스 관점에서 토큰이 어떻게 이루어지는지 프론트를 중점적으로 기술해 놓았다.

저런 식으로 유저가 요청을 하면 유효성 검사나 토큰을 서버와 주고 받고 로그인 및 권한을 주는 것이다.

실습

1일차에서 구성한 화면에 제공된 로그인 API를 붙여볼 것이다.

API 사용 방법은 이러하다.

로그인 (POST)

URL: ‘http://wanted-p2.bluestragglr.com/auth/login’
body: { username*: string, password*: string }
response: { access_token: string }

유저 정보 (GET)

URL: ‘http://wanted-p2.bluestragglr.com/profile’
Header에 Authorzation: `Bearer ${access_token}` 포함
response: { userId: number, userInfo: { name: string }, username: string }

전 코드에 api/login.ts를 만들어 api를 연결해보자!

실습 2-1

import React, { useState } from 'react'
import { getCurrentUserInfoWithToken, loginWithToken } from '../../api/login'
import { UserInfo } from '../../types/user'

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 loginPayload = {
      username: formData.get('username') as string,
      password: formData.get('password') as string
    }

    // TODO: 로그인 연결 및 토큰 가져오기 (loginWithToken 함수 사용)
    // 로그인 실패시 함수를 종료합니다.
    // 로그인 성공시, getCurrentUserInfoWithToken 함수를 호출하여 userInfo를 가져옵니다.

    // TODO: 유저 정보 가져오기 (getCurrentUserInfoWithToken 함수 사용)
    // 유저 정보 가져오기 실패시 함수를 종료합니다.
    // 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.
  }
  
// ...전과 같은 tsx 코드

export default JWTLogin

api

import { BASE_URL } from './const'
import { getAccessTokenFromLocalStorage, saveAccessTokenToLocalStorage } from '../utils/accessTokenHandler'
import { UserInfo } from '../types/user'

type LoginResult = 'success' | 'fail'

export type LoginResultWithToken = {
  result: 'success'
  access_token: string
} | {
  result: 'fail'
  access_token: null
}

export interface LoginRequest {
  username: string
  password: string
}

/*********
 *  실습 2-1
 * */

export const loginWithToken = async (args: LoginRequest): Promise<LoginResultWithToken> => {
  // TODO(2-1): 로그인 API 호출 및 토큰 반환하기
  // POST, `${ BASE_URL }/auth/login`을 호출하세요.
  // API Spec은 강의 자료를 참고하세요.
  // access_token 발급에 성공한 경우에는 { result: 'success', access_token: string } 형태의 값을 반환하세요.

  return {
    result: 'fail',
    access_token: null
  }
}

export const getCurrentUserInfoWithToken = async (token: string): Promise<UserInfo | null> => {
  // TODO(2-1): 함수에서 토큰을 직접 주입받아 사용하기
  // GET, `${ BASE_URL }/profile`을 호출하세요.
  // argument로 전달받은 token을 Authorization header에 Bearer token으로 넣어주세요.
  // API Spec은 강의 자료를 참고하세요.
  // 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.

  return null
}


/*********
 *  실습 2-2
 * */

//... 2-2는 나중에

풀이

api는 알맞은 엔드포인트에, URL도 넣어서 해줬다. 로그인은 username, password 2개를 인수로 POST 요청을 보내 access_token를 받아왔다.

export const loginWithToken = async (args: LoginRequest): Promise<LoginResultWithToken> => {
  // TODO(2-1): 로그인 API 호출 및 토큰 반환하기
  // POST, `${ BASE_URL }/auth/login`을 호출하세요.
  // API Spec은 강의 자료를 참고하세요.
  // access_token 발급에 성공한 경우에는 { result: 'success', access_token: string } 형태의 값을 반환하세요.
  const loginResponse = await fetch(`${BASE_URL}/auth/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(args),
  });

  const loginResponseData = await loginResponse.json();

  if (loginResponse.ok) {
    return {
      result: "success",
      access_token: loginResponseData.access_token,
    };
  }

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

알게된 점은 loginResponse.ok로 조건을 세워서 분기를 나눴다는 거다. 이렇게 사용할 수 있구나 싶었다. try, catch문과 비슷하면서 if로도 표현할 수 있구나 싶었다.

그 다음 getCurrentUserInfoWithToken은 받아온 토큰을 가져와 헤더에 넣고 loginResult를 받아와 파싱하고 userInfo를 뽑아낸다.

export const getCurrentUserInfoWithToken = async (token: string): Promise<UserInfo | null> => {
  // TODO(2-1): 함수에서 토큰을 직접 주입받아 사용하기
  // GET, `${ BASE_URL }/profile`을 호출하세요.
  // argument로 전달받은 token을 Authorization header에 Bearer token으로 넣어주세요.
  // API Spec은 강의 자료를 참고하세요.
  // 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.
  const loginResult = await fetch(`${BASE_URL}/profile`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
  });

  const loginResultData = await loginResult.json();

  return loginResultData.userInfo;
};

그 다음 formData가 있는 곳에 api 함수들을 가져와 form에 입력한 값을 인수로 넣어주고, 그 결과값을 getCurrentUserInfoWithToken의 인수에, 그 결과를 상태로 넣어줬다.

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

    const formData = new FormData(event.currentTarget);

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

    // TODO: 로그인 연결 및 토큰 가져오기 (loginWithToken 함수 사용)
    // 로그인 실패시 함수를 종료합니다.
    // 로그인 성공시, getCurrentUserInfoWithToken 함수를 호출하여 userInfo를 가져옵니다.

    const loginResult = await loginWithToken(loginPayload);

    if (loginResult.result === "fail") return;

    // TODO: 유저 정보 가져오기 (getCurrentUserInfoWithToken 함수 사용)
    // 유저 정보 가져오기 실패시 함수를 종료합니다.
    // 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.

    const userInfo = await getCurrentUserInfoWithToken(loginResult.access_token);

    if (userInfo === null) return;

    setUserInfo(userInfo);
  };

완성! 당연히 axios도 가능하다!

import axios from "axios";

export const loginWithToken = async (args: LoginRequest): Promise<LoginResultWithToken> => {
  try {
    const response = await axios.post(`${BASE_URL}/auth/login`, args, {
      headers: {
        "Content-Type": "application/json",
      },
    });
    
    return {
      result: "success",
      access_token: response.data.access_token,
    };
  } catch (error) {
    console.error(error);
    return {
      result: "fail",
      access_token: null,
    };
  }
};

이런 식으로 하면 된다.

실습 2-2

아까는 토큰을 반환해서 가져와 유저정보를 가져왔다. 이제 그러지 않고 로컬스토리지에 저장하고, 다른페이지에서 유저정보를 가져올 수 있게 하는 식으로 한다.

  • 실습 1에서 만든 로그인 로직에서 토큰을 반환하지 않고 로컬 스토리지에 저장
  • 로컬 스토리지에 저장된 토큰을 가져와 유저 정보 조회
  • 로그인 페이지가 아닌 다른 페이지에서 로컬 스토리지의 값을 이용해 로그인

기본재료

이번엔 로컬스토리지를 사용하기에 로컬스토리지를 저장하는 함수와 읽는 함수를 2개 만들었다.

export const saveAccessTokenToLocalStorage = (accessToken: string) => {
  localStorage.setItem('accessToken', accessToken)
}

export const getAccessTokenFromLocalStorage = (): string => {
  return localStorage.getItem('accessToken') || ''
}
import React, { useState } from 'react'
import { getCurrentUserInfo, login } from '../../api/login'
import { UserInfo } from '../../types/user'

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

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

    const formData = new FormData(event.currentTarget)

    const loginPayload = {
      username: formData.get('username') as string,
      password: formData.get('password') as string
    }

    // TODO: 로그인 연결 및 토큰 가져오기 (login 함수 사용)
    // 로그인 실패시 함수를 종료합니다. 토큰은 login 함수 안에서 localStorage에 저장되도록 구현합니다.

    // TODO: 유저 정보 가져오기 (getCurrentUserInfo 함수 사용)
    // 유저 정보 가져오기 실패시 함수를 종료합니다.
    // 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.
  }

  return (<div>
    // ...똑같
  </div>)
}

export default JWTLoginWithLocalStorage
export const login = async (args: LoginRequest): Promise<LoginResult> => {
  // TODO(2-2): 로그인 API 호출 및 access token 로컬스토리지에 저장하기
  // POST, `${ BASE_URL }/auth/login`을 호출하세요.
  // API Spec은 강의 자료를 참고하세요.
  // access_token 발급에 성공한 경우에는 saveAccessTokenToLocalStorage 함수를 호출하여 access_token을 localStorage에 저장하고 'success'를 반환하세요.
  

  return "fail";
};

export const getCurrentUserInfo = async (): Promise<UserInfo | null> => {
  // TODO(2-2): 로컬스토리지에서 토큰을 가져와 사용하기
  // GET, `${ BASE_URL }/profile`을 호출하세요.
  // 로컬 스토리지에 있는 token을 getAccessTokenFromLocalStorage로 가져와서 Authorization header에 Bearer token으로 넣어주세요.
  // API Spec은 강의 자료를 참고하세요.
  // 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.

  return null;
};

다른 페이지도 로컬스토리지에 저장된 토큰을 이용해 유저정보를 get해올줄 알아야한다.

import React, { useCallback, useEffect, useRef, useState } from "react";
import { getCurrentUserInfo, login } from "../../api/login";
import { UserInfo } from "../../types/user";

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

  const getUserInfo = useCallback(async () => {
    // TODO: 유저 정보 가져오기 (getCurrentUserInfo 함수 사용)
    // getCurrentUserInfo 함수를 이용해 유저 정보를 가져온 후, setUserInfo 함수를 이용해 userInfo 상태를 업데이트해주세요.

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

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

  return (
    <div>
      <h1>Another page</h1>
      <div>
        <h2>User info</h2>
        {JSON.stringify(userInfo)}
      </div>
    </div>
  );
};

export default AutoLogin;

풀이

api 코드는 아래와 같다.

export const login = async (args: LoginRequest): Promise<LoginResult> => {
  // TODO(2-2): 로그인 API 호출 및 access token 로컬스토리지에 저장하기
  // POST, `${ BASE_URL }/auth/login`을 호출하세요.
  // API Spec은 강의 자료를 참고하세요.
  // access_token 발급에 성공한 경우에는 saveAccessTokenToLocalStorage 함수를 호출하여 access_token을 localStorage에 저장하고 'success'를 반환하세요.
  const loginResponse = await fetch(`${BASE_URL}/auth/login`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(args),
  });

  const loginResponseData = await loginResponse.json();
  saveAccessTokenToLocalStorage(loginResponseData.access_token);

  if (loginResponse.ok) {
    return "success";
  }

  return "fail";
};

export const getCurrentUserInfo = async (): Promise<UserInfo | null> => {
  // TODO(2-2): 로컬스토리지에서 토큰을 가져와 사용하기
  // GET, `${ BASE_URL }/profile`을 호출하세요.
  // 로컬 스토리지에 있는 token을 getAccessTokenFromLocalStorage로 가져와서 Authorization header에 Bearer token으로 넣어주세요.
  // API Spec은 강의 자료를 참고하세요.
  // 유저 정보 조회에 성공한 경우에는 UserInfo 타입의 값을 반환하세요.

  const userInfoRes = await fetch(`${BASE_URL}/profile`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${getAccessTokenFromLocalStorage()}`,
    },
  });

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

  return null;
};

formData가 있는 페이지는 똑같다. 대신

...
    // TODO: 로그인 연결 및 토큰 가져오기 (login 함수 사용)
    // 로그인 실패시 함수를 종료합니다. 토큰은 login 함수 안에서 localStorage에 저장되도록 구현합니다.
    const loginResult = await login(loginPayload);

    if (loginResult === "fail") return;

    // TODO: 유저 정보 가져오기 (getCurrentUserInfo 함수 사용)
    // 유저 정보 가져오기 실패시 함수를 종료합니다.
    // 유저 정보 가져오기 성공시, userInfo 상태를 업데이트합니다.

    const userInfo = await getCurrentUserInfo();

    if (userInfo === null) return;

    setUserInfo(userInfo);
...

보안상 문제?

위 서비스를 전개하면서 보안상 문제를 맞닥트릴 수 있다. 알아보자

1. 시크릿키 누출

당연히 시크릿 키가 누출되면 안된다.

2. 데이터 복호화로 인한 유출

그래서 페이로드 등에 누출되면 안되는 정보들(시크릿키 등)은 담기면 안된다.

3. 토큰 탈취

토큰 자체를 탈취당한다는 뜻은 신용카드를 그냥 가져갔다는 얘기다. 그래서 노출이 되면 안된다. 보통 막기 위해 토큰에 만료기간을 두는 편이다.

1번 2번은 백엔드가 주로 담당하기에 프론트에서는 뭔가 도입할 것이 없다. 그런데 3번은 좀 다르다.
3번은 브라우저에서 토큰이 노출되거나, 해커가 XSS(인풋에 스크립트 코드를 넣어 명령), CSRF 등의 공격으로 토큰을 복사 및 재사용해 보안을 뚫을 수 있기 때문이다.
참고: CSRF

프론트에서 보안 보완

리프레쉬 토큰의 등장

그러면 우리는 어떤 기술을 사용해서 보안을 강화할 수 있을까? 액세스 토큰을 사용하는 것이 아닌 바로 리프레쉬 토큰을 사용하는 것이다. 참고: 리프레쉬란? SPA = Single-Page Application, AS = Authorization Server, RS = Resource Server, AT = Access Token, RT = Refresh Token
이런 식으로 시스템 설계를 도입하는 것이다. 액세스 자체의 권한을 줄이면서, 리프레쉬로만 발급을 받게 해 보안을 지키는 것이다.

액세스의 한계

근데 여기서 문제가 또 생긴다.아까 말했던 것처럼 XSS(Cross Site Scripting)로 권한을 받고, 어디 스토리지에 저장해놓은 액세스 토큰을 탈취해 명령을 내릴 수 있다. 뭐냐? 아까랑 똑같지 않느냐? 그래서 HttpOnly Cookie가 나온 것이다.

쿠키는 JS로 접근이 가능하다. 그래서 접근이 불가능한 쿠키를 만들었다.
HttpOnly Cookie는 브라우저에서 접근이 불가능한 쿠키를 설정한 것이다.

참고로 메모리에 넣는다는 건 상태에 넣어서 일시적으로 사용한다는 것(리코일, 리덕스 등)이다.
토큰 발급이 끝나면 메모리에 있던 토큰이 없어지면서 JS로 접근할 수 없는 토큰이 생겨서 해커들이 접근할 방도가 없는 것이다. 그렇게 된다면 XSS 등으로 접근할 수 없다.

CSRF: 해커는 좀비!

해킹의 방도는 끝도 없나보다. CSRF라는게 또 나왔는데 간단히 말해서 어느 웹 이용자의 환경에 피싱 사이트를 들어가게 해서 권한을 얻고, 사용자의 의도와 무관하게 공격자의 의도대로 행동하게 만드는 것이다.

이거는 권한 자체가 사용자의 권한을 이용하니 JS코드로 토큰을 얻지 않아도(XSS) 알맞은 권한과 함께 위조된 요청을 인증서버에 넘겨 의도된 행동을 유도해 공격자의 의도대로 공격을 할 수 있다.

어떻게 해결해야할까? 다양한 방법이 있다. 라이브러리를 사용하든, SameSite쿠키를 사용하든, referer 정책을 사용하든 해결점은 있다.

강의에서 들은 건 CSRF 토큰 사용해 각 요청에 포함되는 고유한 토큰을 사용하여 이 요청의 사용자가 같은지 다른지 서버에서 확인하는 방식을 배웠다.토큰을 통해 사용자가 다르면 토큰 무효화가되고 다시 로그인하라고 리다이렉트를 해서 방지한다. 누가 로그인했습니다 하면서 로그아웃되는 원리랑 같은 거다!

profile
코뿔소처럼 저돌적으로

6개의 댓글

comment-user-thumbnail
2023년 3월 10일

와,, 방대한 양의 정리... 잘보고갑니다 !!

답글 달기
comment-user-thumbnail
2023년 3월 12일

개념이 엄청 꼼꼼합니다 !!

답글 달기
comment-user-thumbnail
2023년 3월 12일

보안의 길은 멀고도 험난하네요. 정리 멋집니다!

답글 달기
comment-user-thumbnail
2023년 3월 12일

와 스크롤이 끝이 안나는것이 여긴데요? 정리 확실하네요 타입스크립트를 자유자재로... 부럽습니다

답글 달기
comment-user-thumbnail
2023년 3월 12일

아직도 보안의 길은 멀고도 험합니다ㅜ

답글 달기
comment-user-thumbnail
2023년 3월 12일

토큰 탈취를 프론트 단에서 보호하는 방법까지..!! 정리를 너무 잘 해주셔서 감사히 읽었습니다 😃

답글 달기