로그인 구현 - React

박정호·2022년 8월 20일
3

Portfolio

목록 보기
4/6
post-thumbnail

🚀 Start

  • Django에서의 JWT 설정은 끝났다. 이제 클라이언트단에서 데이터 요청을 하여 Token을 발급받는 로그인 기능을 구현해보자!

🤫 PrivateRoute.js

  • 가장 먼저 설정해줄 부분은 바로 로그인을 했을때만 사용가능한 페이지를 구분해주는 것이다. 해당 기능을 구현해줄 파일을 생성해보자.
  1. AuthContext.js에서 provide하길 원하는 값user를 useContext에 담아서 사용할 수 있게 만들어준다. 즉, props를 통해 전달할 필요가 없이 context 를 이용해 공유하는 것!
  2. user는 데이터요청을 하여 access token 값을 받는 useState 값이다. 다시말해, access token의 유무에 따라서 로그인을 하는지,안하는지에 따른 조건을 삼항연산자로 표현한 것이다. 만약 로그인을 한다면 Navigate을 통해 로그인페이지로, 그렇지 않으면 children으로 전달된 컴포넌트가 실행될 것이다.
import { Navigate } from 'react-router-dom'
import { useContext } from 'react'
import AuthContext from 'context/AuthContext'

const PrivateRoute = ({children}) => {
    let { user } = useContext(AuthContext) //1번

    return !user ? <Navigate to="/login" /> : children // 2번
    } 

export default PrivateRoute

🤗

  • user에 대한 자세한 설명은 아래의 AuthContext.js에서 확인 가능
  • children으로 전달되는 컴포넌트가 무엇인지는 App.js에서 확인 가능하다.

잠깐)
props.children ??
Go 👉 https://velog.io/@pjh1011409/React-props.children
useContext ??
Go 👉 https://velog.io/@pjh1011409/React-Hooks-useContext#-context

참조
https://www.zerocho.com/category/React/post/5fa63fc6301d080004c4e32b

📡 App.js

  • 나의 프로젝트 내에서는 별도의 router파일을 따로 만들지 않아서, App.js에서 모든 페이지들의 route을 작성해놓았다.
  1. 앞서 보았던 PrivateRoute를 볼 수 있듯이 children 값을 가져오고 있었고, 그 값이 App.js 안의 컴포넌트라는 것을 알 수 있다. 즉, props값으로 컴포넌트를 전달하는 것을 children으로 넘겨준 것이다. 따라서, import해온 PrivateRoute 컴포넌트로 로그인이 필요한 페이지컴포넌트들로 감싸주었다. 앞서 보았던 삼항연산자 중 user값의 유무에 따라 StudyWrite, StudyUpdate 접속이 달린 것!
import PrivateRoute from 'utils/PrivateRoute'

const App = () =>{
	return(
       <Routes>
      		<Route
                    exact
                    path="/studyWrite"
                    element={
                            <PrivateRoute> //1번
                                <StudyWrite/>
                            </PrivateRoute>
                    }
                />
                <Route
                    exact
                    path="/studyUpdate/:id"
                    element={
                            <PrivateRoute> //1번
                                <StudyUpdate />
                            </PrivateRoute>
                    }
                />
         </Routes>
    )
}

🔐 LoginPage.js

  • 로그인페이지에서 onSubmit 속성을 통해서 데이터의 유효성 검사가 이루어진다.
  1. AuthContext.js에서 provide하길 원하는 값인 loginUser를 useContext에 담아서 사용할 수 있게 만들어준다. 즉, props를 통해 전달할 필요가 없이 context 를 이용해 공유하는 것!
  2. form 태그에서는 onSubmit을 통해 submit을 제어할 수 있다. 즉, 폼이 submit(제출)될 때 이벤트(loginUser)가 발생한다. 발생된 이벤트가 잘 동작하여 true가 반환되어 form이 성공적으로 전송되고, false를 반환되어 전송되지 않는다.(false를 반환하면 전송도 안되고(이벤트강제종료), form 안에 작성된 내용이 초기화된다.)

    loginUser에 대한 설명은 AuthContext에서!

  3. type을 text로 지정하여 일반 문자를 입력하는 칸을 구현
  4. type을 password로 지정하여 비밀번호를 입력하는 칸을 구현(알아서 텍스트가 가려져서 입력된다)
  5. type을 submit으로 지정하여 양식 제출용 버튼을 구현
import AuthContext from 'context/AuthContext'


function LoginPage() {
    let { loginUser } = useContext(AuthContext) //1번

    return (
         <form onSubmit={loginUser}> // 2번
   				<input  
                   type="text" //3번
                   name="username"
                   placeholder="Enter Username"
                />
                 <input 
                   type="password" // 4번
                   name="password"
                   placeholder="Enter Password"
                 />
                                    
                <input
                  className={styles.signInBtn}
                 type="submit" // 5번
                 value="Sign In"
                 />      
          </form>
    )
}

잠깐)
form 태그

  • 웹페이지에서의 입력 양식을 의미
  • 로그인 창, 회원가입 폼 등이 해당
  • 텍스트 필드에 글자를 입력하거나, 체크박스나 라디오 버튼을 클릭하고 제출 버튼을 누르면 서버에 양식이 전달되어 정보를 처리
  • form 태그는 전체 양식을 의미하고 화면에 보이지 않는 추상적인 태그
  • form 태그 동작 방법: https://www.nextree.co.kr/p8428/

input 태그

  • 실제로 사용자가 양식을 입력하기 위한 태그
  • type 속성을 통해 종류를 나타내며, name을 통해 데이터의 이름, value를 통해 기본값을 지정

참조

🔎 header.js

  • header 페이지에서는 로그인되면 로그아웃 문자가 usename과 함께 네비게이션바에 출력되고, 로그아웃되면 다시 로그인 문자가 출력되는 코드가 구현되어 있다.
  1. AuthContext.js에서 provide하길 원하는 값인 User, logoutUser를 useContext에 담아서 사용할 수 있게 만들어준다. 즉, props를 통해 전달할 필요가 없이 context 를 이용해 공유하는 것!
  2. user가 true라면 Logout 문자가 출력. Logout을 클릭하면 logoutUser이라는 이벤트가 발생하는 onClick함수 존재.
  3. user가 false라면 Login 문자가 출력. Login을 클릭하면 login페이지로 이동하면 Link태그 존재.
  4. user가 true라면 user에 존재하는 user.name가 출력되며 'houya님 환영합니다'가 출력
import AuthContext from 'context/AuthContext'

const header = () =>{
 	let { user, logoutUser } = useContext(AuthContext) //1번

	return(
    	...
          {user ? (
             <div onClick={logoutUser}> Logout</div> ) //2번
             : (<Link  to="/login">Login </Link>) //3번
           }
          {user && (<div >{user.username}님 환영합니다</div> // 4번
          )}
        ...
    )
}

잠깐) React에서 사용하는 조건문
: 리액트 조건문

⚙️ AuthContext.js

1️⃣ createContext & Provider

createContext

  • Context 객체를 만든다.(Context는 리액트 프로젝트에서 전역으로 사용되는 데이터를 이용할 때 사용되는 기능)
  • Context 객체를 구독하고 있는 컴포넌트를 렌더링할 때 React는 트리 상위에서 가장 가까이 있는 짝이 맞는 Provider로부터 현재값을 읽는다.

Provider

  • Provider는 context를 구독하는 컴포넌트들에게 context의 변화를 알리는 역할
  • Provider컴포넌트는 value prop을 받아서 이 값을 하위에 있는 컴포넌트들에게 전달

💡 중요!
즉, createContext는 Context 객체를 만들 수 있고, Provider를 이용하여 Context 변경 사항을 자손들에게 제공할 수 있다. Provider의 Value는 하위의 모든 Consumer(소비자)에서 사용할 수 있으며, Provider 하위의 모든 Consumer는 Provider의 value가 변경될 때마다 재렌더링 된다.

createContext()를 통하여 생성된 Context

  • AuthContext
const AuthContext = createContext()

export default AuthContext

AuthContext의 변화를 알리고 값을 전달하는 Provider

  • AuthContext.Provider
 <AuthContext.Provider value={contextData}>
            {loading ? null : children}
 </AuthContext.Provider>

Provider에 의해 하위 컴포넌트들(Consumer들)에게 전달되는 value

  let contextData = {
        user:user,
        authTokens:authTokens,
        setAuthTokens:setAuthTokens,
        setUser:setUser,
        loginUser:loginUser,
        logoutUser:logoutUser,
    }

지금까지 존재했던 Consumer들(= context를 구독하는 컴포넌트들)

  • PrivateRoute.js
    let { user } = useContext(AuthContext) 
  • login.js
    let { loginUser } = useContext(AuthContext) 
  • header.js
 	let { user, logoutUser } = useContext(AuthContext) 

정리
1. 로그인,로그아웃 위해서 클라이언트단에서 서버에 인증을 받고 토큰을 부여받기 위한 데이터 통신 기능들이 AuthContext.js에 존재한다.
2. AuthContext.js 안에 존재하는 state값, 함수값들이 저장되어 있는 값들의 상태에 따라서 login.js, header.js, providerRoute.js의 기능들이 좌우된다.
3. 따라서, 그 값들을 공유하기 위해서 createContext와 Provider를 이용하였고, 각각 페이지에서는 useContext를 통해 값을 사용할 수 있게 된다.

참조: https://ko.reactjs.org/docs/context.html#reactcreatecontext

2️⃣ loginUser()

  • 앞서 로그인 페이지에서 유효성검사를 위해 form태그의 onSubmit 속성이 적용된 것을 알 수 있다. 그때 발생했던 이벤트가 바로 loginUser 함수다.

 👉 e.preventDefault()

  • preventDefault를 호출하게 되면 일반적으로 브라우저의 구현에 의해 처리되는 기존의 액션(동작)이 진행되지 않고, 결과적으로 해당 이벤트가 발생하지 않는다.

ex) a태그는 href를 통해 특정 사이트로 이동하거나, submit태그는 값을 전송하면서 창이 새로고침(reload)된다.
-> 이런 태그의 이벤트 기능을 preventDefault를 통하여 동작하지 않도록 막을 수 있다.

Why? 왜 이 코드가 필요할까?

앞서 말했듯이 이 함수는 onSumit속성에 의해 실행되고 있다. 따라서 loginUser가 실행될 것이다. 하지만, onSubmit은 값을 전송하면 창이 새로고침되는 동작을 발생시키게 되어, loginUser 함수 내의 코드들이 완벽히 실행될 수 없게 된다.
따라서, e.preventDefault()를 사용하여 새로고침을 막는 것이다!

  • prevent(방지하다) + default(기본) = 기본동작을 방지(막다)

👉 response


✏️ await

  • promise가 처리될때 까지 기다린다.(비동기 처리, fetch는 Promise 객체를 반환)

✏️ method

  • 사용할 메소드 선택: 'POST'

✏️ headers

  • Content-Type은 api 연동시에 보내는 자원을 명시하기 위해 보통 사용
  • REST API의 경우 보통 JSON 타입으로 요청하고 받기 때문에 application/json 타입을 자주 사용
  • application/json: 애플리케이션간 데이터 통신에서 JSON형식이 사용

✏️ body

  • username과 password가 입력되어 본문으로 전달**
  • target은 이벤트가 발생한 대상 객체를 가리키고, 그 안에 존재하는 usename, password의 value 값이 담긴다.
  • JSON.stringify(): JSON은 JavaScript Object Notation의 약자로, 브라우저와 서버사이에서 오고가는 데이터의 형식

✏️ response.json()

  • 응답 본문을 읽고 JSON 형태로 파싱(어떤 페이지(문서, html 등)에서 내가 원하는 데이터를 특정 패턴이나 순서로 추출해 가공하는 것)함
  • 이 값들에 바로 access token, refresh token이 담겨있는 것이고, data라는 변수에 저장

✏️ response === 200

  • 데이터 요청 변수인 response의 status(HTTP상태코드)가 200이라는 뜻은 정상적으로 요청이 성공했다는 뜻.
  • setAuthTokens(data): AuthTokens라는 useState 값에 data 저장(Header에 token 정보 저장)
  • setUser(jwt_decode(data.access)): access token을 decode(복호화)하여 User에 저장.
  • localStorage.setItem('authTokens', JSON.stringify(data)): 브라우저 Storage에 key값으로 authTokens, value값으로 JSON형식의 data 저장.
  • navigate('/'): 메인 화면으로 이동. (로그인 성공 시 홈화면으로 이동)

✏️ else

  • 로그인 실패시 해당 알림창 출력. 'Please enter your ID or password'
 let loginUser = async (e) => {
        e.preventDefault() 
        let response = await fetch('http://127.0.0.1:8000/api/token/', {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
            },
            // 실시간으로 입력한 아이디, 비번을 JSON형식으로 담는다.
            body: JSON.stringify({
                username: e.target.username.value,
                password: e.target.password.value,
            }),
        })
        let data = await response.json()

        if (response.status === 200) {
            setAuthTokens(data)
            setUser(jwt_decode(data.access))
            localStorage.setItem('authTokens', JSON.stringify(data))
            navigate('/')
        } else {
            alert('Please enter your ID or password')
        }
    }

참조

3️⃣ useEffect

useEffect의 deps로 지정된 authTokens 또는 loading의 변화 시에 렌더링 되게 되어있다
✏️ if(authTokens)

  • 재렌더링 시 authTokens의 유무에 따라서 user의 값에도 변화가 있다. 즉, 로그인, 로그아웃, 토큰의 유효기간 만료 등으로 AuthTokens에 변화가 생길 때 렌더링이 되고, 토큰 값이 있을 경우에는 user에 accessToken을 디코딩한 값이, 토큰 값이 없을 경우 user에는 값이 저장되지 않는다.(user에 값의 유무는 header의 로그인,로그아웃 텍스트 출력에 영향을 준다.)

✏️ setLoading(false)

  • 재렌더링 시 loading의 값이 false로 변경된다. loading의 상태에 따른 변화는 다음의 삼항연산자에 영향을 준다.
  • loading은 재런더링마다 false가 되어 children값인 컴포넌트들이 렌더링된다. 재렌더링은 앞서 말했듯이 토큰의 변화에 영향을 받고 그에 따라 컴포넌트들도 영향을 받는 것이다.
  <AuthContext.Provider value={contextData}>
            {loading ? null : children}
        </AuthContext.Provider>

4️⃣ authTokens & user & loading

✏️ authTokens

  • useState의 초기값으로 콜백함수가 들어왔다. (콜백 형식으로 초깃값을 지정하면 첫 렌더링 시 한 번만 콜백을 실행해서 초깃값을 만들고 그 이후에는 콜백을 실행하지 않아서 불필요한 렌더링 실행 X)
  • 삼항 연산자를 해석해보면 로컬스토리지에 저장되어있는 authTokens를 조회하면 그 authTokens 값을 JSON 문자열에서 JavaScript 객체로 변환하라는 뜻. 그렇지 않으면 null이 반환.
  • authTokens에는 로그인 시 access Token과 refresh Token이 저장된다.

로그인 시 토큰을 저장하지만, 로그인이 된 상태에서 access token의 만료로 authTokens에 값이 없어지는 상황이 생긴다. 이때 refresh token을 통해 재발급을 받게 된다.
-> AxiosInstance를 확인하자.

  let [authTokens, setAuthTokens] = useState(() =>
        localStorage.getItem('authTokens')
            ? JSON.parse(localStorage.getItem('authTokens'))
            : null
    )   
   ... 

✏️ user
loginUser가 실행될 때도, useEffect에서도 토큰이 존재할 시 user에는 디코딩된 access token 값이 저장된다. 말 그대로 사용자가 로그인하여 얻는 access token을 디코딩하면 다음과 같은 payload를 얻을 수 있다.

let [user, setUser] = useState(() =>
       localStorage.getItem('authTokens')
           ? jwt_decode(localStorage.getItem('authTokens'))
           : null
   )

  • header.js에서는 '000님 환영합니다'를 출력하기 위해 user가 필요.
user.username 님 환영합니다. // houya님 환영합니다.
  • privateRoute.js에서는 user에 값이 없다면 디코딩된 토큰이 없다는 것이고, 로그인페이지로 이동하는 코드를 구현.
!user ? <Navigate to="/login" /> : children

💻 AxiosInstance.js

지금까지 위에서 다룬 단계는 로그인을 하면 access token과 refresh token을 발급받는 것까지였다.

그렇다면 만료된 access token을 가지고 데이터를 요청하게 된다면?
서버에서는 access token을 검증하여 만료되었다는 것을 확인하고 401Error를 보낼 것이다. 그렇다면 우리는 발급받았던 refresh token을 가지고 새로운 access token을 발급해달라고 요청할 것이다.

이와 같이 토큰의 손상 또는 토큰의 만료 날짜 등의 상황으로 우리는 refresh token을 사용하여 단순히 업데이트하도록 백엔드에 요청을 호출한다.
이때 백엔드에서 요청에 대해 실패한다면 사용자는 네트워크 탭을 살펴보지 않고서는 실제로 무슨 일이 일어나는지 확인이 불가하다.

프론트엔드에서는 토큰을 업데이트 하지 않는다. 따라서 토큰이 전송되기전에 간단하게 확인을 하고 업데이트 한다면 네트워크 호출이 실패하지 않을 것이다.
바로 API를 중심으로 자체 인터셉더를 구축하는 것이다.

👉 axios interceptors

  • axios interceptors는 then이나 catch로 처리되기 전에 요청(request)나 응답(response)을 가로채 어떠한 작업을 수행할 수 있게 한다.

  • JWT 인증의 관점에서 보면, 토큰을 토큰을 주기적으로 업데이트 하는 대신 단순히 요청을 실행하고 해당 요청이 전송되기 전에 해당 토큰에 문제가 있을 경우 해당 토큰의 수명을 확인하는 함수라고 할 수 있다.

  • 크게 3가지 부분으로 구성 (인스턴스, request 설정, response설정)

다시 말해, 인증이 필요한 서버로 데이터를 보내거나 조작하는 API를 호출할 때 보안을 위해 interceptor를 사용해서 HTTP Authorizaition 요청 헤더에 jwt-token을 보내서 서버 측 해당 API에 요청을 하기 전에 서버 측의 미들웨어에서 이를 확인한 후 검증이 완료되면 API에 요청을 하게 한다.
해당 interceptor를 인증이 필요한 API를 호출하는 게시판 쓰기, 수정, 삭제와 댓글 쓰기, 수정, 삭제에서 불러와서 사용하면 된다!

1️⃣ 기본 URL 설정

  • 8000번 포트로 설정
const baseURL = 'http://127.0.0.1:8000'

2️⃣ 최신 토큰 확인

  • 로컬스토리지에 토큰 존재를 확인하고 존재 시 json.parse를 사용하고 로컬 저장소로 이동하여 해당 코튼을 가져온다. 만약 내부에 값이 없으면 null이 존재.
let authTokens = localStorage.getItem('authTokens')
    ? JSON.parse(localStorage.getItem('authTokens'))
    : null

3️⃣ instance 설정

  • 모든 단일 요청에 대해 사전 구성된 정보를 인스턴스에 설정
  • create을 이용하여 고유의 인스턴스를 생서앟고 전달하고 싶은 데이터를 넣는다
  • 기본 URL을 넣고 , 모든 단일 요청이 헤더 내부에 토큰이 첨부되어 있으므로 헤더 객체를 설정하여 헤더 또한 넣어준다.
  • 키,값 쌍을 백엔드에 이것을 전달.

기본값인 bareer 옆에 변수(authTokens)를 전달하여 인증 토큰을 얻을 수 있다.

const axiosInstance = axios.create({
    baseURL,
    headers:{Authorization: `Bearer ${authTokens?.access}`}
});

💡 잠깐 Baerer?

4️⃣ 인터셉터 실제 요청

  • 요청에 따른 인터셉터를 생성하고, 인터셉터에 해야하는 것을 작성.
    -> authTokens을 새로 고치고 싶은 것.
  • 요청이 이루어 지기 전에 이루어지는 함수가 될 것이며, 비동기 함수를 생성하고 요청 객체를 가져올 것이다.
  • 요청이 이루어진 후에는 계속 진행하여 함수를 반환하고 싶으므로 반환을 수행하여 해당 요철을 반환할 것이다.
axiosInstance.interceptors.request.use(async req => {
   ...
    return req
})

👉 토큰 존재 유무에 따른 절차

  • 로그아웃하면 토큰은 없을 것이며, 로그인하면 인터셉터가 실행되고 요청에 따라 업데이트 한 다음 전송한다.
  • 요청되기전에 업데이트 되는 것이 목적이므로 인증 토큰 변수(authTokens)를 가져온다.
  • 토큰이 존재하지 않을 경우 다음과 같은 코드 실행.
 if(!authTokens){
        authTokens = localStorage.getItem('authTokens') ? JSON.parse(localStorage.getItem('authTokens')) : null
        req.headers.Authorization = `Bearer ${authTokens?.access}`
    }

👉 디코딩

  • 토큰이 존재할 경우, jwt_decode를 사용하여 디코딩하여 토큰을 가져온 다음 만료시간을 얻고 현재 타음스탬프와 비교하기 위해 dayjs 사용
    const user = jwt_decode(authTokens.access)


payload의 exp가 바로 만료시간에 대한 정보

👉 만료시간 비교

  1. expired에 오늘 날짜에 대해 만료 날짜를 입력위해서 dayjs 사용하고 유닉스를 설정
  2. 만료날짜(user.exp)를 입력하고 diff를 이용하여 현재날짜와 비교하여 확인.
  3. 이 값이 만약 1보다 크면 true로 설정하고 이는 만료되었음을 의미
  4. if문과 같이 그렇지 않을 경우(만료 되지 않았을 경우) 원래 요청을 반환한다.
  const isExpired = dayjs.unix(user.exp).diff(dayjs()) < 1;

    if(!isExpired) return req

👉 데이터 요청 진행

  • 이 요청에 따라 엑세스 권한과 새로고침 토큰이 반환
    -> 존재하지 않고 만료되는 토큰의 기본 실패를 처리 할것이고, 새 토큰을 가져오기 때문에 해당호출을 보낸다.
 const response = await axios.post(`${baseURL}/api/token/refresh/`, {
        refresh: authTokens.refresh
      });

👉 스토리지에 저장

  • 반환받은 값을 로컬스토리지에 저장.
    localStorage.setItem('authTokens', JSON.stringify(response.data))
  • 헤더 요청 값을 가져와서 진행되기전에 새 토큰으로 헤더를 업데이트
    req.headers.Authorization = `Bearer ${response.data.access}`

📜Login Architecture

Token 만료 시, 서버에서 만료 신호(401)를 반환한다. 이 점을 보완하자!
Interceptor를 통하여 토큰의 만료유무를 미리 판별하여, 만료 시 Refresh Token을 통한 새로운 Acess Token을 받는다.

profile
기록하여 기억하고, 계획하여 실천하자. will be a FE developer (HOME버튼을 클릭하여 Notion으로 놀러오세요!)

0개의 댓글