function RegisterPage() {
const [values, setValues] = useState({
name: "",
email: "",
password: "",
passwordRepeat: "",
});
const navigate = useNavigate();
const toast = useToaster();
function handleChange(e) {
const { name, value } = e.target;
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
}
async function handleSubmit(e) {
e.preventDefault();
if (values.password !== values.passwordRepeat) {
toast("warn", "비밀번호가 일치하지 않습니다.");
return;
}
const { name, email, password } = values;
await axios.post("/users", {
name,
email,
password,
});
await axios.post(
"/auth/login",
{
email,
password,
},
{
withCredentials: true,
}
);
navigate("/me");
}
이런식으로 axiod를 사용해서 로그인 포스트 요청을 보낸다.
function LoginPage() {
const [values, setValues] = useState({
email: "",
password: "",
});
const navigate = useNavigate();
function handleChange(e) {
const { name, value } = e.target;
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
}
async function handleSubmit(e) {
e.preventDefault();
const { email, password } = values;
await axios.post(
"/auth/login",
{
email,
password,
},
{
withCredentials: true,
}
);
navigate("/me");
}
withCredentials: true,
이 옵션은 서로 다른 도메인에서 쿠키를 주고 받을 때 반드시 필요한 옵션이다.
리퀘스트를 보내는 쪽의 도메인이랑 받는 쪽의 도메인이 다를 때 반드시 true로 설정해줘야 한다. (지금은 localhost:3000에서 api 주소로 보내는 것이기 때문에 도메인이 다르므로 설정해줘야 함)
로그인할때 주로 쿠키를 받기 때문에 여기서 설정 추가
브라우저의 애플리케이션 탭으로 가면 쿠키값을 확인할 수 있음
Origin이랑 리퀘스트를 보내는 사이트의 도메인이다.
http://localhost:3000
여기에서 :3000
은 포트 번호인데 이 포트 번호도 Origin에 포함된다.
http://localhost:3000
에서 https://learn.codeit.kr
로 리퀘스트를 보내는 경우, 서로 다른 Origin이라는 의미에서 Cross Origin이라고 표현하는데 여러 가지 보안 문제가 발생할 수 있기 때문에 주의해야 한다.
-> CORS 문제
웹 개발에서 Credential은 유저를 증명할 수 있는 정보를 말한다.
예를 들면 아이디와 비밀번호라던지 서버에서 발급받은 토큰 같은 것들. 리퀘스트를 보내는 상황에서는 주로 쿠키를 말한다.
Axios에서는 withCredentials
라는 옵션을 불린형으로 지정할 수 있다.
이 값을 true
로 설정해야만 Cross Origin에 쿠키를 보내거나 받을 수 있다.
참고로 이건 fetch()
함수에서 credentials: 'include'
를 설정하는 것과 같다.
axios.post(
'/auth/login',
{ email: 'sunny@sundaymorning.kr', password: 't3st!' },
{ withCredentials: true },
);
fetch()
함수에서 리퀘스트를 보낼 때 쿠키를 사용하려면 적절한 credentials
옵션을 설정해줘야 한다.
'omit'
: 쿠키를 사용하지 않는다. 리퀘스트를 보낼 때도 쿠키를 사용하지 않고, 리스폰스로 Set-Cookie
헤더를 받았을 때에도 쿠키를 저장하지 않는다.'same-origin'
: 아무 옵션을 지정하지 않았을 때 기본 값이다. 같은 Origin인 경우에만 쿠키를 사용하겠다는 옵션이다. 프론트엔드 사이트 주소와 리퀘스트를 보낼 백엔드 서버의 주소가 다르다면 Cross Origin이라고 이해하면 된다.'include'
: 이 옵션을 사용하면 Cross Origin인 경우에도 쿠키를 사용한다.실습 서버와 프론트엔드 사이트가 다르다면 Cross Origin 리퀘스트를 보내게 되기 때문에 include
옵션을 설정해야 한다.
예를 들어서 로그인을 한다면 아래와 같이 credentials: 'include' 옵션을 설정해야 쿠키를 사용할 수 있다.
fetch('https://learn.codeit.kr/api/link-service/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'sunny@sundaymorning.kr', password: 't3st!' }),
credentials: 'include'
});
개발을 할 때 쿠키가 저장이 안 됐다면 크게 두 가지 이유를 생각해볼 수 있다. 1) 프론트 잘못, 2) 백엔드 잘못
프론트의 경우 REST Client로 테스트했을 때 잘 동작하는지 확인해보고, Cross Origin인 경우 프론트에서 리퀘스트를 보내는 코드에 withCredentials
옵션을 적절하게 설정해 줬는지 확인해 봤다면, 쿠키를 저장하는데 문제가 없다고 생각해도 좋다.
만약 그래도 쿠키 저장이 안 됐다면 쿠키 옵션의 문제일 가능성이 높다.
이럴 땐 의심되는 부분을 찾아서 백엔드 개발자에게 확인을 부탁하면 된다.
쿠키가 제대로 저장되지 않을 때 프론트엔드에서 확인해 볼 수 있는 것들에 대해 알아보도록 하겠다.
개발자 도구의 Network 탭에서 리스폰스 헤더를 확인해 보면 된다.
Headers라는 탭에서 Response Headers 안에 있는 Set-Cookie 값을 확인해 보면 된다.
크롬 개발자 도구에서는 경고 표시 아이콘으로 SameSite=Strict 옵션이지만 도메인이 다른(크로스 사이트) 리스폰스이기 때문에 쿠키를 저장하지 않았다고 알려주기도 한다.
This attempt to set a cookie via a Set-Cookie header was blocked because it had the "SameSite=Strict" attribute but came from a cross-site response which was not the response to a top-level navigation.
SameSite는 리퀘스트를 보내는 쪽의 도메인과 리퀘스트를 받는 쪽의 도메인이 일치하는지 확인하고 쿠키의 사용을 허용하는 옵션인데, 이런 옵션은 백엔드 쪽에서 설정할 수 있다.
그 밖에도 Set-Cookie 헤더의 값을 보았을 때 쿠키가 저장되지 않는 다양한 이유가 있을 수 있는데, 이 값들은 리스폰스로 오는 것이기 때문에 프론트엔드 영역에서는 수정할 수 없다. 이럴 땐 개발자 도구에서 리퀘스트 헤더와 리스폰스 헤더를 복사해서 백엔드 개발자에게 확인을 요청해야 한다.
lib/axios.js
import axios from "axios";
const instance = axios.create({
baseURL: "URL",
withCredentials: true,
});
export default instance;
이렇게 하면 일일히 withCredentials를 설정해줄 필요 없이 한 번에 설정할 수 있다.
유저 데이터 같은 것은 거의 모든 페이지에서 쓰기 때문에 매번 리퀘스트를 보내는 것은 비효율적이다.
사이트 전체에서 전역적으로 데이터를 써야할 때 컨텍스트를 사용한다.
AuthProvider.js
import { createContext, useContext, useState } from "react";
const AuthContext = createContext({
user: null,
login: () => {},
logout: () => {},
updateMe: () => {},
});
async function getMe() {
const res = await axios.get("users/me");
const nextUser = res.data;
setUser(nextUser);
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
async function login({ email, password }) {
await axios.post("/auth/login", {
email,
password,
});
await getMe();
}
async function logout() {}
async function updateMe(formData) {
const res = await axios.patch("users/me", formData);
const nextUser = res.data;
setUser(nextUser);
}
return (
<AuthContext.Provider value={{ user, login, logout, updateMe }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("반드시 AuthProvider 안에서 사용해야 합니다.");
}
return context;
}
이런식으로 컨텍스트를 만들고 App.js에
import { AuthProvider } from "../contexts/AuthProvider";
import ToasterProvider from "../contexts/ToasterProvider";
function Providers({ children }) {
return (
<ToasterProvider>
<AuthProvider>{children}</AuthProvider>
</ToasterProvider>
);
}
function App({ children }) {
return <Providers>{children}</Providers>;
}
export default App;
적용을 해주었다.
하지만 새로고침을 하면 로그인이 풀리는 현상이 발생하는데 이를 해결하기 위해 AuthProvider.js
에 useEffect 훅을 활용해서 유저데이터를 가져온다.
useEffect(() => {
getMe();
}, []);
로그인 상태에서는 마이페이지로, 로그아웃 상태에서 마이페이지 들어가면 메인페이지로 이동하기
import { createContext, useContext, useEffect, useState } from "react";
import axios from "../lib/axios";
import { useNavigate } from "react-router-dom";
const AuthContext = createContext({
user: null,
isPending: true,
login: () => {},
logout: () => {},
updateMe: () => {},
});
export function AuthProvider({ children }) {
const [values, setValues] = useState({
user: null,
isPending: true,
});
async function getMe() {
setValues((prevValues) => ({
...prevValues,
isPending: true,
}));
let nextUser;
try {
const res = await axios.get("/users/me");
nextUser = res.data;
} catch {
} finally {
setValues((prevValues) => ({
...prevValues,
user: nextUser,
isPending: false,
}));
}
}
async function login({ email, password }) {
await axios.post("/auth/login", {
email,
password,
});
await getMe();
}
async function logout() {
/** @TODO 로그아웃 구현하기 */
}
async function updateMe(formData) {
const res = await axios.patch("/users/me", formData);
const nextUser = res.data;
setValues((prevValues) => ({
...prevValues,
user: nextUser,
}));
}
useEffect(() => {
getMe();
}, []);
return (
<AuthContext.Provider
value={{
user: values.user,
isPending: values.isPending,
login,
logout,
updateMe,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(required) {
const context = useContext(AuthContext);
const navigate = useNavigate();
if (!context) {
throw new Error("반드시 AuthProvider 안에서 사용해야 합니다.");
}
useEffect(() => {
if (required && !context.user && !context.isPending) {
navigate("/login");
}
}, [context.user, context.isPending, navigate, required]);
return context;
}
interceptor로 리스폰스를 가로챈 다음에 토큰 만료로 추정이 되면 토큰을 재발급하고 재시도 한다는 의미
axios.js
에 아래 코드 추가
instance.interceptors.response.use(res => res, async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
await instance.post('/auth/token/refresh', undefined, { _retry: true });
originalRequest._retry = true;
return instance(originalRequest);
}
return Promise.reject(error);
});
주의할 점은, 토큰 갱신을 실패했을 때도 401 상태 코드 리스폰스가 올 수 있기 때문에 _retry 를 미리 설정해서 재요청을 방지해야 한다는 것이다.
보통 토큰 기반 인증에서는 서버에 따로 로그아웃을 알려줄 필요없이 쿠키만 지우면 되고, 세션 기반에서는 서버에 리퀘스트에 로그아웃이라고 알려줘야 한다.
async function logout() {
await axios.delete('/auth/logout');
setValues((prevValues) => ({
...prevValues,
user: null,
avatar: null,
}));
}
사진출처 : 코드잇