5 minute read



오늘은 [에브리데이] 프로젝트에서 localStorage를 사용해서 구현한 로그인 및 로그아웃을 포스팅해보려고 한다!

localStorage는 간단히 말하면 로컬에 저장하는 임시저장소라고 할 수 있다. windows 전역 객체의 LocalStorage라는 컬렉션을 통해 저장, 조회가 이루어진다.

이 객체는 아래 method와 property를 제공한다.

  • setItem(key, value) – 키-값 쌍을 보관
  • getItem(key) – 키에 해당하는 값을 받아옴
  • removeItem(key) – 키와 해당 값을 삭제
  • clear() – 모든 것을 삭제
  • key(index) – 인덱스(index)에 해당하는 키를 받아옴
  • length – 저장된 항목의 개수를 얻음


이 객체는 Map과 유사하여, setItem/getItem/removeItem을 지원한다. 하지만 인덱스를 사용해 키에 접근할 수 있다는 점(key(index))에서 차이가 있다.



login.js


로그인 버튼을 눌렀을 시, id, pw, type(user or manager), 로그인유지 체크 여부 데이터를 담아 미리 정의해둔 UserAPI를 통해 로그인 api요청을 보다.

import React, { useState } from 'react'
import './Login.css'
import { Link } from 'react-router-dom';
import { FormControlLabel, Checkbox } from '@mui/material';

// import { useDispatch } from 'react-redux';
// import { loginUser } from '../../_actions/user_action';
import * as UserAPI from '../../api/Users';
import { Message } from '../../component/Message';
import { useNavigate } from 'react-router-dom';

function Login(props) {
    const [idVal, setIdVal] = useState("");
    const [pwVal, setPwVal] = useState("");
    const [checked, setChecked] = useState(false);
    // const { state } = useLocation();  //이전페이지에서 받은 type값(user인지 manager인지)
    const navigate = useNavigate();
    // const dispatch = useDispatch();
    
    const handleCheckBox = (event) => {
        setChecked(event.target.checked);
      };

    const handleBtn = (event) => {
        // event.preventDefualt();
        let isKeptLogin = '';
        if (checked) {
            isKeptLogin = 'Y'
        } else {
            isKeptLogin = 'N'
        }
        const data = {
            loginId: idVal,
            password: pwVal,
            type: 'USER',
            isKeptLogin: isKeptLogin,
        }
        UserAPI.login(data).then(response => {
            props.loginCallBack(true);
            navigate('/');
        }).catch(error => {
            console.log(JSON.stringify(error));
            Message.error(error.message);
        });
        // redux사용
        // dispatch(loginUser(data))
        //     .then(response => {
        //         if(response.payload.loginSuccess){
        //             props.history.phsh('/')
        //             // navigate("/");
        //         } else{
        //             alert('Error');
        //         }
        //     })
    }

    return (
        <div className="login-contain">
            <div className="login-content">
                <div >
                    <div className="login-header img-class">
                        <img src={"/images/logo.png"} id="img-id" alt="로고이미지" />
                    </div>
                    <div className="login-header">
                        <p style=>지금 에브리데이를 시작해보세요!</p>
                    </div>
                </div>
                <input type="id" className="login-input" placeholder="아이디"
                    value={idVal} onChange={(e) => { setIdVal(e.target.value) }} />
                <input type="password" className="login-input" placeholder="비밀번호"
                    value={pwVal} onChange={(e) => setPwVal(e.target.value)} />
                <button onClick={handleBtn} type="submit" id="login-btn">로그인</button>
                <div>
                    <div className="login-footer">
                        <FormControlLabel control={<Checkbox value="remember" color="default" size="small" checked={checked} onChange={handleCheckBox} />}
                            label="로그인 유지" />
                    </div>
                    <div className="login-footer">
                        <Link id="forgot-link" to='/forgot'>아이디/비밀번호 찾기</Link>
                    </div>
                </div>

                <div>
                    <p style=>에브리데이에 처음이신가요? <Link id="register-link" to='/register'>회원가입</Link></p>
                </div>
            </div>
        </div>
    )
}

export default Login


Users.jsx


login.jsx에서 바로 Axios 요청을 보내지 않고 Users.jsx에 모두 정의해놓았다. 또한 NewPromise 컴포넌트로 한번더 감싸놓았는데 NewPromise의 코드는 아래에 있다.

import Axios from "../component/Axios/Axios";
import { NewPromise } from "../component/Common";

//이메일인증
export const authenticate = (data) => NewPromise(Axios.post('/email-authenticate', data));
//이메일인증 코드 확인
export const authenticateCodeCheck = (data) => NewPromise(Axios.post('/check-authenticationcode', data));

//가입
export const join = (data) => NewPromise(Axios.post('/users', data));

//로그인
export const login = (data) => NewPromise(Axios.post('/login', data));
//로그아웃
export const logout = () => NewPromise(Axios.get('/logout'));
//탈퇴
export const resign = () => NewPromise(Axios.delete('/users'));

.
.
.


Axios.jsx

Axios 컴포넌트를 생성하여 axios를 사용할때마다 헤더를 매번 넣지 않기 위해, 에러가 발생했을때 공통으로 처리하기 위해 Axios 요청 관련한 설정값은 이 파일에 모두 정리해두었다. 그리고 요청하기 직전에 interceptors를 사용하여 localStorage에서 SESSION_TOKEN_KEY 값을 가져와 header에 토큰을 넣어서 서버에 보낼 수 있게끔 하였다. 이로써 매번 헤더에 토큰을 넣어 서버에 보내는 일을 공통화하였다.

import CommonAxios from 'axios';

const Axios = CommonAxios.create({
    // timeout: 30000,
    headers: {
        'Content-Type': 'application/json; charset=UTF-8;',
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Headers': '*',
    },
});

if (process.env.NODE_ENV === 'development') {
    Axios.defaults.baseURL = 'http://localhost:8080'
}

export const SESSION_TOKEN_KEY = "__EVERYDAY__auth__";

Axios.interceptors.request.use(function (config) {
    const token = localStorage.getItem(SESSION_TOKEN_KEY);  //api요청시 토큰키 값 넣어서 요청
    // config.headers.Authorization = "Bearer" + token;
    config.headers.Authorization = token;
    config.data = JSON.stringify(config.data);
    return config;
});
.
.
.
export default Axios;


Common.jsx

이 파일에서 NewPromise를 export하였고 api요청이 성공했을 때, config의 url이 로그인 관련된 path이면 localStorage에 SESSION_TOKEN_KEY 키값으로 서버에서 받은 response의 headers의 토큰값을 저장하도록 하였다.

import * as CommonJs from "../lib/Common";
import {SESSION_TOKEN_KEY} from '../component/Axios/Axios';
export const NewPromise = (promise) => {
    return new Promise(function (resolve, reject) {
        promise
            .then((response) => {
                if (200 === response.status) {
                    if (response.config.url === '/login' || response.config.url === '/adminlogin') {
                        localStorage.setItem(SESSION_TOKEN_KEY, response.headers.authorization);    //토큰키에 응답받은 토큰값 set
                    }
                    resolve(response.data);
                } else {
                    reject({error: {}, message: response.statusText});
                }
            })
            .catch((error) => {
                const errorMessage = CommonJs.extractErrorMessage(error);
                reject({error: error, message: errorMessage});
            });
    });
};


ModalContainer.jsx

로그아웃 코드로, 로그아웃과 탈퇴하기 모두 localStorage.removeItem()을 통해 토큰값을 지다.

const handleListItemClick = (event, idx) => {
        if (Number(idx) === 0) { //내가 쓴 글
            navigate('/myarticle', {state: {headTitle:'내가 쓴 글', typeId: 1}});
            props.handleClose(false);
        }
        else if (Number(idx) === 1) { //댓글 단 글
            navigate('/mycommentarticle', {state: {headTitle:'댓글 단 글', typeId: 2}});
            props.handleClose(false);
        }
        else if (Number(idx) === 2) { //좋아요 한 글
            navigate('/mylikearticle', {state: {headTitle:'좋아요 한 글', typeId: 3}});
            props.handleClose(false);
        }
        else if (Number(idx) === 3) { //로그아웃                                   //인자값으로 받은 idx가 3일 때, 로그아웃 로직 실행
            UserAPI.logout().then(response => {
                console.log(JSON.stringify(response));
                localStorage.removeItem(SESSION_TOKEN_KEY);
                loginCallBack(false);
                navigate("/");
            }).catch(error => { //만료된 토큰이거나 존재하지않는 토큰이면 강제로그아웃 
                console.log(JSON.stringify(error));
                Message.error(error.message);
                localStorage.removeItem(SESSION_TOKEN_KEY);
                loginCallBack(false);
                navigate("/");
            });
        }
        else if (Number(idx) === 4) { //탈퇴하기                                    //인자값으로 받은 idx가 4일 때, 탈퇴하기 로직 실행
            UserAPI.resign().then(response => {
                console.log(JSON.stringify(response));
                localStorage.removeItem(SESSION_TOKEN_KEY);
                loginCallBack(false);
                navigate("/");
            }).catch(error => { 
                console.log(JSON.stringify(error));
                Message.error(error.message);
            });
        }
.
.
.
<List>
  {myDataList.map(item => (
    <ListItemButton
      key={item.text}
      sx=
      onClick={(event) => handleListItemClick(event, item.idx)}       //리스트 클릭 시 handleListItemClick 함수 호출
    >
      <ListItemIcon>{item.icon}</ListItemIcon>
      <ListItemText primary={item.text} sx= />
    </ListItemButton>
  ))}
</List>
.
.
.


어려웠던 점

아무래도 로그인 정보를 어디에 담을지 어떤 라이브러리를 사용할지 정말 고민이 많았던 것 같다.
login.jsx 주석된 코드를 보면 짐작할 수 있듯이 처음엔 상태 관리 라이브러리인 Redux를 이용하여 로그인을 구현하였다.

로그인 관리와 저장을 설명하는 여러 강의와 블로그를 참고하여 최종적으로 이곳 에서 도움을 받아 코드를 작성해보았는데, 아무래도 리덕스에 대해 정확한 이해와 개념을 가지고 작성하는 것이 아니다 보니 똑같이 작성하여도 알 수 없는 이유로 제대로 구현이 되지 않았다.

결과적으로 비교적 사용이 쉬운 localStorage를 사용하여 로그인을 구현하였지만, 다음 시도에서는 리덕스를 통해 다시 구현해볼 예정이다. 완성되면 다시 포스팅해보도록 할 것이다.


localStoarage 이용한 로그인 장점 :wink:

먼저 로그인 정보인 토큰 값, JWT를 저장하기에 CSRF 공격에는 안전하다.
CSRF(Cross Site Request Forgery)
: 정상적인 request를 가로채 백엔드 서버에 변조된 request를 보내 악의적인 동작을 수행하는 공격을 의미(피해자 정보 수정, 정보 열람)
→ CSRF 자세히 보고싶다면?

그 이유는 자동으로 request에 담기는 쿠키와는 다르게 js 코드에 의해 헤더에 담기므로 XSS를 뚫지 않는 이상, 공격자가 정상적인 사용자인 척 request를 보내기가 어렵다.


localStoarage 이용한 로그인 단점 :disappointed:

하지만, XSS에 취약하여 공격자가 localStorage에 접근하는 Js 코드 한 줄만 주입하면 localStorage를 공격자가 맘대로 드나들 수 있다.
XSS(Cross Site Scripting)
: 공격자가 상대방의 브라우저에 스크립트가 실행되도록 하여, 사용자의 세션을 가로채거나 웹사이트를 변조하거나 악의적 콘텐츠를 삽입하거나 피싱 공격을 진행하는 것을 말함
→ XSS 자세히 보고싶다면?

따라서, 보안을 강화하기 위한 좋은 방법으로는 refresh token을 사용하는 방법도 있고 이곳1, 이곳2 를 참조할 수 있다.

마무리

구현하는데 앞서 여유를 가지고 이 방법, 저 방법 모두 시도해보지 못했던 것이 아쉬움이 남는 것 같다.

이번 프로젝트에서는 access token만 사용해보았지만 다음 번엔 refresh token을 사용하여 ‘로그인 유지’ api요청을 보낼때 사용자의 로그인 유지 유효기간이 지나면 refresh token을 request에 담아 새로운 access token을 발급받아 진행할 수 있도록 해보고 싶다.

아쉬움이 남는 부분이었지만 결과적으로 localStorage로는 구현을 잘 마무리할 수 있었고 그래도 이러한 기회를 통해 로그인 구현에 대한 다양한 방법에 대해서도 많이 알아볼 수 있는 시간이었어서 의미있는 시간이 되었던 것 같다.


→ [에브리데이] 프로젝트 GitHub 보러가기

:page_with_curl: Acknowledgments