728x90
반응형

쿠키와 세션만을 이용한 중복 로그인 방지를 구현할 때에는 몇가지 단점이 있었다.

  • 로그인을 한 상태에서 ( 세션은 유효 ) 사용자가 쿠키를 임의로 삭제할 경우
  • 기존 로그인 된 브라우저에서 ( 세션은 유효 ) 로그아웃을 안하고 다른 브라우저에 접속할 때

이러한 상황에서 사용자는 분명 로그아웃을 한거 같은데 이미 접속중인 사용자라고 뜰 것이다.

물론 개발자야 왜 그런지 이유를 알지만 쿠키와 세션에 관한 이해도가 없는 일반 사용자같을 경우엔 이유를 모르기 때문에 문제가 발생한다.

하지만 jwt를 생성한 뒤 세션에 토큰에 대한 내용을 담고, Next.js의 SSR에서 자신이 가지고 있는 토큰과 세션이 가진 토큰이 같은지 확인하고 다르면 ( 다른 사용자가 로그인을 시도하고 토큰의 내용을 수정할 경우 ) 로그아웃을 진행하면 이러한 문제를 사전에 방지할 수 있다. 

 

 

1. 먼저 서버에 필요한 npm 패키지를 설치한다.

$npm i jsonwebtoken

 

2. secretKey를 위해 파일을 만들고 다음과 같이 작성해준다.

config/jwt.js

let jwtObj = {};

jwtObj.secret = "key"

module.exports = jwtObj

 

3.  jwt 사용이 필요한 (로그인 api) 라우터에 선언을 해준다.

const jwt = require('jsonwebtoken');
const secretObj = require('../config/jwt');

 

4. 세션을 위한 준비를 해준다.

https://typo.tistory.com/entry/MySQL-%EC%84%B8%EC%85%98%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%A4%91%EB%B3%B5-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%A7%80-with-sequelize

 

MySQL | 세션을 이용한 중복 로그인 방지 with sequelize

회사에서 사용자들 수에 대한 제한을 두어서 중복 로그인 방지를 구현하게 되었다. 쿠키는 브라우저에만 남기에 중복 로그인을 쿠키로만으로는 구현할 수 없다. 세션은 데이터베이스에 저장이

typo.tistory.com

 


db 조회 시 필요한 sequelize ( 자세한 내용은 블로그 참조 바랍니다. 이번 포스트에서는 raw query를 사용했습니다. )

const { QueryTypes, sequelize } = require('sequelize');
const db = require('../sequelize/models');

 


 

5. login 라우터에  token을 세션과 쿠키에 주입하는 문구를 추가해준다.

router.get("/login", function (req, res, next) {

	// 로그인 정보가 있는지 확인 
    
    	...
        
    if(로그인정보가 없으면) {
    		return res.send({success : false});
    }
    else {
       
	// 조회한 로그인 정보가 세션이 존재하는지 확인
    const select_data_query = `SELECT JSON_EXTRACT(data, '$.token') as token FROM SESSIONS WHERE JSON_EXTRACT(data, '$.uid') = :uid`

          db.sequelize.query(
            select_data_query,
            {
              replacements: { uid: 로그인정보.user_id }, // 위에 로그인 정보 있는지 확인해서 취합한 object ( db에 user_id로 저장되있음 )
              type: QueryTypes.SELECT
            }
          ).then((result) => {
            // 세션이 존재하지 않을 경우
            if(result.length === 0) {
            	const cookie = {
                	uid: 로그인정보.user_id
                }
                
                // jwt 토큰 생성
  		const token = jwt.sign(
    		{ isLogined: cookie },
    		secretObj.secret,
    		{ expiresIn: '86400000' }
  		);
        
        	// 쿠키 생성
		res.cookie('isLogined', token, { maxAge: 86400000, path: "/", httpOnly: true }); // jwt 토큰을 쿠키에 담아서 생성
  		
        	// 세션 생성
        	req.session.uid = 로그인정보.user_id; 
  		req.session.token = token;
                
                res.send({success : true})
            }
            // 세션이 존재할 경우
            else {
            	return res.send({success : false, result : "isLogined"})
                }
          })
     }
});

 

 

6. client에서 세션이 존재할 경우 alert 창을 띄우고 기존 사용자를 로그아웃 할 것인지 물어보는 로직을 구성한다.

  const loginSubmit = (e: any) => {
    e.preventDefault();

    axios.post('/api/session/login', {
      user_id: id,
      user_password: pw
    }).then(({ data }) => {
      if (data.success) {
        router.push('/view/labor');
      }
      else if (data.result === "isLogined") {
        if (window.confirm('이미 로그인중인 사용자가 있습니다. 접속을 해제하시겠습니까?')) {
          axios.post('/api/session/disconnect', {
            user_id: id,
            user_password: pw
          })
            .then(({ data }) => {
              if (data.success) {
                router.push('/view/labor');
              }
            })
        }
      }
      else {
        alert('존재하지 않는 사용자입니다.');
      }
    })
  };

 

7. 만약 이미 접속중인 사용자를 로그아웃하고 본인이 로그인하길 원하면 세션 내의 토큰을 재발급해준다.

router.post('/disconnect', function (req, res, next) {

  // 로그인 정보가 있는지 확인 
    
    	...

  if(로그인정보가 없으면) {
    return res.send({success : false});
  }
  else {
  
  	const cookie = {
    		name: 로그인정보.name // 위에 로그인 정보가 있는지 확인한 곳에서 가져온 데이터
    	}
        
        // 토큰 발급
        const token = jwt.sign(
        { isLogined: cookie },
        secretObj.secret,
        { expiresIn: '86400000' }
      );
        
        // 세션 db를 조회하여 전에 발급한 토큰이 있는지 확인
        
        const select_data_query = `SELECT JSON_EXTRACT(data, '$.token') as uid FROM SESSIONS WHERE (JSON_EXTRACT(data, '$.uid') = :uid AND JSON_EXTRACT(data, '$.token') = :token)`

      db.sequelize.query(
        select_data_query,
        {
          replacements: { uid: 로그인정보.user_id, token: token },
          type: QueryTypes.SELECT
        }
      ).then((result) => {
        // 쿠키를 임의로 삭제하고 다시 로그인할 경우엔 세션 쿠키 안에 토큰 내용이 없다. 세션을 만들어주어야 한다.
        if (result.length === 0) {

          // 기존에 있던 세션의 token 값을 바꿔준다.
          let sql_update_data = "UPDATE SESSIONS SET data = JSON_SET(data, '$.token', :token) WHERE JSON_EXTRACT(data, '$.uid') = :uid";

          db.sequelize.query(
            sql_update_data,
            {
              replacements: { uid: 로그인정보.user_id, token: token },
              type: QueryTypes.UPDATE
            }
          ).then((result) => {
            console.log("result : ", result)
          });

          req.session.uid = 로그인정보.user_id; // 세션 생성
          req.session.token = token;

          res.cookie('isLogined', token, { maxAge: 86400000, path: "/", httpOnly: true }); // jwt 토큰을 쿠키에 담아서 생성

          res.send({ success: true })


        }
        // 쿠키를 임의로 삭제하지 않았을 경우엔 세션을 따로 만들 필요가 없다.
        else {
          // 기존에 있던 세션의 token 값을 바꿔준다.
          let sql_update_data = "UPDATE SESSIONS SET data = JSON_SET(data, '$.token', :token) WHERE JSON_EXTRACT(data, '$.uid') = :uid";

          db.sequelize.query(
            sql_update_data,
            {
              replacements: { uid: 로그인정보.user_id, token: token },
              type: QueryTypes.UPDATE
            }
          ).then((result) => {
            console.log("result : ", result)
          });

          res.cookie('isLogined', token, { maxAge: 86400000, path: "/", httpOnly: true }); // jwt 토큰을 쿠키에 담아서 생성

          res.send({ success: true })

        }
      })
  }
})

로그인 정보가 존재할 경우에 접속을 해제할 경우의 수가 2가지 있다.

  1. 쿠키를 삭제한 뒤 재접속 할 경우 ( 세션 쿠키 자체는 존재하지만 임의로 만들어준 uid 데이터가 없다. )
  2. 로그아웃 한 뒤 재접속 할 경우 ( 세션 쿠키 자체가 존재하지 않다. )

첫번째의 경우 데이터가 사라진 상태기 때문에 세션을 생성해주어야 하고, 두번째는 필요없다.

 

 

8. getServerSideProps에서 쿠키가 없을 경우 redirect 시킨다.

( 로그인중이 아니였고 쿠키를 제거하고 로그인을 시도할 경우 방지)

export const getServerSideProps: GetServerSideProps = async (context) => {
  const isLogined = context.req.cookies.isLogined ? true : false;
  
  // 쿠키가 존재할 경우
  if (isLogined) {
  
  // 쿠키 안의 토큰을 decoded한 값
  const tokenValue = parseJwt(context.req.cookies.isLogined);
  
  	return {
          props: {
            cate,
            tokenValue
          },
        };
  
  }
  // 쿠키가 존재하지 않을 경우
  else {
  	return {
      	 redirect: {
        	permanent: false,
        	destination: "/",
      	},
    };
  }

 

8. 클라이언트에서 useEffect hook으로 접속한 사용자의 정보를 계속 확인해준다.

(자신이 발급한 토큰이 세션에 있는 토큰과 일치한지, 로그인 해 둔 상태에서 쿠키를 제거했을 때 redirect)

  useEffect(() => {
    axios.post('/api/session/islogined')
      .then(({ data }) => {
        if (data.success) {
          alert('다른 곳에서 로그인하여 로그아웃 됩니다.')
          axios.get('/api/session/isloginedout')
          router.push('/')
        }
        else if (data.result === 'notcookie') {
          alert('쿠키가 삭제되어 로그아웃 됩니다.')
          axios.get('/api/session/logout')
          router.push('/')
        }
        else {
        }
      })
  })

 

9. 세션 안에 내가 발급했던 토큰 값이 잘 들어가있는지 확인한다. ( islogined router )

// client SSR에서 세션에 로그인 데이터가 있는지 확인한다.
router.post('/islogined', function (req, res, next) {
  const cookie = req.cookies;

  try {
    // 쿠키 또는 세션이 존재하지 않을 경우
    if (!cookie.isLogined) {
      return res.send({ success: false, result: "notcookie" })
    }
    else {
      // 쿠키가 존재할 경우 브라우저 세션(쿠키) 안의 토큰을 decoded하여 db의 세션 안의 token값을 비교해본다.
      const verify = jwt.verify(
        cookie.isLogined,
        secretObj.secret,
        { expiresIn: '86400000' }
      )


        const select_data_query = `SELECT JSON_EXTRACT(data, '$.token') as data FROM SESSIONS WHERE (JSON_EXTRACT(data, '$.uid') = :uid AND JSON_EXTRACT(data, '$.token') = :token)`

	// 내가 발급했던 uid와 token이 잘 있는지 확인한다. ( 다른 사용자가 토큰을 재발급하지 않았는지 )
        db.sequelize.query(
          select_data_query,
          {
            replacements: { uid: verify.isLogined.user_id, token: cookie.isLogined },
            type: QueryTypes.SELECT
          }
        ).then((result) => {
          if (result.length === 0) {
            return res.send({ success: true });
          }
          else {
            return res.send({ success: false })
          }
        })
    }
  }
  catch (err) {
    console.log(err)

  }
})

 

 

 사용 도중 로그아웃이 될 경우의 수는

  1. 사용 도중 다른 곳에서 로그인을 했을 경우
  2. 사용 도중 쿠키를 삭제할 경우

2가지가 있다.

10. 각각의 경우를 고려하여 라우터를 만들어준다.

1) 다른 접속자가 로그인을 할 경우

// 다른 접속자가 로그인을 할 경우
router.get('/isloginedout', function (req, res, next) {

    // 토큰만 삭제
    delete req.session.token;

    // 쿠키 삭제
    res.clearCookie('isLogined');

    res.send({ success: true }); // 클라이언트에 결과 반환
})

쿠키와 토큰을 삭제해준다.

 

2) 쿠키가 삭제되거나 로그아웃을 시도할 경우

router.get('/logout', function (req, res, next) {
  console.log("req. : ", req.session)

  if (req.session.uid) {

    const delete_data_query = `DELETE FROM SESSIONS WHERE JSON_EXTRACT(data, '$.uid') = :uid`

    db.sequelize.query(
      delete_data_query,
      {
        replacements: { uid: req.session.uid },
        type: QueryTypes.DELETE
      }
    ).then((result) => {

      // 세션 삭제
      delete req.session.uid;
      delete req.session.token;

      // 쿠키 삭제
      res.clearCookie('isLogined');

      res.send({ success: true }); // 클라이언트에 결과 반환

    }).catch((err) => {
      console.log(err)
    })
  }
  else {
    // 세션 삭제

    delete req.session.token;

    // 쿠키 삭제
    res.clearCookie('isLogined');

    res.send({ success: true }); // 클라이언트에 결과 반환
  }
})

쿠키가 삭제되거나 로그아웃을 했을 경우엔 데이터베이스에서 해당 값을 아예 삭제해버리고

세션 또한 삭제해준다.

728x90
반응형

+ Recent posts