요즘 Spring Framework를 Java를 이용하는 환경 설정을 적용해보면서 기존에 내가 블로그에 올렸던 Spring Security 또한 Java로 환경설정 하는 식으로 적용해보고 있다. 이와 관련된 글도 블로그로 따로 올릴 예정이다. 그러나 아직은 정리를 좀더 해야 할 부분이 있어서 일단은 적용 과정에서 겪은 내용 중 하나를 써볼까 한다.

처음 블로그에 글을 썼을 당시의 Spring Security 버전이 3.2.4여서 일단 이것으로 Java Config도 적용해보기로 했다. Spring Security Reference 문서와 기타 문서들, 그리고 구글링을 통해 기존 DB로 인증관리 하는 식으로의 XML 설정들을 모두  Java로 바꾸는데 얼추 정리가 되었다. 정리가 되다보니 몇몇 기능을 확인하는 과정을 거치게 되었는데 그중 했던것이 바로 세션관리였다. 흔히 Spring Security에서 말하는 중복 로그인 방지 기능(사이트에 동시 로그인 하는 것을 제약을 걸어주는 기능)이 들어가 있는 바로 그 부분이었다. 기존 XML 설정 관련 글에서는 이 내용을 언급하질 않았었는데 그것은 그 기능이 필요없어서..라기보단 활용 빈도가 극히 낮을것이라는 생각이 컸다(관리자 사이트 개발하는 것이 아닌 한에는 사용자의 동시 접속에 제약을 거는 상황이 그리 없을것이라는 생각이 들었다) 그래서 Authentication(인증)과 Authorization(권한)에 중심을 두어 블로그에 글을 썼던 것이다. 어차피 Spring Security에서도 강조하는 부분이 인증과 권한이고 세션관리는 그에 따른 부가 기능 성격이 크기 때문인것도 있었다.


암튼 이 세션관리를 테스트 해 보면서 한가지 버그를 발견하게 되었다. 예전에 XML 설정때도 글로 작성은 해보진 않았지만 테스트는 해봤던것 같아서 그때는 별 생각없이 넘어갔던 부분이 버그로 발견되어서 당혹스러운 것도 있었다. 버그의 내용은 다음과 같은 것이다.


Spring Security에서 세션관리 라 함은 Spring Security 의 Authentication 과정에서 겪게되는 사용자 정보의 세션 보관에 대한 내용이 큰데 이걸 하다보니 로그인 한 사용자의 동시접속 제한을 구현도 할 수 있게 되었다. 그래서 세션관리라 하면 본래의 목적보다  로그인을 한 유저의 동시접속에 제한이 기능적으로 더 크게 부각이 되었다. Spring Security에서의 로그인 과정에서의 세션 처리라 우리가 흔히 하는 로그인 과정의 세션 처리(로그인 하면 사용자 정보를 세션에 보관하고 로그아웃하면 invalidateSession 하는 식의 처리)과 다름이 없다보니 오히려 동시 접속 제한 기능이 더 부각이 되었다고 생각한다.


암튼 이 동시접속을 테스트해볼려고 동시 접속자의 수를 1로 설정했다. 즉 어떤 사용자의 로그인 ID로 현재 로그인 된 상태면 그 로그인 ID가 로그아웃 되기 전까지는 그 로그인 ID로 사이트에 로그인 할 수 없는 것이다. 사용자의 로그인 ID로 로그인 한 상태에서 그 아이디를 이용해 다시 로그인을 시도할 경우 예외가 발생했다. 이 증상은 정상이다. 로그인이 이미 되어 있는 상태이기 때문에 같은 아이디로 로그인할려고 한거니까 예외를 던진것이다. 그런데 해당 아이디를 로그아웃을 하고 다시 그 아이디로 로그인을 할려고 하면 로그인이 안되는 것이었다. 어? 로그아웃을 했기 때문에 로그인이 되어야 하는데 로그인이 안되다니..


그래서 확인해보기로 했다. 먼저 세션이 invalidate가 되는지를 확인하기 위해 jsp 페이지에서 Session ID를 출력해서 이 부분을 검증하고 싶었다. 그래서 다음과 Spring Framework의 @ControllAdvice 어노테이션이 걸린 클래스에 @ModelAttribute 어노테이션이 붙은 메소드에서 Seesion ID를 구해서 jsp 페이지에서 이를 언제든 확인할 수 있게 했다


import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;

import com.terry.springconfig.vo.MemberInfoVO;

@ControllerAdvice
public class GlobalInitBinder {
	private Logger logger = LoggerFactory.getLogger(this.getClass());
	
	@InitBinder
	public void binder(WebDataBinder binder) {
		
	}
	
	@ModelAttribute("sessionId")
	public String getSessionId(HttpServletRequest request){
		String sessionId = request.getSession().getId();
		logger.debug("sessionId : " + sessionId);
		return sessionId;
	}
}

 

위와 같이 @ControllerAdvice 어노테이션이 붙은 클래스에 @ModelAttribute 어노테이션을 이용하여 모든 jsp 페이지에서 sessionId란 이름으로 현재 Session의 Session ID 값을 가져올수 있게 했다. jsp에서 출력해본 결과 jsp 쪽의 Request Session에는 아무 문제가 없었다. 로그아웃을 하면 로그인이 마쳐진 뒤의 Session ID 값이 아닌 새로운 값을 가져올 수 있었다. 이걸 통해 invalidate는 정상적으로 이루어지는 것을 알수 있었다.


그러면 남은 부분은 Spring Security가 로그인 한 세션부분을 관리하는 쪽에서 무슨 문제가 있는 것이 아닐까하는 생각이 들어서 Spring Security의 Log Level을 TRACE로 설정한뒤 따져보기 시작했다. 로그아웃을 한 뒤 로그인을 시도할 경우 나오는 로그들 중에서 다음의 문구가 있었다


DEBUG o.s.s.w.a.UsernamePasswordAuthenticationFilter - Authentication request failed: org.springframework.security.web.authentication.session.SessionAuthenticationException: Maximum sessions of 1 for this principal exceeded


이렇게 나오면 안되는 것이다 왜냐면 로그아웃을 했기 땜에 세션은 0개일텐데 왜 Maximum Session 수로 설정한 1을 넘는다고 하는 것인가? 일단 이 로그가 org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter 클래스에서 찍히기 때문에 UsernamePasswordAuthenticationFilter 클래스 소스에서 이 로그를 출력하는 부분을 찾아보았다. 그러나 UsernamePasswordAuthenticationFilter 클래스 소스에서는 이 로그를 출력하는 부분을 찾을수 없었다. 그래서 클래스 정의를 보니 이 클래스가 org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter를 상속받는 것을 알게 되어서 AbstractAuthenticationProcessingFilter 클래스 소스에서 로그를 출력하는 부분을 찾게 되었고 결국 다음의 메소드에서 출력하고 있는 것을 알게 되었다


protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
            AuthenticationException failed) throws IOException, ServletException {
        SecurityContextHolder.clearContext();

        if (logger.isDebugEnabled()) {
            logger.debug("Authentication request failed: " + failed.toString());
            logger.debug("Updated SecurityContextHolder to contain null Authentication");
            logger.debug("Delegating to authentication failure handler " + failureHandler);
        }

        rememberMeServices.loginFail(request, response);

        failureHandler.onAuthenticationFailure(request, response, failed);
    }


이 unsuccessfulAuthentication 메소드를 어디서 호출하고 있는 것일까? 이 메소드가 호출되는 위치는 AbstractAuthenticationProcessingFilter의 doFilter 메소드에서 다음과 같이 호출되고 있었다. UsernamePasswordAuthenticationFilter 클래스에서 doFilter 메소드에 대한 재정의를 하지 않았기 때문에 Filter 인터페이스에서 제공하는 doFilter 메소드를 호출하게 되면 AbstractAuthenticationProcessingFilter 클래스에서 정의된 doFilter 메소드를 호출하게 되는 것이다

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {

        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        if (!requiresAuthentication(request, response)) {
            chain.doFilter(request, response);

            return;
        }

        if (logger.isDebugEnabled()) {
            logger.debug("Request is to process authentication");
        }

        Authentication authResult;

        try {
            authResult = attemptAuthentication(request, response);
            if (authResult == null) {
                // return immediately as subclass has indicated that it hasn't completed authentication
                return;
            }
            sessionStrategy.onAuthentication(authResult, request, response);
        } catch(InternalAuthenticationServiceException failed) {
            logger.error("An internal error occurred while trying to authenticate the user.", failed);
            unsuccessfulAuthentication(request, response, failed);

            return;
        }
        catch (AuthenticationException failed) {
            // Authentication failed
            unsuccessfulAuthentication(request, response, failed);

            return;
        }

        // Authentication success
        if (continueChainBeforeSuccessfulAuthentication) {
            chain.doFilter(request, response);
        }

        successfulAuthentication(request, response, chain, authResult);
    }


이 doFilter 메소드를 보면 로그를 출력하는 메소드인 unsuccessfulAuthentication 메소드를 예외가 발생하게 되는 경우 try-catch의 catch 부분에서 호출하고 있다. 그래서 try 안에 중단점을 걸고 어떤 예외가 발생되어서 catch 부분의 unsuccessfulAuthentication 메소드를 호출하게 되는지 알아보면 되는 것이다. Eclipse에서 try 안에 있는 authResult = attemptAuthentication(request, response); 이 부분에 중단점을 걸은 뒤 디버그 모드로 서버를 구동시켜 추적을 해보자. 이렇게 추적을 해보면 중단점을 걸은 부분은 예외가 발생하지 않았다. 다음 줄인 authResult가 null인지를 체크하는 부분도 잘 통과가 되었다. authResult가 null이 아닌 특정 객체로 return을 받았기 때문이다. 이 글을 읽고 있는 독자분들께 부탁의 말이 있다면  authResult = attemptAuthentication(request, response);  이 부분을 Step Into로 계속 파고들어가면서 디버깅 해보기 바란다. Step Into로 파고 들면서 디버깅 해보면 authResult가 무엇을 return 하게 되는지 알기 때문이다. 미리 말씀 드리면 authResult가 리턴받은 값은 로그인 한 사용자의 인증정보를 받게 된다. 즉 if문이 하는 역할은 인증정보가 없어서 null을 받게 되면 다음단계를 진행하지 않기 위해 그냥 return 하게 되는 것이다. 그럼 if 문 다음에 있는 줄인  sessionStrategy.onAuthentication(authResult, request, response); 이 부분에서 예외가 발생하게 되는 것일까? Step Into로 들어가서 확인을 해보자.


변수 sessionStrategy SessionAuthenticationStrategy 인터페이스 타입으로 AbstractAuthenticationProcessingFilter 클래스의 멤버변수로 정의되어 있으며 멤버변수로 정의되는 시점에 다음과 같이 NullAuthenticatedSessionStrategy 클래스 객체로 초기화가 된다


private SessionAuthenticationStrategy sessionStrategy = new NullAuthenticatedSessionStrategy();


그러나 sessionStrategy에 대한 setter 메소드가 존재하기 때문에 SessionAuthenticationStrategy 인터페이스를 구현한 클래스로 다시 재정의가 될 여지가 있기 때문에 이클립스의 디버그모드에서 이 변수가 어떤 타입의 객체를 받았는지 확인해볼 필요가 있다. 실제로 확인해보면 sessionStrategy 변수에 할당된 객체는 org.springframework.security.web.authentication.session.CompositeSessionAuthenticationStrategy 클래스 객체가 된다. Step Info로 한번 파고 들어가보자.


CompositeSessionAuthenticationStrategy 클래스의 onAuthentication 메소드는 다음과 같이 정의되어 있다.


public void onAuthentication(Authentication authentication,  HttpServletRequest request, HttpServletResponse response)  throws SessionAuthenticationException {
        for(SessionAuthenticationStrategy delegate : delegateStrategies) {
            if(logger.isDebugEnabled()) {
                logger.debug("Delegating to " + delegate);
            }
            delegate.onAuthentication(authentication, request, response);
        }
}


코드를 보면 delegateStrategies 변수의 값들을 하나하나  꺼내서 onAuthentication 과정을 거치고 있다. 그러면 delegateStrategies 변수가 어떤건지 확인해봐야겠다. 이 변수는 CompositeSessionAuthenticationStrategy 클래스에서 다음과 같이 멤버변수로 정의되어 있다.


private final List<SessionAuthenticationStrategy> delegateStrategies;

변수를 선언한 시점에 초기화를 하지 않았고 또한 final 연산자가 붙어있기 때문에 이 변수를 초기화 할 수 있는 시점은 생성자외에는 없다. 그래서 이 클래스의 생성자를 보면List<SessionAuthenticationStrategy> 객체를 파라미터로 받아 이 멤버변수를 초기화하는 것을 알 수 있다. 이 클래스의 역할을 짐작해보면 여러개의 SessionAuthenticationStrategy 인터페이스를 구현한 클래스들을 이용해서 인증 작업을 시도할려고 할 경우 이렇게 CompositeSessionAuthenticationStrategy 클래스에 해당 클래스들을 넣은 List 객체를 생성자로 넣어서 인증과정을 하게끔 만든것으로 생각해볼수가 있다. 암튼 이 부분을 파보면 delegateStrategies 변수에 들어있는 객체중 첫번째에 들어있는 org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy 객체의 onAuthentication 매소드를 실행할려고 하게 된다. 그럼 이 클래스의 onAuthentication 메소드를 살펴보자. 이 메소드의 소스는 다음과 같다


    public void onAuthentication(Authentication authentication, HttpServletRequest request,
            HttpServletResponse response) {

        final List<SessionInformation> sessions = sessionRegistry.getAllSessions(authentication.getPrincipal(), false);

        int sessionCount = sessions.size();
        int allowedSessions = getMaximumSessionsForThisUser(authentication);

        if (sessionCount < allowedSessions) {
            // They haven't got too many login sessions running at present
            return;
        }

        if (allowedSessions == -1) {
            // We permit unlimited logins
            return;
        }

        if (sessionCount == allowedSessions) {
            HttpSession session = request.getSession(false);

            if (session != null) {
                // Only permit it though if this request is associated with one of the already registered sessions
                for (SessionInformation si : sessions) {
                    if (si.getSessionId().equals(session.getId())) {
                        return;
                    }
                }
            }
            // If the session is null, a new one will be created by the parent class, exceeding the allowed number
        }

        allowableSessionsExceeded(sessions, allowedSessions, sessionRegistry);
    }


이 메소드에서 파라미터로 넘기고 있는것은 우리가 아이디와 패스워드를 입력하여 받게 되는 Spring Security가 만들어주는 Authentication(인증) 정보들이 들어있는 객체와 HttpServletRequest 객체와 HttpServletResponse 객체이다.이 코드의 첫번째 줄에 있는 sessionRegistry 변수는 ConcurrentSessionControlAuthenticationStrategy 클래스의 멤버변수로 다음과 같이 정의되어 있다


private final SessionRegistry sessionRegistry;


이 멤버변수 또한 final로 선언되어 있어서 생성자를 통해 값을 받고 있다. 이 변수는 SessionRegistry 인터페이스를 구현한 클래스 객체가 오게 되는데 실제 오게 되는 클래스 객체는 org.springframework.security.core.session.SessionRegistryImpl 클래스 객체가 오게 된다. 이 SessionRegistryImpl 클래스가 하게 되는 역할을 먼저 알아둘 필요가 있는데 이 객체는 사용자의 인증정보와 그 인증정보가 가지고 있는 세션들(한 사람의 사용자가 여러 세션을 생성하면서 들어올수도 있다. 브라우저를 서로 달리한뒤 각각 로그인 하게 되면 하나의 사용자가 2개의 서로 다른 세션 ID를 갖게 되는 이치이다)의 Session ID 값들을 Map 객체에 넣어서 보관하게 된다. 정확하게 얘기하면 Spring Security에서 사용하는 사용자 정보 객체인 Principal 객체를 Key로 하고 해당 사용자가 가지고 있는 Session ID 값들이 들어가 있는 Set 객체를 Value로 하는 ConcurrentMap에 보관하게 된다. 이런 구조로 보관함으로써 해당 사용자가 가지고 있는 Session ID 값들을 알수 있게 되는 것이다. 실제로 이 내용을 저장하도록 SessionRegistryImpl 클래스는 다음의 멤버변수들을 가지고 있다.


private final ConcurrentMap<Object,Set<String>> principals = new ConcurrentHashMap<Object,Set<String>>();
private final Map<String, SessionInformation> sessionIds = new ConcurrentHashMap<String, SessionInformation>();


이것들도 final로 선언되어 있지만 선언시에 초기화를 시켰기 때문에 위에서 언급했던 final 변수들과는 달리 외부에서 생성자를 통해 값을 주입하는게 아닌 SessionRegistryImpl 클래스의 메소드들이 이 변수의 내부 값들을 변경하게 된다. principals 변수에 들어가는 형태는 사용자 정보를 의미하는 Pricipal 인터페이스를 구현항 객체를 key로 하고 이 사용자 정보와 연관된 Session ID 값들이 들어있는 Set 객체를 Value로 하는 Map이다. sessionIds 변수에 들어가는 형태는 Session ID를 key로 하고 해당 Session에 대한 정보를 가지고 있는 SessionInformation 클래스 객체를 Value로 하는 Map이다, 암튼 다시 원점으로 돌아와서 checkAuthenticationAllowed 메소드를 분석해보자. 첫번째로 다음과 같은 코드가 있다


final List<SessionInformation> sessions = sessionRegistry.getAllSessions(authentication.getPrincipal(), false);


코드의 메소드명을 가지고 추측해보면 이 코드가 하는 일은 인증정보에서 사용자 정보를 빼내온뒤 이를 파라미터로 넘겨서 사용자가 갖고 있는 모든 세션 정보를 넘겨받는 느낌을 준다. 실제로 그러한지 SessionRegistryImpl 클래스의 getAllSessions 메소드를 보도록 하자


public List<SessionInformation> getAllSessions(Object principal, boolean includeExpiredSessions) {
        final Set<String> sessionsUsedByPrincipal = principals.get(principal);

        if (sessionsUsedByPrincipal == null) {
            return Collections.emptyList();
        }

        List<SessionInformation> list = new ArrayList<SessionInformation>(sessionsUsedByPrincipal.size());

        for (String sessionId : sessionsUsedByPrincipal) {
            SessionInformation sessionInformation = getSessionInformation(sessionId);

            if (sessionInformation == null) {
                continue;
            }

            if (includeExpiredSessions || !sessionInformation.isExpired()) {
                list.add(sessionInformation);
            }
        }

        return list;
    }


예상대로 해당 사용자가 가지고 있는 모든 세션 정보(SessionInfomation)을 return 하게 해준다. getSessionInformation 메소드에 특정 session id 값을 parameter로 넘겨주어 그에 대한 세션 정보(SessionInformation) 객체를 받아오고 있다. 여기서 한가지 알아두어야 할 것이 있다. parameter로 받은 includeExpiredSessions이다. 사용자가 여러 세션을 가질수 있다고는 위에서 언급했었다. 그러다보니 그런 세션들중 만기가 된 세션들도 분명 있을것이다. 그러면 Spring Security는 만기가 된 세션의 정보를 없애는 것이 아니라 이 세션은 만기가 되었다고 표시를 세션 정보(SessionInfomation)에 기록해둔다. 실제로 org.springframework.security.core.session.SessionInformation 클래스의 소스를 보면 이를 보관하는 boolean 타입의 expired란 멤버변수가 있다. 암튼 getAllSessions 메소드를 호출할때 includeExpiredSessions를 false로 했으므로 만기되지 않은 세션정보들만 넘어오게 된다.


이제 checkAuthenticationAllowed 메소드의 다음줄을 보자. 다음줄은 다음과 같이 되어 있다


int sessionCount = sessions.size();


단순하게 보면 위에서 구한 만료되지 않은 세션 정보들의 갯수들을 가져오고 있다. 근데 여기에서 버그가 시작됨을 알수있게 된다. 위에서 언급했던 테스트 상황을 생각해보자. 로그인을 한 뒤에 로그아웃을 하고 다시 로그인을 하는 과정에서 우리는 위의 코드들을 디버깅하고 있는 중이다. 그러면 sessionCount는 얼마가 들어있어야 하는가? 로그아웃을 했기 때문에 만료되었을 것이고 만료된 세션은 List 객체에 넣지 않기 때문에 0이어야 한다. 근데 이 코드를 실행해보면 sessionCount는 1을 가지고 있음을 디버깅 하면서 알게 된다. 어? 왜 1을 가지지? 만료된 세션은 List 객체에 넣지 않는다고 했는데..이제 sessions 변수 안을 파고 들어가보자. 이 변수를 파고 들어가보면 예전 로그인을 한 뒤의 Session ID 값을 가진 SessionInformation 객체가 들어있다. 근데 문제는 이 객체의 만료여부를 기록하는 멤버변수인 expired 멤버변수가 false로 되어 있다는 것이다. 로그아웃을 거쳤기 때문에 expired 멤버변수는 true로 셋팅되어 있어야 하는데 맞는데도 로그아웃을 거친뒤에도 expired 변수값을 true로 셋팅하지 않고 있는 것이다. 이 부분때문에  checkAuthenticationAllowed 메소드가 전반적으로 오동작을 일으켜서 로그인을 한뒤 로그아웃을 하고 다시 로그인을 하는데도 로그인이 안되는 증상이 생기고 있는것이다.


자 다시 정리해보자. 로그인을 하기전엔 A라는 세션 ID를 가지고 있을 것이다. 사용자 U로 로그인을 거치게 되면 세션 ID를 B로 재할당을 받게 되고 세션 ID B는 SessionInformation 클래스 객체로 만들어지게 되며 사용자 U는 방금 만든 B SessionInformation 클래스 객체를 갖는 형태로 Spring Security에서 관리가 된다. 이제 사용자 U를 로그아웃을 하게 되면 세션 ID B를 저장하고 있는 SessionInformation 클래스 객체의 expired 변수값을 true로 해서 저장을 한뒤 세션이 invalidate 되기 때문에 세션 ID C를 할당받게 된다. 로그인 한뒤 로그아웃을 했기땜에 사용자 U로 로그인 된 세션은 없다. 이 상태에서 다시 로그인을 시도하게 되면 Spring Security가 사용자 U와 연관된 SessionInformation 객체의 세션 ID B는 expired 되어 있는 상태이기 때문에 Spring Security 설정시 동시 접속자수를 1로 설정되어 있는 경우라면 로그인이 되어야 하는 것이 맞다. 그러나 세션 ID B에 대한 SessionInformation 객체의 expired 멤버변수가 계속 false로 유지되고 있기 때문에 아직 접속중이라고 판단하고 로그인을 할 수 없다는 식의 예외 동작을 하게 되는 것이다.


장황하게 설명이 길었는데, 이 부분이 이해가 안된다면 Eclipse에서 ConcurrentSessionControlAuthenticationStrategy 클래스의 onAuthentication 메소드가 정의된 부분의 첫번째 줄에 중단점을 걸고 디버깅을 하면서 만나게 되는 각 값들의 내부 상태값을 일일이 확인하면서 이 글을 살펴보길 바란다. 실제로 이 글을 쓰는 과정에서도 이 방법을 사용하여 설명했다. 그럼 이 버그를 어떻게 해결해야 하는가? expired 변수를 true/false로 설정하는 것은 외부에서 설정하는게 아니라 Spring Security가 하기 때문에 우리가 인위적으로 설정할수 있는 부분이 없다. 그리고 이 블로그에서는 Spring Security 3.2.4를 사용하고 있으나 이 문제는 Spring Security 3.2.3에서도 발생하고 있는 문제였다. 구글링을 하면서 나와 같은 상황을 접한 사용자가 올린 질문을 찾게 되었다.


http://stackoverflow.com/questions/23455175/spring-security-3-2-and-maximumsessions-logging-out-not-updating-sessionregist


지금 보는 글에서는 언급되지 않은 부분이 있으나 위의 링크 글에는 언급된 부분이 있는데, 로그인을 하게 되면 SessionRegistryImpl 클래스의 registerNewSession 메소드를 실행하여 위에서 언급했던 멤버변수인 sessionIds 멤버변수에 세션 정보를 넣고 있다. 이런 구조라면 로그아웃을 하는 시점엔 sessionIds 멤버변수에 넣은 세션정보를 지우거나 또는 expired 멤버변수를 true로 설정하는 작업을 해주는 부분이 있어야 하는데 그런 작업을 하지 않고 있다. 이 부분에 대해서는 로그아웃을 하는 부분에 대해 디버깅을 걸고 추적해보면 알수 있다. 그래서 동시성 제어에 대한 버그가 존재하고 있는 것이다.


마지막으로 해결책을 얘기하고 이 글을 마치고자 한다. 하루를 끙끙대보았으나 의외로 해결책은 간단했다. Spring Security를 버전업 해주면 된다. 현재 Spring Security 3.2.X 계열 버전으로 가장 최신 버전은 Spring Security 3.2.7 버전인데 이 버전으로 업그레이드를 하니 위와 같은 동시성 제어 버그가 해결이 되었다. 각 버전이 업그레이드 될때마다 버그 fix 관련 내용을 정리를 해서 내보내게 되는데 아직 이 부분에 대해 언급한 내용을 찾질 못했다. 3.2.5, 3.2.6, 3.2.7 중 한군데서 해결된거 같은데 어느 버전인지는 모르겠다. 머 모든 버전에서 테스트해보면 알수 있겠지만..굳이 그래야 할까 싶다. 하지만 그래도 변경된 점을 한번 체크해보기로 했다(갠적으로 궁금해서..)


일단 로그아웃을 하게 되면 로그아웃 당시의 세션값도 보관하는 식이었으나 지금은 보관하지 않게끔 바뀌어서 하나의 브라우저로 테스트를 진행할 경우 principals에는 아무 값도 들어있지 않게 된다. 그래서 나중에 getAllSessions 메소드 실행시 비어있는 List 객체를 return 하기 때문에 전체적으로 정상동작이 이루어지게 되는 것이었다. 정리하자면 내부 동작 방식에 수정을 가해서(expired 된 세션 정보에 대한 보관을 하지 않는 식으로..) 정상 동작이 되도록 했다.



  • BlogIcon 태준아빠 2015.04.06 15:49 신고

    중복 로그인 관련해서 다시한번더 문의 드립니다.

    제가 셋팅한 프로젝트에서는 중복로그인 체크가 아예 되질 않습니다.

    <session-management invalid-session-url="/error3.do" >
    <concurrency-control max-sessions="1" session-registry-ref="sessionRegistry" error-if-maximum-exceeded="true" expired-url="/error3.do"/>
    </session-management>
    이부분에서 max-sessions 가 적용 되질 않는듯 합니다.

    다른 브라우져를 띄워놓고 로그인 하면 같은 아이디로 계속 로그인이 됩니다.

    어디를 다시 봐야 할지 조언좀 부탁 들립니다.

    java 1.6
    spring security 3.2.7버젼 입니다.
    springframework 3.2.9 버젼입니다.

    감사합니다!

    1. BlogIcon 메이킹러브 2015.04.06 16:12 신고

      안녕하세요..
      음..버전업을 하셔도 그런 증상이 있으시다는거죠..?
      제 글을 읽어보시면 이 부분의 시작점은 AbstractAuthenticationProcessingFilter 의 doFilter 메소드에서 시작합니다..

      sessionStrategy.onAuthentication(authResult, request, response);

      이 부분에서 중단점을 걸고 디버그를 진행하시면서 파고 들어가셔야 되요..
      만약 이 중단점에서 break가 걸리지 않았다면 그 보다는 앞단계..
      즉 인증 단계에서 에러가 발생되었다고 생각이 됩니다..
      그 이전단계라는게..위에서 언급한 코드 이전 부분에서 실행하는 과정에서 예외가 발생되어서 Filter 클래스를 빠져나갔거나..
      또는 AbstractAuthenticationProcessingFilter 클래스를 상속받아 이 작업을 진행하고 있는 UsernamePasswordAuthenticationFilter 클래스를 접근하기 전에 작업되는 Filter 클래스에서 예외가 발생되어서 UsernamePasswordAuthenticationFilter에 도달되지 못한거죠..

      암튼 저도 이 글을 쓸때 위에서 언급한 코드부터 중단점을 찍고 파고 들어가면서 파고 들어간 클래스의 멤버변수와 로칼변수의 클래스 타입과 값들을 따져가며 비교했거든요..

      지금 상황을 읽어봐서는 1개로 하든 2개로 하든..
      아예 비교작업 자체를 하지 않는 것으로 보이는것 같은데요..
      흠..글쎄요..제가 직접 태준아빠님 코드를 디버깅해보지 않는 이상에는 더 자세히는 알 수가 없고요..
      SpringFramework 관련 소스도 다운로드 받을수 있게끔 Maven 셋팅도 되신거죠..?
      그러면 제가 방금 말씀드린 이 부분부터 중단점을 찍고 비교하면서 들어가보세요..

      그리고 테스트 과정에 로그아웃도 포함되어 있으면 로그인, 로그아웃을 한 뒤의 세션 ID를 화면에 직접 출력시켜보시고 로그아웃을 한 뒤의 새로운 세션 ID를 받았는지의 확인도 해보셔야 합니다..
      만약 그게 같은 ID를 가지고 있다면 로그아웃 하는 과정에서 Session invalidate가 안되고 있다는 거거든요..
      그렇게 된 경우면 Filter를 Logout Filter를 보고 Session을 invalidate 하고 있는지에 대한 확인을 하셔야 합니다..

      일단 테스트 시나리오를 한번 잡아보신뒤에..
      그 시나리오대로 진행해보시면서..
      디버깅을 진행하셔야 좀더 확인이 될 듯 합니다..
      아..그리고 로그레벨을 TRACE로 하셔서 spring security 관련 로그를 전부 출력하도록 하세요..
      그래야 로그를 통해 이상징후를 발견하고 그게 어느 클래스에서 출력되었는지 알수 있습니다..
      로그 레벨 조정은 logback 으로 설명드리면..

      <logger name="org.springframework.security" level="trace" additivity="false">
      <appender-ref ref="console" />
      </logger>

      이런식으로 org.springframework.security로 시작하는 패키지에 속하는 모든 클래스의 로그 레벨을 trace로 하면 모든 로그를 다 찍습니다..
      이 부분은 사용하시는 로그 라이브러리에 맞춰서 셋팅하시구요..
      로그를 다 출력하시고 로그를 한줄한줄 보면서 미심쩍은 로그 구문 나온부분을 찾은게 있으면 거기 클래스 소스에서 그 로그 출력하는 부분을 보고 그 로그가 catch 문에 있으면 try 시작하는 부분 첫번째에..catch 문이 아니면 거기 바로 윗줄에 중단점을 걸으세요..

      설명이 좀 뒤죽박죽 됐지만..디버그하시면서 찾아보시는 방법뿐이 없어서요..
      그래서 디버그 위주로 설명드렸습니다..

    2. BlogIcon 세상여백 2015.10.19 17:44 신고

      저같은 경우

      UserDetails를 구현한 클래스에 다음의 메소드들을 오버라이드해서
      equals가 동작하도록 처리하니 세션 동시접속 제한이 가능하더군요.

      public class XXXUserDetails implements UserDetails {

      private Integer ID;
      /*** 나머지 필드 ***/

      @Override
      public int hashCode() {

      return (ID != null ? ID.hashCode() : 0);
      }

      @Override
      public boolean equals(Object obj) {

      if (!(obj instanceof XXXUserDetails)) {
      return false;
      }

      XXXUserDetails other = (XXXUserDetails) obj;
      if ((this.getID() == null && other.getID() != null) ||
      (this.getID() != null && !this.getID().equals(other.getID()))) {
      return false;
      }

      return true;
      }

      /*** 나머지 코드 ***/
      }

      덧붙여 좋은 글을 써주신 블로그 주인님께 감사의 말씀 드릴게요.

  • 2015.04.07 22:00

    비밀댓글입니다

    1. BlogIcon 메이킹러브 2015.04.07 23:40 신고

      안녕하세요..
      설정만으로는 원인 파악및 수정에 한계가 있습니다..
      xml 태그 설정으로 하게 되면 해당 태그에 물려들어가는 클래스를 실행하는 것이기때문에..
      정확하게 짚어낼려면 클래스를 디버깅해서 원인이 무엇인지 파악을 하셔야 되요..
      중복 로그인 처리가 안되는 근본적인 원인을 파악을 못한채 세션값을 지우는 식으르 작업하시게 되면..
      다른 문제가 발생할 소지도 있을것 같네요..
      그러나 저로서도 일단 상황을 모르다보니 머라 조언을 드리기가 그렇네요..이전 댓글에서 말씀드렸다시피 중단점을 걸고 클래스를 하나하나 스텝 밟아가며 진행해보셔야 파악이 될꺼라고 생각이 드네요..

      롤 문제와 관련해서는 이것도 내용을 좀 봐야 알겠지만..제 글을 읽어보시면 아시겠지만..
      ORDER 컬럼을 준 이유는 URL 매핑시 패턴으로 매핑하는 경우가 있을수 있습니다../board/board.do 란 구체적인 URL이 있을수 있지만../board/bo* 이런식으로의 패턴도 존재할수가 있죠..
      Spring Security에서는 detail한것(/board/board.do가 /board/bo*보다는 더 detail하죠)을 매칭 우선순위로 올려줘야 하기때문에 order 컬럼을 주에 detail 한 URL의 ORDER 컬럼 값을 낮은값을 주고 URL 패턴 형식에 대한 ORDER 컬럼 값을 큰 값으로 준뒤 ORDER 컬럼으로 order by를 하면 detail한 URL의 매칭 순위를 올려주겠죠..
      이 기준에 따라 URL에 대한 ROLE을 얻는다고 보시면 됩니다..
      제가 봤을땐 그 부분에서 order 정렬이 잘못되어서 그런 현상이 있는것 같네요..

  • BlogIcon dalasian 2015.09.17 16:58 신고

    spring security 관련된 글 잘보았습니다.

    그런데 첫번째 댓글을 보고 지금 까지 올린 글을 바탕으로 테스트를 해보니 중복로그인이 안되는 문제가 발생하여 이를 해결하고자 spring security의 ConcurrentSessionFilter 를 확인해 보았습니다.
    자세한 내용은 이미 본문에 잘 나와있기때문에 결론부터 얘기하자면 이전에 올리신 글 중 UserDetails interface를 상속받는 MemberInfo 클래스에서 객체의 같음을 비교하는 equals 메소드를 재정의하는 부분이 빠졌음을 알게되었습니다. SessionRegistryImpl 클래스의 전역변수인 principals의 사용자 세션정보를 가져오는 getAllSessions에서 서로 다른 MemberInfo 객체를 비교하기 위해서 equals 메소드를 재정의 해야 하기 때문입니다. 관련된 코드는 org.springframework.security.core.userdetails.User 클래스를 참고하였습니다.
    그리고 본문 중 checkAuthenticationAllowed 메소드는 onAuthentication 메소드가 아닐까 하는 생각이 듭니다. 본문에서 한번도 언급된적이 없는 checkAuthenticationAllowed 메소드가 중간에 갑자기 나온것도 그렇고 관련 코드들도 onAuthentication 메소드와 일치하는 걸로 보아 착오가 있는게 아닐까 생각 합니다.
    중복 로그인과 관련된 부분은 본문의 주제와 달라 어디에 써야 좋을지 고민하다 이곳에 남깁니다. 혹시 제가 잘못 알고있거나 본문에 언급되었지만 제가 놓친부분이 있다면 너그러이 이해해 주시길 부탁드립니다.
    앞으로도 많은 활동 부탁드리고 좋은 글 잘 보고갑니다.

    1. BlogIcon 메이킹러브 2015.10.09 17:20 신고

      댓글을 지금에서야 봤네여..정말 죄송합니다..
      실은 제가 이 부분을 Spring Security 버전업을 하면 해결이 됐던지라 그 후에는 더 집중적으로 파보지를 않아서 그 부분까지는 몰랐습니다..
      지금은 제가 JPA 관련 공부중이라 따로 볼 시간은 없지만 차후에 다시 살펴보도록 하겠습니다..
      그리고 checkAuthenticationAllowed 메소드는 onAuthentication 메소드가 맞습니다. 이건 제가 잘못적었네여. 이렇게 일관적으로 적었을때는 먼가 이유가 있을것 같긴 한데..현재로써는 왜 그렇게 썼는지 모르겠어요..너무 무책임한 답변이라 죄송합니다..ㅠㅠ..
      본문 내용도 일단 좀더 파악하고 수정해야겠네요..정말 죄송합니다..

  • BlogIcon 호구형아 2017.09.11 13:00 신고

    안녕하세요 요즘 Spring Seceurity 관련해서 이 블로그를 보며 배우고 있는 초급자 입니다.
    중복로그인 방지를 위해 maxSession 설정까지는 잘 됐는데요
    로컬에 같은 어플리케이션을 포트만 바꿔 (localhost:8080, localhost:8090) 올려놓고 테스트를 하고 있는데요 8080으로 로그인 후 8090 로그인을 하면 8080접속이 끈기고 이후 8090도 끈기는 현상이 있습니다.
    혹시 이런경우 어떻게 처리해야할지 알고 계신가요?

트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Spring Security 카테고리의 포스트 목록을 보여줍니다