팀 프로젝트를 하면서 처음으로 쿠키를 다뤄봤다.
지금까지 한다한다 해놓고 시도를 안하고 있었는데 좋은 경험이 되었다.
우리 팀은 express와 React로 백, 프론트를 구현했는데
이 둘이 쿠키를 어떻게 사용했는지 기록해놓고자 한다.
리프레쉬 토큰(refresh token)은 사용하지 않았다는 점 미리 참고해주시면 감사하겠습니다!
우선 cookie를 생성하고 전달하기 전에 어떤 방식으로 이 친구가 사용되는지 살펴보자.
우리 팀은 cookie에 JWT(access token)를 담아서 사용했다.
위와 같은 흐름으로 쿠키를 사용하게 된다.
cookie-parser
라이브러리cors
라이브러리쿠키를 생성하는 것은 백엔드에서 처리한다.
우리 팀은 로그인 시 JWT를 생성하고 이를 cookie에 담았기 때문에, API controller를 보며 설명하겠다.
// src/controllers/member-controllers.ts
export const loginUser = async (
req: Request,
res: Response,
next: NextFunction,
) => {
// ... //
let customJWT;
// JWT 생성
customJWT = jwt.sign(
{
email: response[0].email,
name: response[0].name,
generation: response[0].generation,
isAdmin: response[0].isAdmin === 0 ? 0 : 1,
},
process.env.JWT_SECRET_KEY,
{ expiresIn: '1h' },
);
// cookie 생성
res.cookie('elice_token', customJWT, {
path: '/',
domain: process.env.COOKIE_DOMAIN,
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: 60 * 60 * 1000,
});
return res.status(200).json({
token: customJWT,
email: response[0].email,
name: response[0].name,
generation: response[0].generation,
isAdmin: response[0].isAdmin === 0 ? 0 : 1,
});
} catch (err) {
res.status(500).json({ message: '로그인 처리 중 에러가 발생했습니다.' });
next(err);
}
};
로그인 과정에서 res.cookie
를 통해 쿠키를 생성하고, 클라이언트 쪽으로 전달한다.
코드를 좀 더 자세하게 살펴보자.
// elice_token이라는 cookie를 만든다.
// cookie에 customJWT를 담았다.
res.cookie('elice_token', customJWT, {
path: '/',
domain: process.env.COOKIE_DOMAIN,
httpOnly: true,
secure: true,
sameSite: 'none',
maxAge: 60 * 60 * 1000,
});
설정값에 대해서도 알아볼 필요가 있다.
설정 때문에 상당히 고생하기 때문에 사용하는 이유와 효과에 대해 숙지해놓으면 좋을 것 같다.
여러 옵션이 있지만, 우리 팀에서 사용한 옵션만 설명하고자 한다.
path
: 쿠키가 적용되는 경로를 지정한다. 기본값은'/'
이며, 이는 모든 경로에서 쿠키를 사용할 수 있도록 해준다. 예를 들어,path: '/admin'
으로 설정하면/admin
경로와/admin/*
하위 경로에서만 쿠키를 사용할 수 있다.
domain
: 쿠키의 도메인을 설정한다. 기본값은 현재 호스트의 도메인이다. 예를 들어,example.com
도메인에서 쿠키를 사용하려면domain: 'example.com'
으로 설정한다. 이렇게 설정하면하위 도메인(sub.example.com)
에서도 쿠키를 사용할 수 있다.
httpOnly
: 기본값은false
이며,true
인 경우 클라이언트 쪽에서document.cookie
로 접근할 수 없게 만든다. 따라서 JS로 건들 수 없다. 이를 통해 XSS(Cross-Site Scripting) 공격으로 쿠키가 탈취되는 것을 막는다.
secure
: 기본값은false
이며,true
인 경우 HTTPS에서만 쿠키를 사용할 수 있게 만든다. 따라서 쿠키는 암호화된 연결을 통해서만 서버로 전송되므로 네트워크 감청으로부터 보호할 수 있다. 이를 통해 중간자 공격(Man-in-the-Middle Attack)을 어렵게 만든다.
sameSite
:strict
,lax
,none
중 선택할 수 있다.
strict
: 쿠키가 동일 출처에서만 전송되게 한다.lax
: 쿠키가 일부 상황에서 다른 출처로 전송될 수 있도록 한다.none
: 쿠키가 동일한 출처에서도 전송되고, 다른 출처에서도 전송되는 상황에서 쿠키가 전송될 수 있도록 허용한다.
maxAge
: 쿠키의 유효 기간을 설정한다. 유효 기간은ms
단위로 지정된다. 예를 들어,maxAge: 60 * 60 * 1000
은 쿠키를 1시간 동안 유지하도록 설정한다. 만약 이 값을 설정하지 않으면 세션 쿠키가 생성되며, 브라우저가 닫힐 때 쿠키가 삭제된다.
어떤 두 URL을 명시한다고 했을 때 프로토콜(protocol)
, 호스트(host)
, 포트(port)
가 동일한 경우를 동일 출처라고 한다.
예시를 살펴보자.
기준 : http://store.company.com/dir/page.html
URL | 결과 | 이유 |
---|---|---|
http://store.company.com/dir2/other.html | 성공 | 경로만 다름 |
http://store.company.com/dir/inner/another.html | 성공 | 경로만 다름 |
https://store.company.com/secure.html | 실패 | 프로토콜 다름 |
http://store.company.com:81/dir/etc.html | 실패 | 포트 다름 (http://는 80이 기본값) |
http://news.company.com/dir/other.html | 실패 | 호스트 다름 |
만약, 백엔드와 프론트엔드가 같은 도메인을 사용하고 있다면 strict
를 사용해도 되겠지만,
다른 도메인을 사용한다면 lax
혹은 none
의 사용을 고려해야할 것 같다.
참고로, 별도의 설정이 없다면 http
는 80
, https
는 443
포트를 사용한다고 한다.
만약 sameSite
를 none
으로 사용한다면, secure
는 반드시 true
로 설정해야한다.
그렇지 않으면 아래와 같은 경고 문구가 보일 것이다.
block이 된다고 하니 정상적인 작동을 기대할 수 없을 것이다.
주의해서 사용하도록 하자.
쿠키의 만료를 설정하는데에는 maxAge
외에도 expires
라는 옵션이 있는데,
두 개가 어떤 차이가 있는지 짚고 넘어가자.
앞서, maxAge
는 설명했으니 expires
를 살펴보자.
expires
: 쿠키의 만료 날짜를 설정한다.expires
옵션은 날짜 객체 형식이어야 하며, 쿠키가 해당 날짜 이후에 만료된다.maxAge
와expires
중 하나를 선택하여 사용할 수 있으며,expires
를 사용하면maxAge
대신 날짜 기반의 만료 시간을 지정할 수 있다.
그렇다. maxAge
와 똑같은 기능을 하는 옵션인데...사용법만 다르다!
만약, 쿠키를 1시간 뒤에 만료하고자 한다면
res.cookie('elice_token', customJWT, {
path: '/',
domain: process.env.COOKIE_DOMAIN,
httpOnly: true,
secure: true,
sameSite: 'none',
// maxAge: 60 * 60 * 1000,
expires: new Date(Date.now() + (60 * 60 * 1000)),
});
위와 같은 방법으로 값을 설정하면 된다. Date
객체를 사용한다는 점이 다르다.
왜 쿠키 설명하다가 갑자기 cors
가 나오느냐?
여기서의 설정을 통해 클라이언트와 서버가 쿠키를 주고 받을 수 있기 때문이다.
// app.ts
app.use(
cors({
origin: [
// ... //
'http://localhost:3000',
process.env.CALLBACK_URL || '',
],
credentials: true,
allowedHeaders: ['Origin', 'X-Requested-With', 'Content-Type', 'Accept'],
exposedHeaders: ['set-cookie'],
}),
);
여기서 중요한 것은 origin
, credentials
, exposedHeaders
설정이다.
하나씩 살펴보자.
origin
은 CORS
를 해결해서 요청을 허용할 도메인을 설정하는 것이다.
CORS
에러 해결하는거..중요하긴한데, 쿠키랑 무슨 상관이에요?
가끔 개발 단계에서, 혹은 배포해서까지도 origin
에 "*"(와일드카드, wildcard)
를 사용하시는 분들이 계신다.(바로 나^^)
와일드카드를 사용하면 뭐 입력해야하는지 따질 필요없이 그냥 다 허용되기 때문에 편해서 사용한다.
그런데, 와일드카드의 사용이 쿠키 사용을 방해한다.
위 이미지는 origin
에 와일드카드를 사용하고 있는 경우에 발생하는 에러이다.
credentials
를 사용하려면 origin
에 와일드카드를 사용하면 안되는 것이다!
그럼 credentials
는 무엇이냐?
credentials
는 쿠키 등과 같은 정보를 헤더에 저장할 수 있게 해준다.
즉, 쿠키를 주고 받기 위해 설정해줘야하는 부분이
origin
이 와일드카드일 때에는 먹통이 된다는 것이다!
이런 이유가 있으니, origin
에 와일드카드를 사용하는 것은 피하도록하자.
도메인을 정확히 입력하는 것으로 합시다 :)
마지막으로, exposedHeaders
는 브라우저에 노출시킬 헤더 목록을 만드는 것인데,
set-cookie
를 추가해주지 않으면 헤더의 Set-cookie
부분이 노출되지 않아서
브라우저에서 cookie를 저장할 수 없게된다.
이제 쿠키를 생성하고 클라이언트 쪽으로 보내줬으니, 잘 들어왔는지 확인해보자!
우선, 로그인 API에서 쿠키를 생성하고 보내주므로,
Network
탭에서 로그인 API 통신 부분을 확인해보자.
Response Headers
에 Set-Cookie
가 잘 들어와 있는 것을 확인할 수 있다.
이제 Application
탭에서 쿠키가 잘 저장되어 있는지도 확인해보자.
쿠키가 잘 저장되어있는 것을 확인할 수 있다.
이제 브라우저에 저장도 했으니, API 통신에 활용하는 일만 남았다.
이 부분이 처음 공부할 때는 상당히 막막했다.
쿠키를 body
에 담아서 데이터처럼 보내는 줄 알았는데, 그게 아니었다.
설정만 해놓으면, Request Headers
에 자동으로 담겨서 보내진다.
보통 API 통신을 할 때 fetch
혹은 axios
를 사용하는데,
쿠키를 담아서 보내는 방법이 약간 다르니 둘 다 살펴보자.
const response = await fetch(
`${process.env.REACT_APP_BACKEND_ADDRESS}/admin/reservations/${date}`,
{
method: 'GET',
credentials: 'include',
},
);
credentials: 'include'
설정을 통해 쿠키를 헤더에 담을 수 있다.
const response = await axios.post(
`${process.env.REACT_APP_BACKEND_ADDRESS}/access/login`,
data,
{ withCredentials: true },
);
withCredentials: true
설정을 통해 쿠키를 헤더에 담을 수 있다.
이런 설정을 통해 API 통신을 진행하면 아래와 같이 Request Headers
가 설정된다.
이제 API 통신의 헤더에 쿠키가 담기는 것까지 확인했다.
끝!
아니다. 왜 이 고생을 하면서 쿠키를 헤더에 넣었는가...
헤더에 담겨온 쿠키를 얻고, 거기 들어있는 JWT를 얻고, JWT를 디코딩해서 유저 정보를 얻는 것이 목표다.
우리는 백엔드에서 이를 처리할 것이다.
우선, 헤더에서 쿠키를 파싱하기 위해 cookie-parser
라이브러리를 미들웨어로 등록한다.
// app.ts
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
이제, 이 미들웨어는 헤더에서 쿠키를 파싱해줄 것이다.
그리고 자동으로 req.cookies
를 통해 쿠키 값에 접근할 수 있게 만들어준다.
앞서, 쿠키에는 JWT를 담아놨다고 언급한 바 있다.
JWT에는 유저 정보가 들어있고, 이를 디코딩해서 유저의 이메일이나 관리자 여부를 판단하고자 한다.
그런데...매번 req.cookies
에 접근해서 값을 얻고, 디코딩하고, 예외 처리하면...
코드가 너무 반복될 것이다!
이를 해결하기 위해 커스텀 미들웨어를 만들어서 사용하고자한다.
// src/middlewares/check-auth.js
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
try {
// req.cookies를 통해 클라이언트에서 전달받은 쿠키에 접근할 수 있다.
const token = req.cookies.elice_token;
// 토큰이 없는 경우
if (!token) {
throw new Error('NO token!');
}
// 토큰 유효한지 검사
const decodedToken = jwt.verify(token, process.env.JWT_SECRET_KEY);
// 이메일 검증으로 로그인 여부 체크
if (!decodedToken) {
throw new Error('로그인 유저가 아닙니다.');
}
// 동적으로 req에 데이터 추가할 수 있다.
// 따라서, 이 미들웨어를 거친 라우터에서는 req.user를 통해 email, isAdmin에 접근할 수 있다.
req.user = {
email: decodedToken.email,
isAdmin: decodedToken.isAdmin,
};
next();
} catch (err) {
console.log(err);
const error = new Error('Authentication failed!');
return next(error);
}
};
이런 미들웨어를 썼다는 것을 기록하고자 적어놓은 것도 있지만, 중요한 부분이 있다.
바로 이 부분이다.
const token = req.cookies.elice_token;
맨 처음 res.cookie
로 쿠키를 생성하면서 지정했던 그 이름에 접근한다.
왜냐?
직전에 살펴본 Request Headers
의 Cookie
를 보면
elice_token=암호화된JWT값
위와 같은 형식의 값이 들어있었다.
이는 cookie-parser
를 통해 다음과 같이 변경되어 req.cookies
에 담긴다.
개발자가 일일이 =
을 기준으로 파싱하고 그런 것이 아니라, 자동으로 이렇게 담아준다.
그렇기 때문에 req.cookies.[cookie_key값]
으로 접근하는 것이다.
좀 더 나은 코드를 작성하고자 한다면 cookie의 이름(key)도 환경 변수로 관리하는게 좋을 것 같다.
이 이후에는 본인이 구현하고자 하는 방향에 따라 자유롭게 코드를 작성해나가면 될 것이다 :)
동일 출처 - MDN docs
res.cookie - express docs
res.cookie - tutorialspoint 게시글
kjwsx23님 블로그
charles098님 블로그
ssmin님 블로그
inpa님 블로그
wangmin님 블로그
stackoverflow 질문글 1
stackoverflow 질문글 2
stackoverflow 질문글 3
stackoverflow 질문글 4
stackoverflow 질문글 5
식사는 없어 배고파도
음료는 없어 목말라도