프리온보딩 프로젝트 요구 사항 중 하나인 토큰을 발급받아 로컬스토리지에 저장.
🔥토큰에 대한 개념이 없어 강의에서 멘토님의 말씀에 속으로 ㄹㅇㅋㅋ만 외치던 나, 이번 기회에 공부하고 리액트에 인증 기능을 추가해보려 한다.
인증
대부분의 웹 앱에는 인증 DOM이 필요하며, 해당 인증을 통해 애플리케이션에 접근 가능한 영역들이 있다. 즉, 회원가입과 로그인을 통해 인증을 받아야 접근할 수 있는 부분이 있을 것이다.
[ 인증의 필요성 ]
인증이 동작하는 원리를 이해하기 전 인증의 필요성을 이해하면 좋을 것 같다.
인증이 필요한 이유는 보호해야 할 정보가 있기 때문이다. 사이트의 모든 이용자나 방문자가 모든 정보에 접근 권한을 가져선 안되니 인증이 필요하다.
이때 인증을 통해 보호해야 하는 정보는 여러 가지가 될 수 있다.
- 사용자 정보에 관한 페이지에서는 사용자의 비밀번호 등의 정보를 변경할 수 있기 때문에 로그인한 사용자만 접근할 수 있어야 한다. 따라서 로그인하지 않은 사용자는 접근할 수 없도록 별도의 조건을 걸어주어야 한다.
- 데이터베이스에 저장된 데이터는 보호해야 하는 정보일 수 있다. 미인증 사용자의 접근이 제한된 페이지에 리액트 앱이 요청을 보낼 때, 요청을 받는 API 엔드포인트가 이에 속한다.
만약 비밀번호를 변경하는 페이지가 인증을 통해 보호되지 않는다면 데이터베이스 내의 사용자 비밀번호를 업데이트하겠다는 요청이 서버로 전송될 것이다. 때문에 해당 페이지에도 접근 제한을 걸어야겠지만, 로그인 사용자가 보낸 요청이 아니라면 API 엔트포인트로 전송된 요청이 성공하지 않게 API 엔트포인트에도 접근 제한을 걸어 주어야 한다.
해당 페이지에만 접근 제한을 걸어도 된다고 생각할 수 있지만, API 엔트포인트에 접근 제한을 걸지 않을 경우 엔드포인트의 URL을 아는 사람이 비밀번호 변경 요청을 보내 다른 사람의 비밀번호를 바꿀 수 있어 보안이 취약해지게 된다.
[ 인증 동작 원리]
일반적으로 인증은 2단계 절차를 거치게 된다.
- 1단계 : 사용자가 로그인 등의 절차를 통해 접근에 대한 허가를 받을 수 있다.
로그인을 통해 접근에 대한 허가를 받게 되면 데이터가 서버로 전송되고, 서버에서는 데이터베이스를 살펴 사용자의 이메일/비밀번호를 확인하게 된다.
- 2단계 : 서버에서 사용자의 이메일/비밀번호가 유효하다 판단하면 접근을 허가해 준다.
허가를 받으면 특성 페이지에 접근이 가능해지며, 필요에 따라 접근 허가를 활용해 API 엔드포인트의 다른 보호된 리소스에 후속 요청을 보낼 수도 있다. 접근이 허가되었다는 정보를 첨부해 다른 엔드 포인트에 더 많은 요청을 보낼 수 있게 되는 것이다.
브라우저에서 리액트 앱이 실행 중인 클라이언트와 백엔드 서버가 존재한다.
사용자가 입력한 이메일과 비밀번호를 서버에 요청으로 보내게 되면 서버에서 전달받은 이메일과 비밀번호에 대해 유효성을 판단하고 접근에 대한 자격을 증명해 후속 요청을 허가하거나 거절하게 된다.
❓이렇게 응답으로 받은 허가를 활용해서 후속 요청을 보내는 것이 끝일까?
물론 아니다. Reponse로 전달받은 '허가😃'와 '거절🙁'만으로는 API 엔드포인트 같은 보호된 리소스에 접근이 불가능하다. 이는 '허가😃'와 '거절🙁'과 같은 응답은 위조하기가 쉽기 때문이다. 자격에 대한 증명을 받는 서버가 '허가😃'와 '거절🙁'로만 응답을 한다면 아예 허가를 받는 절차를 생략하고 보호된 리소스에 직접 허가에 대한 요청을 보낼 수도 있다.
그러므로 허가에 대한 응답으로는 단순한 '허가😃'와 '거절🙁'보다는 보다 정교해야 한다. 접근에 대한 자격을 증명을 요청하고 확인하는 과정은 반드시 필요하지만, 서버가 클라이언트에 보내는 응답은 허가/거절보다는 복잡해야 한다.
위에 대한 해결책은 크게 두 가지가 있다.
- 서버 사이드 세션을 활용
서버 사이드 세션은 전통적이면서도 훌륭한 인증 처리 기법으로 사용자 접근을 허가한 서버가 허가를 받은 클라이언트, 즉 특정 사용자의 고유 ID를 저장한다. 서버가 특정 클라이언트의 고유 ID를 생성하고 저장하는 것이다. 따라서 인증을 거치는 모든 사이트 방문자의 고유 ID가 서버에 저장된다.
이 ID는 서버에만 저장되지 않고 클라이언트에도 전송이 되기 때문에 서버의 응답은 단순한 허가/거절이 아닌 고유 ID가 포함된 응답이 된다. 서버와 클라이언트 모두 고유 ID를 알고 있게 되며, 해당 고유 ID를 서버에 후속 요청을 보낼 때 함께 첨부하게 되고 서버는 해당 고유 ID를 알고 있기 때문에 ID 위조가 불가능하게 된다. 임의로 만든 ID는 서버는 알지 못하기 때문에 접근이 거부되고 후속 요청 또한 불가능하게 된다.
❗️이러한 훌륭한 방식에도 단점이 존재한다. 백엔드와 프론트엔드의 결합이 긴밀하면 문제 될 것이 없지만, 분리되어 있다면 이야기가 달라진다.
예를 들어 SPA의 경우 서버 A로, 백엔드의 REST API는 서버 B로 운영 중이라면 결합이 느슨해서 백엔드 API와 프론트엔드의 SPA는 서로 독립적으로 작동하게 될 것이다. 또는 여러 사이트에서 이용할 수 있는 API를 만드는 중이라면 이 역시 특정 프론트엔드에 긴밀하게 결합하지 못하고 유동적인 상태로 있게 될 것이다.
따라서 이런 경우 서버에 ID를 저장하면 안 되며, 서버는 이른바 무상태여야 한다. 즉, 접속한 클라이언트의 데이터를 저장해서는 안된다. 백엔드와 프론트엔드 분리 상태, 특히 SPA 개발에서 자주 마주하게 되는 상황이다. 이러한 상황에서는 인증 토큰을 사용하면 된다.
- 인증 토큰을 활용
서버 사이트 세션과 기본 개념은 얼추 비슷하지만, 중요한 차이점이 존재한다. 사용자가 이메일과 비밀번호를 서버에 접근에 대한 자격 증명을 보내게 되면 서버가 데이터베이스에 저장된 이메일/비밀번호를 비교해 유효성을 확인하게 되는 것까지는 같다.
여기서 사용자의 자격이 증명이 되면 접근에 대한 허가 토큰(데이터가 인코딩 된 아주 긴 문자열)을 생성하게 된다. 서버는 특정 알고리즘을 활용해 사용자의 이메일을 비롯한 각종데이터를 인코딩하고, 나중에 다시 개별 데이터로 디코딩을 하게 된다.
토큰은 서버가 특정 알고리즘에 따라 생성하는데, 이때 중요한 건 서버만 아는 키를 사용해 데이터를 문자열로 해시하게 된다.
*해시 : 주어진 데이터에 접근하여 생성되는 정해진 길이의 독특한 무작위 문자열
즉, 사용되는 키는 클라이언트는 모르고 서버만 알게 되는 것이다. 또한 토큰은 서버에 저장되지 않고 클라이언트에 다시 전송되지만, 토큰을 생성하는 방법은 클라이언트는 모르고 서버만 알고 있다. 이는 토큰을 생성하는 과정에 서버만 알고 있는 키가 사용되기 때문이다.
리액트 앱 같은 클라이언트는 이 토큰을 후속 요청에 첨부해 서버에 보호된 리소스에 보내게 된다. 서버는 서버 사이드 세션과 달리 ID를 저장하지 않고도 자신이 생성한 토큰인지 아닌지 확인할 수 있다. 토큰 생성에 사용된 프라이빗한 키는 서버만이 알고 있기 때문이다. 그래서 보호된 리소스에 접근 요청을 보낼 때 첨부된 토큰의 유효성을 서버가 확인할 수 있게 된다. 토큰이 위조됐거나 다른 키로 생성되었다면 서버가 위조를 감지하고 접근을 거부하게 된다.
이러한 토큰을 활용한 인증은 서버가 클라이언트를 식별하는 또 다른 보안 방식이자 프론트엔드와 백엔드 분리를 디커플링 하는 기법이기도 하다.
사용 예시
🚨사용 예시에서는 firebase를 사용하여 인증을 진행하고자 한다. firebase 프로젝트가 존재할 경우 Firebase Auth REST API를 사용할 수 있기 때문이다.
Firebase Auth REST API 문서에는 사용자 생성과 로그인 요청, 보호된 리소스 접근 요청을 어떤 API 엔드포인트에 보내야 하는지 확인할 수 있다. (엔드포인트 URL, Request Body Payload 등 학인 가능)
사용 예시를 정리하기 전 아래의 내용을 한 번 더 짚고 넘어가면 좋을 것 같다.
✏️비밀번호를 변경의 경우 보호된 리소스에 대한 접근에 해당한다. 즉, 로그인 상태에서만 해당 이메일에 대한 비밀번호를 바꿀 수 있어야 한다. 때문에 API 엔드포인트에는 Request Body Payload, 즉 요청 데이터의 일부로 앞서 언급한 IDToken이 필요하다.
[ Firebase 설정 ]
firebase의 Authentication으로 들어간다. (firebase 인증 REST API 사용)
firebase에는 다양한 인증 서비스가 존재하지만, 사용자의 이메일/비밀번호가 담긴 자체 데이터베이스와 자체 API를 가지고 있는 것을 가정하고 사용 예시를 정리할 것이기 때문에 이메일/비밀번호를 선택한다.
이메일/비밀번호 인증만 사용 활성화를 해준다.(비밀번호가 없는 로그인은 활성화하지 않고 진행!)
위의 설정을 마치면 사용자 로그인을 할 수 있다.
[ 회원가입 ]
🚨State isLogin이 true일 경우는 로그인, false일 경우 회원가입을 진행하게 된다.
회원가입 POST의 경우 위의 엔드포인트 URL로 보내야 하며, 요청에 email, password, returnSecureToken 데이터를 첨부해야 새로운 사용자를 생성할 수 있다.
const handleSubmit = (event) => {
event.preventDefault();
const enteredEmail = emailInputRef.current.value;
const enteredPassword = passwordInputRef.current.value;
// 사용자 입력값 유효성 검사...(생략)
if (isLogin) {
// 로그인 모드일 때 firebase에 SignIn 요청
} else {
// 회원가입 모드일 때 firebase에 SignUp 요청
fetch(
"https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyCUxa--RTFGZ2dvlS",
{
method: "POST",
body: JSON.stringify({
email: enteredEmail,
password: enteredPassword,
returnSecureToken: true,
}),
headers: {
"Content-Type": "application/json",
},
}
).then((response) => {
if (response.ok) {
// 회원가입 성공
// 생략...
} else {
// 회원가입 실패
return response.json().then((data) => {
// 오류창 띄우는 작업 수행 콘솔로 대체
console.log(data);
});
}
});
}
};
함수 handleSubmit에서는 isLogin의 값에 따라 firebase에 signUp 요청과 signIn 요청을 하게 된다.
회원가입 요청 로직을 else 구문에서 작성하며, fetch API에 앞서 언급한 signUP 엔드포인트 URL을 넣어주고 [API_KEY] 자리에 API KEY를 넣어주었다. 또한 body와 method, header를 위의 문서에 있는 양식과 일치하도록 해주었다.
추가로 회원가입 성공 로직과 실패 로직을 간략하게 작성해 주었다.
회원가입을 진행하게 되면 위와 같이 사용자가 입력한 이메일과 사용자 고유 ID가 추가된다.
[ 로그인 ]
로그인의 경우도 회원가입과 동일한 방법으로 문서를 참조하여 요청 코드를 작성해 주도록 하되 엔드 포인트 URL만 변경해 주면 된다.
const handleSubmit = (event) => {
event.preventDefault();
const enteredEmail = emailInputRef.current.value;
const enteredPassword = passwordInputRef.current.value;
// 사용자 입력값 유효성 검사...(생략)
setIsLoading(true);
let url;
if (isLogin) {
// 로그인 모드일 때 firebase에 SignIn 요청 엔드포인트 URL
url =
"https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=AIzaSyCUxa--RTFGZ2dvlS0QYYV";
} else {
// 회원가입 모드일 때 firebase에 SignUp 요청 엔드포인트 URL
url =
"https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyCUxa--RTFGZ2dvlS0QYYV";
}
// 공통 로직 분리
fetch(url, {
method: "POST",
body: JSON.stringify({
email: enteredEmail,
password: enteredPassword,
returnSecureToken: true,
}),
headers: {
"Content-Type": "application/json",
},
}).then((response) => {
setIsLoading(false);
if (response.ok) {
// 회원가입 성공
// 생략...
} else {
// 회원가입 실패
return response.json().then((data) => {
let errorMessage = "Authentication failed!";
if (data && data.error && data.error.message) {
// data, data.error, data.error.message가 있을 경우만 에러 메시지 설정
errorMessage = data.error.message;
}
alert(errorMessage);
});
}
});
};
엔드포인트 URL을 제외한 나머지 코드는 동일하기 때문에 로직을 분리하여 공통으로 사용할 수 있도록 해주었다.
이제 위의 조건문에서 결정된 변수 url이 fetch API에 전달되게 된다.
로그인에 성공하게 되면 응답으로 로그인에 사용된 email, idToken(인증 토큰) 등이 오게 된다.
🤨여기서 주목할 것은 idToken, 즉 인증 토큰으로 응답으로 받은 해당 인증 토큰을 보호된 리소스에 후속 요청을 보낼 때 같이 보내주어야 한다. 또한 해당 토큰은 firebase에 유효한 이메일/비밀번호를 전달했을 경우에만 발급받을 수 있기 때문에 리액트 앱의 프론트엔드에서 사용자가 로그인했다는 증명으로 활용할 수도 있게 된다.
fetch(url, {
method: "POST",
body: JSON.stringify({
email: enteredEmail,
password: enteredPassword,
returnSecureToken: true,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((response) => {
setIsLoading(false);
if (response.ok) {
// 회원가입 성공
return response.json();
} else {
// 회원가입 실패
return response.json().then((data) => {
let errorMessage = "Authentication failed!";
if (data && data.error && data.error.message) {
// data, data.error, data.error.message가 있을 경우만 에러 메시지 설정
errorMessage = data.error.message;
}
throw new Error(errorMessage);
});
}
})
.then((data) => {
console.log(data);
})
.catch((error) => {
alert(error.message);
});
또한 응답으로 들어오는 데이터의 경우 회원가입과 로그인이 유사하게 들어오기 때문에 위와 같이 코드를 수정해 줄 수 있다.
로그인이 요청이 resolve일 경우 문서에 나와있는 Response Payload와 동일한 응답 데이터를 받을 수 있게 된다.
⭐️앞서 언급했지만 응답으로 받은 데이터 중 가장 중요한 idToken은 인코딩 된 데이터가 담긴 긴 문자열로 firebase가 생성했기 때문에 firebase의 서버만 Token의 유효성을 확인할 수 있게 된다.
이제 사용자는 서버로부터 접근 제한이 걸린 영역에 대한 접근 자격을 제공받게 되었기 때문에 인증 토근을 후속 요청을 보낼 때 같이 보낼 수 있을 뿐 아니라 사용자가 인증됐다는 증명으로 적절하게 UI 업데이트도 할 수 있게 된다.
이를 위해 전달받은 토큰을 컨텍스트나 로컬 스토리지에 저장하여 사용하면 될 것이다.
- 컨텍스트에 저장
// auth-context.js
import React, { useState } from "react";
const AuthContext = React.createContext({
token: "",
isLoggedIn: false,
login: (token) => {},
logout: () => {},
});
export const AuthContextProvider = (props) => {
const [token, setToken] = useState(null);
// token이 있으면 true, 없으면 false
const userLoggedIn = !!token;
// token 상태를 바꾸는 함수
const handleLogin = (token) => {
setToken(token);
};
const handleLogout = () => {
setToken(null);
};
const contextValue = {
token,
isLoggedIn: userLoggedIn,
login: handleLogin,
logout: handleLogout,
};
return (
<AuthContext.Provider value={contextValue}>
{props.children}
</AuthContext.Provider>
);
};
<AuthContextProvider>
<BrowserRouter>
<App />
</BrowserRouter>
</AuthContextProvider>
위와 같이 컨텍스트를 만들어주고 App 컴포넌트를 감싸주게 되면 이제 App 내부의 다른 영역에서도 token 관련 데이터에 접근이 가능해지게 된다.
앞서 작성한 요청의 resolve에 응답으로 전달받은 인증 토큰을 컨텍스트에 저장해 주면 된다.
✏️프리온보딩 프로젝트의 경우 로컬 스토리지에 저장해야 하기 때문에 resolve 부분에 로컬 스토리지에 저장하는 로직만 작성해 주면 될 것 같다.
[ 리다이렉트 처리 ]
비밀번호 변경 및 로그인 후에 아래와 같이 리다이렉트 코드를 작성해 주었다.
react-router-dom의 useNavigate 사용. 위와 같이 리다이렉트(replace) 시켜 줄 경우 뒤로 가기를 눌러 이전 페이지(로그인 페이지, 비밀번호 변경 페이지)로 돌아갈 수 없게 할 수 있다.
[ 로그아웃 ]
로그아웃의 경우 firebase에 별도의 요청을 보내지 않아도 된다. 인증 토큰의 핵심은 로그인한 클라이언트의 어떤 정보도 서버에 저장되지 않는다는 것이다. 따라서 어떠한 요청도 보내지 않아도 된다. firebase는 로그인 중이거나 로그인했었는지 알지도 못하고 관심이 없다.
로그아웃 시 바꿔야 하는 것은 단 하나, State이다. 즉, 컨텍스트에 저장된 토큰을 비워버리면 되는 것이다. 로컬스토리지의 경우 저장된 토큰을 제거해 버리면 된다.
때문에 앞서 작성한 컨텍스트의 logout 함수를 그대로 사용해 주면 된다.
const { isLoggedIn, logout } = useContext(AuthContext);
const handleLogout = () => {
logout();
};
함수 handleLogout을 로그아웃 버튼에 onClick 이벤트로 전달해 주었다.
하지만 위의 경우 인증 토큰이 없으면 접근이 불가능한 페이지의 경우 프론트엔드의 UI에서만 접근하지 못하도록 해놓았기 때문에 별도의 URL 입력 시 접근 제한 페이지로 이동이 가능하다.
위의 문제는 별도의 URL을 입력하여 접근 제한 페이지로 이동은 가능하지만, 인증 토큰이 없기 때문에 비밀번호 변경 등은 불가능하기 때문에 문제가 발생하지는 않지만 로그인 상태, 즉 인증 토큰이 없을 경우 페이지에 접근이 불가능하도록 처리해 주는 것이 좋다.
이 경우 아래와 같이 내비게이션 가드를 사용하면 문제를 해결할 수 있다.
userIsLoggedIn의 경우 token이 있을 경우 true, 없을 경우 false 이기 때문에 isLoggedIn을 조건에 사용하여 Route 하도록 한다.
<Routes>
<Route path="/" element={<HomePage />} />
{!isLoggedIn && <Route path="/auth" element={<AuthPage />} />}
{isLoggedIn && <Route path="/profile" element={<UserProfile />} />}
<Route path="*" element={<Navigate to="/" />} />
</Routes>
앞서 언급한 isLoggedIn을 사용하여 Route 조건을 만들어주었으며, 등록되지 않은 경로의 경우 리다이렉트를 시켜주었다. 404의 경우 리다이렉트, 404 페이지 둘 중 선택하면 되지만, 사용자 경험에는 404페이지로 이동시키는 것이 좋지 않을까 싶다.
특정 조건에 맞춰 Route를 하여 페이지를 렌더링 한다면, 조건이 참일 경우에만 특정 Route를 할 수 있게 된다. 즉, 사용자의 현재 인증 상태를 특정 Route의 조건으로 삼을 수 있는 것이다. 또한 로그인을 하지 않고 별도의 URL을 입력하여 페이지 이동을 하려고 할 경우 리다이렉트 되도록 해주었다.
[ 인증 state 유지하기 ]
여전히 문제는 존재한다. 새로고침을 할 경우 리액트 앱은 다시 실행되기 때문에 컨텍스트의 모든 상태는 사라지게 되기 때문에 저장된 인증 state 또한 사라지게 된다.
위의 이유로 로드할 때마다 데이터를 잃게 된다. 현실적이지 않지만, 데이터를 잃고 싶지 않다고 가정해 보자. 이 경우 사용자의 로그인 상태를 특정 시간 동안만이라도 유지하고 싶은 경우일 것이다.
토큰의 경우 특정 시간이 지나면 만료가 된다는 특징을 가지고 있다. 그리고 응답의 일부로 만료 시간에 대한 데이터를 얻을 수도 있었다.
이때, 만료 시간은 초단위이며 디폴트는 한 시간에 해당한다. 따라서 firebase에서 받은 토큰은 한 시간 뒤에 만료되는 보안 메커니즘을 가지고 있는 것이다.
다시 앞서 가정한 상황으로 돌아가면 사용자의 로그인 상태를 토큰의 만료 시간인 1시간 동안은 유지하고 싶은 경우가 될 것이다. 이러한 경우를 해결하기 위해서는 리액트 앱 외부에 저장해야 될 것이다. 즉, 로컬 스토리지나 쿠키에 발급받은 토큰을 저장하는 것이다.
컨텍스트의 상태에만 토큰을 관리하는 것은 로그인 상태 유지에 의미가 없기 때문에 추가로 로컬 스토리지에서도 관리를 해 줄 것이다. 또한 페이지가 로드되면 스토리지에 저장된 토큰이 있으면 해당 토큰을 이용하여 사용자가 처음에 새 로운 요청을 보내지 않게 해 줄 것이다.
export const AuthContextProvider = (props) => {
const initialToken = localStorage.getItem("token");
const [token, setToken] = useState(initialToken);
const userIsLoggedIn = !!token;
const loginHandler = (token) => {
setToken(token);
localStorage.setItem("token", token);
};
const logoutHandler = () => {
setToken(null);
localStorage.removeItem("token");
};
const contextValue = {
token: token,
isLoggedIn: userIsLoggedIn,
login: loginHandler,
logout: logoutHandler,
};
return (
<AuthContext.Provider value={contextValue}>
{props.children}
</AuthContext.Provider>
);
};
로그인, 로그아웃 함수에 로컬 스토리지에 발급받은 토큰을 저장하고, 제거하는 코드를 작성해 주었다.
앱이 처음 시작되었을 때 로컬 스토리지에 저장된 토큰 정보를 읽어와 state의 초기 값으로 전달하도록 initialToken 설정을 해주었다. 따라서 별도의 useEffect 사용 없이 초기 토큰 설정을 할 수 있게 되었다.
❗️드디어 토큰의 만료 시간이 끝나지 않은 경우 로컬 스토리지에 해당 토큰이 저장되어 있다면 앱이 다시 시작돼도 로그인 상태를 유지할 수 있게 되었다.
[ 토큰 만료 시 로그아웃 ]
토큰이 만료될 경우 자동으로 로그아웃 되도록 해주어야 한다.
자동 로그아웃 작업을 위해서는 로그인 함수에 토큰만 전달받는 것이 아닌 만료 시간을 추가로 전달받아 줘야 한다. 만료 시간을 전달받게 되면 현재 시간에 만료 시간을 빼주어 토큰의 남은 시간을 계산할 수 있기 때문이다.
const calculateRemainingTime = (expirationTime) => {
// 현재 시간
const currentTime = new Date().getTime();
// 만료 시간 밀리세컨즈 단위로 변경
const adjExpirationTime = new Date(expirationTime).getTime();
// 남은 만료 시간
const remainingTime = adjExpirationTime - currentTime;
return remainingTime;
}
우선 남은 만료 시간을 계산해 주는 함수를 만들어 주었다. 함수 내부에서는 현재 시간과 전달받은 토큰의 만료 시간을 연산하여 남은 만료 시간을 반환하게 된다.
로그인 함수에 만료시간을 계산해 주는 함수를 사용하여 반환된 시간을 setTimeout의 인수로 전달하도록 했기 때문에 로그인 함수는 별도의 만료시간을 인수로 전달받도록 수정해주어야 한다.
fetch(url, {
method: "POST",
body: JSON.stringify({
email: enteredEmail,
password: enteredPassword,
returnSecureToken: true,
}),
headers: {
"Content-Type": "application/json",
},
})
.then((res) => {
// 생략
})
.then((data) => {
// 응답으로 들어오는 만료 시간은 문자열이기 때문에 숫자로 변환.
// 응답으로 들어오는 만료 시간은 초 단위이기 때문에 밀리세컨즈 단위로 변환.
// 현재 시간에 만료 시간을 더해주어 언제 만료되는지 시간 구하기
// 현재 시간 + 만료 시간을 새 날짜 객체로 변환
const expirationTime = new Date(
new Date().getTime() + +data.expiresIn * 1000
);
// 로그인 함수에 만료시간 객체를 문자열로 전달
authCtx.login(data.idToken, expirationTime.toISOString());
navigate("/", { replace: true });
})
.catch((err) => {
alert(err.message);
});
응답으로 들어오는 만료시간의 경우 문자열이기 때문에 숫자로 변환, 초 단위이기 때문에 밀리세컨즈 단위로 변환 후 로그인 함수에 날짜 객체의 문자열로 전달하도록 수정해 주었다.
로그인 함수를 호출부 또한 위와 같이 수정해 주었다.
사용자가 토큰 만료 시간이 남았음에도 로그아웃을 할 수 도 있기 때문에 로그인의 setTimeout의 타이머의 작동을 멈추도록 해주어야 한다.
setTimeout은 레퍼런스와 ID를 리턴하기 때문에 리턴 값을 별도의 전역 변수에 담아 주도록 하고 사용자가 직접 로그아웃할 경우 변수 logoutTimer에 담긴 값은 truthy이기 때문에 타이머가 제거될 것이다.
🔥정리하다 보니 내용이 길어졌지만, 위의 내용을 프로젝트에 적용하면 요구사항도 해결할 수 있을 것 같다.
프로젝트 적용
[ 회원가입 ]
export const signUpAPI = {
signUp: async (
signUpInfo: SignUpInfoValues,
moveHomeCb: () => void,
disfatchResetFeedbackCb: () => void,
dispatchExistEmail: () => void
) => {
const { email, password } = signUpInfo;
try {
await axios.post(
`https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=${process.env.REACT_APP_FIREBASE_AUTH_API_KEY}`,
{
email,
password,
returnSecureToken: true,
}
);
} catch (error: any) {
if (error.response.data.error.message === "EMAIL_EXISTS") {
dispatchExistEmail();
}
return;
}
window.alert("회원가입을 축하합니다 🎉");
moveHomeCb();
disfatchResetFeedbackCb();
},
};
회원가입의 경우 위의 예시와 동일하게 firebase에 사용자가 입력한 email, password, returnSecureToken을 전달해 주었다. 이때 회원가입하고자 하는 이메일이 이미 존재할 경우 firebase error 메시지로 "EMAIL_EXISTS"를 요청에 대한 응답으로 보내주게 되는데 해당 메시지로 별도의 프드백을 전달해 주었다.
[ 로그인 ]
export const loginAPI = {
login: async (
email: string,
password: string,
onMoveHomeCb: () => void,
dispatchNotFoundEmailCb: () => void,
dispatchInvalidPasswordCb: () => void,
dispatchLoginCb: () => void,
resetEmailInputStateCb: () => void,
resetPasswordInputStateCb: () => void
) => {
let token: string;
let expirationTime: string;
try {
const response = await axios.post(
`https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${process.env.REACT_APP_FIREBASE_AUTH_API_KEY}`,
{
email,
password,
returnSecureToken: true,
}
);
// 토큰 만료 시각
expirationTime = new Date(
new Date().getTime() + +response.data.expiresIn * 1000
).toISOString();
token = response.data.idToken;
} catch (error: any) {
if (error.response.data.error.message === "EMAIL_NOT_FOUND") {
dispatchNotFoundEmailCb();
} else if (error.response.data.error.message === "INVALID_PASSWORD") {
dispatchInvalidPasswordCb();
}
return;
}
const remainingTime = calculateRemainingTime(expirationTime);
localStorage.setItem("token", token);
localStorage.setItem("expirationTime", expirationTime);
// 만료 시간 지나면 자동 로그아웃
logoutTimer = setTimeout(loginAPI.logout, remainingTime);
dispatchLoginCb();
onMoveHomeCb();
resetEmailInputStateCb();
resetPasswordInputStateCb();
},
logout: () => {
localStorage.removeItem("token");
localStorage.removeItem("expirationTime");
if (logoutTimer) {
clearTimeout(logoutTimer);
}
},
};
로그인도 예시와 동일하게 엔드포인트 URL에 RequestBodyPayload를 요청으로 보내주었다. 요청에 대한 응답 데이터 중 idToken(인증 토큰), expiresIn(토큰 만료 시간)을 별도 변수에 저장해 주었다.
이때 firebase가 보내주는 expiresIn는 초단위이기 때문에 밀리세컨즈 단위로 변환한 뒤 현재 시간(ms) + 만료 시간(ms)을 날짜 객체로 변환하여 문자열로 변수에 저장해 주었다. (현재 시간 + 만료 시간을 해주었기 때문에 만료 시각으로 변환했다고 생각하면 될 것 같다.)
로그인도 동일하게 존재하지 않는 이메일 입력, 유효하지 않은 비밀번호 입력 시 firebase에서 보내주는 에러 메시지를 이용해 사용자에게 피드백을 전달할 수 있도록 해주었다.
export const calculateRemainingTime = (expirationTime: string): number => {
const currentTime = new Date().getTime();
const adjExpirationTime = new Date(expirationTime).getTime();
const remainingDuration = adjExpirationTime - currentTime;
return remainingDuration;
};
export const retrieveStoredToken = () => {
const storedToken = localStorage.getItem("token");
const storedExpirationTime = localStorage.getItem("expirationTime");
if (storedExpirationTime) {
const remainingTime = calculateRemainingTime(storedExpirationTime);
// 토큰이 유통기한 1분 미만일 경우 로컬스토리지에 저당된 데이터 삭제
if (remainingTime <= 3600) {
localStorage.removeItem("token");
localStorage.removeItem("expirationTime");
return null;
}
// 토큰의 유통기한이 남아 있다면 저장된 토큰과 남은 유효 시간 리턴
return {
token: storedToken,
duration: remainingTime,
};
}
};
함수 calculateRemainingTime의 경우 시간을 전달받아 밀리세컨즈로 변경 후 현재 시간을 뺸 시간(토큰의 남은 유통 기한)을 반환한다.
함수 retrieveStoredToken의 경우 로컬 스토리지에 저장된 토큰과 토큰 만료 시각을 읽어온다. 읽어온 토큰의 만료 시각이 존재한다면, 즉 로컬 스토리지에 토큰의 만료 시각이 저장되어 있다면 함수 calculateRemainingTime를 사용하여 토큰의 남은 유통기한을 구한다. 만약 토큰의 유통기한이 3600ms 이하(상하기 직전)라면 로컬 스토리지에 저장된 데이터를 지우고 이상이라면 토큰과 남은 유통기한을 반환한다.
함수 calculateRemainingTime에 앞서 저장해 두었던 토큰의 만료 시각을 전달하여 토큰의 남은 유통기한을 변수 remainingTime에 할당해 주었다.
그다음 앞서 저장한 idToken과 expiresIn을 로컬 스토리지에 저장해 주었다.
다음으로 setTimeout API를 사용하여 토큰의 유통기한이 지나면, 자동으로 logoutAPI가 호출되도록 해주었다.
// Header.tsx
const handleLogout = () => {
dispatch(loginAction.logout());
loginAPI.logout();
};
useEffect(() => {
const tokenData = retrieveStoredToken();
if (tokenData) {
setTimeout(() => {
handleLogout();
}, tokenData.duration);
}
}, [handleLogout]);
Header 컴포넌트에서 Mount 시 함수 retrieveStoredToken를 호출하도록 했다. 만약 로컬 스토리지에 저장된 데이터가 없을 경우 null, 있을 경우 토큰과 남은 유통기한 데이터를 반환한다. 이를 이용하여 위와 같이 읽어온 토큰의 유통기한이 만료될 경우 로그아웃 함수를 호출하도록 해주었다.
❗️앞서 logInAPI에 설정한 자동 로그아웃 로직의 경우 로그인 버튼을 클릭했을 경우 실행된다. 이 경우는 로그인 버튼을 클릭했을 경우 실행되는 것이지 앱을 리로드 했을 경우 자동 로그아웃 로직이 실행되지 않는다. 때문에 위와 같이 앱이 리로드 되었을 경우도 토큰의 남은 만료시간을 계산하여 자동으로 로그아웃 되도록 해주었다.
[ 로그아웃 ]
로그아웃의 경우 로컬스토리지에 저장된 토큰과 만료시각을 제거하고 타이머가 맞춰져 있을 경우 타이머 초기화가 되도록 해주었다.
🎉 이렇게 ㄹㅇㅋㅋ만 외치던 부분을 공부하여 프로젝트에 적용해 보았다. 리프레쉬 토큰을 적용 못한 것이 아쉬움으로 남기 때문에 추후 리프레쉬 토큰에 대해 살펴보도록 해야겠다.
'Project > 프리온보딩' 카테고리의 다른 글
[프리온보딩 프론트엔드 챌린지 3월] - ROUTE 수정 (feat. Route Object) (0) | 2023.03.07 |
---|---|
[프리온보딩 프론트엔드 챌린지 1월] Suspense RFC 톺아보기 (1) | 2023.01.25 |
[프리온보딩 프론트엔드 챌린지 1월] 비동기 코드 다루기 (0) | 2023.01.16 |
[프리온보딩 프론트엔드 챌린지 1월] 순수 함수 구현 및 코드 리팩토링 (0) | 2023.01.15 |
[프리온보딩 프론트엔드 챌린지 1월] 유틸리티 타입으로 수정하기 (1) | 2023.01.14 |