문득 FE 개발자가 신경써야 하는 보안 이슈가 있다면, 어떤 것들이 있을지 궁금해졌다. 이유는 로그인/회원가입 기능을 구현할 때 Oauth라는 기능이 있다는 걸 알게 된 순간 사용자 정보를 사이트가 갖고 있는 것 조차 굉장히 민감한 이슈이구나 라는 사실을 알게 되었기 때문이다.
그만큼 개발자가 사용자 정보를 갖고 있다면, 이것을 어떻게 관리하고 있는가?에 대한 주제는 굉장히 중요해 보인다.
때문에, 이를 위험하게 하는 보안 공격에 대해서 찾아보았고 자주 언급되는 단어들이 있었으니, 바로 XXS 공격, CSRF 공격, HTTPS였다.
첫번째 XXS 공격이란, 사용자가 웹사이트의 input 같은 곳에 악성 스크립트를 삽입하여 다른 사용자가 해당 스크립트가 포함된 사이트를 열 때 개인 정보가 탈취 되는등의 의도치 않은 동작을 유발하는 것을 의미한다.
두번째 CSRF 공격이란, 사용자가 로그인하여 세션이 살아있는 상태에서 의도치 않게 공격자가 만들어 둔 악성 사이트에 접속했을 때, 사용자의 세션을 이용한 원하지 않는 요청을 정상 서버에 보내게 하는 공격을 의미한다.
마지막으로 HTTPS이다. 기본 HTTP로 된 사이트는 모든 데이터가 요청 및 응답이 이루어질 때 문장 그대로 간다. 다시 말해, 데이터의 그 어떤 암호화도 존재하지 않다. 만약 공격자가 요청 중간에 데이터를 가로채갈 경우, 만약 해당 요청이 로그인 또는 회원가입 요청이라면 사용자가 적은 개인정보가 문자 그대로 탈취된다.
HTTPS 사이트는 데이터에 암호화가 걸려 있어 탈취된다고 해도 실제 데이터를 알 수가 없다.
Wikied에서 관리하는 사용자 정보는 다음과 같다.
웹 사이트의 보안에 대해서 찾아보다가 데이터의 민감성이라는 단어가 있는 것을 발견했다. 데이터가 얼마나 중요하고, 노출되었을 때 얼마나 큰 피해를 줄 수 있는지를 말하는 개념이라고 한다.
Wikied에서는 다행히?? 대부분의 데이터가 이런 민감성이 낮은 데이터로 구성되어 있다. 애초에 기획에서부터 대부분의 데이터가 노출되도록 되어 있기 때문이다(ex. 프로필은 다른 사람에게 보여주는 노출성 데이터이다.)
하지만 그럼에도 불구하고 노출이 되어서는 안되는 데이터가 있다면 바로 AccessToken과 RefreshToken이다.
AccessToken을 Auhtorization 헤더에 실어서 보내는 행위는 "이 사용자는 인증된 사용자입니다."라는 뜻이다. RefreshToken은 그런 AccessToken을 재갱신할 때 사용하는 토큰이다.
학원에서 준 백엔드 API가 Bearer Token 인증 방식을 채택하고 있었다. 때문에 우리는 어쩔 수 없이 모든 인증이 필요한 요청의 Authorization 헤더에 AccessToken을 실어서 보내는 방식을 따라야만 했다. token을 저장하는 방식 또한 처음 기획에는 로컬 스토리지에 저장해 사용할 것이라고 적혀있었다.
처음 보안 이슈에 대해서 까막눈이었을 때는, 기획안을 따라 로컬 스토리지에 저장해 사용했다. 하지만 로컬 스토리지는 전역 공간이기 때문에 Javascript로 쉽게 접근하다는 사실을 뒤늦게 알게 되었다.
그 때부터 다음과 같은 고민이 들었다.
1. 액세스 토큰을 어디에 저장해야 안전할까??
2. 인증 요청이 필요할 때마다 어떻게 헤더에 실어서 보낼 것인가?
javascript로 접근하지 못하게 하는 방법을 찾다보니, 공격자가 javascript 악성 코드를 삽입해 의도치 않은 동작을 하게끔 한다는 XXS 공격에 대해서도 알게 되었고, 이 둘을 동시에 방지하는 방법으로 token을 쿠키에 저장하고 httpOnly 옵션을 달아주는 방법을 찾게 되었다.
쿠키에 httpOnly 옵션을 달면 javascript로 읽고 쓰는 것이 불가능해진다.
다음과 같이 로그인할 때에 쿠키를 set하여 httpOnly 옵션과 기타 다른 옵션을 달아주는 방법을 고안해냈다.
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
try {
const response = await instance.post("/auth/signIn", req.body);
if (response.status !== 200) {
throw new Error("네트워크 응답이 좋지 않습니다.");
}
const data = response.data; // 응답 데이터
// accessToken과 refreshToken을 쿠키에 저장
res.setHeader("Set-Cookie", [
serialize("accessToken", data.accessToken, {
httpOnly: true, // 스크립트로 쿠키에 접근하지 못하게 함. -> XXS 공격을 보완, 허나 클라이언트에서 쿠키를 set,get 하지 못하게 됨. -> 서버에서 관리가 필요.
secure: process.env.NODE_ENV === "production", // 프로덕션 환경에서만 secure 옵션 사용, HTTP 연결에서도 쿠키를 전송할 수 있는 편의 제공.
sameSite: "strict",
maxAge: 60 * 60, // 생명 1시간
path: "/",
}),
serialize("refreshToken", data.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 60 * 60 * 24 * 30, // 생명 30일
path: "/",
}),
]);
(...중략)
export default handler;
부수효과가 발생했다. client에서 실행되는 컴포넌트에서 HTTP 요청을 보낼 때 Authorization 헤더에 AccessToken을 실어서 HTTP 요청을 보내야 하는데 token이 쿠키에 있으니 이를 읽을 수가 없게 되었다. 이를 어떻게 해결했을까?
Next에서는 API route라는 기능을 있다. /pages 폴더 아래 /api 폴더를 만들면 서버 측에서 실행되는 API를 만들 수 있는 기능이다.
이 때 만들어지는 서버 함수는 서버 리스 함수로 동작하며, 서버를 설정하거나 관리할 필요 없이 Next 자체에서 관리해준다.
아래 코드는 로그인 할 때 client에서 호출하는 login 함수이다.
login: async (email, password) => {
try {
const userData = await postSignIn({ email, password });
if (userData) {
set({
user: userData.user,
isLoggedIn: true,
});
return {
status: 200,
message: "로그인에 성공하였습니다.",
ok: true,
};
}
} catch (error) {
if (error instanceof AxiosError) {
return {
status: 400,
message: error,
ok: false,
};
}
}
// 로그인
export const postSignIn = async (body: PostSignInQuery) => {
const res = await proxy.post(`/api/signIn`, body);
if (res.status >= 200 && res.status < 300) return res.data;
};
client에서는 미리 만들어 둔 Next Hanlder 함수로 POST 요청을 보내고
로그인에 성공했다면, 쿠키에 token을 저장함과 동시에 httpOnly 설정을 하여 javascript로 접근하는 행위를 막아주고, sameSite는 strict로, Secure도 배포 모드일 때에만 true로 설정하여 HTTPS가 아닐 때에 자동으로 실리는 일을 방지해주는 모습이다.
(+ 다시 보니 백엔드에서는 Barer 인증 방식을 채택했기 때문에 쿠키가 자동으로 실리는 일이 없어 SameSite와 Secure 옵션은 필요가 없었다..ㅎ)
사용자가 많지 않거나 아예 없다고 할지라도 프로젝트를 할 때 보안 이슈를 신경쓰는 버릇은 좋은 습관이라고 생각한다. 그렇기 때문에 이렇게 포스팅까지 작성하고 있는 것 같다.
재밌었다. Next 함수를 만들어 프록시 서버처럼 동작하게 하는 것도 처음 해봤고, AccessToken이 무엇인지, RefreshToken이 무엇인지, 생명 기한은 얼마나 주어야 하며, 둘의 중요도가 얼마나 높은지도 프로젝트를 하면서 체감하게 되었다.