스프링 시큐리티 세션 (Session Management) 커스터마이징 개념 익히기

devsh·2021년 1월 31일
0

스프링 시큐리티의 세션 관련 레퍼런스를 확인하고 이후 스프링 시큐리티의 세션 관리에 대해서 다뤄보겠습니다.

또한 커스터마이징도 다뤄볼까합니다.

시작하겠습니다.

10.11. Session Management

HTTP 세션 관련 기능은 SessionManagementFilter와, 이 필터가 위임하는 SessionAuthenticationStrategy 인터페이스가 처리한다.

전형적으로 session-fixation 공격을 방어하고, 세션 타임아웃을 감지하고, 인증된 사용자가 동시에 열 수 있는 세션 수를 제한하는 등에 사용한다.

그럼 세션 관련해서 커스터마이징이 필요하다면 SessionManagementFilter 와 SessionAuthenticationStrategy 의

디폴트 구현체를 상속받거나, 구현해서 사용하면 된다는 의미네요.

10.11.1. Detecting Timeouts

스프링 시큐리티는 유효하지 않은 세션 ID를 제출하면 이를 감지해서 적절한 URL로 리다이렉트할 수 있다. 이는 session-management 요소로 설정한다.

<http>
...
<session-management invalid-session-url="/invalidSession.htm" />
</http>

이 메커니즘으로 세션 타임아웃도 감지하도록 설정했다면, 로그아웃한 사용자가 브라우저를 닫지 않고 다시 로그인했을 때 에러로 오인할 수 있다.

세션을 무효화할 때 세션 쿠키를 비우지 않으면 로그아웃했더라도 같은 쿠키를 제출하기 때문이다.

아래와 같은 방식으로 로그아웃할 때 로그아웃 핸들러에서 JSESSIONID 쿠키를 제거할 수 있다.

로그아웃이 JSSESIONID 쿠키를 제거하는 방식을 사용하는 걸 보아하니, 다른 방식의 세션을 적용할 때,

세션 로그아웃은 로그아웃 핸들러를 커스터마이징하면 된다는 의미이다.

<http>
<logout delete-cookies="JSESSIONID" />
</http>

안타깝게도 모든 서블릿 컨테이너에서 동작하는지는 보장할 수 없으므로, 맞는 환경에서 테스트해봐야 한다.

어플리케이션 앞단에 프록시가 있다면 프록시 서버 설정으로도 세션 쿠키를 삭제할 수 있다. 예를 들어 아래와 같이 로그아웃 요청에 대한 응답에서 아파치 HTTPD의 mod_headers로 JSESSIONID 쿠키를 만료시킬 수 있다 (어플리케이션을 /tutorial 경로로 배포했다고 가정):

<LocationMatch "/tutorial/logout">
Header always set Set-Cookie "JSESSIONID=;Path=/tutorial;Expires=Thu, 01 Jan 1970 00:00:00 GMT"
</LocationMatch>

10.11.2. Concurrent Session Control

스프링 시큐리티에서는 간단하게 몇 가지만 추가하면 같은 사용자가 여러 번 로그인할 수 없도록 제한할 수 있다.

먼저 web.xml 파일에 아래 리스너를 추가하면, 스프링 시큐리티는 세션 라이프사이클 이벤트를 통지받는다:

리스너를 추가해서 세션 라이프사이클을 수정할 수 있다라는 의미다.

<listener>
<listener-class>
    org.springframework.security.web.session.HttpSessionEventPublisher
</listener-class>
</listener>

그 다음 어플리케이션 컨텍스트에 다음을 추가해라:

<http>
...
<session-management>
    <concurrency-control max-sessions="1" />
</session-management>
</http>

이렇게 하면 같은 사용자는 로그인을 여러 번 할 수 없다 - 이후 다시 로그인하면 그 전 로그인을 무효로 만든다. 재로그인을 아예 방지하려면 다음과 같이 사용해라:

<http>
...
<session-management>
    <concurrency-control max-sessions="1" error-if-maximum-exceeded="true" />
</session-management>
</http>

이제 두 번째 로그인부터는 거부한다. “거부”란, 폼 기반으로 로그인한 사용자는 authentication-failure-url로 이동됨을 의미한다.

두 번째 인증이 “remember-me”같은, 상호작용이 없는 다른 메커니즘을 통한 인증이었다면, “unauthorized” (401) 에러로 응답한다.

에러 페이지가 따로 있다면 session-management 요소에 session-authentication-error-url 속성을 추가하면 된다.

폼 기반 로그인에서 사용하는 커스텀 인증 필터가 따로 있다면, 세션 동시 제어 설정을 명시해야 한다. 자세한 정보는 [세션 관리 챕터](https://godekdls.github.io/Spring Security/authentication/#1011-session-management)를 참고하라.

다시 말해서 HttpSessionEventPublisher 를 추가하고, session-management의 concurrency-control 의 max-sessions 을 1로 설정하면 로그인을

한번만 수행할 수 있게 할 수 있다는 것이다.

10.11.3. Session Fixation Attack Protection

이해를 돕기 위해 간단하게 Session fixation 에 대해서 설명하고 가겠습니다..

Session fixation (세션 고정)

  • 컴퓨터 네트워크 보안에서 세션 고정 공격은 한 사람이 다른 사람의 세션 식별자를 고정 (찾거나 설정) 할 수 있는 시스템의 취약성을 악용하려고 시도합니다.
  • 대부분의 세션 고정 공격은 웹 기반이며 대부분 URL 또는 POST 데이터에서 허용되는 세션 식별자에 의존합니다.

Session fixation 공격은 사이트에 접근해서 세션을 생성한 뒤, 다른 사용자가 이 세션으로 로그인하도록 유도한다 (세션 식별자를 파라미터로 가지고 있는 링크를 보내는 식으로).

스프링 시큐리티는 사용자가 로그인하면 자동으로 새 세션을 만들거나, 세션 ID를 바꿔서 이 공격을 방어한다.

방어할 필요 없거나 다른 요구사항과 충돌된다면, <session-management>session-fixation-protection 속성으로 설정을 바꿀 수 있다.

사용할 수 있는 옵션은 네 가지다.

  • none - 아무 일도 하지 않는다. 기존 세션을 유지한다.
  • newSession - “깨끗한” 새 세션을 만들고 기존 세션 데이터는 복사해 가지 않는다 (스프링 시큐리티 관련 속성은 복사한다).
  • migrateSession - 새 세션을 만들고 기존 세션 속성을 모두 새 세션으로 복사한다. 서블릿 3.0과 이전 컨테이너에서 디폴트로 사용한다.
  • changeSessionId - 새 세션을 만들지 않는다. 대신에 서블릿 컨테이너가 제공하는 방식으로 session fixation 공격을 방어한다 (HttpServletRequest#changeSessionId()). 이 옵션은 서블릿 3.1 (자바 EE 7)과 그 이상의 컨테이너에서만 사용할 수 있다. 구버전 컨테이너에서 이 옵션을 사용하면 예외가 발생한다. 서블릿 3.1과 이후 컨테이너에서 디폴트로 사용한다.

session fixation을 방어할 땐 어플리케이션 컨텍스트에서 SessionFixationProtectionEvent가 발생한다.

changeSessionId 옵션으로 설정하면 모든 javax.servlet.http.HttpSessionIdListener에도 통보하므로,

어플리케이션이 두 이벤트를 모두 수신 중이라면 주의해서 사용해야 한다. 추가 정보는 [세션 관리](https://godekdls.github.io/Spring Security/authentication/#1011-session-management) 챕터를 확인하라.

이 세션에서 반드시 기억하고 갈 부분을 짚어보겠습니다.

  1. 스프링 시큐리티는 사용자가 로그인하면 자동으로 새 세션을 만들거나, 세션 ID를 바꿔서 세션 고정 공격을 막는다.
  2. 세션 고정 공격에 대응하는 스프링 시큐리티의 방식은 4가지이다. 이 옵션은 session-fixation-protection 속성으로 변경 가능하다.

10.11.4. SessionManagementFilter

SessionManagementFilterSecurityContextRepository 컨텍스트를 SecurityContextHolder에 있는 현재 컨텍스트와 비교해서

현재 요청을 처리하는 동안 사용자를 인증했는지를 확인한다. 보통은 pre-authentication이나 remember-me같은 상호작용이 없는 인증에서 사용한다.

필터는 레포지토리에 인증 컨텍스트가 있으면 아무런 처리도 하지 않는다. 반대로 레포지토리에 인증 컨텍스트가 없고 스레드 로컬 SecurityContext에 (익명이 아닌)

Authentication 객체가 있다면, 이전 필터에서 인증한 것으로 간주한다. 이땐 설정해둔 SessionAuthenticationStrategy를 실행한다.

현재 사용자가 인증된 사용자가 아니면 필터는 유효하지 않은 세션 ID로 요청됐는지 확인해서 (예를 들어 타임아웃 등으로) 설정해둔

InvalidSessionStrategy가 있으면 실행한다. 보통은 고정된 URL로 리다이렉트하는 식으로 가장 많이 사용하며,

표준 구현체 SimpleRedirectInvalidSessionStrategy로 캡슐화한다. 후자는 [앞에서 설명한 것처럼](https://godekdls.github.io/Spring Security/authentication/#1011-session-management) 네임스페이스로 유효하지 않은 세션 요청을

리다이렉트할 URL을 설정할 때도 사용한다.

private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        if (request.getAttribute("__spring_security_session_mgmt_filter_applied") != null) {
            chain.doFilter(request, response);
        } else {
            request.setAttribute("__spring_security_session_mgmt_filter_applied", Boolean.TRUE);
            if (!this.securityContextRepository.containsContext(request)) {
                Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
                if (authentication != null && !this.trustResolver.isAnonymous(authentication)) {
                    try {
                        this.sessionAuthenticationStrategy.onAuthentication(authentication, request, response);
                    } catch (SessionAuthenticationException var6) {
                        this.logger.debug("SessionAuthenticationStrategy rejected the authentication object", var6);
                        SecurityContextHolder.clearContext();
                        this.failureHandler.onAuthenticationFailure(request, response, var6);
                        return;
                    }

                    this.securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response);
                } else if (request.getRequestedSessionId() != null && !request.isRequestedSessionIdValid()) {
                    if (this.logger.isDebugEnabled()) {
                        this.logger.debug(LogMessage.format("Request requested invalid session id %s", request.getRequestedSessionId()));
                    }

                    if (this.invalidSessionStrategy != null) {
                        this.invalidSessionStrategy.onInvalidSessionDetected(request, response);
                        return;
                    }
                }
            }

            chain.doFilter(request, response);
        }
    }

실제로 SessionManagementFilter 가 호출되어 사용되는 doFilter 메소드입니다. 직접 코드를 보면서 설명의 흐름을 다시 따라가보겠습니다.

  1. 필터가 호출되면, 이 SessionManagementFilter 가 적용되었는지 확인을 합니다. 적용되지 않았으면 체인의 다음 필터를 호출합니다.
  2. SecurityContextRepository 에 요청이 있는지 확인을 합니다. 없으면 인증 요청도 없었단 이야기임으로 다음 필터를 수행합니다.
  3. SecurityContextHolder에서 인증 정보를 얻어옵니다.
  4. 인증 정보의 유무에 따라 sessionAuthenticationStrategy 를 사용하거나, invaildSessionStrategy 를 적용합니다.

위에서 호출되는 각 Strategy는 디폴트로 사용되는 구현체들이 있겠죠. 전략 패턴을 사용함으로 필요에 따라 커스터마이징도 가능하다는 의미입니다.

10.11.5. SessionAuthenticationStrategy

SessionAuthenticationStrategySessionManagementFilter, AbstractAuthenticationProcessingFilter

둘 다 사용하므로 커스텀 폼 로그인 클래스를 만드는 등의 상황에선 둘 모두에 주입해줘야 한다. 네임스페이스와 커스텀 빈을 결합하는 전형적인 설정은 다음과 같다:

<http>
<custom-filter position="FORM_LOGIN_FILTER" ref="myAuthFilter" />
<session-management session-authentication-strategy-ref="sas"/>
</http>

<beans:bean id="myAuthFilter" class=
"org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter">
    <beans:property name="sessionAuthenticationStrategy" ref="sas" />
    ...
</beans:bean>

<beans:bean id="sas" class=
"org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy" />

디폴트 SessionFixationProtectionStrategy를 사용한다면, HttpSessionBindingListener를 구현한 빈을 세션에 저장하면 제대로 동작하지 않을 수 있으며,

스프링 세션 스코프 빈도 마찬가지다. 자세한 정보는 SessionFixationProtectionStrategy Javadoc을 참고해라.

여기서는 커스텀 폼 로그인 클래스를 만들고자 한다면 SessionManagementFilter, AbstractAuthenticationProcessingFilter 를 모두 주입해줘야 한다는 것입니다.

10.11.6. Concurrency Control

스프링 시큐리티는 사용자(principal)가 같은 어플리케이션에 동시에 인증할 수 있는 횟수를 제한할 수 있다.

많은 ISV는 이를 사용해서 라이센스를 관리하며, 네트워크 관리자들은 이를 통해 사람들이 로그인 이름을 공유하는 것을 막는다.

예를 들어 “Batman”이란 사용자가 세션 2개로 웹 어플리케이션에 로그인하지 못하게 막을 수 있다.

이전 로그인을 만료시키거나 재로그인 시 에러를 발생시키는 식으로 말이다.

두 번째 방법을 사용한다면 직접 로그아웃하지 않은 사용자는 (예를 들어 단순히 브라우저를 닫은 경우) 기존 세션이 만료되기 전까진 다시 로그인 할 수 없다는 점에 주의해야 한다.

동시성 제어는 네임스페이스로 지원한다. 최소 설정은 이전 네임스페이스 챕터를 확인하라. 설정 일부를 커스텀해야 할 수도 있다.

동시성 제어는 SessionAuthenticationStrategy를 구현한 ConcurrentSessionControlAuthenticationStrategy가 담당한다.

이전에는 ProviderManagerConcurrentSessionController를 설정해서 동시 인증을 체크했다. ConcurrentSessionController는 사용자가 허용하는 세션 횟수를 넘겼는지 체크한다. 하지만 이 방법은 HTTP 세션을 미리 만들어야 해서 바람직하지 않다. 스프링 시큐리티 3에서는 AuthenticationManager가 먼저 사용자를 인증한 다음, 인증에 성공하면 세션을 만들고 다른 세션을 열 수 있는지를 체크한다.

동시 세션을 제어하려면 먼저 web.xml에 다음을 추가해야 한다:

<listener>
    <listener-class>
    org.springframework.security.web.session.HttpSessionEventPublisher
    </listener-class>
</listener>

그다음 FilterChainProxyConcurrentSessionFilter를 추가해야 한다.

ConcurrentSessionFilter는 생성자에 2개의 인자가 필요하다.

일반적으로 SessionRegistryImpl을 가리키는 sessionRegistry와, 세션 만료 시 적용할 전략을 정의하는 sessionInformationExpiredStrategy다.

다음은 네임스페이스로 FilterChainProxy와 다른 디폴트 빈을 설정하는 예시다:

<http>
<custom-filter position="CONCURRENT_SESSION_FILTER" ref="concurrencyFilter" />
<custom-filter position="FORM_LOGIN_FILTER" ref="myAuthFilter" />

<session-management session-authentication-strategy-ref="sas"/>
</http>

<beans:bean id="redirectSessionInformationExpiredStrategy"
class="org.springframework.security.web.session.SimpleRedirectSessionInformationExpiredStrategy">
<beans:constructor-arg name="invalidSessionUrl" value="/session-expired.htm" />
</beans:bean>

<beans:bean id="concurrencyFilter"
class="org.springframework.security.web.session.ConcurrentSessionFilter">
<beans:constructor-arg name="sessionRegistry" ref="sessionRegistry" />
<beans:constructor-arg name="sessionInformationExpiredStrategy" ref="redirectSessionInformationExpiredStrategy" />
</beans:bean>

<beans:bean id="myAuthFilter" class=
"org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter">
<beans:property name="sessionAuthenticationStrategy" ref="sas" />
<beans:property name="authenticationManager" ref="authenticationManager" />
</beans:bean>

<beans:bean id="sas" class="org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy">
<beans:constructor-arg>
    <beans:list>
    <beans:bean class="org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy">
        <beans:constructor-arg ref="sessionRegistry"/>
        <beans:property name="maximumSessions" value="1" />
        <beans:property name="exceptionIfMaximumExceeded" value="true" />
    </beans:bean>
    <beans:bean class="org.springframework.security.web.authentication.session.SessionFixationProtectionStrategy">
    </beans:bean>
    <beans:bean class="org.springframework.security.web.authentication.session.RegisterSessionAuthenticationStrategy">
        <beans:constructor-arg ref="sessionRegistry"/>
    </beans:bean>
    </beans:list>
</beans:constructor-arg>
</beans:bean>

<beans:bean id="sessionRegistry"
    class="org.springframework.security.core.session.SessionRegistryImpl" />

web.xml에 리스너를 추가하면 HttpSession을 시작하고 종료할 때마다 스프링 ApplicationContextApplicationEvent를 발생시킨다.

세션이 끝나면 SessionRegistryImpl에 통지할 수 있기 때문에 매우 중요한 기능이다.

리스너가 없다면, 세션 허용치를 초과한 사용자는 다른 세션을 로그아웃하거나 타임아웃이 나도 절대 다시 로그인할 수 없을 것이다.

로그인 제어의 한 부분은 동시성 제어입니다. 매번 로그인시 세션을 생성한다면, 한번에 많은 사용자가 여러 번을 재로그인하는 경우 out of memory 가 발생하겠죠.

이를 제어하기 위해서는, 이전 로그인에 사용되었던 세션을 만료하거나, 로그인을 거부하는 방식을 사용해야 할 겁니다.

여기서 후자의 방식을 택한다면 레퍼런스에서도 설명했듯이, 개발자가 지정해둔 타임 아웃 시간이 끝날때까지 재로그인이 불가능합니다.

이러한 동시성 제어을 사용하기 위해서는 ConcurrentSessionControlAuthenticationStrategy 가 담당합니다.

따라서 이를 커스터마이징하기 위해서는 빈을 생성할때, 필드값에 아래와 같이 값을 부여해줘야 합니다.

<beans:bean class="org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy">
        <beans:constructor-arg ref="sessionRegistry"/>
        <beans:property name="maximumSessions" value="1" />
        <beans:property name="exceptionIfMaximumExceeded" value="true" />
    </beans:bean>

또한, FilterChainProxy 에 필터인 ConcurrentSessionFilter 를 추가해줘야 합니다.

이 필터는 생성자의 2개에 인자가 필요하다 했습니다.

세션을 등록하는 sessionRegistry 와 만료시 처리하는 sessionInformationExpiredStrategy 입니다.

위에 xml 에서는 필터의 만료 전략으로 valid 한 세션이 아니면 빈 생성할때 등록한 url 로 redirect 하게 하는 전략을 취하고 있습니다.

마지막으로 반드시 리스너를 추가해야 세션이 종료될때 이벤트를 발생시켜서 SessionRegistryImpl 이 처리할 수 있도록 해야합니다.

이 클래스에 메소드를 확인해보면 removeSessionInformation, registerNewSession 이라는 메소드로 세션들을 처리해줍니다.

Querying the SessionRegistry for currently authenticated users and their sessions

네임스페이스을 사용했던 일반 빈을 사용했던, 동시성 제어를 설정했다면 어플리케이션에서 직접 SessionRegistry를 참조할 수 있다. 따라서 사용자의 세션 수를 제한하고 싶지 않더라도 동시성 제어를 설정하는 건 나름의 가치가 있다. 세션을 제한하지 않으려면 maximumSession 프로퍼티를 -1로 설정하면 된다. 네임스페이스를 사용한다면 session-registry-alias 속성으로 내부에서 생성한 SessionRegistry의 alias를 설정할 수 있으며, 다른 원하는 빈에 주입할 때 참조로 사용할 수 있다.

getAllPrincipals() 메소드는 현재 인증된 사용자 리스트를 제공한다. getAllSessions(Object principal, boolean includeExpiredSessions) 메소드는 사용자의 세션 리스트를 SessionInformation 객체 리스트로 리턴한다. SessionInformation 인스턴스의 expireNow() 메소드를 호출하면 세션을 만료시킬 수도 있다. 세션을 만료시키면 사용자가 다시 돌아왔을 때는 다른 작업을 이어갈 수 없다. 어드민성 어플리케이션 등에선 이 메런 메소드가 유용할 것이다. 자세한 정보는 Javadoc을 참고하라.

profile
deep and deep

0개의 댓글