Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

스프링이 지원하는 로그인 방식들 중에 자주 사용하는 4가지 

- 폼 로그인 (UsernamePasswordAuthentication 사용 하여 서버사이드 랜더링에서 폼 전송 방식으로 로그인) 

- ajax 로그인 (Basic Authentication을 사용하여 SPA 또는 모바일 환경에서 요청 헤더에 토큰을 담아서 로그인하는 방식(헤더에 직접 아이디 비밀번호를 담기 때문에 보안에 취약))

- OAuth2 로그인 (OAuth2 규약에 따라 만들어진 로그인 API에 로그인을 위임하는 방식)

- JWT 토큰 로그인 ( SPA , 모바일 환경에서  JWT(Json Web Token)을 생성하여 인증하는 방식 )

그 외 :saml2(인증 정보가 담긴 xml를 넘겨서 인증하는 방식) 로그인  

이 중 폼 로그인 , 혹은 ajax 로그인(sessionless도 가능) 같은 경우 세션에 기반하여 로그인 성공시 세션을 부여하는 방식으로 로그인 상태를 유지한다 . 

세션에 기반한 로그인 방식에서 중요한 2가지는 인증 후에  Authentication을 발급하고 세션을 부여하는 것과

로그인 상태 유지를 위해 세션을 유지하는 것 두가지 일 것이다

지금까지는 로그인에 성공하였을때 Authentication을 발급하는 방식들을  알아봤다면 

이제 session을 유지하는 방식에 대해서 알아보자 

서버의 세션 정책과 스프링의 인증 체계가 맞물려 동작하도록 하려면 SecurityContextPersistanceFilter와 RememberMeAuthenticationFilter , AnonymousAuthenticationFilter 등과 같이 인증을 보조해 주는 다른 필터들의 도움을 받아야한다 . 

session을 이용한 로그인 유지

SecurityContextPersistanceFilter

세션을 기반한 로그인에서 중요한 두가지는

로그인을 하여 세션을 부여하는 것과  부여한 세션을 유지하는 것이다 . 

그림으로 보면 아래와 같다 그림은 로그인 후 재요청에 대한 상황이다 그림을 보면 요청이 들어오고 필터를 거친 후에

SecurityContextPersistanceFilter에서는  SecurityContext를 저장하는 SecurityContextRepository(인터페이스)를 통해

(default로 설정되어 있는 구현체는 세션에 securitycontext를 저장하는 HttpSessionSecurityContextRepository이다 .)

로그인시에 httpSession에 securitycontext를 넣어두고 다음 요청있을 때 session에서 해당 securitycontext를 가져와 securitycontextHolder에 등록한다. 

그림에 나오진 않았지만 처리가 끝난 후에는 securityContextHoleder를 clear하게된다.

세션이 살아있는 동안 아래의 작업을 통해 로그인이 유지된다.

SecurityContextPersistanceFilter의 doFilter메서드에 브레이크 포인트를 두고 디버깅해보면 아래 과정을 확인할 수 있다. 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

cookie를 이용한 로그인유지 

RememberMeAuthenticationFilter

RememberMeAuthenticationFilter는 인증 정보를 세션 관리하는 경우 , 세션 timeout이 발생하게 되면 ,  remember-me 쿠키를 이용해 로그인을 기억했다 자동으로 재로그인 시켜주는 기능을 한다. 

RememberMeAuthenticationFilter는 RemberMeServices(인터페이스)를 갖고 있고 이르 통해 기능을 수행하는데 RemberMeServices의 구현체로는 공통객체인 AbstractRemeberMeServices가 있고 이를 확장한 

TokenBaseRememberMeServicese와 PersisenceTokenBasedRememberMeServicese가 있다 . 

TokenBaseRememberMeServicese

토큰을 쿠키로 브라우저  저장하고 서버에는 토큰을 남기지 않는다 .

토큰의 포맷은 아래와 같다.

아이디:만료시간:Md5Hex서명값(아이디 :만료시간:비밀번호:인증키) 

아이디와 만료시간이 클라이언트 브라우저에서 관리되기 떄문에 만약  토큰이 탈취되었을때  비밀번호가 바뀌지 않는다면 인증을 위한 서명 값은 그대로 유지되기 때문에 다른곳에서 해당 토큰을 사용하더라도 토큰의 만료시간이 지나거나 , 비밀번호가 바뀌지 않는 한 해당토큰을 만능키처럼 사용할 수 있다 .   

PersisenceTokenBasedRememberMeServicese

TokenBaseRememberMeServicese를 보완하기 위해 사용된다 . 

브라우저에는 아이디 만료시간 대신  series:token의 형태로  토큰을 남기고 

서버에서는 TokenBaseRememberMeServicese에 있는 PersistentTokenRepository(인터페이스)의 구현체인 JdbcTokenRepositoryImpl을 통해 DB에  아래 형태로 토큰을 관리한다 . 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

PersisenceTokenBasedRememberMeServicese의 경우 로그인 할때 마다 같은 시리즈 값을 갖는 토큰이 갱신되는데 이때 서버에서는 마지막으로 내려준 토큰값으로 해당 시리즈의 쿠키가 넘어와야 연속된 토큰인 것을 인지하여 토큰을 갱신시켜주기 준다. 

rememberMe를 직접 구현해보며 알아보자 .

cookie를 이용한 로그인유지 구현

브라우저에서 폼 전송시 remember-me라는 value 값을  true로 넘겨 주면된다. 아래의 로그인 기억하기 체크박스가 그 역할을 한다 . 

폼 로그인에서 로그인시  remember-me 라는 name의 value가 true 로 들어오면 UsernamePasswordAuthenticationFilter에서 인증을 한후 인증에 성공했을때  rememberMeService에게 remeber-me라는 value가 true 로 들어왔음을 알리고  rememberMeService는 쿠키를 생성하여 브라우저로 전달한다.   

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

서버에서는 시큐리티 config 클래스에서 HttpSecurity에  rememberMe()를 추가해준다.

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

로그인하고 브라우저의 쿠키를 확인해 보면 아래처럼 쿠키가 base64형태로 저장되었다 . 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

해당값을 decode 해봤다

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

아이디 : 만료시간 : 서명값의 형태로 쿠키가 만들어졌다 

다른 설정을 하지 않는다면 default로 TokenBaseRememberMeServicese를 통해 rememberMe기능이 수행된다. 

RememberMeAuthenticationFillter ,  TokenBasedRememberMeServices ,  PersistenceTokenBasedRememberMeService 를 각각 디버깅하며 구조를 자세히 살펴보자 

RememberMeAuthenticationFillter

일단 RememberMeAuthenticationFillter가 동작하기 위해서는 세션이 만료되어 있어야 하니 세션의 application yml에서 timeout을 30초로 설정해 뒀다 . 

세션이 만료되었는지 , 생성되었는지를 확인하기 위해서 세션의 상태를 출력해주는  리스너를 빈으로 등록하여 두었다 

    @Bean
    public ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher(){
        return new ServletListenerRegistrationBean<HttpSessionEventPublisher>(new HttpSessionEventPublisher(){
            @Override
            public void sessionCreated(HttpSessionEvent event) {
                super.sessionCreated(event);
                System.out.printf("====> [%s] 세션 생성됨 %s \n" , LocalDateTime.now(), event.getSession().getId());
            }

            @Override
            public void sessionDestroyed(HttpSessionEvent event) {
                super.sessionDestroyed(event);
                System.out.printf("====> [%s] 세션 만료됨 %s \n" , LocalDateTime.now(), event.getSession().getId());
            }

            @Override
            public void sessionIdChanged(HttpSessionEvent event, String oldSessionId) {
                super.sessionIdChanged(event, oldSessionId);
                System.out.printf("====> [%s] 세션 아이디 변경 %s \n" , LocalDateTime.now(), event.getSession().getId());
            }
        });
    }

HttpSessionEventPublisher는 세션 발행 이벤트를 담당하는 메서드이다  .ServletListenerRegistrationBean는  서블릿의 상태 변화를  감지하는 리스너 클래스이다 제네릭타입으로 상태를 확인하고 싶은 클래스를 지정한다 .  

세션 생성시 , 세션 만료시 , 세션 변경시를 출력하도록 메서드를 정의해 놨다 . 

이제 로그인을 해보자 

RememberMeAuthenticationFillter의 dofilter 메서드에 브레이크 포인트를 두고 RememberMeAuthenticationFillter의 동작방식을 알아보자 

먼저 로그인을 하고 ,  세션이 만료될때 까지 기달렸다 . 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

인덱스 페이지에서 세션이 만료되었기 때문에  다음 요청에 대해서는  RememberMeAuthenticationFillter를 통해 로그인이 이뤄질 것이다.  

요청을 다시해봤다

RememberMeAuthenticationFillter로 들어온 것을 확인할 수 있다.

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

스텝을 넘기면서 살펴보면  

RememberMeService에 authologin()메서드로 로그인을 요청하고 

autologin에서는 리퀘스트에서 쿠키를 꺼내고 디코드 한 후 쿠키안의 데이터로 토큰을 생성한다음

processAutoLoginCookie라는 메서드로 토큰의 만료기간을 확인하고 만료되지 않았다면 

UserDeatilsService에게 username을 넘겨  userDetails 객체를 얻어내고 리턴해준다. 

다음으로 userDetailsChecker를 통해 UserDetail에 대한 check가 일어나고 

userDetails의 정보로 서명값을 만들고  서명값이 브라우저에서 올라온  쿠키안에 토큰에서  값과 같은지 비교한 후에 같으면  authenticated를 true로 바꾸고 인증을 성공시킨 후에 토큰을 발급한다. . 

이때 토큰은 

UsernamePasswordAuthenticationToken이 아닌  RememberMeAuthenticationToken을 발급 받은 것을 알 수 있다.

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

그 다음으론 SecurityContextHolder에 RememberMeAuthenticationToken을 등록하고 서비스를 수행한다 . 

default로 TokenbasedRemmeberMeService가 실행 되는데 

TokenbasedRemmeberMeService의 경우 위에서 말한 것처럼 유효기간 , 유저아이디를 클라이언트가 전송한 토큰에  담겨있기 떄문

에 토큰탈취시 탈취한 토큰으로도 로그인이 가능하다  

토큰이 탈취 된다고 가정해보자 

원래 사용자가 로그인한 후에 발급받은 토큰은 아래와 같다.  

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

임의로 위의 쿠키를 다른 브라우저에 심어봤다. 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

user-page는 인증을 받아야 들어갈 수 있는 페이지 지만 탈취된 토큰으로 들어갈 수 있게 되었다 . 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

위의 문제의 경우 세션이 만료되거나 유저의 비밀번호가 변경되지 않는(서명값의 변경)이상 해당 토큰으로 로그인이 가능하다.

토큰이 탈취 되었을때 매번 비밀 번호를 바꿔야 한다 .

이를 해결하기 위해  PersistentTokenBasedRememberMeServices를 사용한다. 

PersistentTokenBasedRememberMeServices 구현

PersistentTokenBasedRememberMeService는 브라우저와 서버의 db 두 쪽 모두 token을 관리한다 

토큰의 형태는 TokenbasedRemmeberMeService와는 다르게 username 과 만료시간 대신 series라는 값과 서명값으로 이뤄져있다 

PersistentTokenBasedRememberMeServices를 구현해 보자 

Bean으로 PersistentTokenBasedRememberMeServices를 리턴해주는 메서드를 만들어준다 

이떄 PersistentTokenBasedRememberMeServices의  파라미터로는 

첫번째로 해당 서버에서 내려준다는 걸 확인시켜주는 인증키값을 넣고

(없으면 서버가 재구동 될때마다 인증키 값이 바뀌어 이전에 생성된 인증키 값이 작동하지 않는다 )

두번째 파라미터로는 UserDetails를 생성해줄 UserDetailsService ,

세번째로는 생성한 토큰을 DB에 넣거나 ,수정 , 제거 해줄  PersistantTokenRepository 를 넣어준다 . 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

PersistentTokenBasedRememberMeServices의 마지막인자로 PersistantTokenRepository를 제공하기위해  아래처럼

PersistentTokenRepository 리턴해주는 메서드를 Bean으로 등록해야 한다. 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

JdbcTokenRepositoryImpl은 PersistanceTokenRepository의 구현체이다 JDBC에 접근하여 생성한 토큰을 DB에 넣거나 ,수정 , 제거하는 기능을 제공한다. JdbcTokenRepositoryImpl을 들어가보면 테이블과 스키마들이 정의되어 있다.  JDBC에 접근하기 떄문에 application datasource를 주입해 줘야한다.   

이제 HttpSecurity에  .rememberMe()에 만들어낸 PersistentTokenBasedRememberMeService를 등록해주면 된다 . 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

이제부터는 rememberMeService가 TokenBased가 아닌 PersistentTokenRemeberMeService로 동작할 것이다. 

JdbcTokenRepositoryImpl은 처음에 initDao 메서드를 통해 Persistance_login이라는 테이블을 만들어서 토큰들을 관리한다.  

하지만 initDao의 테이블 생성쿼리가 if not exist 로 되어있지 않아서 테이블이 이미 존재한다면 문제가 생기기 때문에 위처럼 일종의 꼼수로 if not exist처럼 실행되도록 한 것이다 .  

JdbcTokenRepositoryImpl가 생성되고 try 문으로 들어오면 removeUserToken("1") 을 하게 된다.  이떄 테이블이 존재한다면

1이라는 아이디를 갖는 유저는 없기떄문에 해당 코드가 무시되고 ,  테이블이 없다면 에러가 터져  catch문의 setCreateTableOnStrartup이 실행되어 테이블이 생성될 것이다 . 

서버를 돌려서 확인해 보자 

h2 console을 확인해봤다 . 

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

Persistent_Logins 테이블이 생성되었다 .

디버깅을 통해 RememberMeAuthenticationFilter를 따라가보면 PersistanceTokenRepository의 구현체가 JdbcTokenRepositoryImpl 로 사용되는 것을 알 수 있다.  

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

로그인을 해보면 쿠키가 아래 처럼 발행되어 브라우저에 심어지는데  

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

Base64 Decoder로 확인해보면 이전의 TokenBased와는 다르게 출력되는 것을 알 수 있다.  

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji
PersistenceTokenBaseRemberMeToken 해독

위에서 설명했던 것처럼  : 을 기준으로 왼쪽에는 시리즈값 오른쪽에는 서명값이 위치한다 . username이나 토큰 유효기간이 표시되지 않는다 .

h2 console로 확인해보면 DB에도 remberMeToken이 저장되있는 걸 확인 할 수 있다.  

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji

세션이 만료되고 다시 RememberMeAuthenticationFilter가 동작하면 서버에서는 브라우저에서 가져온 쿠키를 열어 토큰을 확인하고  DB에 있는 토큰과 비교한 후에 두 토큰이 같다면 연속성이 있는것으로 간주하고 새로운 토큰을 발행해 DB의 토큰을 업데이트 시키고  브라우저에도 새로 발행한 토큰을 넘겨준다 .

Spring 로그인 세션 유지 - Spring logeu-in sesyeon yuji
재로그인시 토큰값이 바뀐것을 알 수 있다.

PersistentTokenBasedService는 이런 방식으로 토큰의 연속성을 확인하고 유지하기 떄문에 토큰이 탈취된다고 했을때 

탈취한 토큰으로 페이지에 한번 접근은 가능하지만 접근시 재로그인이 일어날 태고 토큰값이 바뀌어 버려 탈취한 사람은 바뀐 토큰을 브라우저 서버에서 모두 가지고 있지만   주인이 되는 사용자가 재로그인을 하게 됬을때는 DB의 토큰값은 바뀌었으나 브라우저의 토큰값은 그대로이기 때문에  토큰값의 불일치가 일어나게 된다 

이때 중요한 것은 위와 같은 토큰 불일치 상황이 발생했을 시에  PersistentTokenBasedService는 CookieTheftException 에러를 발생시키고 . DB에서 해당 유저에게 발급되었던 토큰들을 모두 제거해버린다. 때문에 탈취한 토큰이 유효하지 않게되며 탈취자는 해당 토큰은 사용하여 사이트에 접근할 수 없게 된다. 

한가지 더 장점은  해당 토큰이 DB의 테이블로 관리되기 떄문에 여러대의 서버에서 접근할 수도 있어 서버가 scale out 할때에도 훨씬 유리하다.