치춘짱베리굿나이스

Access Token, Refresh Token 본문

ServerSide/기타

Access Token, Refresh Token

치춘 2022. 10. 20. 14:45

Access Token, Refresh Token

토큰을 이용한 로그인 방식 중 가장 널리 쓰이는 Access Token & Refresh Token 방식이다

옛날에는 Access Token만을 사용했으나 토큰이 탈취당할 위험이 커 Refresh Token을 같이 사용하여 보안성을 높이게 되었다~~ 라 카더라

이번에 Access TokenRefresh 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만 사용할 경우

  1. 사용자가 로그인을 한다 (서버에 로그인 요청을 보낸다)
  2. 사용자가 누구인지 서버에서 인증 절차를 거친다
    • 인증에 성공하면, 사용자 정보 일부를 담은 Access Token을 발급하여 클라이언트에 응답으로 보낸다
    • 인증에 실패하면, 로그인에 실패했음을 클라이언트에 응답으로 알린다
  3. 클라이언트는 응답받은 토큰을 쿠키 등에 저장한다
    • 로컬 스토리지에 저장하기도 하지만, 대개 쿠키에 저장하여 헤더에 손쉽게 포함시킨다
  4. 로그인 권한이 필요한 서비스에 접근할 경우, 클라이언트는 서버에 요청을 보낸다
  5. 서버는 클라이언트 요청의 헤더에 들어있는 토큰을 검증한다
    • 검증에 성공하면, 다음 서비스에 접근할 수 있는 권한을 인가하여 클라이언트가 원하는 동작을 수행할 수 있도록 한다
    • 검증에 실패하면 (토큰이 만료되었거나 손상되었을 경우), 페이지 접근 권한이 없음을 응답으로 알린다
  6. 클라이언트는 서버의 응답에 따라 서비스에 접근한다
    • 페이지 접근 권한이 없을 경우, 로그인을 다시 진행한다 (1번으로 되돌아감)
  7. 로그아웃을 하면 Access Token을 만료시킨다

위 방식의 단점

  • Access Token이 탈취되었을 경우, 누군가 나의 토큰을 악용할 수 있다
    • 유효기간이 지나기 전까지 해당 토큰은 누구나 사용가능하므로 위험하다
    • 서버는 이 토큰이 내가 보낸 게 맞는지, 아니면 제3자가 탈취해서 보낸 것인지 알 수 없기 때문에, 토큰의 유효성 검증만 마치면 응답을 그냥 보내준다
    • 따라서 한번 토큰이 탈취당하면 조치를 취할 방법이 없다
  • Access Token의 탈취를 막기 위해 유효시간을 짧게 설정하면, 로그인 유지가 되지 않으므로 사용자에게 불편을 초래한다
    • 로그인하고 화장실만 갔다왔을 뿐인데 로그인이 풀려있으면 매번 로그인을 다시 해줘야 하는 불편이 따른다
    • 탈취를 완벽하게 막고 싶다면 유효기간을 정말 짧게 설정하는 수밖에 없고, 유효기간이 짧아질 수록 사용자 편의에 악영향을 끼친다

Access Token과 Refresh Token을 둘 다 사용할 경우

  1. 사용자가 로그인을 한다 (서버에 로그인 요청을 보낸다)
  2. 사용자가 누구인지 서버에서 인증 절차를 거친다
    • 인증에 성공하면, 사용자 정보 일부를 담은 Access Token과, Access Token을 재발급받기 위한 Refresh Token을 발급하여 클라이언트에 응답으로 보낸다
    • 인증에 실패하면, 로그인에 실패했음을 클라이언트에 응답으로 알린다
  3. 클라이언트는 응답받은 토큰을 쿠키 등에 저장한다
    • 로컬 스토리지에 저장하기도 하지만, 대개 쿠키에 저장하여 헤더에 손쉽게 포함시킨다
    • Access TokenRefresh Token 둘 다 쿠키에 저장한다
  4. 로그인 권한이 필요한 서비스에 접근할 경우, 클라이언트는 서버에 요청을 보낸다
  5. 서버는 클라이언트 요청의 헤더에 들어있는 Access Token, Refresh Token을 검증한다
    • Access TokenRefresh Token이 둘 다 유효한 경우, 다음 서비스에 접근할 권한을 인가하여 클라이언트가 원하는 동작을 수행할 수 있도록 한다
    • Access Token은 만료되었지만 Refresh Token이 유효한 경우, Access Token을 재발급해주고 동시에 다음 서비스에 접근할 권한을 인가한다
    • Access Token은 유효하지만 Refresh Token이 만료된 경우, Refresh Token을 재발급해주고 동시에 다음 서비스에 접근할 권한을 인가하거나, 로그인 유지가 풀린 것으로 판단하여 재로그인을 요청한다
    • Access TokenRefresh Token이 둘 다 만료된 경우, 페이지 접근 권한이 없음을 응답으로 알린다
  6. 클라이언트는 서버의 응답에 따라 서비스에 접근한다
    • 페이지 접근 권한이 없을 경우, 로그인을 다시 진행한다 (1번으로 되돌아감)
    • 토큰이 재발급되었을 경우, 쿠키를 업데이트한다
  7. 로그아웃을 하면 Access TokenRefresh Token을 모두 만료시킨다

Refresh Token을 도입함으로써 보완된 점

  • Refresh Token의 존재 덕에 Access Token의 유효기간을 짧게 설정해도 로그인 유지가 가능하다
    • Refresh Token의 유효기간을 보통 길게 (시간 단위가 아닌 일 단위로) 설정하기 때문에 Access Token이 만료되어도 Refresh Token이 살아만 있다면 언제든지 로그인 유지가 된다
  • Refresh Token은 탈취당해도 페이로드에 개인 정보가 없기 때문에 Access Token 탈취에 비해 상대적으로 안전하다

Refresh Token을 도입했을 때 생기는 단점

  • 구현이 귀찮아진다
    • Access TokenRefresh Token이 각각 만료되었을 때, 만료되지 않았을 때의 총 4가지 경우를 모두 따져서 검증을 진행해야 한다
    • 또한 쿠키로 저장해야 하는 값이 2개이므로 클라이언트 코드도 조금 더 복잡해진다 (서버보단 낫다)
  • Access Token의 유효기간을 짧게 두는 만큼, 매 서비스 접근마다 서버 측에 권한 인가 요청을 계속 보내야 한다
    • 그러다보니 HTTP 요청 양이 자연스럽게 많아지고, 서버 자원 낭비가 발생한다

Refresh Token이 탈취된다면?

Access Token처럼 바로 권한 인가를 받진 않으니까 상대적으로는? 안전하지만, Refresh Token을 탈취당한다면 제3자가 Access Token을 재발급받을 수 있으므로 아무튼 위험하다

이를 막기 위해

  • Access TokenRefresh Token 쌍을 서버에 같이 저장하는 방식
    1. 서버 쪽에서 각 사용자별로 발급한 Access TokenRefresh Token 쌍을 모두 저장한다
    2. Access Token이 만료되지도 않았는데 Refresh TokenAccess Token을 요청할 경우 Refresh Token이 탈취된 것으로 판단하여 토큰을 모두 만료시켜 버린다
    3. 또한 공격자가 탈취하여 전송한 Access TokenRefresh Token이 서버에 저장된 Access Token - Refresh Token 쌍과 일치하지 않은 경우에도 두 토큰이 탈취되었다고 판단하여 토큰을 모두 만료시킨다
  • Refresh Token Rotation
    • Refresh Token을 단 한 번만 사용하는 방식이다
    • 그렇다는 것은, Access Token을 한 번 재발급받았다면 Refresh Token도 같이 재발급받는다는 것이다
    • 발급받은 Refresh Token은 전부 저장해서 유출을 감지하는 데에 사용한다
    • 만약 Refresh Token이 재사용되었다면, Refresh Token 목록에 해당 토큰이 존재함을 확인하고 탈취되었다고 판단하여 지금까지 발급한 모든 Refresh Token을 폐기시킨다
  • Refresh Tokensecure, 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단계로 나뉘어져 있는데,

  1. ID와 비밀번호 검사 (verifyLoginUser)
  2. Access Token 생성 (createAccessToken)
  3. Refresh Token 생성 (createRefreshToken)
  4. DB에 Refresh Token 저장 (setRefreshTokenToUser)
  5. 클라이언트에 결과 (토큰 또는 에러 메시지) 전송

각 단계에서 예외가 발생할 경우 (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 TokenRefresh 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 TokenRefresh Token을 검증하는 getVerifyLogin 함수를 작성하자

검증 시 발생할 수 있는 경우의 수는 4가지가 있다

  1. Access Token, Refresh Token이 쿠키에 존재하지 않거나, 둘 다 유효하지 않을 때
    • 401 Unauthorized 코드와 더불어 로그인이 되지 않았다는 응답을 보내 클라이언트에서 재로그인을 시도하게끔 한다
  2. Access Token은 만료되었거나 유효하지 않고, Refresh Token만 유효할 때
    • Refresh Token을 토대로 Access Token 재발급을 진행한다
    • 위 코드에서는, Refresh Token을 이용하여 데이터베이스에서 유저 레코드를 검색하고 ID를 가져온 뒤, 이를 createAccessToken 함수의 인자로 사용하여 Access Token을 생성한다
    • 추가적인 Refresh Token 관련 검증이나 보안 관련 코드는 이 부분에 넣으면 된다
  3. Access Token은 유효하지만 Refresh Token이 유효하지 않거나 만료되었을 때
    • 위 코드에서는 두 토큰 모두 유효할 때와 마찬가지로 (Access Token이 유효하므로) 로그인 성공으로 처리했다
    • Access Token을 기반으로 Refresh Token을 재발급받거나, 둘 다 무효 처리하여 재로그인을 요청할 수도 있다
  4. 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;
  }
}

두 토큰을 검증하는 데에 사용되는 verifyTokenjsonwebtoken 라이브러리의 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://nsinc.tistory.com/121

https://velog.io/@nathan29849/JWT를-Header에-Body에

https://cotak.tistory.com/102

https://velog.io/@yaytomato/프론트에서-안전하게-로그인-처리하기

https://tansfil.tistory.com/59

Comments