[React/Dave Gray] React Redux Toolkit #9 Login Authentication Flow with JWT Access, Refresh Tokens, Cookies

최예린·2022년 10월 15일
1

React

목록 보기
16/19

Redux 설정

features/auth/authSlice.js 생성

import { createSlice } from "@reduxjs/toolkit"

const authSlice = createSlice({
    name: 'auth',
    initialState: { user: null, token: null },
    reducers: {
        setCredentials: (state, action) => {
            const { user, accessToken } = action.payload
            state.user = user
            state.token = accessToken
        },
        logOut: (state, action) => {
            state.user = null
            state.token = null
        }
    },
})

export const { setCredentials, logOut } = authSlice.actions

export default authSlice.reducer

export const selectCurrentUser = (state) => state.auth.user
export const selectCurrentToken = (state) => state.auth.token
  • createSlice()
    리덕스 모듈하나를 만들려면 action type을 정의하고, action creator를 만들고 redux-saga를 사용하는 경우 saga 만들고, reducer까지 만들어야 하는데 이 모든게(saga 빼고) createSlice 한 번에 가능하다.

app/api/apiSlice.js 생성

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { setCredentials, logOut } from '../../features/auth/authSlice'

const baseQuery = fetchBaseQuery({
    baseUrl: 'http://localhost:3500',
    credentials: 'include',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.token
        if (token) {
            headers.set("authorization", `Bearer ${token}`)
        }
        return headers
    }
})

위에서만든 setCredentials, logOut를 가져와서 import합니다.

모든 요청이 있을 때마다 해당 액세스토큰을 헤더에 첨부합니다.
매번 해당 쿠키에 해당 credential(자격증명)을 다시 첨부합니다.

기본 쿼리를 사용하지않고 기본쿼리를 감싸서 실패시 refresh 토큰을 보내고 새 access 토큰을 얻은 후 다시 시도해서 접근할 수 있습니다.
토큰이 만료되었지만 새 access 토큰을 얻을 수 있는 refresh 토큰이 있기때문입니다.

const baseQueryWithReauth = async (args, api, extraOptions) => {
    let result = await baseQuery(args, api, extraOptions)

    if (result?.error?.originalStatus === 403) {
        console.log('sending refresh token')
        // send refresh token to get new access token 
        const refreshResult = await baseQuery('/refresh', api, extraOptions)
        console.log(refreshResult)
        if (refreshResult?.data) {
            const user = api.getState().auth.user
            // store the new token 
            api.dispatch(setCredentials({ ...refreshResult.data, user }))
            // retry the original query with new access token 
            result = await baseQuery(args, api, extraOptions)
        } else {
            api.dispatch(logOut())
        }
    }

    return result
}

export const apiSlice = createApi({
    baseQuery: baseQueryWithReauth,
    endpoints: builder => ({})
})

이제 이것을 감싸기위해 baseQueryWithReauth를 만듭니다.
비동기함수와 같이 정의합니다. 사용자 정의 쿼리 함수를 만들때 위 코드처럼 작성합니다.

  • 유효했지만 만료됨 토큰을 받을 경우: 403 > refresh 토큰을 보내 access토큰을 다시 받습니다. > refreshResult?.data가 있으면
    true) 새 토큰을 저장하고(setCredentials) 새로받은 토큰으로 다시 쿼리를 시도합니다.
    false) 로그아웃 합니다.
  • 유효하지않은 토큰을 받을 경우 : 401

features/auth/authApiSlice.js 생성하기

import { apiSlice } from "../../app/api/apiSlice";

export const authApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
        login: builder.mutation({
            query: credentials => ({
                url: '/auth',
                method: 'POST',
                body: { ...credentials }
            })
        }),
    })
})

export const {
    useLoginMutation
} = authApiSlice

위에서만든 apiSlice를 import해서 가져온뒤 endpoints를 만들어줍니다.(아까는 비워두었습니다.)

-Request Credentials:
인터페이스 의 credentials읽기 전용 속성은 Request교차 출처 요청의 경우 사용자 에이전트가 다른 도메인에서 쿠키를 보내거나 받아야 하는지 여부를 나타냅니다.
https://developer.mozilla.org/en-US/docs/Web/API/Request/credentials
옵션 3가지 (omit, same-origin,include)

app/store.js

import { configureStore } from "@reduxjs/toolkit"
import { apiSlice } from "./api/apiSlice"
import authReducer from '../features/auth/authSlice'

export const store = configureStore({
    reducer: {
        [apiSlice.reducerPath]: apiSlice.reducer,
        auth: authReducer
    },
    middleware: getDefaultMiddleware =>
        getDefaultMiddleware().concat(apiSlice.middleware),
    devTools: true
})

여기까지하면 대부분의 redux 설정이 완료됩니다.


이제 반응앱 작업을 시작합니다.

index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

import { store } from './app/store'
import { Provider } from 'react-redux'

import { BrowserRouter, Routes, Route } from 'react-router-dom'

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Provider store={store}>
      <BrowserRouter>
        <Routes>
          <Route path="/*" element={<App />} />
        </Routes>
      </BrowserRouter>
    </Provider>
  </React.StrictMode>
);

위 코드처럼 Provider, BrowserRouter, Routes로 감싸준 뒤 Route에 element로 App을 넣어줍니다.

url pattern

  • /* : 경로의 바로 하위에 있는 모든 경로 매핑.
    ex) AAA/* : AAA/BBB, AAA/CCC 해당, AAA/BBB/CCC 해당하지 않음
  • /** : 경로의 모든 하위 경로(디렉토리) 매핑
    ex) AAA/** : AAA/BBB, AAA/CCC, AAA/BBB/CCC, AAA/BBB/CCC/.../.../... 전부 해당

components/Layout.js

import { Outlet } from "react-router-dom"

const Layout = () => {
    return <Outlet />
}

export default Layout

components/Public.js

import { Link } from "react-router-dom"

const Public = () => {

    const content = (
        <section className="public">
            <header>
                <h1>Welcome to Repair Store!</h1>
            </header>
            <main>
                <p>Located in Beautiful Downtown Foo City, Repair Store provides a trained staff ready to meet your repair needs.</p>
                <p>&nbsp;</p>
                <address>
                    Repair Store<br />
                    555 Foo Drive<br />
                    Foo City, CA 12345<br />
                    <a href="tel:+15555555555">(555) 555-5555</a>
                </address>
            </main>
            <footer>
                <Link to="/login">Employee Login</Link>
            </footer>
        </section>

    )
    return content
}
export default Public

기본 화면이고 직원 로그인화면으로 이동하는 link가 있습니다.

features/auth/Login.js 생성하기

import { useRef, useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'

import { useDispatch } from 'react-redux'
import { setCredentials } from './authSlice'
import { useLoginMutation } from './authApiSlice'

const Login = () => {
    const userRef = useRef()
    const errRef = useRef()
    const [user, setUser] = useState('')
    const [pwd, setPwd] = useState('')
    const [errMsg, setErrMsg] = useState('')
    const navigate = useNavigate()

    const [login, { isLoading }] = useLoginMutation()
    const dispatch = useDispatch()
    
	// when the components loads, focus
    useEffect(() => {
        userRef.current.focus()
    }, [])

    useEffect(() => {
        setErrMsg('')
    }, [user, pwd])

    const handleSubmit = async (e) => {
        e.preventDefault()
		// sumit API
        try {
            const userData = await login({ user, pwd }).unwrap()
            dispatch(setCredentials({ ...userData, user }))
            setUser('')
            setPwd('')
            navigate('/welcome')
        } catch (err) {
            if (!err?.originalStatus) {
                // isLoading: true until timeout occurs
                setErrMsg('No Server Response');
            } else if (err.originalStatus === 400) {
                setErrMsg('Missing Username or Password');
            } else if (err.originalStatus === 401) {
                setErrMsg('Unauthorized');
            } else {
                setErrMsg('Login Failed');
            }
            errRef.current.focus();
        }
    }

    const handleUserInput = (e) => setUser(e.target.value)

    const handlePwdInput = (e) => setPwd(e.target.value)

    const content = isLoading ? <h1>Loading...</h1> : (
        <section className="login">
            <p ref={errRef} className={errMsg ? "errmsg" : "offscreen"} aria-live="assertive">{errMsg}</p>

            <h1>Employee Login</h1>

            <form onSubmit={handleSubmit}>
                <label htmlFor="username">Username:</label>
                <input
                    type="text"
                    id="username"
                    ref={userRef}
                    value={user}
                    onChange={handleUserInput}
                    autoComplete="off"
                    required
                />

                <label htmlFor="password">Password:</label>
                <input
                    type="password"
                    id="password"
                    onChange={handlePwdInput}
                    value={pwd}
                    required
                />
                <button>Sign In</button>
            </form>
        </section>
    )

    return content
}
export default Login

~32분


src/features/auth/RequireAuth.js

require auth 구성요소를 만들면 경로를 보호하는데 도움이 됩니다.

import { useLocation, Navigate, Outlet } from "react-router-dom"
import { useSelector } from "react-redux"
import { selectCurrentToken } from "./authSlice"

const RequireAuth = () => {
    const token = useSelector(selectCurrentToken)
    const location = useLocation()

    return (
      	// token이 있으면 모든것들을 표시, 없으면 로그인화면으로
        token
            ? <Outlet />
            : <Navigate to="/login" state={{ from: location }} replace />
    )
}
export default RequireAuth

src/features/auth/Welcome.js

import { useSelector } from "react-redux"
import { selectCurrentUser, selectCurrentToken } from "./authSlice"
import { Link } from "react-router-dom"

const Welcome = () => {
    const user = useSelector(selectCurrentUser)
    const token = useSelector(selectCurrentToken)

    const welcome = user ? `Welcome ${user}!` : 'Welcome!'
    const tokenAbbr = `${token.slice(0, 9)}...`

    const content = (
        <section className="welcome">
            <h1>{welcome}</h1>
            <p>Token: {tokenAbbr}</p>
            <p><Link to="/userslist">Go to the Users List</Link></p>
        </section>
    )

    return content
}
export default Welcome

App.js

import { Routes, Route } from 'react-router-dom'
import Layout from './components/Layout'
import Public from './components/Public'
import Login from './features/auth/Login'
import Welcome from './features/auth/Welcome'
import RequireAuth from './features/auth/RequireAuth'
import UsersList from './features/users/UsersList'

function App() {
  return (
    <Routes>
      <Route path="/" element={<Layout />}>
        {/* public routes */}
        <Route index element={<Public />} />
        <Route path="login" element={<Login />} />

        {/* protected routes */}
        <Route element={<RequireAuth />}> 
          <Route path="welcome" element={<Welcome />} />
          <Route path="userslist" element={<UsersList />} />
        </Route>

      </Route>
    </Routes>
  )
}

export default App;

<Route element={<RequireAuth />}> 에 의해 경로가 보호됩니다.
UsersList는 아래쪽에서 만들겁니다.

Test the Login flow

Node.js REST API 코드 확인후 실행 > npm start

features/users/usersApiSlice.js

import { apiSlice } from "../../app/api/apiSlice"

export const usersApiSlice = apiSlice.injectEndpoints({
    endpoints: builder => ({
      	// method
        getUsers: builder.query({
            query: () => '/users',
            keepUnusedDataFor: 5, // default: 60s -> modified to 5s
        })
    })
})

export const {
    useGetUsersQuery
} = usersApiSlice 

캐시가 지속되는 시간인 keepUnusedDataFor(사용하지않는데이터 유지하기) 디폴트로 60초로 설정되어있지만 테스트를 위해 5초로 바꾸었습니다.

features/users/UsersList.js

import { useGetUsersQuery } from "./usersApiSlice"
import { Link } from "react-router-dom";

const UsersList = () => {
    const {
        data: users,
        isLoading,
        isSuccess,
        isError,
        error
    } = useGetUsersQuery()

    let content;
    if (isLoading) {
        content = <p>"Loading..."</p>;
    } else if (isSuccess) {
        content = (
            <section className="users">
                <h1>Users List</h1>
                <ul>
                    {users.map((user, i) => {
                        return <li key={i}>{user.username}</li>
                    })}
                </ul>
                <Link to="/welcome">Back to Welcome</Link>
            </section>
        )
    } else if (isError) {
        content = <p>{JSON.stringify(error)}</p>;
     }

    return content
}
export default UsersList

실행결과

여기까지가 모든 코드입니다.


이제 실행 후 로그인을 해보면

사용자 목록을 확인할 수 있습니다.

위에서 keepUnusedDataFor를 5초로 설정해놓았기때문에 5초가 지나기전에는 사용자 목록에 접근 요청을 한다면 토큰이 만료되지않아 캐시된 데이터로 접근이 가능하지만,

5초동안 아무런 요청이 없다면 만료되어 403 에러코드를 반환합니다.

profile
경북대학교 글로벌소프트웨어융합전공/미디어아트연계전공

0개의 댓글