본문 바로가기

프론트엔드

[로그인 구현] JWT 방식 로그인

1. 로그인 기능 연결

로그인(POST)

URL: urlbody: { username: string, password: string }

response { access_token: string }**

type LoginResult = 'success' | 'fail'

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

export interface LoginRequest {
  username: string
  password: string
}

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
  }
}

유저 정보(GET)

URL: urlHeader에 Authorzation: Bearer ${access_token}포함response { access_token: string }

export const getCurrentUserInfoWithToken = async (token: string): Promise<UserInfo | null> => {
  const userInfoRes = await fetch(`${ BASE_URL }/profile`, {
    method: 'GET',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${ token }`
    }
  })

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

  return 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
  }

  return (
  	<div>
      <h1>
        Login with Mock API
      </h1>
      <form onSubmit={loginSubmitHandler}>
          ...
        <button type="submit" value="Submit">submit</button>
      </form>
  </div>
 )
}

로그인 요청 형태 확인

로그인 결과 토큰으로 유저 정보 요청하고 보여주기

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)
  }

  return (
  	<div>
      <h1>
        Login with Mock API
      </h1>
      <form onSubmit={loginSubmitHandler}>
          ...
        <button type="submit" value="Submit">submit</button>
      </form>
  </div>
 )
}

2. 자동 로그인 구현

토큰 반환 대신 로컬 스토리지에 저장하기

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'
}

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

토큰을 직접 전달하지 않고 함수만 호출하도록 변경

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

    const formData = new FormData(event.currentTarget)

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

    if (loginResult.result === 'fail') return

    const userInfo = await getCurrentUserInfo()

    if (userInfo === null) return

    setUserInfo(userInfo)
  }

로컬 스토리지에 있는 값을 가져와서 로그인

export const getCurrentUserInfo = async (): Promise<UserInfo | null> => {
  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
}

export const getAccessTokenFromLocalStorage = (): string => {
  return localStorage.getItem('accessToken') || ''
}

다른 페이지도 로컬 스토리지에 저장되어 있는 토큰 사용

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)
}

3. Refresh Token

Axios instance에 기본 동작 추가하기

Axios 사용 이유: error에 따라서 response가 끝나기 전에 중간 조치(interceptor)할 때 사용하기 편함

  • axiosInstance에 설정을 달아주면, 요청이 가거나 올 때 instance설정(ex:baseURL)을 붙여준다.
  • interceptior를 달아주면 API를 주고받는 중간에 특정 상황에서 수행할 함수를 넣을 수 있다.
    • onFulfilled: 성공
    • onRejected: 실패
      • 에러코드가 ECONNABORTED인 건 방화벽에 막혔거나 잘못된 주소를 요청한 경우
      • 401: 인증 정보 없음(누구세요?) / * refresh token 해줘야함
      • 403: 권한 없음(누군진 알겠는데 여기 오면 안됨) (forbidden)
      • error.config 에는 에러난 요청의 정보들이 들어있어서 토큰 재발급 후에 실패했던 정보로 다시 요청을 보내준다.
      • 그래도 실패하면 다시 로그인 필요

❗️위 코드는 예시 코드로, 잘못하면 무한루프가 돌기 때문에 추가 조치를 해줘야함 (refreshToken에서 access token이 전달이 잘 안됐으면 또 401이되서 무한루프가 됨) -> retry count를 만들거나, 1번밖에 못하는 식으로 해결

Refresh Token으로 access token 재발급하기

1. 로컬 스토리지에 리프레쉬 토큰 저장

2. 세션에 리프레쉬 토큰 저장

Refresh Token rotation

  • 토큰이 스토리지에 저장되어 있어서 만료될 때까지 탈취자가 쓸 수 있는 경우: **"아직 만료안됐는데 왜 재발급해달라고함?"**이라고 서비스가 막을 수 있다.
  • 리프레쉬 토큰을 1회용으로 만들어서 한번 사용하면 매번 새로 발급하는 방법이 있다: 털리고 나서 리프레쉬가 되버리면 **이미 사용한 토큰인데 왜가져옴? **하면서 막을 수 있음. <<-- refresh token rotation 방법으로, 리프레쉬 토큰은 1회용이고, 리프레쉬 할 때마다 새로운 access token과 새로운 refresh token이 넘어옴
  • --> 유저가 사용을 하다가 자연스럽게 리프레쉬 되면서 탈취를 막을 수 있다.

🔗 프리온보딩 학습내용입니다.