짧은 기간동안 핵심적인 기능들로 이루어진 간단한 프로젝트를 진행하게 되었다.
10일 사이에 내가 개발해야 하는 기능은 크게는 이렇게 4가지이다.
여기서 회원가입/로그인에 관해서 백엔드와 직접 협업해서 기능을 만들어보는 것은 처음이어서
JWT 토큰을 주고 받고 요청 작업을 하는 과정에 대한 공부가 더 필요했다.
Access Token
과 Refresth Token
을 생성/발급Access Token
을 함께 전송대략적인 큰 과정은 이렇다.이 때, 두 토큰을 살펴보면
Access Token
: 실질적인 인증 정보를 담고 있다. 그렇기 때문에 토큰의 유효시간이 짧아 일정 시간이 지나면 금방 만료되는 토큰이다.Refresh Token
: 로그인을 지속적으로 유지할 수 있도록 한다. Access Token
이 만료되면 Refresh Token
을 서버에 보내서 그때마다 새로운 Access Token
을 발급해서 돌려주는 것이다. (*Refresh Token
사용은 옵션이다)유저 인증에서 보편적으로 이용되는 취약점은 XSS와 CSRF 두가지가 있다.
공격자가 클라이언트 브라우저에 JavaScript를 삽입해 실행하는 공격이다. 즉, 공격자의 코드가 내 사이트의 로직인 척 행동하는 것이다.
<input>
을 통해 JavaScript를 서버로 전송해 서버에서 스크립트를 실행공격자가 다른 사이트에서 우리 사이트의 API 콜을 요청해 실행하는 공격이다.
API 콜을 요청할 수 있는 클라이언트 도메인이 누구인지 서버에서 통제하지 않은 경우 CSRF에 취약해진다.
🚨 XSS 취약점을 통해 그 안에 담긴 값을 불러오거나, 불러온 값을 이용해 API 콜을 위조할 수 있다.
🚨 XSS 취약점을 통해 그 안에 담긴 값을 불러오거나, API 콜을 보내면 쿠키에 담긴 값들이 함께 전송되어 로그인한 척 공격을 수행할 수 있다.
🚨 CSRF 취약점이 있을 경우 인증 정보가 쿠키에 담겨 서버로 보내진다. 공격자는 유저 권한으로 정보를 가져오거나 액션을 수행할 수 있다.
🛡️ 쿠키에
Refresh Token
만 저장하고 새로운Access Token
을 받아와 인증에 이용하는 경우 CSRF 취약점 공격을 방어할 수 있다
Refresh Token
으로Access Token
을 받아와도Access Token
을 스크립트에 삽입할 수 없다면 유저 정보를 가져올 수 없기 때문이다.
secure
을 적용하면 https 접속에서만 동작한다🛡️ httpOnly 쿠키 방식으로 저장된 정보는 XSS 취약점 공격으로 담긴 값을 불러올 수 없다.
🛡️ 쿠키에
Refresh Token
만 저장하고 새로운Access Token
을 받아와 인증에 이용하는 경우 CSRF 취약점 공격을 방어할 수 있다
🚨 쿠키 저장 방식과 같은 이유로
Access Token
은 저장하면 안된다
어떤 저장 방식도 XSS 취약점이 있다면 보안 이슈가 존재한다 (XSS로 API 콜을 보내는 방식으로 다 뚫린다)
그러므로 유저 정보 저장 방식을 바꾸는 것만으로는 방어할 수 없고, 클라이언트와 서버에서 추가적으로 XSS 방어 처리가 필수다.
✅ <input>
에서 입력된 값이 HTML/JavaScript로 인식되지 않도록 서버에서 escape 처리를 해준다
✅ URL을 통해 JavaScript를 수행할 수 없도록 라우팅을 꼼꼼하게 관리한다
✅ JWT 유효기간을 짧게 설정
+) jwt blacklist
JWT가 공격자에 의해서 탈취되었을 경우, 사이트에서 해당 유저의 이용권을 정지시킬 수 있어야 한다.
세션아이디를 이용한 방식은 해당 아이디와 매칭되는 유저의 이용을 정지시킬 수 있겠지만, JWT의 경우에는 따로 그 유저의 이용권(JWT)을 정지시키지 못하고 해당 토큰의 만료시간이 끝날 때까지 공격자가 이용할 수 있게 된다.
이런 경우에는 서버에서 jwt blacklist를 만들어서 요청마다 jwt blacklist를 확인해서 해당 토큰을 이용할 수 없게 만드는 방법이 있기도 한다. (참고)
🥲 BUT, 이렇게 하면 JWT를 사용하는 장점이 사라진다.
이번에 같이 프로젝트할 때 백엔드 개발자분께서 http 프로토콜로 개발하셔서 http 주소로 보냈다!
http
하면 로컬호스트가 제일 먼저 생각나서 내 컴퓨터에서 서버가 돌아가야 저쪽으로 요청을 보낼 수 있는거 아닌가? 했었는데 http vs. https는 SSL 인증서의 유무에서 차이가 나고 배포하고 나면 http 주소에도 요청을 보낼 수 있는 것을 깨달았다!
secure
쿠키 전달을 하려면 프론트(React)와 로그인 API를 제공할 백엔드(서버 API)는 같은 도메인을 공유해야한다.
클라이언트에서 요청을 보내기 위해 axios 설정을 다음과 같이 해준다.
import React from "react";
import ReactDOM from "react-dom";
import axios from "axios";
import App from "./App";
axios.defaults.baseURL = "https://www.abc.com";
axios.defaults.withCredentials = true; // 쿠키를 주고받을 수 있도록 허용
🚨 유효성 검사는 프론트에서만 체크할 경우 브라우저의 개발자 도구를 통해 값을 변조하여 회피할 수 있기 때문에 원래는 프론트엔드와 백엔드 두 부분에서 모두 체크하고 넘어가는 것이 좋으나, 이번 프로젝트에서는 10일 내에 MVP 위주로 개발이 우선이기 때문에 일단은 비밀번호 검사는 프론트에서만 체크하기로 하였다..! (참고)
1. 아이디 유효성 검사
사용자 입력을 통해서 생성할 아이디의 형식과 중복여부를 서버로 요청을 보내서 확인해주어야한다.
🔍 아이디 유효 조건
const idReg =
/^(?=.*[ㄱ-ㅎㅏ-ㅣ가-힣A-Z!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?])/; // 한글, 대문자, 특수문자, 공백 체크
// 아이디 검사
const handleIdBlur = async (
e: React.FocusEvent<HTMLInputElement, Element>
) => {
const { value } = e.target;
try {
if (value.length <= 3 || value.length >= 16) {
setMessage({
...message,
idError: "4~15자의 영문 소문자, 숫자만 사용 가능합니다",
});
return;
}
if (idReg.test(value)) {
setMessage({
...message,
idError: "한글, 대문자, 특수문자, 공백은 포함할 수 없습니다",
});
return;
}
setMessage({ ...message, idError: "" });
// 요청 코드 (아이디 체크)
const res = await axios.post("/usernameValid", { username: username });
if (res.status === 200) {
setMessage({
...message,
idSuccess: "사용 가능한 아이디입니다",
});
}
} catch (e: any) {
const { response } = e.response.data;
if (response === NOT_VALID) {
alert("사용할 수 없는 아이디입니다. 다른 아이디를 입력해주세요.");
}
if (response === USERNAME_DUPL) {
alert("사용중인 아이디입니다.");
}
}
};
아이디 체크는 버튼을 눌러서 조건을 체크해줄 수도 있지만, onBlur
라는 이벤트를 이용해서 아이디 인풋창에서 포커스가 벗어났을 때 조건을 체크해줄 수 있도록 했다.
2. 비밀번호 유효성 검사
처음에는 비밀번호도 서버로 요청을 보내서 형식을 체크하고, 결과값을 받는 식으로 하려고 했었는데 이부분은 요청을 보내지 않고, 프론트에서 형식을 체크해서 넘기는 방식으로 바꾸었다.
🔍 비밀번호 유효 조건
const pwReg = /[\{\}\[\]\/?.,;:|\)*~`!^\-_+<>@\#$%&\\\=\(\'\"\s]/g; // 특수문자, 공백 체크
// 비밀번호 검사
const handlePwBlur = (e: React.FocusEvent<HTMLInputElement, Element>) => {
const { value } = e.target;
if (value.length <= 5 || value.length >= 16) {
setMessage({
...message,
pwError: "6~15자의 영문, 숫자만 이용 가능합니다.",
});
return;
}
if (pwReg.test(value)) {
setMessage({
...message,
pwError: "특수문자, 공백을 포함할 수 없습니다.",
});
return;
}
setMessage({ ...message, pwError: "" });
};
3. 회원가입 요청
회원가입 시 입력받은 아이디와 비밀번호의 <input>
상태값을 username
, password
로 설정해주었고 요청 시 data
로 묶어서 보내주었다.
이 때 보내줄 때 JSON 형태로 보내야하기 때문에 요청을 주고 받을 때 변수명은 백엔드 개발자님과 API 명세서에 적어두고 그 이름으로 보내기로 하였다!
ex) 회원가입 시 유저 아이디 - username
, 입력받은 비밀번호 - password
const [username, setUsername] = useState('');
const [password, setPasswrod] = useState('');
const onSignUp = async () => {
try {
if ([username, password].includes("")) {
alert("빈 칸을 모두 입력해주세요!");
return;
}
if (message.idError || message.pwError) {
return;
}
// 요청 코드 (회원가입)
const res = await axios.post("/signUp", { username, password });
if (res.status === 200) {
navigate("/");
}
} catch (e: any) {
const { response } = e.response.data;
if (response === NOT_VALID) {
alert(
"아이디 또는 비밀번호의 형식이 올바르지 않습니다. 다시 확인해주세요."
);
}
if (response === USERNAME_DUPL) {
alert("사용중인 아이디입니다.");
}
}
};
Access Token
은 웹 어플리케이션 내 로컬 변수에 저장해 사용한다.Authorization
헤더에 넣어 보내준다.const onLogin = async () => {
try {
// 요청 코드 (로그인)
const res = await axios.post("/login", { username, password });
const { token } = res.data;
dispatch(setToken(token));
// API 요청하는 콜마다 헤더에 accessToken 담아 보내도록 설정
axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} catch (e: any) {
// 로그인 실패 시 auth clear
axios.defaults.headers.common["Authorization"] = "";
dispatch(clearToken);
const { response } = e.response.data;
if (response === LOGIN_FAIL) {
alert("아이디 또는 비밀번호를 잘못입력했습니다. 다시 확인해주세요.");
}
}
};
현재 프로젝트에서는 Access Token로만 인증하도록 구현되어 있지만, Refresh Token 까지 사용한다면 아래와 같이 사용할 수 있다. (🔍 참고)
export const requestAccessToken = async (refresh_token) => {
return await axios
.post(`${serverURL}/token/refresh/`, {
refresh: refresh_token,
})
.then((response) => {
return response.data.access;
})
.catch((e) => {
console.log(e.response.data);
});
};
export const checkAccessToken = async (refresh_token) => {
if (axios.defaults.headers.common["Authorization"] === undefined) {
return await requestAccessToken(refresh_token).then((response) => {
return response;
});
} else {
return axios.defaults.headers.common["Authorization"].split(" ")[1];
}
};
출처