치춘짱베리굿나이스
Access Token, Refresh Token 본문
Access Token, Refresh Token
토큰을 이용한 로그인 방식 중 가장 널리 쓰이는 Access Token
& Refresh Token
방식이다
옛날에는 Access Token
만을 사용했으나 토큰이 탈취당할 위험이 커 Refresh Token
을 같이 사용하여 보안성을 높이게 되었다~~ 라 카더라
이번에 Access Token
과 Refresh Token
을 이용한 로그인 유지는 구현 성공했으나, 그 생명주기나 각각의 역할에 관해 잘 모르고 구현했다는 티가 많이 나서 ㅡ,,ㅡ;; (리뷰 받으면서 많이 느꼈다) 싹 정리를 하고 가는 것이 좋을 것 같다
각 토큰에 관하여
Access Token
말 그대로 접근용 토큰이다
내가 누구인지 서버에 인증을 완료했을 경우 서버에서 클라이언트로 발급해주며, 이 토큰을 헤더에 실어 보내면 서버 측에서는 아 얘가 누구구나 알게 되어 적절한 권한을 내려줄 수 있다
쉽게 말해 권한 인가용 토큰이라고 보면 된다
- JWT 형식을 따른다
Access Token
에 들어있는 정보 (페이로드) 는 서버가 어떻게 구성하느냐에 따라 다르지만, 비밀번호는 안 넣는 것이 좋다 (탈취되기 매우 쉬우므로)- 유효기간을 짧게 둔다
Refresh Token
새로고침용 토큰이라는 이름에서 알 수 있듯, Access Token
이 만료되었을 경우 재발급을 위한 토큰이다
Access Token
이 탈취당하면 계정이 도용당하거나 개인정보가 탈취될 위험이 매우 큰데, 그렇다고 Access Token
의 유효기간을 매우 짧게 두면 로그인 유지 시간이 그만큼 짧아지므로 사용자 경험이 💩 이 된다
뭐 하려고만 하면 갑자기 로그아웃이 되어버린다고 생각해 보자… 매우 불편하기 그지없다
따라서 로그인을 유지하되, Access Token
의 유효기간은 짧게 설정하기 위해서 도입한 것이 Refresh Token
이다
Access Token
과 마찬가지로 JWT 형식을 따른다- 클라이언트만 가지고 있는
Access Token
과 다르게 서버의 세션 또는 데이터베이스에도 저장해둔다 - 대개 페이로드에 유의미한 정보는 거의 없다
- 유효기간이
Access Token
에 비해 길다
Access Token - Refresh Token을 이용한 로그인 흐름
Access Token만 사용할 경우
- 사용자가 로그인을 한다 (서버에 로그인 요청을 보낸다)
- 사용자가 누구인지 서버에서 인증 절차를 거친다
- 인증에 성공하면, 사용자 정보 일부를 담은
Access Token
을 발급하여 클라이언트에 응답으로 보낸다 - 인증에 실패하면, 로그인에 실패했음을 클라이언트에 응답으로 알린다
- 인증에 성공하면, 사용자 정보 일부를 담은
- 클라이언트는 응답받은 토큰을 쿠키 등에 저장한다
- 로컬 스토리지에 저장하기도 하지만, 대개 쿠키에 저장하여 헤더에 손쉽게 포함시킨다
- 로그인 권한이 필요한 서비스에 접근할 경우, 클라이언트는 서버에 요청을 보낸다
- 서버는 클라이언트 요청의 헤더에 들어있는 토큰을 검증한다
- 검증에 성공하면, 다음 서비스에 접근할 수 있는 권한을 인가하여 클라이언트가 원하는 동작을 수행할 수 있도록 한다
- 검증에 실패하면 (토큰이 만료되었거나 손상되었을 경우), 페이지 접근 권한이 없음을 응답으로 알린다
- 클라이언트는 서버의 응답에 따라 서비스에 접근한다
- 페이지 접근 권한이 없을 경우, 로그인을 다시 진행한다 (1번으로 되돌아감)
- 로그아웃을 하면
Access Token
을 만료시킨다
위 방식의 단점
Access Token
이 탈취되었을 경우, 누군가 나의 토큰을 악용할 수 있다- 유효기간이 지나기 전까지 해당 토큰은 누구나 사용가능하므로 위험하다
- 서버는 이 토큰이 내가 보낸 게 맞는지, 아니면 제3자가 탈취해서 보낸 것인지 알 수 없기 때문에, 토큰의 유효성 검증만 마치면 응답을 그냥 보내준다
- 따라서 한번 토큰이 탈취당하면 조치를 취할 방법이 없다
Access Token
의 탈취를 막기 위해 유효시간을 짧게 설정하면, 로그인 유지가 되지 않으므로 사용자에게 불편을 초래한다- 로그인하고 화장실만 갔다왔을 뿐인데 로그인이 풀려있으면 매번 로그인을 다시 해줘야 하는 불편이 따른다
- 탈취를 완벽하게 막고 싶다면 유효기간을 정말 짧게 설정하는 수밖에 없고, 유효기간이 짧아질 수록 사용자 편의에 악영향을 끼친다
Access Token과 Refresh Token을 둘 다 사용할 경우
- 사용자가 로그인을 한다 (서버에 로그인 요청을 보낸다)
- 사용자가 누구인지 서버에서 인증 절차를 거친다
- 인증에 성공하면, 사용자 정보 일부를 담은
Access Token
과,Access Token
을 재발급받기 위한Refresh Token
을 발급하여 클라이언트에 응답으로 보낸다 - 인증에 실패하면, 로그인에 실패했음을 클라이언트에 응답으로 알린다
- 인증에 성공하면, 사용자 정보 일부를 담은
- 클라이언트는 응답받은 토큰을 쿠키 등에 저장한다
- 로컬 스토리지에 저장하기도 하지만, 대개 쿠키에 저장하여 헤더에 손쉽게 포함시킨다
Access Token
과Refresh Token
둘 다 쿠키에 저장한다
- 로그인 권한이 필요한 서비스에 접근할 경우, 클라이언트는 서버에 요청을 보낸다
- 서버는 클라이언트 요청의 헤더에 들어있는
Access Token
,Refresh Token
을 검증한다Access Token
과Refresh Token
이 둘 다 유효한 경우, 다음 서비스에 접근할 권한을 인가하여 클라이언트가 원하는 동작을 수행할 수 있도록 한다Access Token
은 만료되었지만Refresh Token
이 유효한 경우,Access Token
을 재발급해주고 동시에 다음 서비스에 접근할 권한을 인가한다Access Token
은 유효하지만Refresh Token
이 만료된 경우,Refresh Token
을 재발급해주고 동시에 다음 서비스에 접근할 권한을 인가하거나, 로그인 유지가 풀린 것으로 판단하여 재로그인을 요청한다Access Token
과Refresh Token
이 둘 다 만료된 경우, 페이지 접근 권한이 없음을 응답으로 알린다
- 클라이언트는 서버의 응답에 따라 서비스에 접근한다
- 페이지 접근 권한이 없을 경우, 로그인을 다시 진행한다 (1번으로 되돌아감)
- 토큰이 재발급되었을 경우, 쿠키를 업데이트한다
- 로그아웃을 하면
Access Token
과Refresh Token
을 모두 만료시킨다
Refresh Token을 도입함으로써 보완된 점
Refresh Token
의 존재 덕에Access Token
의 유효기간을 짧게 설정해도 로그인 유지가 가능하다Refresh Token
의 유효기간을 보통 길게 (시간 단위가 아닌 일 단위로) 설정하기 때문에Access Token
이 만료되어도Refresh Token
이 살아만 있다면 언제든지 로그인 유지가 된다
Refresh Token
은 탈취당해도 페이로드에 개인 정보가 없기 때문에Access Token
탈취에 비해 상대적으로 안전하다
Refresh Token을 도입했을 때 생기는 단점
- 구현이 귀찮아진다
Access Token
과Refresh Token
이 각각 만료되었을 때, 만료되지 않았을 때의 총 4가지 경우를 모두 따져서 검증을 진행해야 한다- 또한 쿠키로 저장해야 하는 값이 2개이므로 클라이언트 코드도 조금 더 복잡해진다 (서버보단 낫다)
Access Token
의 유효기간을 짧게 두는 만큼, 매 서비스 접근마다 서버 측에 권한 인가 요청을 계속 보내야 한다- 그러다보니 HTTP 요청 양이 자연스럽게 많아지고, 서버 자원 낭비가 발생한다
Refresh Token이 탈취된다면?
Access Token
처럼 바로 권한 인가를 받진 않으니까 상대적으로는? 안전하지만, Refresh Token
을 탈취당한다면 제3자가 Access Token
을 재발급받을 수 있으므로 아무튼 위험하다
이를 막기 위해
Access Token
과Refresh Token
쌍을 서버에 같이 저장하는 방식- 서버 쪽에서 각 사용자별로 발급한
Access Token
과Refresh Token
쌍을 모두 저장한다 Access Token
이 만료되지도 않았는데Refresh Token
이Access Token
을 요청할 경우Refresh Token
이 탈취된 것으로 판단하여 토큰을 모두 만료시켜 버린다- 또한 공격자가 탈취하여 전송한
Access Token
과Refresh Token
이 서버에 저장된Access Token
-Refresh Token
쌍과 일치하지 않은 경우에도 두 토큰이 탈취되었다고 판단하여 토큰을 모두 만료시킨다
- 서버 쪽에서 각 사용자별로 발급한
- Refresh Token Rotation
Refresh Token
을 단 한 번만 사용하는 방식이다- 그렇다는 것은,
Access Token
을 한 번 재발급받았다면Refresh Token
도 같이 재발급받는다는 것이다 - 발급받은
Refresh Token
은 전부 저장해서 유출을 감지하는 데에 사용한다 - 만약
Refresh Token
이 재사용되었다면,Refresh Token
목록에 해당 토큰이 존재함을 확인하고 탈취되었다고 판단하여 지금까지 발급한 모든Refresh Token
을 폐기시킨다
Refresh Token
을secure
,httpOnly
쿠키로 설정한다secure
쿠키는https
환경에서만 주고받을 수 있으므로 더욱 안전하다httpOnly
쿠키는 브라우저에서 접근할 수 없기 때문에 (브라우저 내의 JS 코드 등으로 접근할 수 없기 때문에) XSS 공격에 상대적으로 안전하다- 그 외에도
SameSite
등으로 도메인 제한을 두는 방법도 있다
보안을 최대한 신경쓸 수는 있지만 완벽하게 막는 방법은 없다시피하므로 최선의 대책을 세우는 것이 중요하겠다
위의 방법들을 적용하기 위해서는 Refresh Token
을 서버에 저장하는 것이 적절하다
Access Token, Refresh Token으로 로그인 구현해보기
Access Token, Refresh Token 발급하기
function postLogin(req: express.Request, res: express.Response, pool: Pool) {
const { id, password } = req.body; // 요청에서 아이디와 비밀번호 가져오기
try {
await verifyLoginUser(pool, id, passwordHash); // ID와 비밀번호 검사
const accessToken = createAccessToken(id); // 액세스 토큰 생성
const refreshToken = createRefreshToken(); // 리프레쉬 토큰 생성
await setRefreshTokenToUser(pool, id, refreshToken); // 해당 유저 레코드에 리프레쉬 토큰 저장
res.cookie('access-token', accessToken, { /* 설정값 */ });
res.cookie('refresh-token', refreshToken, { /* 설정값 */ });
res.send({ accessToken, refreshToken }); // 토큰 전송
} catch (e) {
console.log(e); // 로그 출력
res.status(400); // 인증 실패 (400 에러) 전송
res.send({ message: 'login failed' }); // 인증 실패 이유 전송
}
}
...
app.post('/login', (req, res) => postLogin(req, res, pool));
// /login 경로에 POST할 경우 해당 콜백 함수 호출하여 인증 및 토큰 발급하도록 설정
postLogin
이라는 함수를 작성해 보자
이 함수는 클라이언트가 전송한 ID와 비밀번호를 서버의 데이터와 대조하여 인증 절차를 거치고, 인증에 성공하였다면 토큰을 발급해 줄 것이다
이 함수는 크게 5단계로 나뉘어져 있는데,
- ID와 비밀번호 검사 (
verifyLoginUser
) Access Token
생성 (createAccessToken
)Refresh Token
생성 (createRefreshToken
)- DB에
Refresh Token
저장 (setRefreshTokenToUser
) - 클라이언트에 결과 (토큰 또는 에러 메시지) 전송
각 단계에서 예외가 발생할 경우 (ID와 비밀번호가 유효하지 않거나, 토큰 생성 중에 오류가 발생했거나 등등…) 예외가 throw 되므로 클라이언트는 400 에러를 받게 된다
function verifyLoginUser(pool: Pool, id: string, password: string) { // pseudo code
/*
* 데이터베이스에 저장된 유저와 현재 들어온 유저 정보 대조하여 검증
* 비밀번호는 회원가입 시에 사용한 알고리즘으로 해시화하여 대조한다
* pool은 데이터베이스 접근용 커넥션 풀 인스턴스
*/
}
우선 verifyLoginUser
함수를 통해 ID와 비밀번호가 유효한지 검증한다
필자는 비밀번호를 회원가입할 때 사용했던 알고리즘으로 해시화하고, 데이터베이스에 저장된 비밀번호 해시와 대조하여 정상적인 비밀번호인지 검사하였는데, 이 외에도 여러가지 방법이 있을 듯하다…
타 사이트 OAuth를 사용한다면 이 단계에서 유저 정보가 DB에 존재하는지 검증하게 된다
import jwt from 'jsonwebtoken';
function createAccessToken(id: string) {
const token = jwt.sign({ id }, process.env.JWT_SECRET as string, {
algorithm: 'HS256', // 서명 해시화 알고리즘
expiresIn: '30m', // 소멸까지 걸리는 시간
});
return token;
}
function createRefreshToken() {
const token = jwt.sign({}, process.env.JWT_SECRET as string, {
algorithm: 'HS256',
expiresIn: '1d',
});
return token;
}
그리고 Access Token
과 Refresh Token
을 생성한다
나는 빠른 구현을 위해 jsonwebtoken
라이브러리를 사용하였다 (관련 포스팅은 요기)
Access Token
의 페이로드는 사용자 검증이 가능하도록 ID를 넣어주었고,Refresh Token
의 페이로드는 비워주었다Access Token
의 소멸 시간은 30분 정도로 짧게 두었고,Refresh Token
의 소멸 시간은 1일으로 설정하였다
서명 해싱에 필요한 비밀 키는 dotenv
등의 라이브러리를 사용하여 환경변수로 등록하는 것이 보안상 좋다 (깃허브에 비밀 키를 올리면 안 되므로…)
function setRefreshTokenToUser(pool: Pool, id: string, password: string) { // pseudo code
/*
* 회원 정보 레코드를 id를 이용하여 찾은 후 (WHERE 문법 사용),
* 해당 레코드의 refreshToken 컬럼에 토큰을 추가한다
* pool은 데이터베이스 접근용 커넥션 풀 인스턴스
*/
}
setRefreshTokenToUser
에서는 데이터베이스에 Refresh Token
을 저장한다
나는 mysql2
를 사용했기 때문에 쿼리문을 작성하여 커넥션을 통해 데이터를 넣어주었었는데, ORM을 사용해도 좋고 세션에 저장해도 무방하다
여기서 저장한 토큰은 유저 검증 및 권한 인가 시에 사용된다
function postLogin(req: express.Request, res: express.Response, pool: Pool) {
...
res.cookie('access-token', accessToken, { /* 설정값 */ });
res.cookie('refresh-token', refreshToken, { /* 설정값 */ });
} catch (e) {
console.log(e); // 로그 출력
res.status(400).send({ message: 'login failed' }); // 에러코드 설정 및 인증 실패 이유 전송
}
토큰이 정상적으로 생성되었다면 response
(응답) 으로 토큰 2개를 실어 보낸다
토큰 생성 도중 예외가 발생했다면 에러 코드 400 및 실패 메시지를 전달한다
헤더에 쿠키를 실어 보내려면 res.cookie
메서드를 사용하며,
- 첫 번째 인자: 쿠키 이름
- 두 번째 인자: 쿠키로 보낼 내용물
- 세 번째 인자: 옵션
secure
: HTTPS 환경에서만 쿠키를 전송하도록 하는 옵션httpOnly
: 쿠키를 클라이언트 측 자바스크립트 코드에서 접근할 수 없도록 은닉하는 옵션 (XSS 공격 방지용)path
: 쿠키의 경로expires
: 쿠키 소멸 시간 (GMT 기준)domain
: 쿠키의 도메인 이름
위의 세 개의 인자를 받는다
보안을 위해서 secure
, httpOnly
옵션이 많이 사용되는 편이다
토큰은 문자열로 구성되어 있으므로 body
에 보내는 것도 상관 없지만, 클라이언트 쪽에서 쿠키를 저장하는 코드가 추가되어야 하며 보안 옵션을 설정할 수 없어 괜찮은 방법인지는 조금 애매하다?
간단한 프로젝트에서 토큰 전달 테스트를 해보고 싶다면 body
에 담아 빠르게 전송하는 것도 나쁘지 않다
Access Token, Refresh Token 검증하기
function getVerifyLogin(req: express.Request, res: express.Response, pool: Pool): Promise<void> {
if (!req.headers.cookie) res.statue(401).send({ log: 'not logged in' });
else {
const [temp, refreshToken] = req.headers.cookie.split('; refresh-token=');
const accessToken = temp.split('access-token=')[1];
const accessTokenVerifyResult = verifyToken(accessToken);
const refreshTokenVerifyResult = verifyToken(refreshToken);
if (!accessTokenVerifyResult && !refreshTokenVerifyResult) {
// Access Token, Refresh Token 둘 다 유효하지 않은 경우
// 재로그인을 요청해야 한다
res.status(401).send({ log: 'not logged in' });
} else if (!accessTokenVerifyResult && refreshTokenVerifyResult) {
// Refresh Token만 유효할 경우
// Access Token만 재발급받는다
const userID = await getUserIDWithRefreshToken(pool, refreshToken);
const newAccessToken = createAccessToken(id);
res.send({ accessToken: newAccessToken, /* 인가에 필요한 정보들 */ });
} else {
// Access Token이 유효할 경우
// Refresh Token 검증은 시도하지 않았으나, Refresh Token 검증 후 재발급받는 경우도 있다
res.send({ /* 인가에 필요한 정보들 */ });
}
}
}
Access Token
과 Refresh Token
을 검증하는 getVerifyLogin
함수를 작성하자
검증 시 발생할 수 있는 경우의 수는 4가지가 있다
Access Token
,Refresh Token
이 쿠키에 존재하지 않거나, 둘 다 유효하지 않을 때- 401 Unauthorized 코드와 더불어 로그인이 되지 않았다는 응답을 보내 클라이언트에서 재로그인을 시도하게끔 한다
Access Token
은 만료되었거나 유효하지 않고,Refresh Token
만 유효할 때Refresh Token
을 토대로Access Token
재발급을 진행한다- 위 코드에서는,
Refresh Token
을 이용하여 데이터베이스에서 유저 레코드를 검색하고 ID를 가져온 뒤, 이를createAccessToken
함수의 인자로 사용하여Access Token
을 생성한다 - 추가적인 Refresh Token 관련 검증이나 보안 관련 코드는 이 부분에 넣으면 된다
Access Token
은 유효하지만Refresh Token
이 유효하지 않거나 만료되었을 때- 위 코드에서는 두 토큰 모두 유효할 때와 마찬가지로 (
Access Token
이 유효하므로) 로그인 성공으로 처리했다 Access Token
을 기반으로Refresh Token
을 재발급받거나, 둘 다 무효 처리하여 재로그인을 요청할 수도 있다
- 위 코드에서는 두 토큰 모두 유효할 때와 마찬가지로 (
Access Token
,Refresh Token
둘 다 유효할 경우- 로그인 성공 응답을 전송한다
function verifyToken(token: string) {
try {
const decodedData = jwt.verify(token, process.env.JWT_SECRET as string);
return decodedData;
} catch (e: Error) {
console.log(e);
return null;
}
}
두 토큰을 검증하는 데에 사용되는 verifyToken
은 jsonwebtoken
라이브러리의 verify
메서드를 사용하였다
verify
메서드는 검증에 실패했을 경우 예외를 throw
하므로 try-catch
로 감싸주자
검증에 성공했을 경우 페이로드를 반환하며, 실패했을 경우 catch
문에서 null
을 반환한다
null
반환히 썩 좋은 예외처리 방법은 아니라는 말이 있지만… 일단은 이렇게 구성하였다
function getUserIDWithRefreshToken(pool: Pool, refreshToken: string) {// pseudo code
/*
* 회원 정보 레코드를 토큰을 이용하여 찾은 후 (WHERE 문법 사용),
* 해당 회원의 id를 select 하여 가져온다
* pool은 데이터베이스 접근용 커넥션 풀 인스턴스
*/
}
getUserIDWithRefreshToken
에서는 Refresh
토큰을 이용하여 유저 id를 가져온다
가져온 유저 id는 Access Token
재발급에 사용된다
여담
로그아웃 요청이 왔다면 Access Token
, Refresh Token
모두 만료시켜 버린 뒤 데이터베이스에서도 지워주면 된다
사실 jsonwebtoken
라이브러리를 사용하면 (사용하지 않더라도) 로그인 구현 자체가 어려운 것이 아니라 CORS나 쿠키 저장 이슈 같은 곁다리 이슈 대처가 더 어려운 듯 싶다…
참고자료
https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation
https://velog.io/@kshired/Express에서-JWT로-인증시스템-구현하기-Access-Token과-Refresh-Token
https://velog.io/@nathan29849/JWT를-Header에-Body에