쿠키와 세션만을 이용한 중복 로그인 방지를 구현할 때에는 몇가지 단점이 있었다.
로그인을 한 상태에서 ( 세션은 유효 ) 사용자가 쿠키를 임의로 삭제할 경우
기존 로그인 된 브라우저에서 ( 세션은 유효 ) 로그아웃을 안하고 다른 브라우저에 접속할 때
이러한 상황에서 사용자는 분명 로그아웃을 한거 같은데 이미 접속중인 사용자라고 뜰 것이다.
물론 개발자야 왜 그런지 이유를 알지만 쿠키와 세션에 관한 이해도가 없는 일반 사용자같을 경우엔 이유를 모르기 때문에 문제가 발생한다.
하지만 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가지 있다.
쿠키를 삭제한 뒤 재접속 할 경우 ( 세션 쿠키 자체는 존재하지만 임의로 만들어준 uid 데이터가 없다. )
로그아웃 한 뒤 재접속 할 경우 ( 세션 쿠키 자체가 존재하지 않다. )
첫번째의 경우 데이터가 사라진 상태기 때문에 세션을 생성해주어야 하고, 두번째는 필요없다.
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)
}
})
사용 도중 로그아웃이 될 경우의 수는
사용 도중 다른 곳에서 로그인을 했을 경우
사용 도중 쿠키를 삭제할 경우
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 }); // 클라이언트에 결과 반환
}
})
쿠키가 삭제되거나 로그아웃을 했을 경우엔 데이터베이스에서 해당 값을 아예 삭제해버리고
세션 또한 삭제해준다.