어제 쿠키, 세션에 대한 이해가 너무 부족해 불안감에 거의 다섯시간 가까이 삽질을 하여 이해를 하였다. 완벽히 이해는 되지 않았지만, 그래도 어떻게 돌아가는지 정도는 설명할 수 있다. 이번 쿠키를 공부하면서 cors에 대한 이해도가 높아졌고 모듈들의 전반적인 사용법을 익힐 수 있는 기회가 되었다.
먼저 이 모든 것은 HTTP의 connectionless, statelss 하다는 단점을 보완 하기위해 만들어진 것들이다. 예를 들자면, 우리가 로그인을 하고 다른 페이지를 이동할 때 로그인이 유지 되는 것들, 장바구니에 담았던 물건이 다른 페이지를 다녀와도 저장되어있는 것도 쿠키, 세션, 토큰 덕분이다. 다시 말해 HTTP 자체만으로 이러한 상태를 갖기가 불가능 하다는 것이다.
요번에 진행한 스프린트를 예를 들어 설명을 해보겠다.
간단히 말하자면 유저는 클라이언트를 통해 정보(예를들어 로그인 시 아이디 패스워드)를 주고 서버에선 그에 부합한 데이터베이스가 존재하면 쿠키를 헤더에 포함시켜 응답한다, 그 뒤로 클라이언트로 요청시 헤더에 쿠키가 같이 전송이 되는데 그 것을 기반으로 서버는 사용자가 지난 요청과 같은 사용자인지 구별한다.
1) 쿠키 자체에 값이 담기기 때문에 요청 속도가 빠르다.
2) 쿠키 자체에 값이 담겨있고, 클라이언트에 저장되기 때문에 보안면에서 취약하다.
3) 쿠키는 파일로 저장되기 때문에 만료 또는 직접 삭제하기 전까지 유지된다. (브라우저를 종료해도)
4) 클라이언트 최대 300개, 도매인 하나당 최대 20개, 하나의 쿠키 값은 최대 4kb까지 저장이 가능하다
express를 기준으로 설명할 것이다.
app.use(cors(
{
origin: 'https://localhost:3000',
credentials: true,
methods: ['GET', 'POST', 'OPTIONS']
}
));
cors설정이 선행이된다. credentials는 자격증명을 이용할 것인가에 대한 설정이며 클라이언트에서 자격증명에 대한 설정이 되어 있으면 꼭 필수로 설정해 주어야한다. 그리고 이것을 true로 했을 때 꼭 origin을 특정 해주어야 한다.
// 로그인 라우터를 통한 요청을 처리하는 함수
post: async (req, res) => {
let userInfo = await Users.findOne({
where: { userId: req.body.userId, password: req.body.password },
}); // (1)
if (!userInfo) {
res.status(400).json({message: 'not authorized'});
} else {
res.status(200)
.cookie('id', userInfo.id , {
domain: 'localhost',
path: '/',
httpOnly: true,
secure: true,
sameSite: 'None',
maxAge: 30000
}) // (2) 쿠키관련 메소드를 이용하려면 cookie-parser 모듈이 있어야한다.
.json({message: 'ok'})
}
},
(1) 에서 요청에 담긴 아이디와 패스워드를 데이터베이스에서 비교하여 찾는다. 만약 요청에 대한 정보가 없다면 상태코드 400을 돌려준다. 만약 있다면
(2) .cookie() 메소드를 이용하여 쿠키를 담아서 보내준다. 인자는 ('쿠키이름', '쿠키값', '옵션') 으로 보내어진다. 여기서 httpOnly는 document.cookie를 이용해 쿠키를 탈취할 수 있는 경우를 차단하기 위해 http로 한정하는 것이다. secure 옵션은 https에서만 통신이 가능하게 만드는 것이며, maxAge는 밀리세컨 단위로 만료기간을 설정할 수 있다.
위와 같이 설정이 되면 헤더에 'set-cookie'라는 속성이 담겨서 간다.
set-cookie 헤더가 담겨온다면 자동으로 브라우저에 저장이 된다.
추후에 서버에서 'req.cookies.쿠키이름'을 이용하여 사용이 가능하며, 제거도 가능하다.
// 로그아웃 라우터를 처리하는 함수
post: (req, res) => {
if (!req.cookies.id)
res.status(400).json({ data: null, message: 'not authorized' });
else {
res.clearCookie('id'); // 로그아웃 시 쿠키 제거
res.json({ data: null, message: 'ok' });
}
},
위에서 직접 console.log(req.cookies)를 해보면 더욱 이해가 편하다. 그리고 clearCookie('쿠키이름') 메소드를 쓰면 제거도 가능하다. 브라우저를 종료해도 남아있는 쿠키의 특징 때문에 중요한 정보가 담긴 쿠키는 로그아웃 시 제거하게 만든다.
세션은 유저의 정보를 세션 객체를 이용하여 서버에서 보관을하고, 세션 아이디를 부여해준다. 세션 아이디를 부여하는 방법은 쿠키를 이용해 헤더에 실어보낸다. 쿠키와의 차이는 클라이언트가 서버로부터 받은 세션을 헤더에 같이 실어보내 서버에서 세션아이디를 비교를 하여 정보를 찾는 것이다.
1) 세션 자체에 값이 담겨있지 않고 서버에 저장되어 있기 때문에 보안에 유리하다.
2) 세션은 브라우저가 종료되면 사라져 휘발성이 강하다. 물론 보안에도 유리하다.
3) 세션 객체를 이용하여 서버에 저장되기 때문에 많은 양의 세션은 서버에 부담을 줄 수 있다.
4) 세션 아이디를 확인하여 서버의 세션 아이디와 대조해 서버에 저장된 정보를 찾는 방식으로 쿠키보다 속도가 느리다.
5) 세션이 저장된 서버에서만 사용이 가능하여, 유연하지 못한 방식이다.
const express = require('express');
const cors = require('cors');
const session = require('express-session'); // (1)
const fileStore = require('session-file-store')(session);// (2)
//생략
const app = express();
app.use(
session({
secret: '@codestates',
resave: false,
saveUninitialized: true,
cookie: {
domain: 'localhost',
path: '/',
maxAge: 24 * 6 * 60 * 10000,
sameSite: 'none',
httpOnly: true,
secure: true,
},
store: new fileStore()
}) // (3)
);
app.use(cors({
origin: 'https://localhost:3000',
methods: ['GET', 'POST', 'OPTIONS'],
credentials: true
}));
//생략
세션 설정을 더욱 쉽게 도와주는 express-session 미들웨어를 사용하였고 세션이 파일에 저장되는 것을 볼 수 있는 session-file-Store를 사용하였다. session 미들웨어를 사용해 설정을 하면된다. 쿠키를 사용하기 때문에 필요한 옵션들을 설정해준다.
// 로그인 요청시
post: async (req, res) => {
const userInfo = await Users.findOne({
where: { userId: req.body.userId, password: req.body.password },
});
if (!userInfo) {
res.status(400).json({message: 'not authorized'});
} else {
req.session.userId = userInfo.id;// (1)
res.status(200).json({message: 'ok'});
}
}
사용자 요청 정보에 일치한 데이터 베이스가 존재하면 세션을 만들어 세션객체에 정보를 할당(1) 하여 응답을 한다.
// 로그아웃 요청시
post: (req, res) => {
if (!req.session.userId) {
res.status(400).send({message: 'not authorized'})
} else {
req.session.destroy(err => {
res.clearCookie('connect.sid');
if (err) console.log(err);
res.status(200).json({message: 'ok'});
})
}
},
위와같이 req.session으로 세션 정보를 볼 수 있으며, 만약 일치하는 세션 아이디가 없거나 잘못된 요청일 경우 정보를 얻을 수 없다.
세션도 마찬가지로 제거할 수 있다. 세션을 제거함과 동시에 쿠키를 제거하게 만들었지만 사실상 세션이 제거되면 쿠키가 사용가치가 없기 때문에 쿠키가 남아 있어도 상관은 없을 것이라 생각한다.
토큰이란 회원증과 같은 개념으로 볼 수 있다. 클라이언트로 로그인할 시 서버에서 인증을 거친 다음 토큰을 내어주는 방식으로, 다음 부터 서버에 요청시 토큰을 같이 보내어 인증을 거진다.
1) 세션 방식과 달리 별도의 저장이 필요 없으므로 서버의 부담을 덜어줄 수 있다.
2) 서버가 분산이 되어 있어도 사용이 가능하여 유연하다.
3) 토큰도 저장할 필드 수에 따라 커질 수 있다.
4) 토큰이 모든 요청에 전송되면 트래픽 크기에 영향을 미칠 수 있다.
JWT(Jason Web Token)을 기준으로 설명하자면,
먼저 JWT 형식은 '헤더.정보.서명' 의 형식으로 이루어진다. 헤더에는 토큰타입, 해싱 알고리즘을 명시해주고, 정보에는 말 그대로 정보, 서명에는 헤더/정보의 인코딩 값을 비밀키로 해쉬를 하여 생성하게 해주는 것이다.
마찬가지로 cors설정 필수이다.
const jwt = require('jsonwebtoken')
//로그인 시
module.exports = async (req, res) => {
const userInfo = await Users.findOne({
where: {userId: req.body.userId, password: req.body.password}
});
const makeToken = (secret, expiresIn) => {
return jwt.sign({
id: userInfo.id,
userId: userInfo.dataValues.userId,
email: userInfo.dataValues.email,
createdAt: userInfo.dataValues.createdAt,
updatedAt: userInfo.dataValues.updatedAt
}, secret, { expiresIn })
}
if (!userInfo) {
res.status(400).json({message: 'not authorized'});
} else {
const accessToken = await makeToken(process.env.ACCESS_SECRET, '5s');
const refreshToken = await makeToken(process.env.REFRESH_SECRET, '24h');
const newResponse = {accessToken: accessToken};
return res.status(200).cookie('refreshToken', refreshToken, {
domain: 'localhost',
path: '/',
httpOnly: true,
secure: true,
sameSite: 'None',
})
.json({data: newResponse, message: 'ok'});
}
// TODO: urclass의 가이드를 참고하여 POST /login 구현에 필요한 로직을 작성하세요.
};
먼저 express에서 토큰을 이용하기위해 jsonwebtoken모듈을 설치하고 불러와 사용을 하면된다. .sign()이라는 메소드를 통해 쉽게 토큰을 만들 수 있고. 첫번째 인자는 정보, 두 번째 인자로 들어가는 secret은 salt 즉 비밀키가 된다, 세 번째 인자는 옵션으로 나는 만료시간을 주었다. 그리고 액세스 전용토큰과 리프레시 전용토큰을 만들어 액세스 토큰은 제이슨형식 응답으로, 리프레시 토큰은 헤더에 쿠키로 실어 보낸다.
말 그대로 액세스는 서버에 요청이 필요할 때 쓰는 토큰으로 만료가 되면 리프레시 토큰으로 다시 요청을 하면된다.
위에서 보듯이 액세스 토큰이 만료 되자 요청이 거부가 되는 것을 볼 수 있고, 리프레시 토큰을 이용해 다시 발급을 받는 것을 볼 수 있다.
그럼 인코딩된 토큰을 어떻게 디코딩 할 수 있을까?
//생략
module.exports = (req, res) => {
const authorization = req.headers['authorization'];
const token = authorization.split(' ')[1];
jwt.verify(token, process.env.ACCESS_SECRET, async (err, decoded) => {
//생략
})
};
위 처럼 클라이언트에서 authorization이라는 헤더에 "Bearer 액세스토큰"(Bearer란 통신 서비스의 한 종류 라고한다.) 을 실어 보내고 서버에선 토큰만 가져와 verify 메소드를 이용하여 디코딩 할 수 있다.
(참고: https://interconnection.tistory.com/74 | https://thgus13.tistory.com/4)
서버에서 뿐만 아니라 클라이언트에서도 자격증명 설정이 필요하다. 정말정말 중요한 개념이다. 특히 cors에 대해선 다시 한번 봐야겠다는 생각이 들었다. 무한한 가능성이 있는 공간이지만 이러한 가능성이 또 무섭게 작용될 수도 있다는 생각이 들었다.
인터넷을 이용하면서 나의 전화번호부터 내 계좌 비밀번호 까지 무심코 입력하지만, 평소에 생각지도 못했는데 코딩 공부를 하면서 누군가가 이를 들여다 볼 수 있다는 무한한 가능성이 있다는 것을 알았기에 조금 불안해졌다. 그래서 자격증명, 통신규약, 인증 등등 보안에 대한 지식을 개발자로서 뿐 만 아니라 인터넷을 이용하는 사람이라면 누구나 갖추어야한다는 생각이 들었다.