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 한 번에 가능하다.
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를 만듭니다.
비동기함수와 같이 정의합니다. 사용자 정의 쿼리 함수를 만들때 위 코드처럼 작성합니다.
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)
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 설정이 완료됩니다.
이제 반응앱 작업을 시작합니다.
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/.../.../... 전부 해당
import { Outlet } from "react-router-dom"
const Layout = () => {
return <Outlet />
}
export default Layout
- Outlet react-router-dom
https://reactrouter.com/en/main/components/outlet
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> </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가 있습니다.
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분
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
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
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는 아래쪽에서 만들겁니다.
- Node JS REST API with Refresh Token 코드
https://github.com/gitdagray/nodejs_jwt_auth
Node.js REST API 코드 확인후 실행 > npm start
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초로 바꾸었습니다.
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 에러코드를 반환합니다.