글 작성자: beaniejoy

Image by  Darwin Laganzon  from  Pixabay

1. Overview

백엔드 개발에 있어서 인증/인가 처리는 빼놓을 수 없는 부분입니다. 사용자가 어떤 웹서비스를 이용하려면 기본적으로 로그인이라는 인증과정을 거쳐야하는데요. 그래야 쇼핑이든, 서비스 예약이든 간에 할 수 있겠죠(인증된 정보를 기반으로 해야하기 때문에)

개인 프로젝트를 통해 JWT 토큰으로 access token과 이를 보완하는 refresh token을 적용해보는 작업을 해보았는데요. 토큰 방식의 인증/인가를 구현해보면서 몇 가지 들었던 의문점들을 저의 의식이 흐르는 대로 두서없이 작성해보고자 합니다.

특히나 이번 게시글은 JWT 토큰방식으로 인증/인가를 적용한 내용들을 정리해보는 내용이 아니라, 토큰 방식의 인증/인가에 대해 마주했던 의문점들, 한계에 대한 내용을 세션 방식부터 설명하면서 풀어보고자 합니다.
(이 다음 게시글에서 access, refresh token을 이용한 인증/인가 구현에 대해서 정리해보도록 하겠습니다.)

 

2. 세션에 의한 인증/인가 방식

인증, 인가는 왜 있어야할까요. 서비스를 이용하다보면 사용자 본인만 가능한 행위들이 존재합니다. 예를 들어 온라인 쇼핑몰에서 구매하는 행위, 내 게시판에 나의 글을 게시하는 행위, 내 정보를 수정하는 행위등이 있습니다. 이러한 행위들에 내가 아닌 다른 사용자가 마음대로 접근할 수 있다면 서비스에 대한 신뢰도는 0가 될 것이고 안전하지 못한 그 서비스를 절대로 이용하지 않을 것입니다.

즉, 안전한 서비스를 제공하기 위해 나와 다른 사용자를 안전하고 확실하게 분리하고 나 아닌 다른 사람들을 배제할 수 있어야 합니다. 여기서 인증/인가 기능이 필수가 된 것입니다.

그런데 오늘날 거의 모든 웹서비스는 HTTP 프로토콜에 기반하고 있습니다. HTTP의 중요한 특징 중 하나는 Stateless(무상태)하다는 것입니다. 즉, HTTP 프로토콜 기반의 통신만으로는 클라이언트의 상태를 저장할 수 없기 때문에 사용자가 인증을 해도 해당 사용자가 인증을 했던 사용자인지 구분할 수 없어서 매번 인증 과정을 거쳐야 합니다. 비효율의 극치입니다.

이를 보완하기 위해 세션과 쿠키를 이용해 인증/인가 프로세스를 적용해 볼 수 있습니다.

출처: 본인 작성

위의 그림에서 볼 수 있듯이 사용자는 email(혹은 계정ID), password를 입력해 서버에서 타당한 사용자라고 인증이 되면 해당 인증 정보를 서버 메모리에 있는 세션에 저장하고 그 세션의 ID 값을 클라이언트의 쿠키로 설정하도록 응답을 보내게 됩니다.

이렇게 되면 인가가 필요한 어떤 기능을 이용할 때 해당 세션ID 값을 서버에 보내주기만 하면 다시 인증과정을 거치지 않고도 사용자는 바로 서비스를 이용할 수 있게 됩니다.

과정을 보면 괜찮은 방법 같아 보이지만 여기에는 한계가 존재합니다.

 

2-1. 확장성의 한계

실제 서비스를 구현하고 서버에 배포를 하게 되면 애플리케이션 한 개만 띄어놓는 경우는 없다고 보시면 됩니다. 애플리케이션 인스턴스를 8개, 10개씩 띄어두고 load balancer를 통해 트래픽 분산처리되도록 인프라를 구축해두는데요.
(scale out 방식으로 서버 확장을 하는 경우가 대부분입니다.)

출처: 본인 작성

이러한 분산된 인스턴스 환경에서 세션은 각 인스턴스에 종속적이므로 인스턴스마다 세션 안에 있는 데이터는 기본적으로 공유할 수 없습니다. App 1 인스턴스에 인증된 정보가 세션으로 저장되어 있는데 다음 api 요청에 대해 App 2 인스턴스에서 처리가 이루어진다면 같은 사용자이지만 인증된 정보는 확인할 수 없어 다시 인증과정을 거쳐야 하는 상황이 발생할 수 있습니다.

물론 대안도 있습니다. 인증 세션이 발생한 첫 요청을 처리한 서버로만 고정하는 sticky session, 여러 개 분산되어 있는 서버 인스턴스의 세션들을 클러스터링해 하나로 관리하는 session clustering, 아예 구분된 메모리 서버를 두어 모든 인스턴스들이 해당 메모리 서버를 바라보게 하는 session server 등이 있습니다.

하지만 이를 구현하는 것도 만만치 않아서 어드민 서버나 간단한 서비스 구현을 제외하고는 세션 방식의 인증/인가 방식은 잘 사용되지 않는 것 같습니다.

 

3. 토큰(JWT)에 의한 인증/인가 방식

토큰 인증/인가 방식은 Stateless한 HTTP 프로토콜과 잘 맞는 방식입니다.

출처: 본인 작성

인증 후 세션에 인증정보를 저장한 것과 다르게 토큰 방식은 인증정보를 토대로 문자열로 이루어진 토큰을 생성해서 사용자에게 응답합니다. 사용자는 해당 토큰을 가지고 LocalStorage, Cookie와 같은 클라이언트 저장소에 저장해두었다가 인가가 필요한 기능을 요청할 때 해당 토큰을 header에 같이 담아 보내게 됩니다.

Stateless한 방식으로 매요청마다 들고 있는 header의 토큰값을 가지고 인증여부를 체크하기 때문에 별도의 서버쪽 세션이 필요가 없게 됩니다. 이렇게 되면 scale out과 같은 분산된 인스턴스 환경에서 일관되게 인증된 상태임을 검증할 수 있게 됩니다.

 

3-1. 보안 취약성

토큰 인증/인가 방식에도 단점은 존재합니다. 바로 보안에 취약하다는 점입니다.
토큰은 인증 정보를 바탕으로 암호화된 문자열입니다. 해커가 토큰을 획득했다 하더라도 이를 복호화하기는 힘듭니다.
그런데 서버쪽에서는 보통 요청으로 같이 들어온 인증 토큰을 가지고 인가 여부만 판단해 클라이언트가 요청한 api에 대해 응답을 내려주게 되는데요. 이를 충분히 악용할 수 있습니다.

사용자가 가지고 있는 토큰을 복호화할 필요없이 사용할 수만 있다면 언제든 해커는 인증된 사용자로 위장해 마음껏 기능을 사용할 수 있게 됩니다. 대표적으로 이러한 취약점 공격으로 잘 알려진 것이 Cross-Site Scripting(XSS)Cross Site Request Fogery(CSRF)가 있습니다.
(영어이긴 한데 XSSCSRF를 잘 설명해주는 글이 있어 읽어보시는 것을 추천드립니다.)

 

What is cross-site scripting (XSS) and how to prevent it? | Web Security Academy

In this section, we'll explain what cross-site scripting is, describe the different varieties of cross-site scripting vulnerabilities, and spell out how to ...

portswigger.net

 

What is CSRF (Cross-site request forgery)? Tutorial & Examples | Web Security Academy

In this section, we'll explain what cross-site request forgery is, describe some examples of common CSRF vulnerabilities, and explain how to prevent CSRF ...

portswigger.net

또한 토큰은 한 번 발급하면 만료되지 않는 이상 발급한 사람이 토큰을 수정하거나, 조작할 수 없게 됩니다.
세션 방식에서는 쿠키에 저장되어 있는 세션ID가 탈취되거나 악용되고 있음을 감지하면 해당 사용자에 대한 세션을 바로 비활성화 처리라도 할 수 있는데요. 토큰은 이러한 비활성화 처리가 불가능합니다. JWT 같은 토큰을 사용한다면 최초 토큰 발행시 설정했던 만료시간이 지날 때까지 제거할 수도 없고 무효화 처리같은 조작도 불가능하기 때문에 속수무책으로 당할 수 밖에 없습니다.

즉, 사용자에게 토큰을 제공하는 순간 보안에 상당히 취약해질 수 밖에 없다고 볼 수 있습니다.

 

4. 해결방안 생각해보기

토큰 방식의 인증/인가의 보안 취약성을 보완하기 위해서는 쿠키와 같은 클라이언트 저장공간 조차도 이용하지 않고 클라이언트 단에서 javascript 같은 코드상에 인증 토큰을 변수에 할당하는 방식을 생각해볼 수는 있습니다.

let accessToken

const requestAuth = async (email, password) => {
    try {
        const loginRequest = { email: 'xxxx', password: 'xxxx' }
        const response = await axios.post('/auth/login', loginRequest)
        accessToken = response.data
    } catch (e) {
        console.debug(error)
        alert('로그인 실패')
    }
}

const getUserInfo = async () => {
    const headers = { 
    	Authorization: `Bearer ${accessToken}`
    }
    
    try {
    	const response = await axios.get(`/api/users/me`, headers)
        const userInfo = response.data
        
        //...
    } catch (e) {
	    //...
    }
}
// 인가가 필요한 api 호출 때마다 accessToken 획득 필요
const email = params.email
const password = params.password

await requestAuth(email, password)
await getUserInfo()

하지만 결정적으로 문제가 되는 부분은 매번 api 호출때마다 로그인을 해야한다는 것입니다. 대부분의 웹서비스는 한 번 로그인을 하면 브라우저를 종료했다가 다시 접속해도 인증된 상태를 유지하도록 되어 있습니다. 위의 방식으로는 페이지 이동할 때마다 매번 로그인 인증과정을 거쳐야 합니다.

인증 상태를 유지하려면 결국은 클라이언트 저장공간을 활용해야 하는데요. 좀 더 안정적으로 관리하기 위해서는 토큰 자체를 바꿀 수 없으니 발급할 때 Access Token 유효기간을 아주 짧게 설정하는 방식을 많이들 사용하는 것 같습니다.

그런데 유효기간을 짧게 설정해도 번거로운 것은 마찬가지일 것입니다. 예를 들어, 1시간 유효기간이 설정된 인증토큰을 발급받으면 1시간 이후에는 해당 토큰이 만료되어 결국 1시간 마다 로그인을 새로 해야하는 상황이 발생하게 되는데요.
이를 위해 Refresh Token을 가져와 사용하는 것 같습니다.

 

4-1. Refresh Token 사용하여 토큰 기반의 인증/인가 보완하기

출처: 본인 작성

로그인 인증시 Access Token은 ResponseBody로 반환, Refresh Token은 Cookie에 저장하고, 이후 API 요청시 Access Token을 header에 담아 요청합니다. 여기서 Access Token의 만료시간을 아주 짧게 설정해주었기에 만료응답(Token Expired)을 받더라도 Cookie에 Refresh Token만 있으면 언제든 토큰을 갱신해서 사용할 수 있습니다. 유효한 Refresh Token만 있다면 인증된 상태를 계속 유지하면서 서비스를 이용할 수 있게 됩니다.

만료시간이 긴 Refresh Token을 이용해서 갱신할 때만 이용하고 나머지 API 요청에 대해서는 Access Token을 사용함으로써 좀 더 토큰에 대한 보안을 높일 수 있습니다. 왜냐하면 Access Token은 만료시간 짧기에 해커가 토큰을 가로챌 여지를 줄이고, Refresh Token은 갱신시에만 사용되기에 이 또한 해커에게 여지를 줄일 수 있기 때문입니다.

이와 관련된 좋은 글이 있어 공유드립니다. (위의 제가 작성한 시퀀스 다이어그램도 해당 글을 참고하여 작성한 것입니다.)

 

🍪 프론트에서 안전하게 로그인 처리하기 (ft. React)

localStorage냐 쿠키냐 그것이 문제로다

velog.io

 

4-2. Refresh Token는 결국 세션 방식처럼 별도의 저장공간을 필요로 한다.

Refresh Token을 가지고 토큰을 갱신하기 위해서는 서버에서 Refresh Token을 별도의 공간에 저장해서 관리를 해야합니다. 그래야 해당 토큰으로 요청이 들어왔을 때 저장된 내용과 비교를 통해 검증을 할 수 있게 됩니다. 다음 게시글에 구현 부분에서 설명하겠지만 저는 Redis를 사용해 한 곳에서 데이터가 관리되도록 하였습니다.

@RedisHash("auth_tokens")
class AuthToken protected constructor(
    id: Long,
    accessToken: String,
    refreshToken: String,
    expiration: Long
) {
    @Id
    var id: Long = id
        protected set

    var accessToken: String = accessToken
        protected set

    var refreshToken: String = refreshToken
        protected set

    @TimeToLive
    var expiration: Long = expiration
        protected set

	//...
 }

결국 세션 방식의 인증/인가 프로세스와 마찬가지로 scale out 환경에서 데이터 일관성을 고려할 수 밖에 없습니다. 

또한 Refresh Token은 클라이언트에서도 쿠키로 저장되어 관리해야 하기 때문에 Session ID를 쿠키에 저장하는 세션 방식과 비슷하다고 볼 수 있습니다.

결국 stateless한 HTTP 프로토콜에 가장 잘 맞다고 생각했던 토큰 방식도 결국에는 세션처럼 stateful한 성격도 가지게 될 수 밖에 없습니다. 이부분에 있어서 토큰 방식이 결국 세션 방식하고 비슷해졌는데 Access Token, Refresh Token 관리대상도 더 많아졌고, 구현도 오히려 더 복잡해진 느낌이 들긴 했습니다. 세션 방식에서 session server로 Redis 적용하는 것과 별차이도 없어 보인다는 생각까지 했습니다.

 

4-3. Refresh Token 적용하기 위해 신경써야할 부분이 많다.

위의 게시글에서는 Refresh Token만 쿠키에 저장하고 Access Token는 javascript단에서 local variable에 할당해서 사용하는 방식을 소개하고 있습니다. 하지만 딱 이정도만으로는 완전히 XSS, CSRF를 방지할 수는 없습니다. 

위의 게시글에서 언급한 대로 클라이언트, 서버 둘 다 토큰 관리에 대해서 신경써야 한다는 내용에는 저 또한 적극 공감합니다.
Refresh Token만 쿠키에 저장하고 Access Token을 response로 받아서 처리한다해도 Access Token에 대해서 XSS에 결국 취약한 것 아닌가 하는 의문이 들기는 했지만 클라이언트 단에서 추가적으로 XSS 방어처리를 할 수 있다고 언급된 것을 보니 클라이언트 단에서 어떤 방어 수단이 여럿 존재하는 것 같습니다.

예: <input>에서 입력된 값이 html / Javascript로 인식되지 않도록 서버에서 escape 처리를 해준다. 또 url을 통해 Javascript를 수행할 수 없도록 라우팅을 꼼꼼하게 관리한다. 다행인 것은 React는 공격자가 string에 html / Javascript를 담아 JSX에 삽입할 경우 자동으로 escape 처리한다. (XSS 방어 처리는 또 다른 주제기에 여기서는 이 정도에서 마무리한다.

- 출처: 프론트에서 안전하게 로그인 처리하기(ft. React) (링크는 바로 위 상단 참고)

또한 쿠키에 저장할 때 쿠키에 HttpOnly, Secure 속성을 설정하고 domain 속성까지 고려해볼 수 있을 것 같습니다.

서버단에서는 CSRF 공격을 방지하기 위해 Referer header 값 검증, CSRF 토큰 적용 등을 고려해볼 수 있을 것 같습니다.
(쿠키를 사용하는 것 자체가 CSRF 공격 수단으로 사용될 여지가 있다는 것이기에 필히 신경 써야할 것입니다.)

 

5. 결론 및 생각정리

세션 방식부터 토큰 방식까지 간략하게 내용을 정리해보았는데요.
구글링 조금만 해봐도 인증/인가 프로세스 개발할 때 세션 방식보다 토큰 방식을 더 선호하고 훨씬 더 많이 사용하고 있는 것 같습니다.
저 또한 개인 프로젝트 진행하면서 JWT 토큰 방식으로 적용했고 실무에서조차 JWT 토큰 방식으로 인증 과정을 적용한 것을 쉽게 볼 수 있었습니다.

여기서 문득 들었던 생각은 '왜 토큰 방식을 선호하는 것일까' 입니다. Access Token 만으로 보안에 취약하기에 Refresh Token 까지 적용해서 보완하는 것까지 좋았지만 결국 세션과 같이 토큰을 저장 관리해야되는 이슈가 생기게 되고 쿠키에 저장된 토큰은 결국 Session ID처럼 탈취될 여지 또한 있다고 생각이 들었습니다.

무엇보다 토큰이 어떻게 탈취가 될 수 있는지, XSS, CSRF 공격은 구체적으로 어떻게 이루어지는지에 대해 공부해봐야될 것 같습니다.
세션 방식보다 토큰 방식을 더 많이 사용하는 이유에 대해서 명확하게 아시는 분이 계시다면 댓글로 알려주시면 정말 감사하겠습니다!

이 다음 게시글에서 위에서 언급했던 Access Token, Refresh Token을 활용한 인증/인가 프로세스에 대해 개인 프로젝트에서 구현했던 내용을 프론트와 백엔드로 나눠 정리해보도록 하겠습니다.

틀린 내용이 있을 수 있습니다. 언제나 피드백 환영합니다.