프로그래밍/Spring Security 검색 결과

18개 발견
  1. 미리보기
    2015.04.06

    Spring Security 3.2.4 적용에서 겪은 세션 동시성 관련 로그인 버그와 해결책..

  2. 미리보기
    2014.08.27

    Spring Security에서 설정하는 Access Denied 페이지

  3. 미리보기
    2014.08.26

    Spring Security에서 DB를 이용한 자원 접근 권한 설정 및 판단 (2)

  4. 미리보기
    2014.08.19

    Spring Security에서 DB를 이용한 자원 접근 권한 설정 및 판단 (1)

  5. 미리보기
    2014.08.08

    Spring Security의 자원 접근 판단에 대한 설명

  6. 미리보기
    2014.08.08

    권한에 대한 설계 및 사상

  7. 미리보기
    2014.08.06

    Spring Security에서의 비밀번호를 암호화시켜 적용해보자

  8. 미리보기
    2014.08.05

    Spring Security에서 로그인 작업 후 부가적인 작업을 설정해보자(로그인 실패시)

요즘 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 카테고리의 포스트 목록을 보여줍니다

지난 2개의 글로 Spring Security에서 권한을 어떤식으로 이용하여 자원(URL) 접근을 제어하는지를 살펴보았다. 이번에는 권한과 관련된 마지막 얘기로 자원을 이용하는데 있어 권한이 충분치 않은 경우 어떻게 진행하는지를 알아보자.


우리가 자주 보는 웹페이지는 아니지만 다음과 같은 유형의 웹페이지를 보는 경우가 있다.



이 웹 페이지는 Tomcat에서 띄우는 것으로 Http Status Code가 403인 경우, 즉 서버가 접근을 허용하지 않는 자원을 접근할려고 시도했을때 보여지는 페이지이다. 이런 성격의 페이지는 모든 Web Server와 WAS 모두 가지고 있다. 모양은 약간약간씩 다르지만 보여지는 내용을 읽어보면 동일함을 알 수가 있다.


이 페이지를 왜 설명하는가? Spring Security에서 인증을 거진 사용자가 접근하고자 하는 자원(여기서는 웹페이지라고 생각해보자)에 대해 충분한 권한을 가지고 있지 않은 경우 이 Http 403 에러 페이지를 보여주기 때문이다. 쉬운말로 풀어보자면 로그인 한 사람이 관리자 권한을 가지고 있는사람이 아닌데 관리자만 이용할 수 있는 페이지를 접근할려고 시도한 경우 Http 403 에러 페이지를 보여준다는 얘기이다.


이런 웹페이지를 보여주는것은 사실 당연한 것이다. 특정 권한을 갖지 않은 상태에서 특정 권한을 갖고 있어야 접근할 수 있는 페이지를 그냥 접근하게 할 수는 없지않은가? 그렇기때문에 에러 처리하고 이를 적절한 표현방법(여기서는 Http 403 에러 페이지)로 표현하는 것이다. 그러나 이걸 실제 웹사이트 개발시에 나오게 할 순 없다. 생각해보라. 잘 이용하다가 에러가 생겼을 경우 디자인 된 웹페이지가 아닌 일반 Web Server나 WAS가 제공하는 전혀 디자인이 안된 페이지를 보여줄 수는 없지 않은가? 


Spring Security에서는 <http> 태그의 하위 태그로 <access-denied-handler> 라는 태그가 있는데 이 태그의 error-page 속성에 특정 페이지 URL을 입력하여 접근 권한이 없을 경우 사용자가 지정한 페이지를 보여줄 수가 있다.


<http auto-config="true" use-expressions="true">
	<access-denied-handler error-page="/common/access_denied.do"/>
</http>


이렇게 설정하면 기존 제공하는 Http 403 에러 페이지를 보여주는 것이 아니라 /common/access_denied.do 페이지를 보여주게 된다. 그러면 이 페이지에서는 무엇을 보여줘야 할까? 에러 메시지를 보여줘야 할 것이다. 다음은 /common/access_denied.do의 소스이다.


<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page isErrorPage="true"%>
<%@ page import="org.springframework.security.core.context.SecurityContextHolder" %>
<%@ page import="org.springframework.security.core.Authentication" %> 
<%@ page import="org.springframework.security.core.userdetails.UserDetails" %>
<%@ page import="org.springframework.security.core.userdetails.UserDetailsService" %>
<html>
<head>
<title></title>
<style>
table{
	width:800px;
}
table, th, td
{
	border-collapse:collapse; 
	border:1px solid gray;
}
</style>
<jsp:include page="/WEB-INF/views/include/jsInclude.jsp"></jsp:include> 
<script type="text/javascript">
$(document).ready(function () {
	
});
</script>    
</head>
<body>
<div style="display:inline-block;">
    <jsp:include page="/WEB-INF/views/include/leftmenu.jsp"></jsp:include> 
    <div style="float:right;">
    	접근권한이 없습니다.<br> 담당자에게 문의하여 주시기 바랍니다. <br>
		${SPRING_SECURITY_403_EXCEPTION}							
		<br>
		<%
		Authentication auth = SecurityContextHolder.getContext().getAuthentication();
		Object principal = auth.getPrincipal();
		if (principal instanceof UserDetails) {
			String username = ((UserDetails) principal).getUsername();
			String password = ((UserDetails) principal).getPassword();
			out.println("Account : " + username.toString() + "<br>");
		}
		%>
		<a href="<c:url value='/main.do'/>">확인</a>	
    </div>
</div>
</body>
</html>


이 페이지의 소스는 특별히 하는 일은 없다. 즉 Spring Security가 설정한 예외 메시지를 화면에 보여주고 있다. 그리고 계정 정보를 조회해서 계정 아이디를 보여주고 있다. 다음은 관리자 권한이 없는 계정으로 로그인 한 상황에서 관리자 권한이 필요한 웹페이지를 접근할려고 시도했을때 위의 소스로 보여준 웹페이지가 실제 브라우저에서 어떤식으로 나타나고 있는지 보여주고 있다.




그러면 이런 화면이 어떤 식으로 나타나게 되는지 원리를 알아봐야 할 것이다. 이전 글에서 우리는 로그인 한 사용자가 가지고 있는 권한을 가지고 해당 자원을 접근할 수 있는지 판단하는 판단 주체(Access Manager)에 대해 설명한 적이 있다. 이 판단 주체에 등록된 거수기 역할을 하는 Voter들의 결과를 이용해 결정을 내리게 되는데, 우리는 이전 글에서 이 판단 주체를 등록된 Voter 중 단 1개라도 접근에 대해 허가해주면 이를 허가해주는 클래스인 org.springframework.security.access.vote.AffirmativeBased를 사용했다. 이 AffirmativeBased 클래스의 decide 메소드에서 등록된 Voter들의 판단 결과를 이용해서 최종 판단을 하게 되는데 이 decide 메소드에서 허가를 하지 않을 경우 org.springframework.security.access.AccessDeniedException을 생성해서 던지게 된다. AccessDeniedException을 AffirmativeBased 클래스가 던지면 이를 FilterSecurityInterceptor Filter가 받아 던지게 될 것이고, 이렇게 던져진 예외는 다시 FilterSecurityInterceptor Filter 전에 있는 Filter는 ExceptionTranslationFilter가 받게 될 것이다. ExceptionTranslationFilter는 두 가지의 예외를 전문으로 처리하게 되는데 하나는 org.springframework.security.core.AuthenticationException이고 다른 하나가 위에서 언급했던 AccessDeniedException 이다. ExceptionTranslationFilter는 AccessDeniedException을 받았을 경우 Anonymous 사용자(로그인 과정을 거치지 않은 사용자)인지 파악해서 Anomymous 사용자인 경우 로그인 페이지를 보여주도록 작업하고, 로그인을 거쳐서 권한을 가지고 있는 사용자인 경우 ExceptionTranslationFilter 에 등록된 handler에서 이 예외를 처리하도록 한다. 이 handler는 org.springframework.security.web.access.AccessDeniedHandler 인터페이스를 구현한 클래스가 handler로 등록이 된다. ExceptionTranslationFilter는 이 인터페이스에 있는 handle 메소드를 실행하여 AccessDeniedException 예외에 대한 처리를 하게 된다. 기본적으로 등록되는 handler는 AccessDeniedHandler 인터페이스를 구현한 org.springframework.security.web.access.AccessDeniedHandlerImpl 클래스가 등록된다. 즉 위에서 보여준 화면은 이 AccessDeniedHandlerImpl 클래스의 handle 메소드에서 보여준다. handle 메소드는 AccessDeniedHandler 인터페이스에 다음과 같이 정의되어 있다.


public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)

            throws IOException, ServletException


handle 메소드를 보면 HttpServletRequest와 HttpServletResponse, AccessDeniedException 객체를 파라미터로 받고 있기 때문에 사용자가 입력한 값과 출력할 부분을 별도로 정의할 수가 있게 되는 것이다.


예외 메시지는 handle 메소드에서 SPRING_SECURITY_403_EXCEPTION을 HttpServletRequest 객체의 key로 해서 저장하게 된다. HttpServletResponse 객체에 상태 코드를 403으로 셋팅한 뒤 사용자가 설정한 페이지가 있을 경우 해당 페이지로 forward를 시켜준다. 만약 설정한 페이지가 없을 경우엔 HttpServletResponse 객체에 상태 코드를 403으로 셋팅했기 때문에 웹서버나 WAS가 가지고 있는 페이지를 보여주게 된다. 위에서 <access-denied-handler> 태그 설정시 error-page 속성에 페이지 URL을 지정했었는데 error-page 속성에 지정한 페이지 URL이 바로 forward 시켜주는 사용자 설정 페이지 URL이다.


이런 원리를 왜 이리 장황하게 설명했는가 하면 이렇게 동작하는 것 또한 우리가 임의로 지정할 수 있기 때문이다. 위에서 handler로 사용되는 클래스는 AccessDeniedHandler 인터페이스를 구현했다고 말했다. 즉 우리가 AccessDeniedHandler 인터페이스를 구현한 클래스를 만들어 접근 권한 예외 발생시 어떤 식으로 process를 흐르게 할 지 지정할 수가 있는 것이다. 기존 제공되는 클래스인 AccessDeniedHandlerImpl 클래스는 예외 메시지만 보여주고 있는데 예외 메시지 뿐만 아니라 기타 특정 작업을 해야 할 경우 AccessDeniedHandler 인터페이스를 구현한 클래스를 하나 만들어서 handler로 등록하면 된다. <access-denied-handler> 태그에는 ref 속성이 있는데 이 ref 속성에 AccessDeniedHandler 인터페이스를 구현한 클래스를 등록하면 ExceptionTranslationFilter에서 이 ref 속성에 등록한 bean 을 AccessDeniedandlerImpl 클래스 대신 사용하게 된다.


이렇게 사용자 정의 AccessDeniedHandler 인터페이스를 구현하는 예를 하나 들어보자. 우리가 어떤 페이지를 접근할 때 일반적인 방법이 아닌 ajax로 접근하는 경우가 있다고 가정해보자. ajax로 접근하게 되면 callback 함수로 그 결과를 받게되는데 문제는 이 결과가 자바스크립트에서 해석이 되어야 한다는 것이다. 사이트의 모든 페이지가 ajax로 접근하는 것이 아님을 감안한다면 일반적인 페이지 접근시에는 위에서 보여준 저런 화면 스타일로 접근 권한 에러 화면을 보여주면 되겠지만 ajax로 접근할 경우엔 저런 화면으로 보여줄 수가 없다. 즉 callback 함수에서 ajax 결과를 받아 이를 자바스크립트로 해석해서 alert로 보여주는 식이어야 한다는 것이다. 이 두가지 상황을 모두 소화해야 하는 AccessDeniedHandler 인터페이스를 구현한 클래스를 정의해보자.


이걸 구현할려면 한가지 해야 할 작업이 있다. 그것은 일반적인 접근과 ajax로 접근한 것을 구분해야 할 방법을 정의해야 하는 것이다. 일반적인 접근과 ajax로 접근한 것을 어떻게 구분할까? 여기서는 ajax로 접근할 때 특정 헤더값을 넣어주는 것으로 이것을 구분하도록 하겠다. 즉 특정 헤더값을 넣은뒤에 handle 메소드에서 넘겨주는 HttpServletRequest 객체를 이용해 특정 헤더값이 있는지의 여부를 판단하여 구분할 수가 있다. ajax를 호출할때 다음과 같이 호출한다고 보자.


$.ajax({
	url : "${ctx}/admin/ajaxTest",
	data : {title : title, content : content},
	contentType: "application/x-www-form-urlencoded; charset=UTF-8",
	type : "POST",
	dataType : "json",
	beforeSend: function (xhr) {
		xhr.setRequestHeader("X-Ajax-call", "true");
	},
	success:function(data, textStatus, jqXHR){
		if(data.result == "OK"){
			alert("ok");
		}else{
			alert("fail");
		}
	},
	error:function(jqXHR, textStatus, errorThrown){
		if(jqXHR.status == 403){
			var response = $.parseJSON(jqXHR.responseText);
			alert("result : " + response.result + "\nmessage : " + response.message);
		}else{
			alert(errorThrown);	
		}
		
	}
});


다음의 코드는 jQuery를 이용한 ajax 사용 코드이다. jQuery를 이용해서 ajax를 사용해 본 경험이 있는 사람이라면 이 코드가 어떤식으로 동작하게 될지 이해가 갈 것이다. 여기서 중점적으로 봐야 할 부분은 beforeSend 부분과 error 를 정의한 부분이다. beforeSend는 지정된 URL로 요청을 보내기 전에 선행되는 작업을 하는 부분을 정의하게 되는데 여기서는 Request Header로 X-Ajax-call이란 이름의 헤더에 true란 값을 설정하고 있다. 바로 이 부분이 우리가 구현할 AccessDeniedHandler 인터페이스를 구현한 handler 클래스에서 Ajax로 호출했는지의 여부를 알아낼 수 있는 구분값이 된다. 즉 handler 클래스에서 X-Ajax-call이란 헤더값을 읽어서 그 값이 true이면 Ajax로 호출되었음을 알 수가 있는 것이다. 그리고 error 는 ajax를 호출했을때 http status code가 200(OK)가 아닌 다른 code가 return 되었을때 처리하게 되는 callback 함수를 지정하게 되는데 여기서 status code 값이 403인지를 확인하여 그에 따라 작업을 하고 있다. 여기서는 던지는 문자열을 json 객체로 변환한뒤 이를 alert 함수로 내용을 보여주고 있다.


그러면 이렇게 ajax를 호출할 때 해당 URL에 대한 접근 권한이 없어서 예외가 발생했을 경우 이를 처리하게 될 AccessDeniedHandler 인터페이스를 구현한 클래스를 보도록 하자


import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.access.AccessDeniedHandler;

public class CustomAccessDeniedHandler implements AccessDeniedHandler {

	private String errorPage;
	
	@Override
	public void handle(HttpServletRequest request,	HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
		// TODO Auto-generated method stub
		// Ajax를 통해 들어온것인지 파악한다
		String ajaxHeader = request.getHeader("X-Ajax-call");
		String result = "";
		
		response.setStatus(HttpServletResponse.SC_FORBIDDEN);
		response.setCharacterEncoding("UTF-8");
		
		if(ajaxHeader == null){					// null로 받은 경우는 X-Ajax-call 헤더 변수가 없다는 의미이기 때문에 ajax가 아닌 일반적인 방법으로 접근했음을 의미한다
			Authentication auth = SecurityContextHolder.getContext().getAuthentication();
			Object principal = auth.getPrincipal();
			if (principal instanceof UserDetails) {
				String username = ((UserDetails) principal).getUsername();
				request.setAttribute("username", username);
			}
			request.setAttribute("errormsg", accessDeniedException);
			request.getRequestDispatcher(errorPage).forward(request, response);
		}else{
			if("true".equals(ajaxHeader)){		// true로 값을 받았다는 것은 ajax로 접근했음을 의미한다
				result = "{\"result\" : \"fail\", \"message\" : \"" + accessDeniedException.getMessage() + "\"}";
			}else{								// 헤더 변수는 있으나 값이 틀린 경우이므로 헤더값이 틀렸다는 의미로 돌려준다
				result = "{\"result\" : \"fail\", \"message\" : \"Access Denied(Header Value Mismatch)\"}";
			}
			response.getWriter().print(result);
			response.getWriter().flush();
		}
	}
	
	public void setErrorPage(String errorPage) {
        if ((errorPage != null) && !errorPage.startsWith("/")) {
            throw new IllegalArgumentException("errorPage must begin with '/'");
        }

        this.errorPage = errorPage;
    }

}


이해를 돕기 위해 소스에 주석을 달아놓았으나 그래도 설명을 하자면..

이 클래스에는 errorPage란 멤버변수가 있는데 이 변수가 하는 역할은 위에서 설명했던 접근 권한 에러가 발생했을시 보여줘야 할 페이지 URL을 여기에 설정하게 된다.그리고 handle 메소드에서 본격적인 작업을 하게되는데 먼저 X-Ajax-call이란 request header 값을 읽고 response.setStatus(HttpServletResponse.SC_FORBIDDEN);을 해서 http status code값을 403으로 셋팅을 해주도록 한다. 그리고 읽어들인 X-Ajax-call header 값이 null 일 경우 이런 header를 설정하지 않았음을 의미하기 때문에 이는 ajax를 이용한 호출이 아닌 일반적인 web page 호출 방식을 거쳤다고 보고 예외 메시지와 사용자가 로그인 한 계정 정보를 조회하고 이를 이 handler에 지정된 error page(위에서 언급했던 errorPage 멤버변수)에 관련 정보를 request attribute에 셋팅해준뒤 forward 하게 된다.

그러나 X-Ajax-call 이란 request header 값이 null이 아닐 경우(이 경우는 위에서 jquery ajax 메소드 설명시 beforeSend 에서 request header에 X-Ajax-call 헤더에 값을 셋팅한 경우가 되겠다) 이 값이 true 일 경우 관련 예외 메시지를 response stream에 write 하고 있다. 이 때 json 형식을 사용하고 있기 때문에 위에서 jquery ajax 메소드 설명시 error callback 함수에서 jquery의 parseJSON 메소드를 사용하여 json 객체로 변환을 할 수가 있게 되는 것이다. true가 아니라면 헤더값 설정을 잘못했다고 간주하고 그에 따른 에러메시지를 셋팅해서 response stream에 write 한다.


이렇게 만든 AccessDeniedHandler 인터페이스 구현 클래스는 다음과 같이 <bean> 태그에 선언한뒤 이를 <access-denied-handler> 태그에서 사용해주면 된다.


<access-denied-handler ref="accessDenied"/>

<beans:bean id="accessDenied" class="com.terry.springsecurity.common.security.handler.CustomAccessDeniedHandler">
	<beans:property name="errorPage" value="/common/access_denied2.do" />
</beans:bean>


errorPage 란 property에 보여줘야 할 에러 페이지 URL을 지정한뒤 이를 <access-denied-handler>의 ref 속성에 지정하면 끝난다. 이제 /common/access_denied2.do의 소스를 보자. 위에서 살폈던 /common/access_denied.do 소스와는 다소의 차이가 있다. 왜냐면 CustomAccessDeniedHandler 클래스에서 에러 메시지를 Spring Security가 지정한 상수를 request attribute의 key로 사용하지 않고 별도 key(erromsg)를 사용했고 사용자 계정 정보를 jsp 단에서 조사해서 내려주는 것이 아니라 CustomAccessDeniedHandler 클래스에서 조회해서 이를 request attribute에 셋팅해서 내려주고 있기 때문이다. 그래서 예전 /common/access_denied.do 소스보다는 더욱 간결해졌다. 보여지는 화면 내용은 예전 /common/access_denied.do와 같은 화면을 보여주고 있어서 이 화면은 생략하도록 하겠다.


<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<%@ page isErrorPage="true"%>
<html>
<head>
<title></title>
<style>
table{
	width:800px;
}
table, th, td
{
	border-collapse:collapse; 
	border:1px solid gray;
}
</style>
<jsp:include page="/WEB-INF/views/include/jsInclude.jsp"></jsp:include> 
<script type="text/javascript">
$(document).ready(function () {
	
});
</script>    
</head>
<body>
<div style="display:inline-block;">
    <jsp:include page="/WEB-INF/views/include/leftmenu.jsp"></jsp:include> 
    <div style="float:right;">
    	접근권한이 없습니다.<br> 담당자에게 문의하여 주시기 바랍니다. <br>
		${errormsg}					
		<br>
		<c:if test="${not empty username}">
			${username}<br/>
		</c:if>
		<a href="<c:url value='/main.do'/>">확인</a>	
    </div>
</div>
</body>
</html>


위의 설명과 샘플 소스들은 먼저 로그인을 해서 인증을 얻은 뒤에 권한을 체크한다는 전제로 만들어진 것들이다. 그러면 만약 인증을 하지 않은 상태에서 접근 권한을 체크하게 된다면 어떻게 될까? 즉 Anonymous 권한이 있는 상태에서 Anonymous 권한으로는 접근할 수 없는 페이지를 접근할려고 시도할때 어떻게 될까? 기존 방법대로라면 당연 로그인 페이지로 이동을 한다. 그러나 위와 같이 ajax로 접근한다면 어떻게 될까? 이 부분을 이해할려면 ExceptionTranslationFilter의 이해가 필요하다. 위에서 설명했을때 ExceptionTranslationFilter는 두가지의 예외를 처리한다고 했다. AuthenticationException과 AccessDeniedException이 그것이었다. 만약 로그인을 하지 않은 상황이어서 Anonymous 권한을 가지고 있는 상태에서 Anonymous 권한으로는 접근할 수 없는 페이지를 접근할려고 시도한다고 생각해보자. 그러면 FilterSecurtyInterceptor 클래스에서는 권한을 만족시키지 못하기 때문에 AccessDeniedException을 던지게 되고 이것을 ExceptionTranslationFilter 클래스가 받게 될 것이다. 그러면 이 클래스는 이 예외에 대한 처리를 handleSpringSecurityException 메소드가 처리하도록 하게 하는데 이 메소드의 소스를 살펴보면 AccessDeniedException을 받았다 해도 그것이 로그인을 하지 않은 상태에서 받은 AccessDeniedException이라면 sendStartAuthentication 메소드에서 차리하도록 하고 로그인을 한 상태에서 받은 AccessDeniedException이라면 우리가 등록한 AccessDeniedHandler 에서 처리하도록 한다(정확하게는 등록한 AccessDeniedHandler 인터페이스의 handle 메소드를 실행한다) sendStartAuthentication 메소드에서 하는 일은 권한 정보를 null로 셋팅하고, 관련 정보를 세션에 저장한뒤 org.springframework.security.web.AuthenticationEntryPoint 인터페이스를 구현한 클래스의 commence 메소드를 실행한다. 우리가 <form-login> 태그를 설정하면 ExceptionTranslationFilter가 만들어질때 AuthenticationEntryPoint 인터페이스를 구현한 클래스인 org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint 클래스 객체가 셋팅이 되어서 들어가게 되며 이 클래스의 commence 메소드에서 로그인 페이지로 이동하는 작업을 하게 된다. 그러나 commence 메소드에서는 위에서 사용한 request header 값을 이용하는 작업을 하는 것이 아니기 때문에 Anonymous 권한으로 ajax를 통해 접근을 시도하려 할 경우 오동작을 하게 된다. 이 부분을 해결할려면 LoginUrlAuthenticationEntryPoint 클래스를 상속받은 새로운 클래스에서 commence 메소드를 위의 AccessDeniedHandler 코드 스타일로 헤더값을 조회해서 적절하게 처리해주는 작업을 해준뒤 이 클래스를 <bean> 태그를 이용해서 등록하고 <http> 태그의 entry-point-ref 속성에 해당 bean으로 설정해주면 된다. 그러나 entry-point-ref 속성을 지정할 경우 <form-login> 태그와 같이 사용하는 것은 문제가 있을 소지가 있어서(관련 샘플을 보면 entry-point-ref 속성과 <form-login> 태그를 같이 사용한 샘플을 본적이 없어서 같이 사용해도 문제가 없을지 장담을 할수가 없다) 아예 이런 상황일 경우 spring security가 제공하는 태그를 이용해서 화면에서 접근을 못하게 동적 화면을 구성하는 편이 좋을듯 하다


지금까지 Spring Security에서 접근 권한 예외 발생시 어떻게 처리하는지 알아보고 이를 어떻게 커스터마이징하는지 설명했다. 이번 글을 끝으로 해서 예전글에서 설명했던 커스터마이징 포인트에 대한 모든 설명을 다 마쳤다. Spring Security에 대해 몇가지 다루지 않은 주제가 있기는 하다. 세션 관련 관리나 SSL 관련쪽이 있는데, 세션 관련 내용은 구글링하면 설명이 잘 되어 있어서 별도로 언급을 할 필요성이 없었고 SSL 관련쪽은 아직 내공이 부족해서 글로써 설명하기에는 시점이 아직 이르다. 이거는 차후에 내공이 쌓이면 설명할 기회가 있을꺼라 생각한다. 


지금까지 설명했던 Spring Security 관련 샘플 소스를 github에 올려놓았다(https://github.com/TerryChang/spring_security_blog_sample). 사실 소스가 제대로 정리된 소스가 아니다(몇가지 시험삼아 구현해볼 기능을 할려고 템플릿을 잡아놓은것에 Spring Security를 적용한거라 불필요한 구조가 있다). 그래도 보는데는 큰 지장은 없을거라 생각한다. 소스를 받은뒤 README.txt를 읽어서 셋팅할때 참조하길 바란다. 궁금한 점은 언제든 댓글을 남겨주면 답글을 남기도록 하겠다.



  • 2015.02.12 13:19

    비밀댓글입니다

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

      댓글을 지금에서야 봤네요..죄송합니다..
      제가 일단 jersey를 알지 못해서..
      급한대로 검색을 해보니 REST 서비스 구현과 관련된 라이브러리 같더군요..
      물론 Spring이 연동되게끔 자체 클래스를 제공해주는것 같더군요..

      여기서 제가 추측하는건 이렇습니다..
      Spring에서 @Autowired나 @Resource 등을 이용해서 특정 Bean(여기서는 A라고 하죠..)안에서 Bean(여기서는 B라고 할께요)을 Injection 받을려고 하는 경우..
      A와 B 모두 Spring을 통해 관리되는 Bean이어야 한다는겁니다..

      jersey rest로 된 service 클래스(편의상 C라고 할께요)가 Spring에 등록된 Bean이라면(이것을 확인 하는 방법은 Controller에서 @Autowired로 Injection이 되어서 기능이 수행된다면 Spring에 등록된 Bean입니다)..
      문제의 securityContext를 C에서 Injection 받는데 문제가 없습니다..왜냐면 Spring Security 또한 Spring Context에 등록이 되기 때문이죠..

      그러나 이렇게 되어 있는 상태인데도 불구하고 못가져올 경우 추측할 수 있는 것은 Context 설정에 문제가 있어서일 가능성도 존재합니다..
      Spring의 Context를 Root와 Servlet Context 이렇게 2개의 Context,로 나눠서 관리하게 될 경우
      만약 Servlet Context에 Spring Security 관련 설정이 되어 있으면 Root Context쪽에서는 이를 참조하지 못합니다..
      일반적으로 Service 클래스들은 Root Context에 설정을 할텐데요..
      Context 간의 참조관계를 보시면..
      Servlet Context에 등록되어 있는 Bean은 Root Context에 등록되어 있는 Bean을 참조할 수 있습니다..
      그래서 제가 연재한 글을들 보시면 초반에 web.xml을 이용해서 spring security 설정 파일을 Root Context에 올라가도록 되어 있는걸 보실수 있을겁니다..
      여기다가 설정해야 Controller에서도 참조할수가 있거든요..
      근데 거꾸로 Servlet Context인 dispatcher servlet에 spring security 설정 파일을 등록할 경우 Root Context에 있는 Bean은 Servlet Context에 있는 Bean을 참조하지 못하기 때문에 Root Context에 등록되어 있는 Servuce 클래스에서 securityContext를 가져올수 없습니다..

      하지만 컨트롤러 단에서는 가져오신다고 하는걸로 봐서는..
      만드신 서비스 클래스가 Spring에서 관리되고 있지 않다는 느낌을 받네요..
      일단 설정과 소스를 모르는 지라..아는 선에서 답변을 드렸습니다..

      개발툴에서 디버그 모드 걸어서 C 에서 securityContext가 null로 할당이 되는지도 확인해보세요..

  • BlogIcon 태준아빠 2015.03.30 09:21 신고

    블로그 참고 하여 설정을 해봤습니다..
    일단 제가 sts 에다 mysql 적용 해서 프로젝트에 적용 해봤습니다..
    권한 설정이라든지 그룹 설정을 잘 됩니다.

    중복 로그인 방지를 위해
    <session-management>
    <concurrency-control max-sessions="1" />
    </session-management>

    안되더군요..

    http://docs.spring.io/spring-security/site/docs/3.2.4.RELEASE/reference/htmlsingle/#nsa-session-management
    여기를 참고 해서 해보려는데 잘 안되네요
    조언 부탁 드립니다.

  • BlogIcon 메이킹러브 2015.04.02 15:41 신고

    아..댓글을 지금 봤네요..죄송합니다..
    제가 이 글을 쓸 당시에 이 부분을 테스트해본적이 있었는지 아닌지 기억이 불확실해서..ㅠㅠ..
    근데 결론부터 말씀드리자면 현재 확인된 바로는 이 부분이 버그가 있습니다.
    제가 요즘 Spring Framework를 Java Config 하는 식으로 바꾸는 법을 공부하면서..
    블로그에 쓴 Spring Security 설정을 Java Config로 바꾸어보고 있는데요..
    그 과정에서 중복 로그인 방지 부분에 버그가 있는것을 발견했습니다..
    제가 테스트 했던 방법이 태준아빠님이 하신 방법과 동일한지는 모르겠으나 일단 테스트 방법을 간략하게 써보면..
    A라는 id로 로그인을 한 뒤에 로그아웃을 하지 않고 다른 브라우저로 다시 A로 로그인을 시도하면 로그인이 안됩니다..이건 정상인데요..
    A로 로그인한 부분을 로그아웃한뒤 다시 A로 로그인을 할려고 시도하면 로그인이 안되더군요..로그아웃을 했으면 다시 로그인이 되어야 맞는건데..
    로그인이 되질 않았습니다..
    원인이 뭔지는 파악을 했으나 이 부분을 고치는건 쉽진 않더군요..현재는 이 부분과 관련되어서 블로그 글로 정리중이니 나중에 참고하시면 될듯 합니다..
    또한 이 문제는 다른 사람도 stackoverflow에 글을 올렸었는데..해결은 되지는 않았던거 같더라구요..
    관련글은
    http://stackoverflow.com/questions/23455175/spring-security-3-2-and-maximumsessions-logging-out-not-updating-sessionregist
    에 있습니다..작성자는 Spring Security 3.2.3에서 했구요..구글에서 검색어로 spring security maximumSessions logout 하시면 맨 위에 나오는 글이 이 글입니다..
    이 글에서도 원인을 밝혔기 때문에 원인이 궁금하시면 읽어보심 되구요..
    해결책을 말씀드리자면..Spring Security를 버전업을 하시면 해결됩니다..저도 이것땜에 어제 하루를 다 버렸는데요..
    제가 이 글을 쓸 당시의 테스트했던 Spring Security가 3.2.4 였는데요..
    현재 Spring Security 3.2.X 버전중 가장 최고 버전은 3.2.7입니다..이 버전으로 업글하시면 이 부분은 해결이 되요..

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

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

다른 카테고리의 글 목록

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

지난 글에서는 FilterSecurityInterceptor 클래스를 커스터마이징 하여 DB에서 권한을 조회하여 제어하는 방법에 대해 얘기해보았다. 클래스 소스 위주로 설명했기 때문에 이번에는 이렇게 만든 클래스를 어떤 식으로 설정하여 사용하는지에 대해 언급하도록 하겠다.


<authentication-manager>
	<authentication-provider user-service-ref="customJdbcDaoImpl">
		<password-encoder ref="bcryptPasswordEncoder" />
	</authentication-provider>
</authentication-manager>

<beans:bean id="filterSecurityInterceptor" class="org.springframework.security.web.access.intercept.FilterSecurityInterceptor">
	<beans:property name="authenticationManager" ref="org.springframework.security.authenticationManager" />
	<beans:property name="accessDecisionManager" ref="accessDecisionManager" />
	<beans:property name="securityMetadataSource" ref="reloadableFilterInvocationSecurityMetadataSource" />
</beans:bean>

<beans:bean id="accessDecisionManager" class="org.springframework.security.access.vote.AffirmativeBased">
	<beans:constructor-arg>
		<beans:list>
			<beans:bean class="org.springframework.security.access.vote.RoleVoter">
				<beans:property name="rolePrefix" value="" />
			</beans:bean>
		</beans:list>
	</beans:constructor-arg>
	<beans:property name="allowIfAllAbstainDecisions" value="false" />
</beans:bean>

<beans:bean id="reloadableFilterInvocationSecurityMetadataSource" class="com.terry.springsecurity.common.security.ReloadableFilterInvocationSecurityMetadataSource">
	<beans:constructor-arg ref="requestMap" />
	<beans:property name="securedObjectService" ref="securedObjectService" />
</beans:bean>

<beans:bean id="securedObjectService" class="com.terry.springsecurity.common.security.service.impl.SecuredObjectServiceImpl">
	<beans:property name="secureObjectDao" ref="securedObjectDao" />
</beans:bean>

<beans:bean id="securedObjectDao" class="com.terry.springsecurity.common.security.dao.SecuredObjectDao">
	<beans:property name="dataSource" ref="logDataSource_pos" />
	<beans:property name="sqlRolesAndUrl" value="
		SELECT A.RESOURCE_PATTERN AS URL, B.AUTHORITY AS AUTHORITY 
		FROM SECURED_RESOURCES A, SECURED_RESOURCES_ROLE B 
		WHERE A.RESOURCE_ID = B.RESOURCE_ID 
		AND A.RESOURCE_TYPE = 'url' 
		ORDER BY A.SORT_ORDER
	" />
</beans:bean>

<beans:bean id="requestMap" class="com.terry.springsecurity.common.security.UrlResourcesMapFactoryBean" init-method="init">
	<beans:property name="securedObjectService" ref="securedObjectService" />
</beans:bean>


위의 설정은 지난 글에서 설명한 클래스를 spring security 설정 xml에 설정한 내용을 보여주고 있다. 몇몇은 이미 이전 글들에서 설명한 적이 있다(<authentication-manager>, <bean id="accessDecisionManager">) 이전 글에서 설명한 적이 있던 것들은 FilterSecurityInterceptor 클래스에 설정하는 인증정보(Authentication Manager)와 판단 주체(Access Manager)이다. 여기 설정에서는 대상 정보(Security MetaDataSource)를 어떻게 설정하고 이를 FilterSecurityInterceptor 클래스에 어떤 식으로 설정하는지를 알아본다.


이전 글에서 DB에 저장되어 있는 URL 별 접근 권한을 조회하는 기능을 하는 SecureObjectDao 클래스에 대해 설명한 것이 있다. 이 클래스를 선언한 부분을 보자


<beans:bean id="securedObjectDao" class="com.terry.springsecurity.common.security.dao.SecuredObjectDao">
	<beans:property name="dataSource" ref="logDataSource_pos" />
	<beans:property name="sqlRolesAndUrl" value="
		SELECT A.RESOURCE_PATTERN AS URL, B.AUTHORITY AS AUTHORITY 
		FROM SECURED_RESOURCES A, SECURED_RESOURCES_ROLE B 
		WHERE A.RESOURCE_ID = B.RESOURCE_ID 
		AND A.RESOURCE_TYPE = 'url' 
		ORDER BY A.SORT_ORDER
	" />
</beans:bean>


DB에서 조회해야 하기 때문에 DataSource를 받아야 하는 부분이 있다. 그래서 dataSource property에 DataSource를 지정했다. 그리고 sqlRolesAndUrl 프로퍼티에 URL 패턴 별 권한을 조회하는 쿼리를 설정해두어 SecureObjectDao 클래스 내부에서 JdbcTemplate을 이용해 조회하게 된다. 쿼리를 보면 ORDER BY 문을 사용하고 있는데 이를 통해 우선 적용되어야 할 패턴을 제일 먼저 검색이 되게끔 하도록 하고 있다.


다음은 이렇게 선언된 SecureObjectDao bean 클래스를 사용하는 SecureObjectService 클래스를 선언한 부분이다.


<beans:bean id="securedObjectService" class="com.terry.springsecurity.common.security.service.impl.SecuredObjectServiceImpl">
	<beans:property name="secureObjectDao" ref="securedObjectDao" />
</beans:bean>


지난 글에서 이 클래스를 설명했을때도 얘기했지만 이 클래스는 단순히 SecureObjectDao 클래스를 통해 나온 조회 결과를 받아다가 다시 SecureObjectService bean을 사용하는 클래스에 넘겨주기 때문에 SecureObjectDao bean만 설정하면 된다. 그렇지만 SecureObjectService는 사용되는 곳이 몇몇 군데가 있다.


<beans:bean id="reloadableFilterInvocationSecurityMetadataSource" class="com.terry.springsecurity.common.security.ReloadableFilterInvocationSecurityMetadataSource">
	<beans:constructor-arg ref="requestMap" />
	<beans:property name="securedObjectService" ref="securedObjectService" />
</beans:bean>

<beans:bean id="requestMap" class="com.terry.springsecurity.common.security.UrlResourcesMapFactoryBean" init-method="init">
	<beans:property name="securedObjectService" ref="securedObjectService" />
</beans:bean>


지난 글에서 누누히 강조했던 requestMap을 생각해보자. 이 requestMap에 들어있는 것은 SecuredObjectDao를 통해 조회된 URL 패턴별 접근권한이 LinkedHashMap으로 들어있다고 했었다. 그리고 이렇게 조회된 requestMap을 DefaultFilterInvocationSecurityMeatadataSource 클래스와 같은 구조로 만들게 되는 클래스인 ReloadableFilterInvocationSecurityMetadataSource 클래스의 생성자를 이용해서 셋팅된다고 했었다. 그래서 ReloadableFilterInvocationSecurityMetadataSource를 등록할때 constructor-arg 를 이용해서 생성자에 requestMap을 셋팅하도록 했다. 


또한 이 requestMap은 FactoryBean을 구현한 UrlResourcesMapFactoryBean을 통해 bean이 만들어져 셋팅이 된다. 이때 이 FactorytBean에 SecuredObjectService를 property에 셋팅해 줌으로써 UrlResourcesMapFactoryBean에서 DB를 이용한 URL 패턴 별 권한 조회를 할 수 있도록 하여 requestMap을 만들수 있도록 한다. 


ReloadableFilterInvocationSecurityMetadataSource 클래스에도 property에 SecuredObjectService를 셋팅하여 줌으로써 여기서도 DB를 이용한 URL 패턴별 권한 조회를 하여 requestMap을 재구성할 수 있도록 한다(이전 글에서 화면에 에러가 발생하여 접근 권한을 관리자로 셋팅하여 일반인이 접근하지 못하도록 한 뒤에 조치를 취하는 예를 설명한 적이 있다. 이부분을 구현하기 위함인 것이다. 즉 관리자 화면을 처리하는 Controller 클래스에 ReloadableFilterInvocationSecurityMeatadataSource를 Injection 한 뒤에 reload 메소드를 Controller에서 호출하면 reload 메소드 내부에서 SecureObjectService를 이용해 DB를 다시 조회한뒤 requestMap을 재구성하는 것이다. 저번 글에 올라와 있는 ReloadableFilterInvocationSecurityMetadataSource 클래스를 참조하기 바란다)


그리고 다음과 같이 FilterSecurityInterceptor에 인증 정보(Authentication Manager)와 대상 정보(Security MetaDataSource)와 판단 주체(Access Manager)를 설정한 뒤에 FilterSecurityInterceptor 클래스를 등록함으로써 DB를 이용한 자원 접근 권한 설정을 마무리한다


<beans:bean id="filterSecurityInterceptor" class="org.springframework.security.web.access.intercept.FilterSecurityInterceptor">
	<beans:property name="authenticationManager" ref="org.springframework.security.authenticationManager" />
	<beans:property name="accessDecisionManager" ref="accessDecisionManager" />
	<beans:property name="securityMetadataSource" ref="reloadableFilterInvocationSecurityMetadataSource" />
</beans:bean>


그러면 이렇게 등록된 FilterSecurityInterceptor를 이제는 사용해야 하는데 문제는 FilterSecurityInterceptor는 Spring Security에서 기본적으로 올라가는 Filter 라는 것이다. 즉 이렇게 <bean> 태그를 이용해 등록을 해도 이렇게 등록된 것을 사용하는 것이 아니라 Spring Security가 기본적으로 올리고 있는 FilterSecurityInterceptor를 사용하게 된다는것이다. 기본적으로 올라가는 것을 사용하게 되면 우리가 이제껏 설정한 내용이 아무 소용이 없지 않은가? 즉 Spring Security가 올리는 FilterSecurityInterceptor를 사용하지 말고 우리가 설정한 FilterSecurityInterceptor를 사용하도록 해주어야 한다. 이 부분은 어떻게 하면 좋을까?


<http> 태그에는 하위 태그로 <custom-filter> 태그란 것이 있다. 이 태그를 사용하면 Spring Security가 올리는 특정 Filter의 이전, 다음 또는 해당 특정 Filter가 올라가는 그 위치에 원하는 Filter를 셋팅할 수가 있다. 예를 들어..


<http>
	<custom-filter before="FORM_LOGIN_FILTER" ref="mycustomformloginfilter"/>
</http>


위와 같이 <custom-filter> 태그를 설정하는  UsernamePasswordAuthenticationFilter(Filter의 alias로 FORM_LOGIN_FILTER가 된다)가 올라가는 위치의 바로 전에 <bean> 태그로 등록한 mycustomformloginfilter를 올리겠다는 의미이다. 즉 mycustomformloginfilrer를 거쳐서 Spring Security가 등록하는 UsernamePasswordAuthenticationFilter를 거친다는 의미가 되겠다. 이렇게 내가 올리고자 Filter를 원하는 위치에 셋팅할 수 있다. 위치를 지정하는 속성은 before, after, position이 있는데 before는 지정된 Filter의 이전, after는 지정된 Filter의 이후, position은 지정된 Filter 그 위치에 해당 Filter를 등록하게 된다. 즉 before나 after의 경우는 Spring Security가 올리는 Filter를 대체하지는 않지만 position으로 설정할 경우는 지정된 Filter 대신 사용자가 설정한 Filter가 올라가게 된다. 그리고 이렇게 before나 after, position을 통해 지정되는 Filter는 alias로 Spring Security 관련 글에서 맨 처음 글에 썼던 Spring Security가 사용하는 Filter 목록에서 해당 Filter의 alias를 사용하면 된다.


이때 주의점이 있다. before나 after의 경우 모든 Filter의 alias를 사용할 수 있으나 position의 경우 사용할 수 없는 alias가 있다. 즉 특정 위치를 대신해서 사용자 정의 Filter를 넣고자 할때 그걸 할 수 없는 Filter가 존재함을 의미한다. SecurityContextPersistenceFilter, ExceptionTranslationFilter, FilterSecurityInterceptor 가 바로 이에 해당하는 filter 들이다. 왜 그런가 하면 이 3가지의 Filter는 <http> 태그 사용시 자동으로 올라가기 때문이다. <custom-filter> 태그의 사용여부와는 상관없이 이 3개의 filter는 항상 올라가게 되며 이미 올라간 시점에서 <custom-filter> 태그를 이용해 대체를 할 수 없기 때문이다. 또한 position을 사용해서 Filter를 대체할 경우 그 Filter가 올라가게끔 만드는 태그는 사용하면 안된다. 위에서 예를 들은 UsernamePasswordAuthenticationFilter를 예로 들어보다. 위에서는 before로 했지만 만약 before가 아닌 position을 사용해서 UsernamePasswordAuthenticationFilter를 대체할 경우 UsernamePasswordAuthenticationFilter가 올라가게끔 만드는 태그인 <form-login> 태그를 사용하면 안된다는 얘기이다. 즉 <form-login> 태그에서 했던 각종 설정 작업들을 UsernamePasswordAuthenticationFilter를 대체하는 Filter에서 대신 설정되어져서 올라가야 한다는 뜻이다.


※ SecurityContextPersistenceFilter, ExceptionTranslationFilter, FilterSecurityInterceptor 필터를 대체하고 싶을 경우 방법이 아주 없지는 않다. Spring Security는 Spring Security가 사용하는 Filter들을 chain 개념으로 연결해서 사용하고 있는데 그런 역할을 해주는 클래스가 org.springframework.security.web.FilterChainProxy 클래스이다. 이 클래스를 이용해서 다음과 같은 형식으로 Filter 순서를 지정하면 <custom-filter> 태그로도 지정할 수 없는 것을 지정할 수 있게 된다. 그러나 이렇게 설정할 경우 Spring Security가 제공하는 설정 관련 xml 태그(<http>태그나 <authentication-manager> 태그 등)를 사용하면 안된다(즉 Spring Framework가 제공하는 <bean> 태그와 <property> 태그 등으로 관련 설정을 하라는 얘기이다.)


<bean id="filterChainProxy" class="org.springframework.security.web.FilterChainProxy">
  <constructor-arg>
    <list>
      <sec:filter-chain pattern="/restful/**" filters="
           securityContextPersistenceFilterWithASCFalse,
           basicAuthenticationFilter,
           exceptionTranslationFilter,
           filterSecurityInterceptor" />
      <sec:filter-chain pattern="/**" filters="
           securityContextPersistenceFilterWithASCTrue,
           formLoginFilter,
           exceptionTranslationFilter,
           filterSecurityInterceptor" />
    </list>
  </constructor-arg>
</bean>



이러한 내용을 보았으니 이제는 <custom-filter> 태그를 사용해서 우리가 만든 FilterSecurityInterceptor 클래스를 사용하도록 해보자. 방금 전에 설명했듯이 <custom-filter> 태그의 position 속성을 통해서 기존 FilterSecurityInterceptor 클래스를 대체하도록 할 수는 없다. 그래서 before 속성을 이용해서 Spring Security가 등록하는 FilterSecurityInterceptor 클래스 전에 우리가 만든 FilterSecurityInterceptor 클래스를 거치게 하고 기존 Spring Security가 등록하는 FilterSecurityInterceptor  클래스는 bypass 하도록(그렇게 전개될 수 밖에 없는 것이 <http> 태그의 <intercept-url> 태그가 선언되어 있어야 Spring Security가 올리는 FilterSecurityInterceptor 클래스에서 권한 체크를 할텐데 그런 부분이 없기 때문이다) 그렇게 컨셉을 잡는다. 이렇게 해서 우리가 만든 FilterSecurityInterceptor 클래스에서 권한 체크를 하도록 한다


<http auto-config="true" use-expressions="true">
	<form-login
		username-parameter="loginid"
		password-parameter="loginpwd" 
		login-page="/login.do"
		default-target-url="/main.do"
		authentication-success-handler-ref="customAuthenticationSuccessHandler"
		authentication-failure-handler-ref="customAuthenticationFailureHandler"
	/>
	<anonymous granted-authority="ANONYMOUS" />
	<logout 
		logout-success-url="/main.do"
		delete-cookies="JSESSIONID"
	/>
	<custom-filter before="FILTER_SECURITY_INTERCEPTOR" ref="filterSecurityInterceptor"/>        
</http>


이<http> 태그의 <custom-filter> 태그를 사용한 것을 보자. Spring Security가 올리는 FilterSecurityInterceptor 전에 우리가 만든 FilterSecurityInterceptor 클래스를 올리기 위해 before 속성에 FilterSecurityInterceptor의 Alias인 FILTER_SECURITY_INTERCEPTOR를 사용했고 ref 속성에 우리가 만든 FilterSecurityInterceptor bean을 지정했다.


이번글로 Spring Security에서 DB를 이용한 자원 접근 권한 설정 및 판단에 대한 설명을 마치겠다. 다음글에서는 이렇게 URL 패턴별로 접근 권한을 검증하도록 했는데 해당 권한을 가지고 있지 않아 접근할 수 없을 경우 사용자가 지정하는 화면으로 이동하는 방법을 설명하도록 하겠다.



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

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

다른 카테고리의 글 목록

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

지난 글에서는 Spring Security의 FilterSecurityInterceptor 클래스를 통해 어떤 식으로 인증 정보를 이용하여 자원 접근 권한을 제어하는지에 대한 설명을 진행해봤다. 오늘은 이를 응용하여 자원 접근 권한을 XML이 아닌 DB에 설정한 뒤 이를 이용해서 자원 접근 권한을 제어해보도록 하자. 지금부터 설명하는 내용은 전자정부 프레임워크 세미나 중 Spring Security 기능소개 및 활용방법 세미나 동영상을 OLC 사이트에서 보고 이를 적용한 것임을 밝혀둔다(이 부분은 내가 직접 생각해서 만든 부분이 아니다. 혹여 내가 생각한 거라고 오해하는 분이 있으실 듯도 하여 미리 밝혀둔다)


이전 글을 다시 복습하는 차원에서 한번 기억을 떠올려보도록 하자. Spring Security의 FilterSecurityInterceptor 클래스에 설정되는 것은 3가지가 있다고 했었다. 인증 정보(Authentication Manager), 대상 정보(Security MetadataSource), 판단 주체(AccessManager) 이렇게 3가지다. 그리고 기존의 tag를 이용한 설정에서 기본적으로 셋팅이 되는 FilterSecurityInterceptor 클래스에 대해서도 알아보았다. 저번 글을 회상했을때 DB를 이용한 자원 접근 권한 설정 및 판단을 할려면 기존의 셋팅으로는 할 수가 없다는 것을 알 수가 있다. 인증 정보야 <authentication-manager> 태그로 만들어지고 FilterSecurityInterceptor 클래스에 설정이 되기 때문에 별 문제가 없지만, 대상 정보를 <intercept-url> 태그에서 읽은 것이 아닌 DB에서 읽은 값으로 구성이 되어져야 한다. 또 판단 주체를 만들때도 설정되어지는 Voter가 DB에서 읽은 값으로 구성된 대상 정보로 판단해야 하는 부분이 생기기 땜에 커스터마이징이 없이는 할 수가 없다. 즉 커스터마이징 셋팅이 된 FilterSecurityInterceptor 클래스의 필요성이 생기게 된다. 


그러면 DB에서 읽은 대상 정보를 만드는 방법을 고민해보도록 하자. 이전 글에서 기본 셋팅에서 만들어지는 대상 정보 클래스는 org.springframework.security.web.access.expression.ExpressionBasedFilterInvocationSecurityMetadataSource 라고 언급했었다. 그리고 이 클래스는 org.springframework.security.web.access.expression.DefaultFilterInvocationSecurityMetadataSource 클래스를 상속받고 있으며 이 DefaultFilterInvocationSecurityMetadataSource 클래스는 내부에 Map<RequestMatcher, Collection<ConfigAttribute>> 타입의 멤버변수 requestMap이 있고 실제로는 LinkedHashMap이 들어간다고 했었다. ExpressionBasedFilterInvocationSecurityMetadataSource 클래스가 <intercept-url> 태그에 spel 표현식을 사용한 권한 설정을 읽어서 Spring Security에 설정하기 위해 기존의 DefaultFilterInvocationSecurityMetadataSource 클래스를 상속받은 것을 감안한다면 우리는 다음과 같은 확장 포인트를 잡을수 있다.


● DefaultFilterInvocationSecurityMetadataSource 클래스와 같은 구조 또는 이 클래스를 상속받아 클래스를 만든다.

● 이때 requestMap에 해당하는 멤버변수를 만들어주고 여기에는 DB에서 조회한 내용으로 셋팅해준다.

● 이것을 셋팅하는 시점은 bean이 올라가는 시점에 셋팅해준다.


이렇게 잡아볼수가 있다. 여기서 또 하나의 커스터마이징 포인트가 존재하는데 그것은 관리자 페이지를 통해 이런 자원별 접근 권한을 수정했을 경우 이 내용이 바로 반영되어야 한다는 점이다. 관리자 페이지에서 자원별 접근 권한을 수정했는데 그것을 반영하기 위해 WAS를 내렸다가 올릴수는 없다. 그렇기땜에 수정된 내용을 반영하는 서비스를 하나 만들어야 한다. 그래서 DB에서 자원별 접근 권한을 조회하는 서비스를 만들어야 한다. 이 서비스는 여기뿐만 아니라 위에서 언급했던 requestMap 멤버변수 셋팅시에도 사용되어질것이다.


여기서는 DefaultFilterInvocationSecurityMetadataSource 클래스와 같은 구조를 갖는 클래스를 만들어보도록 하자. DefaultFilterInvocationSecuityMetadataSource 클래스의 정의는 다음과 같다.


public class DefaultFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    protected final Log logger = LogFactory.getLog(getClass());

    private final Map<RequestMatcher, Collection<ConfigAttribute>> requestMap;

    public DefaultFilterInvocationSecurityMetadataSource(
            LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap) {

        this.requestMap = requestMap;
    }

    ...
}


위의 클래스 소스를 보자. 이 클래스는 org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource 인터페이스를 구현하고 있다. 그리고 생성자로 LinkedHashMap<RequestMatcher, Collection<ConfigAttributes>> 객체를 받아 이를 requestMap 멤버변수에 셋팅해주고 있다. DefaultFilterInvocationSecurityMetadataSource 클래스가 가장 기본이 되는 대상정보 클래스(이 클래스를 상속받아 다른 클래스를 만들어서 사용하고 있었다)임을 감안한다면 우리가 만들 클래스는 FilterInvocationSecurityMetadataSource 인터페이스를 구현한 클래스로 대성정보 클래스를 사용하면 된다는 것을 알 수 있다. 그리고 생성자에 LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> 타입의 객체를 requestMap 멤버변수에 셋팅해주면 되겠다.


그러면 이제 LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> 타입 객체를 만들때 어떤 내용을 채워야 하는가를 고민할 차례다. 이전 글에서는 <intercept-url> 태그에 설정된 Ant 방식의 url pattern과 spel로 설정된 권한을 넣었다고 설명했다. 그럼 이런 패턴과 권한이 어디있을까? 바로 DB에 있다. 즉 우리는 DB에서 이 url 패턴과 이 패턴이 접근할 권한을 DB에서 조회한뒤 이를 org.springframework.security.web.util.matcher.AntPathRequestMatcher 클래스 객체를 key로 하고 Spring에서 제공하는 권한 클래스 객체들이 들어있는 List 객체를 value로 넣는 LinkedHashMap을 만들면 되는 것이다. 이를 위해 Dao 및 Service를 다음과 같이 만든다


SecuredObjectDao 클래스


import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;

import javax.sql.DataSource;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.SecurityConfig;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

public class SecuredObjectDao {

	private Logger logger = LoggerFactory.getLogger(this.getClass());

	/**
     * url 형식인 보호자원 - Role 맵핑정보를 조회하는 default 쿼리이다.
     */
    public static final String DEF_ROLES_AND_URL_QUERY =
    		"SELECT A.RESOURCE_PATTERN AS URL, B.AUTHORITY AS AUTHORITY "
    			+ "FROM SECURED_RESOURCES A, SECURED_RESOURCES_ROLE B "
    			+ "WHERE A.RESOURCE_ID = B.RESOURCE_ID "
    			+ "AND A.RESOURCE_TYPE = 'url' "
    			+ "ORDER BY A.SORT_ORDER ";

    /**
     * method 형식인 보호자원 - Role 맵핑정보를 조회하는 default 쿼리이다.
     */
    public static final String DEF_ROLES_AND_METHOD_QUERY =
    		"SELECT A.RESOURCE_PATTERN AS METHOD, B.AUTHORITY AS AUTHORITY "
        			+ "FROM SECURED_RESOURCES A, SECURED_RESOURCES_ROLE B "
        			+ "WHERE A.RESOURCE_ID = B.RESOURCE_ID "
        			+ "AND A.RESOURCE_TYPE = 'method' "
        			+ "ORDER BY A.SORT_ORDER ";

    /**
     * pointcut 형식인 보호자원 - Role 맵핑정보를 조회하는 default
     * 쿼리이다.
     */
    public static final String DEF_ROLES_AND_POINTCUT_QUERY =
    		"SELECT A.RESOURCE_PATTERN AS POINTCUT, B.AUTHORITY AS AUTHORITY "
        			+ "FROM SECURED_RESOURCES A, SECURED_RESOURCES_ROLE B "
        			+ "WHERE A.RESOURCE_ID = B.RESOURCE_ID "
        			+ "AND A.RESOURCE_TYPE = 'pointcut' "
        			+ "ORDER BY A.SORT_ORDER ";

    /**
     * 매 request 마다 best matching url 보호자원 - Role 맵핑정보를
     * 얻기위한 default 쿼리이다. (Oracle 10g specific)
     */
    public static final String DEF_REGEX_MATCHED_REQUEST_MAPPING_QUERY_ORACLE10G =
        "SELECT a.resource_pattern uri, b.authority authority "
            + "FROM secured_resources a, secured_resources_role b "
            + "WHERE a.resource_id = b.resource_id "
            + "AND a.resource_id =  "
            + " ( SELECT resource_id FROM "
            + "    ( SELECT resource_id, ROW_NUMBER() OVER (ORDER BY sort_order) resource_order FROM secured_resources c "
            + "      WHERE REGEXP_LIKE ( :url, c.resource_pattern ) "
            + "      AND c.resource_type = 'url' "
            + "      ORDER BY c.sort_order ) "
            + "   WHERE resource_order = 1 ) ";

    /**
     * Role 의 계층(Hierarchy) 관계를 조회하는 default 쿼리이다.
     */
    public static final String DEF_HIERARCHICAL_ROLES_QUERY =
        "SELECT a.child_role child, a.parent_role parent "
            + "FROM ROLES_HIERARCHY a LEFT JOIN ROLES_HIERARCHY b on (a.child_role = b.parent_role) ";

    private String sqlRolesAndUrl;

    private String sqlRolesAndMethod;

    private String sqlRolesAndPointcut;

    private String sqlRegexMatchedRequestMapping;

    private String sqlHierarchicalRoles;

    public SecuredObjectDao() {
        this.sqlRolesAndUrl = DEF_ROLES_AND_URL_QUERY;
        this.sqlRolesAndMethod = DEF_ROLES_AND_METHOD_QUERY;
        this.sqlRolesAndPointcut = DEF_ROLES_AND_POINTCUT_QUERY;
        this.sqlRegexMatchedRequestMapping =
            DEF_REGEX_MATCHED_REQUEST_MAPPING_QUERY_ORACLE10G;
        this.sqlHierarchicalRoles = DEF_HIERARCHICAL_ROLES_QUERY;
    }

    private NamedParameterJdbcTemplate namedParameterJdbcTemplate;

    public void setDataSource(DataSource dataSource) {
        this.namedParameterJdbcTemplate =
            new NamedParameterJdbcTemplate(dataSource);
    }

    /**
     * 롤에 대한 URL 정보를 가져오는 SQL을 얻어온다.
     * @return
     */
    public String getSqlRolesAndUrl() {
        return sqlRolesAndUrl;
    }

    /**
     * 롤에대한 URL 정보를 가져오는 SQL을 설정한다.
     * @param sqlRolesAndUrl
     */
    public void setSqlRolesAndUrl(String sqlRolesAndUrl) {
        this.sqlRolesAndUrl = sqlRolesAndUrl;
    }

    public String getSqlRolesAndMethod() {
        return sqlRolesAndMethod;
    }

    public void setSqlRolesAndMethod(String sqlRolesAndMethod) {
        this.sqlRolesAndMethod = sqlRolesAndMethod;
    }

    public String getSqlRolesAndPointcut() {
        return sqlRolesAndPointcut;
    }

    public void setSqlRolesAndPointcut(String sqlRolesAndPointcut) {
        this.sqlRolesAndPointcut = sqlRolesAndPointcut;
    }

    public String getSqlRegexMatchedRequestMapping() {
        return sqlRegexMatchedRequestMapping;
    }

    public void setSqlRegexMatchedRequestMapping(
            String sqlRegexMatchedRequestMapping) {
        this.sqlRegexMatchedRequestMapping = sqlRegexMatchedRequestMapping;
    }

    public String getSqlHierarchicalRoles() {
        return sqlHierarchicalRoles;
    }

    public void setSqlHierarchicalRoles(String sqlHierarchicalRoles) {
        this.sqlHierarchicalRoles = sqlHierarchicalRoles;
    }

    public LinkedHashMap<Object, List<ConfigAttribute>> getRolesAndResources(String resourceType) throws Exception {

        LinkedHashMap<Object, List<ConfigAttribute>> resourcesMap = new LinkedHashMap<Object, List<ConfigAttribute>>();

        String sqlRolesAndResources;
        boolean isResourcesUrl = true;
        if ("method".equals(resourceType)) {
            sqlRolesAndResources = getSqlRolesAndMethod();
            isResourcesUrl = false;
        } else if ("pointcut".equals(resourceType)) {
            sqlRolesAndResources = getSqlRolesAndPointcut();
            isResourcesUrl = false;
        } else {
            sqlRolesAndResources = getSqlRolesAndUrl();
        }

        List<Map<String, Object>> resultList = this.namedParameterJdbcTemplate.queryForList(sqlRolesAndResources, new HashMap<String, String>());

        Iterator<Map<String, Object>> itr = resultList.iterator();
        Map<String, Object> tempMap;
        String preResource = null;
        String presentResourceStr;
        Object presentResource;
        while (itr.hasNext()) {
            tempMap = itr.next();

            presentResourceStr = (String) tempMap.get(resourceType);
            // url 인 경우 RequestKey 형식의 key를 Map에 담아야 함
            presentResource = isResourcesUrl ? new AntPathRequestMatcher(presentResourceStr) : presentResourceStr;
            List<ConfigAttribute> configList = new LinkedList<ConfigAttribute>();

            // 이미 requestMap 에 해당 Resource 에 대한 Role 이 하나 이상 맵핑되어 있었던 경우, 
            // sort_order 는 resource(Resource) 에 대해 매겨지므로 같은 Resource 에 대한 Role 맵핑은 연속으로 조회됨.
            // 해당 맵핑 Role List (SecurityConfig) 의 데이터를 재활용하여 새롭게 데이터 구축
            if (preResource != null && presentResourceStr.equals(preResource)) {
                List<ConfigAttribute> preAuthList = resourcesMap.get(presentResource);
                Iterator<ConfigAttribute> preAuthItr = preAuthList.iterator();
                while (preAuthItr.hasNext()) {
                    SecurityConfig tempConfig = (SecurityConfig) preAuthItr.next();
                    configList.add(tempConfig);
                }
            }

            configList.add(new SecurityConfig((String) tempMap.get("authority")));
            
            // 만약 동일한 Resource 에 대해 한개 이상의 Role 맵핑 추가인 경우 
            // 이전 resourceKey 에 현재 새로 계산된 Role 맵핑 리스트로 덮어쓰게 됨.
            resourcesMap.put(presentResource, configList);

            // 이전 resource 비교위해 저장
            preResource = presentResourceStr;
        }
                
        return resourcesMap;
    }

    public LinkedHashMap<Object, List<ConfigAttribute>> getRolesAndUrl() throws Exception {
        return getRolesAndResources("url");
    }

    public LinkedHashMap<Object, List<ConfigAttribute>> getRolesAndMethod() throws Exception {
        return getRolesAndResources("method");
    }

    public LinkedHashMap<Object, List<ConfigAttribute>> getRolesAndPointcut() throws Exception {
        return getRolesAndResources("pointcut");
    }

    public List<ConfigAttribute> getRegexMatchedRequestMapping(String url) throws Exception {

        // best regex matching - best 매칭된 Uri 에 따른 Role List 조회, 
    	// DB 차원의 정규식 지원이 있는 경우 사용 (ex. hsqldb custom function, Oracle 10g regexp_like 등)
        Map<String, String> paramMap = new HashMap<String, String>();
        paramMap.put("url", url);
        List<Map<String, Object>> resultList = this.namedParameterJdbcTemplate.queryForList(getSqlRegexMatchedRequestMapping(), paramMap);

        Iterator<Map<String, Object>> itr = resultList.iterator();
        Map<String, Object> tempMap;
        List<ConfigAttribute> configList = new LinkedList<ConfigAttribute>();
        // 같은 Uri 에 대한 Role 맵핑이므로 무조건 configList 에 add 함
        while (itr.hasNext()) {
            tempMap = itr.next();
            configList.add(new SecurityConfig((String)tempMap.get("authority")));
        }

        if (configList.size() > 0) {
        	logger.debug("Request Uri : {}, matched Uri : {}, mapping Roles : {}", url, resultList.get(0).get("uri"), configList);
            
        }
        return configList;
    }

    public String getHierarchicalRoles() throws Exception {

    	List<Map<String, Object>> resultList = this.namedParameterJdbcTemplate.queryForList(getSqlHierarchicalRoles(), new HashMap<String, String>());

        Iterator<Map<String, Object>> itr = resultList.iterator();
        StringBuffer concatedRoles = new StringBuffer();
        Map<String, Object> tempMap;
        while (itr.hasNext()) {
            tempMap = itr.next();
            concatedRoles.append(tempMap.get("child"));
            concatedRoles.append(" > ");
            concatedRoles.append(tempMap.get("parent"));
            concatedRoles.append("\n");
        }

        return concatedRoles.toString();
    }
}


SecuredObjectDao 클래스는 예전 전자정부 Spring Security 세미나때 사용했던 코드를 그대로 사용했다. 이 코드에서는 url 타입 뿐만 아니라 메소드나 포인트컷도 할수 있도록 했기 때문에 여기서 사용되지 않는 코드도 붙어 있는 상황이다. 그러나 그렇다고 이걸 빼버리면 오히려 혼선이 있을것 같아서 일단은 넣어두었다. 소스를 보면 DEF_ROLES_AND_URL_QUERY란 final String 변수가 있는데 이 쿼리를 만들때는 Ant 패턴으로 등록된 URL과 이 URL이 접근할 수 있는 권한을 넣어주면 된다. 즉 쿼리를 실행하면 다음과 같은 스타일의 결과가 나오게끔 한다면 된다는 얘기다.


 /notmember/**

 ANONYMOUS

 /notmember/**

 ADMIN_BOARD_VIEW

 /admin/**

 ADMIN_BOARD_VIEW


첫번째 컬럼엔 Ant Pattern 방식의 URL 표현식이 나와야 하고 두번째 컬럼엔 권한 이름이 나와야 한다. 이 검색 결과 형태를 보자. 우리가 <intercept-url> 태그 설정시 썼던 데이터 표현과 같다. 다만 여기서는 SPEL을 사용할 수 없기 때문에 permitAll, denyAll, hasRole 같은 함수를 사용하면 안된다. 권한 명칭을 써야 한다. 그래서 로그인 하지 않은 권한도 ANONYMOUS라고 명시해준 것이다. 이 부분이 사실 단점이 되는 부분이기도 하다. /notmember/** 부분을 보자. 모든 사람이 접근하도록 하겠다면 permitAll 하나만 넣어도 가능한 것이었는데 DB로 바뀌면서 접근할 수 있는 모든 권한을 일일이 명시해줘야 한다. DEF_ROLES_AND_URL_QUERY 변수는 기본적으로 설정되는 쿼리를 지정하는 것으로 만약 이 쿼리를 바꾸고 싶다면 sqlRolesAndUrl 멤버변수에 셋팅해주면 된다. setter 메소드가 있기 때문에 <property> 태그를 이용해서 설정할 수가 있다. 그리고 실제로 사용되는 변수도 sqlRolesAndUrl 이다. DEF_ROLES_AND_URL_QUERY 변수에 있는 내용이 생성자에서 sqlRolesAndUrl에 들어가고 있다.


이번 글을 시작하는 시점에서 설명했던 내용 중에 DefaultFilterInvocationSecurityMetadataSource 클래스는 내부에 Map<RequestMatcher, Collection<ConfigAttribute>> 타입의 멤버변수 requestMap이 있고 실제로는 LinkedHashMap이 들어간다고 설명한 부분이 있다. 위에서 DefaultFilterInvocationSecurityMetadataSource와 같은 구조의 클래스를 만든다고 했기 때문에 우리가 만들 클래스에도 Map<RequestMatcher, Collection<ConfigAttribute>> 타입의 멤버변수 requestMap이 있을 것이다. 여기에 들어가야 하는 내용이 URL과 거길 접근하는 권한이 들어가야 한다는 것은 이전 글에서 설명한 바가 있다. 그렇기 때문에 우리가 만든 쿼리를 실행하여 나온 결과가 requestMap에 들어가게끔 조작을 해줘야 한다. 즉 LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> 타입으로 결과를 만들어야 한다는 것이다. 그런 작업을 해주는 메소드가 getRolesAndUrl() 메소드이다. 이 메소드는 내부에서 getRolesAndResources('url')을 호출하고 있기 때문에 실제 LinkedHashMap을 만들어주는 작업은 getRolesAndResources 메소드가 하게 된다.


getRolesAndResources 메소드는 url과 메소드, 포인트컷 모두 LinkedHashMap을 만들기 때문에 혼선이 생길수 있으나 3개 모두 만들어지는 스타일은 동일하기 때문에 분석하는데 있어서 그리 어렵지는 않다. this.namedParameterJdbcTemplate.queryForList 를 통해 쿼리를 실행하여 위에서 언급한 형태의 결과를 얻으면 이를 Map 형태로 검색된 쿼리 결과의 레코드를 꺼낸뒤 첫번째 컬럼값인 url 패턴을 이용하여 AntPathRequestMatcher 객체로 생성하고 두번째 컬럼값인 권한 이름을 이용해서 SecurityConfig 객체를 만든뒤 이렇게 만든 SecurityConfig 객체를 LinkedList 객체에 넣어둔다. 그래서 이렇게 만든 AntPathRequestMatcher 객체를 key로 해서 LinkedHashMap에 AntPathRequestMatcher 객체와 LinekedList 객체를 넣어둔다. 그리고 다음 레코드 값을 꺼냈을때 첫번째 컬럼값인 url 패턴이 이전 레코드것과 동일하면 LinkedHashMap에서 방금 넣은 것을 꺼낸뒤에 LinkedList 객체를 다음 레코드값에 있는 권한을넣은 것으로 재구성해서 다시 LinkedHashMap에 넣는다. 이런 식으로 getRolesAndResources 메소드는 LinkedHashMap을 만들게 된다.


이렇게 LinkedHashMap을 만들때 주의해야 할 사항이 있다. 이전 글에서 LinkedHashMap은 넣은 순서대로 key 목록을 가져온다고 언급한 적이 있다. 즉 Spring Securiry는 이 순서를 이용해서 url의 적용 우선 순위를 잡게 되는데 이 부분을 어디서 설정할까? DEF_ROLES_AND_URL_QUERY 변수에 설정된 쿼리를 다시 보자. 쿼리를 보면 SECURED_RESOURCES 테이블의 SORT_ORDER 컬럼 값으로 정렬을 하고 있다. SORT_ORDER 컬럼..이 컬럼은 무슨 역할을 하는걸까?


<intercept-url> 태그 설명시 상세한 URL 선언을 먼저하고 러프한 URL 선언을 나중에 해야 한다고 말한것을 기억할 것이다. 예를 들어 다음과 같이 /notmember/board.do와 /notmember/** 이렇게 2가지의 url 패턴이 있다고 하자. /notmember/board.do는 /notmember/**의 특정 부분이다. 이 특정 부분을 먼저 선언하고 /notmember/**를 선언해줘야 /notmember/board.do 접근시 /notmember/board.do 패턴으로 적용한 것을 먼저 만나도록 할 수가 있는것이다. SORT_ORDER 컬럼은 바로 이 순서를 정하는 것이다. DB에서 /notmember/board.do 에 대한 SORT_ORDER 컬럼의 값(예를 들어 이 값을 10으로 했다고 가정해보자)이 /notmember/** 에 대한 SORT_ORDER 컬럼의 값(예를 들어 이 값을 100으로 했다고 가정해보자)보다 작아야 쿼리 결과에서 먼저 올라오게 될 것이다. 그리고 먼저 올라왔기 때문에 LinkedHashMap에 먼저 들어가게 될것이다. 이런식으로 SORT_ORDER 값을 설정할때는 이런 주의점을 기억하고 설정해야 한다.


이렇게 SecuredObjectDao 클래스에서 LinkedHashMap을 만들었다면 이제 이 DAO 를 Injection 받아서 LinkedHashMap을 가져오는 서비스 bean이 있어야 할것이다. 그것이 다음에 나오는 SecuredObjectService 인터페이스를 구현한 SecuredObjectServiceImpl 클래스이다 


SecuredObjectService 인터페이스


import java.util.LinkedHashMap;
import java.util.List;

import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.util.matcher.RequestMatcher;

public interface SecuredObjectService {

	/**
     * 롤에 대한 URL의 매핑 정보를 얻어온다.
     * @return
     * @throws Exception
     */
    public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getRolesAndUrl() throws Exception;

    /**
     * 롤에 대한 메소드의 매핑 정보를 얻어온다.
     * @return
     * @throws Exception
     */
    public LinkedHashMap<String, List<ConfigAttribute>> getRolesAndMethod() throws Exception;

    /**
     * 롤에 대한 AOP pointcut 메핑 정보를 얻어온다.
     * @return
     * @throws Exception
     */
    public LinkedHashMap<String, List<ConfigAttribute>> getRolesAndPointcut() throws Exception;

    /**
     * Best 매칭 정보를 얻어온다.
     * @param url
     * @return
     * @throws Exception
     */
    public List<ConfigAttribute> getMatchedRequestMapping(String url) throws Exception;

    /**
     * 롤의 계층적 구조를 얻어온다.
     * @return
     * @throws Exception
     */
    public String getHierarchicalRoles() throws Exception;
}


SecuredObjectServiceImpl 클래스


import java.util.LinkedHashMap;
import java.util.List;
import java.util.Set;

import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;

import com.terry.springsecurity.common.security.dao.SecuredObjectDao;
import com.terry.springsecurity.common.security.service.SecuredObjectService;

public class SecuredObjectServiceImpl implements SecuredObjectService {

private SecuredObjectDao securedObjectDao;
	
	public SecuredObjectDao getSecuredObjectDao() {
		return securedObjectDao;
	}

	public void setSecureObjectDao(SecuredObjectDao secureObjectDao) {
		this.securedObjectDao = secureObjectDao;
	}

	@Override
	public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getRolesAndUrl() throws Exception {
		// TODO Auto-generated method stub
		LinkedHashMap<RequestMatcher, List<ConfigAttribute>> ret = new LinkedHashMap<RequestMatcher, List<ConfigAttribute>>();
		LinkedHashMap<Object, List<ConfigAttribute>> data = securedObjectDao.getRolesAndUrl();
		Set<Object> keys = data.keySet();
		for(Object key : keys){
			ret.put((AntPathRequestMatcher)key, data.get(key));
		}
		return ret;
	}

	@Override
	public LinkedHashMap<String, List<ConfigAttribute>> getRolesAndMethod()
			throws Exception {
		// TODO Auto-generated method stub
		LinkedHashMap<String, List<ConfigAttribute>> ret = new LinkedHashMap<String, List<ConfigAttribute>>();
		LinkedHashMap<Object, List<ConfigAttribute>> data = securedObjectDao.getRolesAndMethod();
		Set<Object> keys = data.keySet();
		for(Object key : keys){
			ret.put((String)key, data.get(key));
		}
		return ret;
	}

	@Override
	public LinkedHashMap<String, List<ConfigAttribute>> getRolesAndPointcut()
			throws Exception {
		// TODO Auto-generated method stub
		LinkedHashMap<String, List<ConfigAttribute>> ret = new LinkedHashMap<String, List<ConfigAttribute>>();
		LinkedHashMap<Object, List<ConfigAttribute>> data = securedObjectDao.getRolesAndPointcut();
		Set<Object> keys = data.keySet();
		for(Object key : keys){
			ret.put((String)key, data.get(key));
		}
		return ret;
	}

	@Override
	public List<ConfigAttribute> getMatchedRequestMapping(String url) throws Exception {
		// TODO Auto-generated method stub
		return securedObjectDao.getRegexMatchedRequestMapping(url);
	}

	@Override
	public String getHierarchicalRoles() throws Exception {
		// TODO Auto-generated method stub
		return securedObjectDao.getHierarchicalRoles();
	}

}


SecuredObjectService 인터페이스에는 5개의 메소드가 정의되어 있는데 우리는 URL에 따른 권한 작업을 하는 것이기 때문에 getRolesAndUrl() 메소드만 살펴보도록 하겠다. 그러면 SecuredObjectService 인터페이스를 구현한 SecuredObjectServiceImpl 클래스를 보자. 이 클래스에는 방금 만들었던 SecuredObjectDao bean 객체를 setter 메소드를 이용해서 Injection 받을수가 있다. 그리고 SecuredObjectService 인터페이스에서 정의한 getRolesAndUrl 메소드에서 SecurityObjectDao bean 객체의 getRolesAndUrl() 메소드를 호출해서 그 결과를 return 해주고 있다(SecuredObjectServiceImpl 클래스의 getRolesAndUrl 메소드에서 SecuredObjectDao bean 객체의 getRolesAndUrl() 메소드 결과를 재가공해서 이를 return 하는 이유를 잘 모르겠다. Set 객체를 사용하는 이유는 중복제거를 하기 위해서 하는건데 Map이란게 key는 중복이 될 수 있는 것이 아니어서 저렇게 할 필요가 없다고 생각되는데..)


그러면 이렇게 URL에 따른 권한 정보를 조회할 수 있는 기능을 가지고 있는 서비스를 어떻게 활용할 수 있을까? 일단 우리가 이걸 만든 목적은 Spring Security의 FilterSecurityInterceptor에 대상 정보(Security MetaDataSource)로 사용하기 위한 DefaultFilterInvocationSecurityMetadataSource와 같은 구조의 클래스에서 이 서비스를 사용해서 requestMap을 얻기 위함이었다. 그러면 이 requestMap이란걸 어떤식으로 설정해주는게 좋을까? Spring Security가 처음 올라가는 시점에 바로 DB를 조회해서 requestMap을 꾸며주는것이 가장 좋다. 근데 문제는 이렇게 URL에 따른 권한이 운영하는 도중에 바뀌는 상황이 없을까? 아니다. 운영하는 도중에 얼마든지 바뀔수 있는 사항이 발생한다. 예를 들어보자. 어떤 특정 페이지가 문제(여기서 문제라 함은 WAS에서 보여주는 jsp 500 에러 페이지라고 생각해보자)가 생겼다. 그러면 일단은 그 페이지에 대한 접근 권한 설정을 관리자 권한으로 바꾸어서 일반인은 못들어오게 한뒤에 다른 페이지(권한을 만족하지 못할것이기 때문에 별도로 만든 에러페이지로 가게 될 것이다)로 유도하는 방법이 있을것이다. 이렇게 URL에 따른 권한 설정은 운영중에서도 바뀔수가 있는 사항이다. 문제는 이런 작업을 할때 관리자 페이지에서 권한을 수정한 뒤에 WAS를 재가동시켜서 requestMap을 다시 재생성해야 하느냐이다. 그렇게 할 수는 없다. 그래서 특정 액션이 발생했을때 이 requestMap을 다시 만들어주는 기능도 필요하게 된다. 다시 만들어주는거야 어렵지는 않다. 우리가 방금 만든 SecuredObjectServiceImpl 클래스의 getRolesAndUrl() 메소드를 다시 호출해서 requestMap을 다시 가져오면된다. 그럼 이런 부분을 어떻게 구현하는것이 좋을까? 우리가 앞으로 만들 DefaultFilterInvocationSecurityMetadataSource와 같은 구조의 클래스에 이런 기능을 넣으면 된다. 그리고 아예 requestMap을 전문으로 만드는 bean(결국 SecuredObjectServiceImpl 클래스가 만들기는 하지만..)을 하나 제작해보도록 하자. 일종의 wrapper를 덧씌우는 것인데 이렇게 하면 URL 별 권한 뿐만 아니라 메소드나 포인트컷에 대한 조회도 가능하게끔 확장성을 가져갈 수 있다(실제 전자정부 프레임워크 세미나에서는 확장성을 가지고 있으나 여기서는 메소드나 포인트컷에 대한 설명을 하질 않을 것이라 이 wrapper에 대해서는 확장성을 빼버렸다. 관심있는 사람은 OLC에서 전자정부 프레임워크 세미나 중 Spring Security 세미나를 보면 이 부분을 알 수 있다)


UrlResourcesMapFactoryBean 클래스


import java.util.LinkedHashMap;
import java.util.List;

import org.springframework.beans.factory.FactoryBean;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.util.matcher.RequestMatcher;

import com.terry.springsecurity.common.security.service.SecuredObjectService;

public class UrlResourcesMapFactoryBean implements
		FactoryBean<LinkedHashMap<RequestMatcher, List<ConfigAttribute>>> {

	private SecuredObjectService securedObjectService;
	
	private LinkedHashMap<RequestMatcher, List<ConfigAttribute>> requestMap;
	
	public void setSecuredObjectService(SecuredObjectService securedObjectService) {
		this.securedObjectService = securedObjectService;
	}

	public void init() throws Exception {
		requestMap = securedObjectService.getRolesAndUrl();
	}
	
	@Override
	public LinkedHashMap<RequestMatcher, List<ConfigAttribute>> getObject() throws Exception {
		// TODO Auto-generated method stub
		if(requestMap == null){
			requestMap = securedObjectService.getRolesAndUrl();
		}
		return requestMap;
	}

	@Override
	public Class<?> getObjectType() {
		// TODO Auto-generated method stub
		return LinkedHashMap.class;
	}

	@Override
	public boolean isSingleton() {
		// TODO Auto-generated method stub
		return true;
	}
}


UrlResourcesMapFactoryBean 클래스는 FactoryBean이다. 바꿔 말해서 이 클래스를 <bean> 태그를 이용해 설정한 뒤 다른 <bean> 태그에서 이것을 참조하게 되면 UrlResourcesMapFactoryBean 클래스가 참조가 되는 것이 아니라 이 클래스의 getObject() 메소드를 통해 return 되는 객체가 셋팅되는 것이다. 이 클래스를 참조하게 되면 getObject() 메소드가 우리가 requestMap 타입으로 잡고 있는 LinkedHashMap<RequestMatcher, List<ConfigAttribute>> 타입의 객체가 참조되게 되는 것이다.  UrlResourceMapFactoryBean 클래스는 SecuredObjectService 인터페이스를 구현한 객체를 Injection 받을수 있기 때문에 우리가 위에서 만든 SecuredObjectServiceImpl 클래스를 Injection 시킬수가 있다. 그리고 getObject() 메소드에서 Injection 받은 SeciredObjectService 인터페이스 구현 bean의 getRolesAndUrl() 메소드를 호출함으로써 DB에서 URL에 따른 권한을 조회한 결과인 LinkedHashMap 클래스 객체를 return 하게 되는 것이다.


이제 대상 정보 클래스를 만들기 위해 먼저 선작업해야 할 것들을 다 만들었다. 이제는 대상 정보 클래스를 만들도록 하자. 위에서도 언급했지만 DefaultFilterInvocationSecurityMetadataSource와 같은 구조의 클래스를 만든다고 했었다. 이제 이 클래스를 만들어보도록 하자.


ReloadableFilterInvocationSecurityMetadataSource 클래스


import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import javax.servlet.http.HttpServletRequest;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.util.matcher.RequestMatcher;

import com.terry.springsecurity.common.security.service.SecuredObjectService;

public class ReloadableFilterInvocationSecurityMetadataSource implements
		FilterInvocationSecurityMetadataSource {

	private Logger logger = LoggerFactory.getLogger(this.getClass());
	
	private final Map<RequestMatcher, Collection<ConfigAttribute>> requestMap;
	
	private SecuredObjectService securedObjectService;
	
	public ReloadableFilterInvocationSecurityMetadataSource(LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> requestMap){
		this.requestMap = requestMap; 
		
	}
	
	public void setSecuredObjectService(SecuredObjectService securedObjectService) {
		this.securedObjectService = securedObjectService;
	}


	@Override
	public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
		// TODO Auto-generated method stub
		HttpServletRequest request = ((FilterInvocation)object).getRequest();
		Collection<ConfigAttribute> result = null;
		for(Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : requestMap.entrySet()){
			if(entry.getKey().matches(request)){
				result = entry.getValue();
				break;
			}
		}
		return result;
	}

	@Override
	public Collection<ConfigAttribute> getAllConfigAttributes() {
		// TODO Auto-generated method stub
		Set<ConfigAttribute> allAttributes = new HashSet<ConfigAttribute>();
		for(Map.Entry<RequestMatcher, Collection<ConfigAttribute>> entry : requestMap.entrySet()){
			allAttributes.addAll(entry.getValue());
		}
		return allAttributes;
	}

	@Override
	public boolean supports(Class<?> clazz) {
		// TODO Auto-generated method stub
		return FilterInvocation.class.isAssignableFrom(clazz);
	}

	public void reload() throws Exception {
		LinkedHashMap<RequestMatcher, List<ConfigAttribute>> reloadedMap = securedObjectService.getRolesAndUrl();

        Iterator<Entry<RequestMatcher, List<ConfigAttribute>>> iterator = reloadedMap.entrySet().iterator();

        // 이전 데이터 삭제
        requestMap.clear();

        while (iterator.hasNext()) {
        	Entry<RequestMatcher, List<ConfigAttribute>> entry = iterator.next();
            
            requestMap.put(entry.getKey(), entry.getValue());
        }
        
        if (logger.isInfoEnabled()) {
            logger.info("Secured Url Resources - Role Mappings reloaded at Runtime!");
        }
	}

}


ReloadableFilterInvocationSecurityMetadataSource 클래스는 requestMap과 SecuredObjectService 인터페이스를 구현한 클래스를 Injection 받아서 작업하게 된다. requestMap을 Injection 받을때 어떻게 받을까? 방금 설명했던 클래스인  UrlResourcesMapFactoryBean 클래스를 Injection 받아서 requestMap을 받게 된다. UrlResourcesMapFactoryBean 클래스가 FactoryBean 클래스이기 때문에 UrlResourcesMapFactoryBean 타입 객체가 Injection 되는 것이 아니라는 것을 다시한번 강조한다. ReloadableFilterInvocationSecurityMetadataSource는 SecuredObjectService 인터페이스를 구현한 클래스를 Injection 받을 수 있기 때문에 위에서 언급했던 SecuredObjectServiceImpl 클래스 객체를 Injection 시킬수가 있고 이 클래스를 이용해서 DB에서 URL에 따른 권한이 들어있는 requestMap을 조회할 수 있게 된다. ReloadableFilterInvocationSecurityMetadataSource 클래스의 reload() 메소드는 바로 이런 기능을 쓰기 위해 만든것이다. 즉 이 클래스의 reload 메소드를 호출하면 기존에 셋팅된 requestMap을 지운뒤에 Injection 받은 SecuredObjectServiceImpl 클래스에서 다시 requestMap을 조회해서 셋팅하게 된다. 위에서 페이지 에러 발생시 관리자 페이지에서 권한을 수정한다고 했었는데 권한을 수정한뒤 이 bean 클래스의 reload 메소드를 호출하게 되면 DB에서 다시 requestMap을 만들어서 셋팅하게 되는 것이다.


ReloadableFilterInvocationSecurityMetadataSource 클래스는  FilterInvocationSecurityMetadataSource 인터페이스를 구현하기 때문에 getAttributes 메소드와 getAllConfigAttributes 메소드를 구현해야한다. getAttributes 메소드의 소스를 보면 파라미터로 받은 Object 객체를 HttpServletRequest 객체로 캐스팅 한 뒤 requestMap에서 캐스팅 된 HttpServletRequest 객체와 Ant 패턴으로 맞는 것을 찾아 그에 따른 권한 목록을 return 한다. 여기서 명심해야 할 부분이 있는데 이 메소드를 보면 loop를 돌면서 Ant 패턴과 맞는 것을 찾다가 발견하면 리턴할 변수에 설정하고 break를 걸어 loop 를 빠져나오고 있다. 이 Ant 패턴은 반드시 한개만 있는 것이 아니다. 예를 들면 HttpServletRequest로 온 것이 /notmember/board.do 라고 가정해보자. 그리고 requestMap에 등록되어 있는 패턴은 /notmember/board.do와 /notmember/** 이렇게 2개가 있다고 가정해보자. 이 패턴 2개 모두 /notmember/board.do를 모두 만족하지만 requestMap에 등록된 /notmember/board.do와 매핑이 되어야 올바른 권한 설정이 될 것이다. 이런점 때문에 requestMap을 만들때 순서가 의미가 있는 것이며 loop를 계속 돌 경우 다음 패턴에서도 만족할 가능성이 있기 때문에 찾으면 break를 걸어서 loop를 빠져나오게 한 것이다. 그리고 getAllConfigAttributes 메소드는 requestMap에 등록된 모든 Ant 패턴에 대한 권한 목록을 return 해주고 있다.


이번 글에서는 대상 정보 클래스를 만드는 법에 대해 살펴보았다. 내용이 길었는데, requestMap에 대한 이해가 필수이다. 이 requestMap을 순서에 의미를 두면서 만들어야 한다. 자원에 따른 권한이 requestMap에 설정되고 이 requestMap에 있는 내용을 가져다가 판단한다고 보면 되는 것이다. 다음 글에서는 이렇게 만든 대상 정보 클래스를 어떻게 활용하는지에 대해 다루겠다.



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

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

다른 카테고리의 글 목록

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

지난 글에서는 권한에 대한 개념을 조금 짚어봤다. 지난 글에 동의하는 사람도 있고 동의하지 않는 사람도 있을것이다. 지난 글에서도 언급했지만 그게 절대적인 진리는 아니다. 어찌보면 내가 가지고 있는 권한 설계의 사상을 얘기한 것이다. 다만 그 설계 사상이 모든 플젝에 맞는 것은 아니기땜에 단순하게 할수도 있고 장기적인 안목을 보고 좀더 신경써서 디테일하게 할수도 있는 것이다. 암튼 플젝에 따른 권한 설계 사상을 가지고 신중히 설계하길 바란다.


이번 글에서는 이렇게 설계한 권한을 이용해서 Spring Security가 어떤식으로 자원과 매핑을 지어 관리하는지를 보도록 하자. 이 기능을 하는데 있어 총괄적인 역할을 하는 클래스는 org.springframework.security.web.access.intercept.FilterSecurityInterceptor 클래스이다. 이 클래스를 어디서 보았는지 기억하는가? Spring Security 관련 연재글에서 처음 글을 쓸때 Spring Security의 Filter에 대해 언급한 적이 있는데 이 클래스가 Filter중 가장 마지막으로 올라오는 Filter 클래스이다. 즉 이 Filter 다음으로 오는게 Spring MVC의 Controller 클래스이다. 이 클래스 객체에 설정되어야 하는 것은 다음의 3가지 이다


● 인증 정보(Authentication Manager)

● 대상 정보(Security MetaDataSource)

● 판단 주체(Access Manager)


인증 정보는 로그인 한 주체의 정보가 된다. 즉 로그인 한 사람의 계정 및 권한 정보이다. 우리는 이것을 이전의 글에서 DB에서 조회해서 세션에 보관하는 식으로 넣었다. 대상 정보는 우리가 가지고 있는 자원과 이를 접근할 수 있는 권한이 매핑된 정보이다. 우리는 이전글에서 <intercept-url> 태그에서 URL 패턴과 그 패턴을 이용할 수 있는 권한 설정을 했던 적이 있다. 이것이 대상 정보이다. 그래서 인증 정보와 대상 정보는 이전 글에서 어떤 식으로든 구현하거나 설정을 이용해서 표현한 적이 있다. 문제는 판단 주체이다. 판단 주체? 이것에 대한 설정은 한번도 한적이 없는데? 판단 주체란 무엇이지?


판단 주체는 인증 정보와 대상 정보를 이용해서 이를 허용할 것인지 말 것인지를 결정하는 주체이다. Spring Security는 이와 관련하여 3가지의 Class를 제공한다. 이 3개의 클래스는 org.springframework.security.access.vote.AbstractAccessDecisionManager 클래스를 모두 상속 받는데 3개의 클래스는 다음과 같다.


● org.springframework.security.access.vote.AffirmativeBased

● org.springframework.security.access.vote.ConsensusBased

● org.springframework.security.access.vote.UnanimousBased


위에서 우리는 판단 주체라는 말을 사용했는데 이것은 일종의 조직이라고 이해하는 것이 더 쉽다. 이 판단 주체는 일종의 위원회 개념이라고 보는 것이 더 편하다. 즉 판단을 심사하는 것(판단을 심사하는 위원)들이 여러개가 모여있는 조직이다. 판단을 심사하는 것을 Voter라고 하는데 Spring Security는 이런 Voter를 org.springframework.security.access.AccessDecisionVoter 인터페이스를 구현한 클래스들로 제공해주고 있다. 어떤 클래스가 제공되는지는 Spring Security API 문서에서 AccessDecisionVoter 인터페이스에 대한 내용을 보면 알 수 있다(Spring Security 3.2.4 기준으로 보면 이 인터페이스를 구현한 8가지의 클래스를 제공해주고 있다). 이 인터페이스에는 3가지 상수가 설정되어 있는데 이 상수는 다음과 같다


● ACCESS_GRANTED

● ACCESS_DENIED

● ACCESS_ABSTAIN


ACCESS_GRANTED는 자원에 대한 접근을 허가한다는 의미이고, ACCESS_DENIED는 자원에 대한 접근을 거부한다는 의미이고, ACCESS_ABSTAIN은 자원에 대한 접근 여부 판단을 보류하겠다는 의미이다. 우리가 무언가를 허가/거절/보류 할때를 생각해보자. 그런 판단을 하기 위한 자료를 다 받은뒤에 판단을 할 것이다. 그때 허가할수도 있고, 거절할수도 있고, 또는 판단하기에는 받아온 자료가 미진해서 판단을 보류할 수도 있는 것이다. 그리고 이 인터페이스에서 제공되는 함수인 vote 함수를 통해 판단하여 그 결과를 위에서 언급한 3개의 함수 중 하나를 return 하게 되는 것이다. AccessDecisionVoter 인터페이스에서 vote 함수는 다음과 같이 정의되어 있다.


public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes)


vote 함수에서 첫번째 인자는 로그인 한 사용자의 인증정보로 여기에는 사용자가 가지고 있는 권한도 포함되어 있다. 두번째 인자는 현재 접근하고자 하는 자원이다. 세번째 인자는 현재 접근하는 자원에 접근할 수 있는 권한 목록이 넘어가게 된다.


지금까지의 설명으로 Voter가 잘 와닿지가 않는가? 그러면 이해하기 쉽도록 Spring Security에서 제공하는 AccessDecisionVoter 인터페이스를 구현한 클래스들 중 하나인 org.springframework.security.access.vote.RoleVoter 클래스에 대해 설명하도록 하겠다. RoleVoter 클래스는 권한을 가지고 판단하는 voter 클래스이다. 이 클래스의 vote 함수 소스를 보면 다음과 같다


public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> attributes) {
	int result = ACCESS_ABSTAIN;
	Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);

	for (ConfigAttribute attribute : attributes) {
		if (this.supports(attribute)) {
			result = ACCESS_DENIED;

			// Attempt to find a matching granted authority
			for (GrantedAuthority authority : authorities) {
				if (attribute.getAttribute().equals(authority.getAuthority())) {
					return ACCESS_GRANTED;
				}
			}
		}
	}

	return result;
}


vote 함수에서 첫번째 파라미터로 넘어오는 것은 인증 정보로써 Authentication 인터페이스를 구현한 클래스가 넘어오게 된다. form 기반 로그인 작업시에는 로그인을 하지 않았을때는 Anonymous 권한을 가진 org.springframework.security.authentication.AnonymousAuthenticationToken 객체를 넘겨받게 되고 로그인을 했을 경우 org.springframework.security.authentication.UsernamePasswordAuthenticationToken 객체를 넘겨받게 된다(form 기반 로그인이라고 명시한 이유는 로그인이 반드시 form 방식만 있는건 아니기 때문이다. form 기반이 아닌 방식에서는 다른 클래스 객체가 넘어올 수도 있다. 그러나 그렇다 해도 Authentication 인터페이스를 구현한 클래스 객체가 넘어오는 것은 분명하다). 두번째 파라미터로 넘어오는 것은 현재 접근하고자 하는 자원으로 우리가 웹페이지를 방문할 경우 해당 웹페이지에 대한 org.springframework.security.web.FilterInvocation 객체가 넘어온다. 세번째 파라미터로 넘어오는 것은 현재 접근하는 자원(여기서는 두번째 파라미터인 object)에 접근 가능한 권한 목록이 넘어오게 된다. 이 권한 목록은 이 글 맨 위에서 FilterSecurityInterceptor 클래스 설명 당시 대상 정보를 얘기한 적이 있는데 이 대상 정보를 통해 가져올 수가 있다. 즉 해당 자원(여기서는 URL)을 접근할 수 있는 권한의 목록을 가져오게 되는 것이다. 여기서는 LinkedList 객체로 넘겨지게 되는데 이 객체는 내부에 org.springframework.security.access.SecurityConfig 클래스 객체로 설정되어 있는 권한 객체들이 들어있다.


이렇게 넘어오는 값을 가지고 한번 위의 소스를 분석해보자. 먼저 첫번째 파라미터를 이용해서 사용자가 가지고 있는 권한 목록을 뽑아낸다(Collection<? extends GrantedAuthority> authorities = extractAuthorities(authentication);) 그리고 중첩 for문을 이용해서 사용자가 가지고 있는 권한 목록과 접근하는 자원에 대한 권한 목록을 비교하여 하나라도 만족하는 권한이 있으면 ACCESS_GRANTED를 return 한다. 만약 찾질 못하면 첫번째 for문에서 초기화한 값인 ACCESS_DENIED가 return이 된다. 그리고 사용자가 가지고 있는 권한 목록에 아무런 권한값이 들어있질 않은 경우 중첩 for문을 들어가기 전에 초기화한 값인 ACCESS_ABSTAIN이 return 된다.


이 소스 분석 내용과 아까 AccessVoter 인터페이스 설명한 내용을 맞춰보자. 사용자가 가지고 있는 권한들과 자원을 접근할 수 있는 권한들 중 하나라도 맞는게 있으면 접근할 수 있다는 의미로 ACCESS_GRANTED를 return 한다. 그러나 사용자가 가지고 있는 권한들과 자원을 접근할 수 있는 권한을 다 비교해봐도 맞는게 없으면 접근할 수 없다는 의미로 ACCESS_DENIED를 return 한다. 마지막으로 사용자가 가지고 있는 권한들을 가져왔으나 아무런 권한도 들어있는게 없으면 판단을 유보하는 의미로 ACCESS_ABSTAIN을 return 한다. 이제 이 부분이 이해가 되는가?


이렇게 Voter는 권한과 자원에 대해 접근 가능/불가능/보류를 판단하는 일종의 거수기 역할을 하게 된다. 그러나 이러한 권한과 자원에 대한 접근 판단을 하나의 Voter 만으로 하지 않는 경우도 있다. Spring Security가 제공하는 AccessDecisionVoter 인터페이스를 구현한 클래스 중 org.springframework.security.access.vote.AuthenticatedVoter 클래스가 있다. 이 클래스가 하는 역할은 로그인 여부를 가지고 판단하는 것이다. Spring Security는 로그인에 대해 3가지 상태를 가지고 판단하게 되는데 이 3가지는 다음과 같다.


● IS_AUTHENTICATED_FULLY

● IS_AUTHENTICATED_REMEMBERED

● IS_AUTHENTICATED_ANONYMOUSLY


로그인 상태의 경우는 크게는 로그인을 한 경우와 로그인을 하지 않은 경우(IS_AUTHENTICATED_ANONYMOUSLY)로 두 가지로 나눌수 있다. 또 로그인을 한 경우도 두 가지로 나눌수가 있는데 하나는 아이디와 패스워드를 입력받아 로그인 한 경우(IS_AUTHENTICATED_FULLY)와 예전에 로그인 한 아이디와 비밀번호를 기억하고 있다가 그것으로 로그인 하는 Remember me 기능을 사용한 로그인 하는 경우(IS_AUTHENTICATED_REMEMBERED)로 나눌수 있다. IS_AUTHENTICATED_REMEMBERED의 경우는 로그인 아이디와 비밀번호를 직접 입력받아 로그인 하는 경우와 Remember me 기능 중 하나라도 로그인 된 경우를 얘기한다. 기본적으로 사용되는 AuthenticatedVoter 클래스는 위의 3가지 경우 모두 접근을 허용하고 있다. 즉 아이디와 패스워드를 입력해서 로그인 했든, Remember Me 기능을 이용해서 로그인 했든, 아니면 아예 로그인을 하지 않았든 모두 접근을 허용(ACCESS_GRANTED)하고 있다. 그러나 우리는 AuthenticatedVoter 클래스를 상속받아 vote 메소드를 override 함으로써 이러한 결과를 수정할 수가 있다. 즉 Remember Me 기능을 이용한 로그인은 허용하지 않겠다고 코드를 수정할 수가 있는 것이다.


지금까지 두 개의 Voter 클래스를 언급했는데 이 2개를 서로 섞어서 판단할 수 있게 된다. 이 2개를 모두 만족해야 한다거나, 이 2개 중 하나만 만족해도 허용한다거나, 아니면 이것에 대해 또 다른 적절한 기준을 두어서 판단할 수가 있다. 이렇게 여러개의 Voter를 등록해서 이 Voter들이 내놓는 판단결과에 따라 최종 판단을 내리는 것이 위에서 얘기했던 판단 주체가 되는 것이다. 이 판단 주체를 얘기하기 위해 Voter를 먼저 설명하게 되었는데 이제는 본격적으로 이 거수기 역할을 하는 1개 이상의 Voter를 어떻게 세팅해서 판단주체로 하는지 알아보겠다.


다음의 설정을 보자


<beans:bean id="accessDecisionManager" class="org.springframework.security.access.vote.AffirmativeBased">
	<beans:constructor-arg>
		<beans:list>
			<beans:bean class="org.springframework.security.access.vote.RoleVoter">
				<beans:property name="rolePrefix" value="" />
			</beans:bean>
			<beans:bean class="org.springframework.security.access.vote.AuthenticatedVoter" />
		</beans:list>
	</beans:constructor-arg>
	<beans:property name="allowIfAllAbstainDecisions" value="false" />
</beans:bean>


위의 설정은 이 글의 맨 처음에 언급했던 클래스 중 AffirmativeBased 클래스에 RoleVoter와 AuthenticatedVoter를 2개를 설정한 것이다. 자 이제 이런 의문을 가질 수 있다. 사용자의 인증 정보와 접근하고자 하는 자원(이해하기 쉽게 URL이라고 생각하자)의 권한 정보를 voter가 이용해서 접근 가능 여부를 판단할텐데 RoleVoter는 RoleVoter의 판단 기준에 맞춰 판단해보니 접근 허가(ACCESS_GRANTED)한다고 판단을 했는데 AuthenticatedVoter는 자신의 판단 기준에 맞춰 판단해보니 접근 거부(ACCESS_DENIED)라고 판단했다. 그러면 이럴 경우 접근을 허용해야 하는가? 아니면 접근을 거부해야 하는가?


바로 이런 2개 이상의 Voter 클래스를 등록하는 상황에서 이 글의 맨 위에서 설명했던 판단 주체의 역할이 나오게 된다. 위에서 판단 주체를 설명할때 위원회라는 개념으로 설명했는데 위원회엔 위원들이 구성될 것이다. 이 위원들이 Voter 클래스들로 거수기 역할을 하는 것이다. 접근 허용한다, 접근 거절한다..이런 식으로 등록된 Voter 클래스들이 거수기 역할을 했으면 이제 위원회가 이 결과들을 종합적으로 판단하게 되는 것이다. 위의 설정에서 보면 위원회 역할을 하는 클래스로 AffirmativeBased 클래스를 사용했다. 이 글의 맨 위에서 AffrimativeBased 클래스를 보여줬을때 그것과 같이 보여준 UnanimousBased 클래스와 ConsensusBased 클래스를 같이 나열했었다. 이제 이 3가지 클래스의 차이를 설명하겠다.


AffrirmativeBased 클래스는 등록된 Voter 클래스 객체 중 단 하나라도 접근 허가(ACCESS_GRANTED)로 결론을 내면 최종적으로 접근 허가 한다고 판단한다. 즉 RoleVoter 클래스가 vote 메소드에서 ACCESS_GRANTED를 return 하고, AuthenticatedVoter가 ACCESS_DENIED를 return 할 경우 ACCESS_GRANTED를 return한 voter 객체가 1개가 있기 때문에 최종적으로 접근 허가한다고 판단해준다. 예를 들어 Voter 클래스 객체를 3개 만들어서 등록했다고 가정해보자. 이런 상황에서 등록된 voter들 중 1개는 ACCESS_GRANTED, 2개는 ACCESS_DENIED를 return 해서 접근 거부가 더 많아도 접근 허가를 시켜준다. 왜? ACCESS_GRANTED가 return 된 것이 하나는 있으니까..


그러나 이와는 달리 UnanimousBased 클래스의 경우 등록된 모든 Voter 클래스 객체가 접근 허가(ACCESS_GRANTED)  결론을 내야 최종적으로 접근 허가한다고 판단한다. 만장일치제도를 생각하면 된다. 한명의 반대표라도 있으면 안되는 것이다. Voter 클래스 객체를 3개 만들어서 등록했는데 2개는 ACCESS_GRANTED, 1개는 ACCESS_DENIED를 return 하면 ACCESS_DENIED가 1개라도 나왔기 때문에 최종적으로는 접근을 거부하는 것이다.


마지막으로 ConsensusBased 클래스는 다수결로 결정하는 방법을 택한다. 즉 ACCESS_GRANTED가 ACCESS_DENIED 보다 많을 경우는 접근 허가, 반대의 경우는 접근 거부로 결정한다. 이러한 결과가 동수일수도 있다. 즉 ACCESS_GRANTED 와 ACCESS_DENIED가 동수일 경우 기본 셋팅은 접근 허가로 결정한다(이 부분은 ConsensusBased 클래스의 allowIfEqualGrantedDeniedDecisions 프로퍼티 값을 false(기본은 true)로 설정하면 접근 거부로 결정한다)


이 글의 맨 처음에 얘기했던 AffirmativeBased, UnanimousBased, ConsensusBased 클래스의 기능이 맘에 안들수도 있다. 즉 Voter에 우선순위를 매겨서 이를 이용해 작업하고 싶을수도 있을 것이다. Voter 객체를 등록할 때 List에 담아두기 땜에 List의 index가 가장 낮은 것이 우선순위를 높게 해서 voter의 우선순위에 따라 최종 결정을 내리고 싶은 맘도 있을 것이다. 그런 기능을 어떻게 구현할까? 간단하다. 위에서 3개의 클래스를 설명할 때 이 3개의 클래스는 AbstractAccessDecisionManager 클래스를 상속 받았다고 언급한적이 있다. 이 AbstractAccessDecisionManager 클래스를 상속 받아 decide 메소드를 override 하면 된다. 3개의 클래스 소스를 참고삼아서 decide 메소드를 override 해서 구현하면 충분히 가능하다.


RoleVoter 선언한 부분을 보면 rolePrefix에 value를 빈 문자열을 설정한 부분이 있다. RoleVoter는 권한 앞에 특정 문자열을 붙인다. 기본적으로 붙이는 문자열이 ROLE_ 인데 이것을 빈 문자열로 설정하도록 하여 권한 앞에 특정 문자열을 붙이지 않도록 했다. 그래서 내가 설정한 권한 명칭 그대로 비교하도록 한다.


지금까지 Spring Security의 자원 접근 판단에 대한 설명을 했다. 이것을 설명해야 이 원리를 이해하고 DB를 이용한 접근 권한 판단 작업을 할 수 있기 때문이다. 지금까지의 설명을 한 줄로 정리하자면 여러개의 Voter 객체들이 내린 결론들을 AbstractAccessDecisionManager 클래스를 상속받은 클래스 객체가 모아놓은 후 이 객체가 자신만의 기준으로 종합적으로 판단해서 결정을 한다라고 할 수 있다(이렇게 한줄로 설명하기 위해 나열한 내용이..ㅠㅠ..) 다음 글에서는 이런 판단을 하는 과정에서 <intercept-url> 태그를 이용해서 자원에 대한 접근 권한 설정으로 하는 것이 아닌 DB를 이용해서 하는 방법으로 설명하도록 하겠다.


추가 설명의 글..

중간에 끼워넣기가 좀 어정쩡해서 마지막에 한꺼번에 다루기로 했다.


사실 위에서 이제껏 설명했던 내용은 다음에 다룰 DB를 이용해서 하는 방법을 위한 이해땜에 먼저 이렇게 설명을 했다. 그러나 우리가 이런 설정(voter 나 accessDesicionManager 등)을 이제껏 설정하지 않았는데도 잘 이뤄지고 있는 걸 보면 Spring Security가 기본적으로 이런 내용을 등록해주는 것이 있다는 것을 의미한다. 그래서 이 부분에 대해 짤막하게 다뤄보도록 하겠다


FilterSecurityInterceptor 클래스는 자동으로 올라오는 Filter 클래스이기 때문에 이것에 대해서는 따로 할말이 없다. 문제는 여기에 설정되는 인증 정보(Authentication Manager), 대상 정보(Security MetaDataSource), 판단 주체(Access Manager) 이다. 무엇이 설정되는가?


FilterSecurityInterceptor 클래스의 인증 정보로 org.springsecurity.security.authentication.ProviderManager 클래스 객체가 셋팅이 된다. ProviderManager 클래스 객체는 그러면 무슨 근거로 셋팅되는가? 우리가 설정 파일에서 보면 <authentication-manager> 태그를 설정한 것이 있다. 이 태그가 설정이 되면 ProviderManager 클래스 객체가 만들어지게 된다. 이 클래스는 생성자로 org.springframework.security.authentication.AuthenticationProvider 인터페이스를 구현한 객체들이 들어있는 List 인터페이스 구현 객체(List<AuthenticationProvider>)를 받도록 되어 있다. <authentication-manager> 태그의 하위 태그로 <authentication-provider> 태그를 설정했었다. 이 <authentication-provider> 태그 설정시 AuthenticationProvider 인터페이스를 구현한 클래스 객체가 만들어지는데 이때 태그에 ref 속성을 이용해서 AuthenticationProvider 인터페이스를 구현한 bean을 별도로 지정하지 않으면 내부적으로 org.springframework.security.authentication.dao.DaoAuthenticationProvider 클래스 객체가 만들어진다. 이 DaoAuthenticationProvider 클래스에 우리가 DB에서 사용자 정보를 조회하기 위해 만들었던 서비스 클래스 객체가 셋팅이 된다. 또 <authentication-provider> 태그는 여러개가 들어갈 수 있다. 이런 설정으로 List<AuthenticationProvider> 객체를 만들수가 있게 되는 것이다.(Spring Security가 제공하는 AuthenticationProvider 인터페이스를 구현한 클래스는 17개가 제공된다. 대부분의 상황에서는 이렇게 제공되는 클래스로 해결할 수 있을 것이나 제공되는 걸로 해결이 안될 경우 기존 클래스 소스를 참고하여 만들어도 된다)


대상 정보로 셋팅이 되는 것은 우리가 <http> 태그의 하위 태그로 <intercept-url> 태그에 설정한 값들로 구성된 객체가 설정이 된다. Spring Security에서는 내부적으로 org.springframework.security.web.access.expression.ExpressionBasedFilterInvocationSecurityMetadataSource 클래스 객체가 셋팅이 되는데 이 객체를 살펴볼 필요가 있다. 이 ExpressionBasedFilterInvocationSecurityMetadataSource는 org.springframework.security.web.access.expression.DefaultFilterInvocationSecurityMetadataSource 클래스를 상속받고 있는데 이 DefaultFilterInvocationSecurityMetaDataSource에 있는 멤버변수 중 Map<RequestMatcher, Collection<ConfigAttribute>> 타입의 requestMap이란게 있다. ExpressionBasedFilterInvocationSecurityMetadataSource 클래스는 <intercept-url> 태그에 설정되어 있는 내용을 읽어다가 이를 LinkedHashMap<RequestMatcher, Collection<ConfigAttribute>> 타입의 객체로 만들어서 requestMap 멤버변수에 넣게 된다. 예전에 썼던 글에서 <intercept-url> 태그 설정시 주의 사항을 하나 준 것을 기억할 것이다. 디테일한 설정을 먼저 기록하고 러프한 설정을 맨 나중에 기록하라고 했던 적이 있다. LinkedHashMap은 Map 타입의 자료구조이지만 한편으로는 Linked 자료구조이기 때문에 order 개념도 가질수가 있다. 즉 Map에 저장된 elemement 들의 이전,이후 element와도 연결관계를 가지고 있기 때문에 order 개념도 가질수가 있는것이다. 실제 LinkedHashMap에서 keyset를 구해와서 살펴보면 LinkedHashMap에 넣은 순서대로 key들을 가져오는 것을 볼 수 있다. RequestMatcher 타입으로 element의 key를 셋팅하고 있는데 우리는 <intercept-url> 태그 구성시 Ant Pattern을 사용하고 있기 때문에 org.springframework.security.web.util.matcher.AntPathRequestMatcher 타입의 객체가 key로 셋팅된다. 이 AntPathRequestMatcher 객체는 내부에 pattern을 저장할수가 있는데 이 패턴이 Ant 패턴 문자열로 입력한 URI(ex: /admin/**)가 들어가게 된다. key가 들어가고 있으니 이 key를 이용해서 가져올 수 있는 value 가 당연 저장되고 있을 것이다. Collection<ConfigAttribute> 타입으로 들어가게 되는 value로 들어가지는 것은 org.springframework.security.web.access.expression.WebExpressionConfigAttribute 클래스 객체가 들어있는 ArrayList 객체가 value로 들어가지게 된다(WebExpressionConfigAttibute는 Spring Security API 문서에서는 발견할수가 없는데 이 클래스가 public class가 아니어서 그런듯 하다) 이 WebExpressionConfigAttibute 클래스 객체에 들어가지는 것은 우리가 <intercept-url> 태그 사용시 spel을 이용해서 권한을 표시한 내용이 들어가지게 된다(ex:hasRole("ADMIN")) 방금 설명한 key와 value를 매핑시켜보자. 그러면 해당 AntPathRequestMatcher 타입 객체의 key는 WebExpressionConfigAttribute 객체들로 표현되는 권한을 만족해야 접근할 수 있다고 설정되어 질 수 있지 않겠는가? 물론 이것에 대한 판단은 곧 이어 설명할 판단 주체(Access Manager)가 판단하게 될 것이다.


판단 주체로 셋팅되는 것은 이 글에서 설명했던 클래스 중 하나인 AffirmativeBased 클래스 객체가 셋팅된다. 이 클래스는 당연 거수기 역할을 하는 하나 이상의 Voter를 가지고 있을 것은 당연하다. 그러면 여기에 셋팅된 Voter가 무엇인지 알아봐야 할 것이다. 여기에 셋팅된 Voter 객체는 org.springframework.security.web.access.expression.WebExpressionVoter 클래스 객체가 셋팅되어서 이 클래스의 vote 메소드가 접근여부 판단을 결정한다. Voter가 1개만 등록되는데다가 AffrimativeBased 클래스의 성격을 생각해볼때 이 Voter가 접근을 허가하게 되면 최종적으로 접근이 허가 되는 것을 알 수가 있다.


기본 셋팅 정보를 설명한 이유는 이렇게 셋팅되는 것을 알아야 커스터마이징 포인트를 이해하는데도 쉽기 때문이었다. 그리고 대상정보 설명시 잠깐 언급했던 requestMap의 경우 다음 글에서도 사용할 부분이어서 이 부분에 대한 설명을 하지 않으면 다음의 글을 이해할수가 없을것 같아서 설명을 달았다. 별도로 빼기에는 글의 내용이 작아서 추가의 글로 마지막에 달았다.




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

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

다른 카테고리의 글 목록

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

지난글 까지 꽤 오랜 시간동안 Spring Security의 인증(로그인)에 대한 내용을 다루었다. 최대한 쉽게 설명할려고 장황하게 썼지만 아는 사람 입장에선 오히려 장황했을수도 있다. 그러나 아는 사람이라 해도 본인이 몰랐던 시절을 생각해보라..그 시절 이렇게 친절(?)하게 콕콕 집어 준 사람이 있어서 자신이 알은게 아니었다면 정말 깜깜함 그 자체였을 것이다. 그런 시절을 생각하면서 이해하고 넘어가주길 바란다.


이번글부터는 Spring Security의 권한에 대한 내용으로 다루도록 하겠다. 예전에 인증과 권한에 대한 설명을 언급했을때 권한은 사이트를 이용하는 사람이 화면을 이용할 자격이 있는지 확인하는 과정..이라 설명한 적이 있다. 그러나 이것은 기능을 화면에만 맞춰서 설명한 것이라 정확한 표현은 아니다. Spring Security에서 다루는 권한..이라는 것을 설명하기 위해 이제는 권한을 화면에 맞추어 설명하진 않도록 하겠다.


화면으로 기준을 잡았을때는 눈에 보이기 쉬운 것이 화면이라 그리 설명했지만 실제로는 기능에 초점이 맞춰져 있다. 화면에 따른 권한 이라는 것은 1차적인 뜻에서 보자면 화면을 볼 수 있는 권한이다. 화면을 본다 라는 기능에 맞춰져 있다. 그러면 기능에 따른 권한이란 어떤 개념일까? 흔히 개발할때 많이 표현하는 CRUD 작업별 권한이라 볼수 있다. 즉 만드는(등록하는) 권한, 조회 권한, 수정 권한, 삭제 권한..크게는 이런 기능 권한을 둘 수 있다. 그럼 좀더 디테일하게 보자.


게시판을 예를 들어보자. 게시판의 기능은 이런 기능들이 있을 것이다.


(1) 글의 목록을 조회하는 기능

(2) 글을 검색할 수 있는 기능

(3) 특정 글의 상세 내용을 볼 수 있는 기능

(4) 글을 등록할 수 있는 기능

(5) 글을 수정할 수 있는 기능

(6) 글을 삭제할 수 있는 기능


이 기능 별로 권한이 생기는 것이다. 즉..


(1) 글의 목록을 조회하는 권한

(2) 글을 검색할 수 있는 권한

(3) 특정 글의 상세 내용을 볼 수 있는 권한

(4) 글을 등록할 수 있는 권한

(5) 글을 수정할 수 있는 권한

(6) 글을 삭제할 수 있는 권한


그러면 이러한 게시판이 3가지가 있다고 가정해보자. 즉 준회원 게시판, 정회원 게시판, 회원 목록 게시판 이렇게 3가지가 있다고 가정해 보자. 3가지 게시판을 놓고 회원 등급을 유추해본다면 회원 등급으로는 준회원, 정회원, 관리자 이렇게 있을 것이라고 생각해볼 수 있겠다. 그러면 지금부터 준회원 권한에 대해 설계해보자. 준회원 권한이란 무엇인가? 준회원 게시판에 대해 위에서 언급했던 6가지의 권한을 가진 것을 준회원 권한이라고 말할 수 있을 것이다. 즉 준회원 권한은 준회원 게시판에 대한 6가지의 권한을 그룹화 한 권한이라 볼 수 있다. 정회원 권한 또한 정회원 게시판에 대한 6가지의 권한을 그룹화 한 권한이라 볼 수 있다. 관리자 권한 또한 회원 목록 게시판에 대한 6가지의 권한을 그룹화 한 권한이라 볼 수 있다. 여기에서 권한을 그룹화 한 그룹 권한이란 개념을 하나 알게 된다.


그런데 권한은 방금 말한 그룹 개념 뿐만 아니라 상/하위 개념도 존재한다. 기능에 따른 상하위 개념은 없다. 억지로 갖다가 붙이면 글을 등록, 수정, 삭제하는 기능에 대한 권한이 있으면 글을 조회할 수 있는 권한이 있는거 아니냐 라고 반문할수도 있다. 물론 논리적으로는 틀린 말이 아니다. 그러나 반드시 옳다고 말할수는 없다. 예를 들어서 외부 조직에서 데이터를 보내줘서 내부 조직에서 그 내용을 본다고 가정해보자. 업무적인 설계에서 외부 조직에서 데이터를 보내줬다고 외부 조직이 내부 조직에서 운영하는 조회 기능을 가질 수 있는 것은 아니다. 즉 작업의 주체가 단일화된 주체냐 아니면 나뉘어 있냐에 따라 이런 상황이 발생하는 것이다. 그래서 기능만 가지고는 상하위 개념을 엮을수는 없는 것이다. 그럼 어느 시점에서 상/하위가 존재하느냐? 권한을 그룹화 한 시점에서 상하위가 존재하게 된다. 기능별 권한을 묶어서 그룹화 된 권한을 만들때는 기능적이라기 보단 조직적인 차원에서 만들게 된다. 관리자 권한이라는 관리자 게시판 권한을 묶은 그룹 권한을 예를 들어보자. 관리자 게시판을 사용하는 기능별 권한을 관리자 권한이라 했다. 관리자..란 무엇인가? 어떤 조직의 관리책임을 맡은 하위 조직이다. 준회원과 정회원도 결국 조직으로 접근할 수 있다. 조직에 대한 하위 조직이 모두 동등하게 설정될 수도 있지만 그런 조직은 거의 드물다. 비회원 < 준회원 < 정회원 < 관리자 이렇게 조직별 서열이 정해진다. 조직별 서열이 정해지면 하위 조직에 대한 권한도 자동으로 승계가 되는 것이 일반적이다. 관리자가 준회원, 정회원 게시판에 대한 권한이 없다고 가정해보자. 준회원이나 정회원이 게시판에 온통 광고글로 도배를 해도 관리자는 준회원,정회원 게시판 삭제 권한이 없기 때문에 어쩌지도 못하는 상황이 발생하게 된다. 즉 그룹화 된 권한을 만드는 시점에서는 권한별 상하관계가 존재하게 되는 것이다.


내용이 장황했는데 권한에 대한 설계시 다음의 기준으로 설계를 한다(이 기준은 절대적인 기준은 아니다. 글쓴이의 기준임을 밝혀둔다)


● 기능에 대한 권한을 먼저 설계한다. 자잘자잘한 기능이래도 그 기능에 따른 권한을 다 설계한다

● 조직에 따른 권한을 설계한다. 그리고 조직에 따른 권한이 가질 기능적 권한을 매핑시켜준다(권한 그룹)


그럼 이렇게 권한에 대해 설계 했으면 이 권한을 자원과 엮어주어야 한다. 자원이란 무엇인가? 웹사이트에서 제공하는 서비스가 자원이다. 서비스는 눈에 보이는 화면이 될 수도 있고 웹서비스 같이 눈에 보이지 않는 것일수도 있다. 이미지도 웹사이트가 제공되는 서비스이기도 하다. 즉 이러한 자원과 권한을 엮어주어야 한다. 


Spring Security도 이런 개념에 맞춰 권한 그룹과 권한의 상/하위 개념을 주어 권한에 대한 관리, 그리고 자원별 권한을 엮어주는 그런 기능을 가지고 있다. 이제껏 했던 내용에서 항상 이런 설정 내용을 봤을 것이다.


<intercept-url pattern="/admin/**" access="hasRole('ROLE_ADMIN')" />
<intercept-url pattern="/login.do" access="isAnonymous()" />
<intercept-url pattern="/main.do" access="permitAll" />
<intercept-url pattern="/**" access="permitAll"/>


지금까지는 아무 생각없이 썼지만 위에서 했던 내용과 맞추어 보자. URL 패턴이 /admin으로 시작하는 모든 자원은 ROLE_ADMIN 권한을 가진 사람만 이용할 수 있도록 엮어준 것이다. URL이 login.do인 화면(자원)은 ANONYMOUS 권한을 가진 사람만 이용할 수 있도록 엮어준 것이다. URL이 /main.do인 화면(자원)은 권한이 있든 없든 모든 사람이 이용할 수 있도록 엮어준 것이다. 방금 언급한  3가지를 제외한 나머지 자원은 모두 이용할 수 있도록 엮어준 것이다. 이런식으로 우리는 자원에 따른 권한을 엮어주었다. 디테일하게 하거나 또는 러프하게..


지금까지 설명한 내용은 앞으로 설명할 Spring Security의 권한 관리에 대한 설명을 좀더 쉽게 하기 위해 권한의 그룹화와 상/하위 개념을 설명한 것이다. 그러나 반드시 이런 마인드로 설계를 하라는 것은 아니다. 자기가 수행하는 플젝에 따라 이런 상황은 충분이 바뀔수 있다. 다만 기능에 따른 권한과 이를 그룹화한 조직적 권한 개념으로 설계하면 차후 권한에 대한 세부 설정이 용이하기 때문에 이런 컨셉으로 설명한 것이다. 다음 글에서는 Spring Security에서의 권한 설정 부분에 대해 설명하도록 하겠다



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

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

다른 카테고리의 글 목록

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

지난 글에서는 Spring Security가 로그인 작업을 성공한 후 또는 로그인 작업을 실패한 후의 부가작업 설정하는 부분에 대해 설명했다. 이번 글에서는 암호화된 패스워드를 Spring Security에서 사용하는 방법에 대해 알아보도록 하겠다.


지금까지 로그인 테스트를 진행했다면 아마 이 부분에 대해 의아심을 가졌던 분들이 많을 것이다. 흔히 로그인 하는 과정을 보면 암호화된 비밀번호 값을 DB에 저장한뒤 사용자가 입력한 암호화 되지 않은 비밀번호를 지정된 암호화 방식으로 비교해서 확인하거나 또는 DB에 저장되어 있는 암호화된 비밀번호 값을 복호화한뒤에 사용자가 입력한 패스워드와 비교하는 식으로 아이디와 패스워드 인증을 거칠텐데, 지금까지 Spring Security를 설명하면서 이런 내용에 대해 언급한 것이 전혀 없기 때문이다. 이번글은 이런 부분에 대한 설명을 할려고 한다.


데이터를 암호화하는데는 여러가지 암호화 방식이 존재한다. 그러나 이러한 방식이 모두 안전하다고 말하는데는 무리가 있다. md5 방식만 봐도 md5로 암호화 된 값은 복호화 할 수 없다고 얘기했었지만 이젠 md5도 복호화가 되기 때문에 이 방식을 선택할수도 없게 되었다. 그만큼 어떤 암호화 방식을 선택해야 할지는 정말 신중한 고민을 해야 할 필요성이 있다. Spring Security는 plaintext, sha, sha256, md4, md5 이렇게 5가지 방식을 제공해주고 있다. 그러나 Spring Security는 장기적인 측면에서 기존의 암호화 방식을 그대로 사용하지 않을 것으로 보인다.(개인적인 의견이므로 정확하지 않음을 미리 밝혀둔다) 이렇게 얘기하는 이유를 설명할려면 PasswordEncoder 인터페이스에 대한 설명을 먼저 해야 하지 싶다.


Spring Security는 Password의 암호화와 비교 작업을 하기 위한 인터페이스를 제공하는데 그것이 org.springframework.security.authentication.encoding.PasswordEncoder 인터페이스 이다. 이 인터페이스에는 2개의 메소드가 있는데 다음과 같다


String encodePassword(String rawPass, Object salt);

boolean isPasswordValid(String encPass, String rawPass, Object salt);


encodePassword는 입력된 문자열(rawPass)를 암호화 작업을 거쳐 결과물인 암호화 된 값을 return 하고, isPasswordValid는 암호화 된 문자열과 암호화 되지 않은 문자열을 입력받아 서로 match가 되는지 그 결과를 return 하게 된다. 각각의 메소드 모두 salt라고 하는 파라미터가 들어간다. 문자열을 암호화 할 때 특정 데이터를 같이 포함시켜서 암호화 시킬수 있다. 이렇게 하는 이유는 사람들에게 단어를 무작위로 입력해서 패스워드가 통과되는 것을 방지하기 위함이다. 예를 들어 비밀번호를 사람들이 누구나 아는 단어인 "mother"라고 했다고 가정하자. 로그인 해킹 프로그램에서 사람들에게 친숙한 단어 사전을 이용해서 해킹을 시도할때 mother가 들어가면 뚫려버리게 되는 것이다. 이런 이유로 mother 같은 친숙한 단어를 사용해도 특정 데이터(예를 들면 가입자의 생년월일)를 섞어서 암호화 된 문자열을 만들면 로그인 해킹 프로그램에서 mother로 넣어도 특정 데이터를 모르면 해킹할 수가 없게 된다. 바로 이런 특정 데이터를 salt 라고 하는 것이다. salt를 넣어서 encodePassword 메소드를 실행했다면 isPasswordValid 메소드에서도 encodePassword 메소드 실행시 사용했던 salt를 넣어야 올바른 비교를 할 수 있을 것이다. 


그러나 Spring Security 3.1로 넘어와서는 salt 조차 노출이 되면 해킹이 될 수 있어서 그런지 이 PasswordEncoder 인터페이스를 deprecated 시켰다. 이 salt가 되는 데이터는 흔히 회원과 연관되는 데이터(ex : 회원 생년월일이나 회원 이름 등)을 설정하게 되는데 회원 정보가 외부로 노출이 되면 salt 되는 데이터도 같이 노출이 되기 때문에 해킹이 될 소지는 분명 있다. 그런 이유에서인지는 모르겠으나 Spring Security는 3.1로 넘어와서는 기존의 PasswordEncoder를 deprecated 시키고 새로이 org.springframework.security.crypto.password.PasswordEncoder 인터페이스를 다시 재정의했다. 이 인터페이스 또한 2개의 메소드가 있다.


String encode(CharSequence rawPass);

boolean matches(CharSequence rawPassword, String encodedPassword);


기존의 org.springframework.security.authentication.encoding.PasswordEncoder와 혼선이 없게끔 하기 위해 메소드 명도 바뀌고(encodePassword->encode, isPasswordValid->matches), 메소드의 파라미터가 들어가는 순서도 바뀌었다(isPasswordValid 메소드의 경우 첫번째 파라미터로 암호화 된 문자열, 두번째 파라미터로 암호화 되지 않은 문자열이 들어가지만 matches 메소드에서는 첫번째 파라미터로 암호화 되지 않은 문자열, 두번째 파라미터로 암호화 된 문자열이 들어간다. 그러나 여기에서는 이런 차이점 말고 한 가지 중대한 차이점이 존재한다. 바로 위에서 설명한 salt 역할을 하는 파라미터가 빠졌다는 것이다. 그럼 이 기능이 이제는 없는건가? 아니다. 이 salt를 외부에서 입력받는게 아니라 암호화 하는 시점에 랜덤하게 salt 역할을 하는 데이터를 만들어서 salt 기능을 수행하도록 바뀌었다. 이런 이유로 기존 Spring Security 사용자들중 org.springframework.security.authentication.encoding.PasswordEncoder 인터페이스를 사용하여 Password Encoder를 사용했다면 이제는 org.springframework.security.crypto.password.PasswordEncoder 인터페이스를 사용하는 방법으로 갈아타는 것이 좋다고 본다. 그리고 앞으로 PasswordEncoder 인터페이스를 얘기할 때는 org.springframework.security.crypto.password.PasswordEncoder 인터페이스를 이야기 하는 것으로 하겠다. 


그럼 이렇게 PasswordEncoder 인터페이스를 구현한 클래스는 무엇이 있는가? Spring Security는 이와 관련하여 3가지 클래스를 제공한다


● org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder

● org.springframework.security.crypto.password.NoOpPasswordEncoder

● org.springframework.security.crypto.password.StandardPasswordEncoder


NoOpPasswordEncoder 클래스는 암호화 기능을 수행하지 않는 암호화 클래스이다. 무슨 얘기냐면 암호화 시키기 위해 encode 메소드에 암호화 할 문자열을 넣어도 실제로는 암호화 기능을 수행하지 않고 입력받은 암호화 할 문자열을 그대로 return 해준다. 이 클래스는 암호화 기능을 수행하는지에 대한 테스트용으로 만들어진 클래스이니 실제 프로젝트에서는 테스트 용도가 아닌 용도로는 사용하면 안된다.


실제 암호화 작업까지 수행하는 클래스는 BCryptPasswordEncoder 클래스와 StandardPasswordEncoder 클래스이다. BCryptPasswordEncoder 클래스는 내부적으로 org.springframework.security.crypto.bcrypt.BCrypt 클래스를 이용하는데 이 BCrypt 클래스가 

bcrypt 해시 알고리즘을 이용하여 입력받은 데이터를 암호화하는 작업을 수행한다. StandardPasswordEncoder는 sha 해시 알고리즘을 이용하여 입력받은 데이터를 암호화한다(StandardPasswordEncoder 클래스 이용시 별도 설정이 없으면 sha-256으로 암호화한다). 또한 두 클래스 모두 내부적으로 salt 데이터를 랜덤하게 생성하여 적용하는 기능을 가지고 있다. Spring Security 측에서는 신규로 개발하는 시스템이라면 BCryptPasswordEncoder 클래스를 사용하는 bcrypt 해시 알고리즘 사용을 권장하고 있고, 기존 sha 해시 알고리즘을 적용한 상황이라면 StandardPasswordEncoder 사용을 권장하고 있다. 이 글에서는 신규니까 BCryptPasswordEncoder 클래스를 사용하는 것으로 패스워드 암호화를 하도록 하겠다.


Spring Security 에서 암호화 된 패스워드로 인증할 때 다음과 같은 설정을 한다. 


<authentication-manager>
	<authentication-provider user-service-ref="customJdbcDaoImpl">
		<password-encoder hash="bcrypt" />
	</authentication-provider>
</authentication-manager>


<authentication-provider> 태그에 하위 태그로 <password-encoder> 태그를 만들어서 설정하는 방법으로 한다. hash에는 어떤 암호화 알고리즘을 사용할 것인지를 지정하는 것으로 여기에 들어가는 값은 {sha}, {ssha}, bcrypt, md4, md5, plaintext, sha-256, sha 중 하나를 지정할 수 있다. 또한 base64 속성을 사용하면 암호화된 문자열이 base64 인코딩 과정까지 거쳐두었는지를 지정할 수 있다. 미리 얘기해두지만 {sha}, {ssha}가 무엇을 의미하는지는 이 글을 작성하는 시점에서는 모르겠다. Spring Security 레퍼런스 문서에서도 값에 대한 의미를 분명히 밝혀두질 않아서..ㅠㅠ..또한 sha-256과 sha의 차이도 모르겠다.


그러나 개인적으로 이런 설정 방식을 권장하지는 않는다. 권장하지 않는데는 이유가 있다. 위에서 언급했던 PasswordEncoder 인터페이스를 보면 두 가지 기능을 제공하는데 하나는 기존 문자열을 암호화된 문자열로 바꾸는 기능암호화 된 문자열과 원래 문자열을 입력받아 일치하는지 비교하는 기능이 그것이다. 암호화 된 문자열과 원래 문자열을 입력받아 일치하는지 비교하는 기능은 로그인 때 사용하는 것이므로 반드시 필요하다. 그러나 이 기능 못지 않게 기존 문자열을 암호화된 문자열로 바꾸는 기능 또한 필요한 시점이 있다. 어느 때 일것이라고 생각하는가? 로그인 페이지가 있다는건 어떤 형식으로든 회원 정보를 관리한다는것이고 그럴 경우 회원 정보에 대한 조회, 등록, 수정, 삭제가 있다는 것이다. 회원 정보를 등록하거나 수정할때 사용자가 자신이 사용해야 할 패스워드 문자열을 입력하면 이를 암호화 시켜서 DB에 보관해야 할 것이다. 바로 패스워드 문자열을 암호화 시킬때 이 기존 문자열을 암호화된 문자열로 바꾸는 기능을 사용하게 되는 것이다. 그럼 위와 같이 설정하면 그 기능을 써 먹을수 없는 것인가? 현재로썬 그렇다. <authentication-provider> 태그가 설정되면 Spring Security는 내부적으로 org.springframework.security.authentication.dao.DaoAuthenticationProvider 클래스가 bean으로 등록이 되는데 이 bean에 있는 property 중 userDetailService 에는 우리가 이전 글에서 만든 로그인 시 DB에서 사용자 조회하는 CustomJdbcDaoImpl 클래스가 설정이 되고 또 다른 property인 passwordEncoder에는 우리가 <password-encoder> 태그의 hash 속성에서 설정한 암호화 알고리즘에 따른 PasswordEncoder 인터페이스를 구현한 클래스가 셋팅이 된다. 위에서 설정한 예를 따르자면 bcrypt로 설정했기 때문에 위의 분홍색 글박스에 언급했던 BCryptPasswordEncoder 클래스가 설정이 된다. 문제는 이 설정 과정에서 BCryptPasswordEncoder를 DaoAuthenticationProvider 클래스 바깥에서 bean을 생성시키도록 설정한뒤 참조를 걸은것이아니라 DaoAuthenticationProvider 클래스의 passwordEncoder 프로퍼티 내부에 BCryptPasswordEncoder 클래스 객체를 만들어서 설정한 것이다. XML로 예를 들자면 <bean> 태그 설정시 <property> 태그의 하위 태그로 <bean> 태그를 설정했다고 보면 된다. 이렇게 할 경우 이 클래스를 외부의 다른 bean 클래스에서 참조할 방법이 없다. 위에서 들은 상황과 같이 회원 정보를 등록하거나 수정하는데 사용하는 bean 클래스에서 BCryptPasswordEncoder 클래스를 Injection 받아서 사용자가 입력한 문자열을 암호화 한 문자열로 바꿔주는 메소드를 사용할 방법이 없게 된다. 그래서 외부에서 PasswordEncoder 인터페이스를 구현한 클래스를 bean으로 등록한 뒤 <password-encoder> 태그에 등록된 bean을 참조로 걸어주는 방식으로 진행하는 것이 여러모로 좋다. 다음과 같이 말이다.


<beans:bean id="bcryptPasswordEncoder" class="org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder" />	

<authentication-manager>
	<authentication-provider user-service-ref="customJdbcDaoImpl">
		<password-encoder ref="bcryptPasswordEncoder" />
	</authentication-provider>
</authentication-manager>


BCryptPasswordEncoder 클래스를 등록하는 <bean> 태그의 위치는 <authentication-manager> 태그보다 먼저 선언되어야 하는 그런건 없다(<bean> 태그의 위치는 중요하지 않다는 얘기) 위의 설정을 보면 <bean> 태그를 이용해 BCryptPasswordEncoder 클래스를 등록한 뒤 이를 <password-encoder> 태그에 ref 속성을 이용해서 참조를 걸어두었다. 이렇게 설정해두면 로그인 할 때 사용자가 입력한 문자열을 암호화 시켜서 DB에 등록되어 있는 암호화 된 문자열과 비교하는 기능 뿐만 아니라 다음의 코드와 같이 사용자가 입력한 문자열을 암호화 된 문자열로 바꾸는 기능도 사용할 수 있게 된다. 다음의 코드는 사용자가 입력한 문자열을 입력받아 이를 암호화 된 문자열로 화면에 내려주는 Controller 클래스 소스 코드의 일부이다.


@Autowired
BCryptPasswordEncoder passwordEncoder;

@RequestMapping(value="passwordEncoder", method={RequestMethod.GET, RequestMethod.POST})
public String passwordEncoder(@RequestParam(value="targetStr", required=false, defaultValue="") String targetStr, Model model){
	if(StringUtils.hasText(targetStr)){
		// 암호화 작업
		String bCryptString = passwordEncoder.encode(targetStr);
		model.addAttribute("targetStr", targetStr);
		model.addAttribute("bCryptString", bCryptString);
	}
	return "/common/showBCryptString";
}


<bean> 태그로 등록된 BCryptPasswordEncoder 클래스를 @Autowired를 이용해 Injection 받은 뒤에 이를 Controller의 passwordEncoder 메소드에서 사용하고 있다. passwordEncoder 메소드에서는 <input> 태그의 name이 targetStr인 입력값을 받아서 이 값을 암호화 한 뒤 다시 화면에 내려보내주고 있다. 테스트 데이터를 만들때 특정 문자열을 입력하여 이 문자열의 암호화 된 값을 얻은뒤 DB에 입력할때 사용하는 용도로 이 passwordEncoder 메소드를 제작해봤다.


또한 위의 xml 설정 같이 <password-encoder> 태그의 ref를 이용해 참조를 걸어주는 방식으로 할 경우 Spring Security가 지원하지 않는 암호화 방식에 대한 구현도 가능하다. 예를 들어 Spring Security에 KISA에서 만든 SEED 알고리즘을 이용해서 암호화 작업을 하겠다고 가정해보자. SEED 알고리즘을 구현한 자바 코드는 구글에서 검색하면 나오니 별도로 언급하진 않겠다. PasswordEncoder 인터페이스를 구현한 클래스를 하나 만든뒤에 encode 메소드는 SEED 알고리즘을 이용하여 암호화된 문자열을 return 하도록 구현하면 되고, matches 메소드는 SEED로 암호화 된 문자열과 암호화 되지 않은 문자열을 입력받아 암호화 되지 않은 문자열을 SEED 알고리즘으로 암호화 된 문자열을 만든 뒤에 파라미터로 받은 SEED로 암호화 된 문자열과 equals 메소드로 비교한 결과값을 return 하면 된다(개인적으로 SEED 알고리즘을 적용한 PasswordEncoder 클래스를 테스트 삼아 만들어 본적이 있고 테스트도 성공했었다) 하지만 random salt 적용이 안되었기 때문에 이 부분에 대해서는 기존에 구현된 클래스인 BCryptPasswordEncoder 클래스와 StandardPasswordEncoder 클래스의 소스를 참조해서 random salt 적용을 해야 할 것이다.


지금까지 로그인 시 암호화 된 패스워드를 사용하는 방법을 설명했다. 이 글에서는 별도로 로그인 화면을 보여주질 않았다. 이전 글에서 늘 사용했던 로그인 화면 코드 그대로 이용하면 된다. 다만 이렇게 설정한 뒤 MEMBERINFO 테이블의 PASSWORD 컬럼의 값을 암호화 된 문자열로 바꿔야 할 것이다. 그 작업을 하기 위해 위의 Controller 메소드를 이용해서 원래 문자열을 입력하면 화면에 원래 문자열과 암호화된 문자열을 같이 보여주는 화면을 만들었던 것이다. 이번 글을 끝으로 Spring Security에서 인증, 즉 로그인과 관련된 글은 마무리 하도록 하겠다. 다음에는 권한과 관련된 내용으로 시작하도록 하겠다



  • BlogIcon 노랑머플러 2014.11.02 20:48 신고

    잘보고 갑니다. ㅎㅎ
    스프링 시큐리티 자료는 너무 귀해서리 ㅎㅎ

  • BlogIcon 나마코엘 2016.06.09 14:43 신고

    아 정말 메이킹러브님 덕분에 스프링 시큐리티를 어느정도 잘 적용하고 있습니다. 정말 감사드려요. 인사 드리려고 티스토리 계정까지 만들었습니다. ㅎㅎ

  • BlogIcon 최홍희 2017.06.14 17:43 신고

    안녕하세요 공부 정말 잘 되고 있는데요 궁금한게 있어서 여쭤봅니다
    말씀중에 <security:password-encoder ref="passwordEncoder"/> 이렇게 선언만해주면
    유저가 로그인하거나 수정하거 등등 encoder하고 matching을 시큐리티 내부에서 해주게되는건가요? 따로 구현을 안해줘도...? 답변 부탁드립니다 ㅜㅜ!! 몇일째 삽질중이네요!! ㅎㅎ...

    1. BlogIcon 메이킹러브 2017.06.21 14:56 신고

      안녕하세요..제가 요즘 사정이 있어서 블로그를 와보질 못해서 답변이 늦었습니다..
      어쩌면 이미 해결하셨을수도 있는데..
      일단 로그인을 할때 사용자가 입력한 패스워드를 이용해서 matching을 해주는 작업을 하는 것은 맞습니다..
      즉 사용자가 지정한 passwordEncoder를 이용해서 사용자가 입력한 패스워드를 암호화(이 작업을 하는게 encode 메소드입니다)를 한 뒤에
      DB등에 저장된 패스워드와 비교하는 작업(이 작업을 하는게 matches 메소드입니다)을 하게 되구요..

      그러나 사용자의 기존 패스워드를 새로운 패스워드로 수정하는 작업을 해주지는 않습니다. 이것도 이렇게 얘기하면 오해의 소지가 있어서 이 부분에 대해서도 좀더 상세히 설명을 해드릴께요
      엄밀하게 얘기하면 PasswordEncoder 구현 객체를 등록하는 것만으로 자동으로 해주는..그런 작업을 한다는게 아니구요..
      다만 사용자 정보 수정 작업을 하는데 있어서 이 PasswordEncoder 인터페이스를 구현한 클래스를 이용해서 사용자가 입력한 새로운 패스워드를 암호화 한 뒤에
      사용자 정보 수정하는 DB Update 문에서 새로운 암호화 문자열을 사용하면 됩니다..
      이걸 감을 잡게 해드리기 위해 이 글에 예시로 평문을 입력받아 암호화로 변환된 문자열을 보여주는 Controller 클래스 소스 코드 일부를 보여드린거에요..
      이렇게 만든 암호화된 문자열을 화면에 보여줄게 아니라 사용자 정보 수정하는 update 문에서 사용하면 되는 것이죠..

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

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

다른 카테고리의 글 목록

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

지난글에서는 로그인 작업 후 로그인이 성공했을때 부가적인 작업을 어떤식으로 설정하는지 설명했다. 한줄로 요약하면 org.springframework.security.web.authentication.AuthenticationSuccessHandler 인터페이스를 구현한 클래스를 만든 뒤 이를 <bean> 태그를 이용하여 등록한 뒤 <http> 태그의 authentication-success-handler-ref 속성에 해당 bean 클래스를 설정하는 것으로 구현할 수가 있다.(이 한 줄 요약을 풀어서 살을 붙여가며 설명하면 저번 글 처럼 엄청난 양의 글이 된다..물에 불린 라면도 아닌 것이 머 그리 불어나는건지..ㅠㅠ..) 로그인이 성공했을때의 부가작업을 다루었으니 이번엔 로그인이 실패했을때의 부가작업을 설명할 차례가 됐다.


이 작업을 하기에 앞서 무엇을 해야 할지를 먼저 생각해야 한다. 로그인을 실패했다고 가정할 경우 무엇을 해야 할까? 최종 화면을 로그인 페이지를 보여줘야 하는 것은 당연함이다(로그인 실패했다고 로그인 실패했습니다..란 식의 메시지 뿌리고 로그인 화면 링크를 걸어 사용자가 수동으로 로그인 화면을 이동하게 할 수는 없잖은가) 로그인 페이지를 보여줄때 무슨 이유로 로그인을 실패했는지를 보여줘야 할 것이다. 


Spring Security에서는 로그인 실패했을때 부가적인 작업을 org.springframework.security.web.authentication.AuthenticationFailureHandler 인터페이스를 구현한 클래스에서 하도록 되어 있다. 방금 위에서 언급했던 한줄 요약 표현의 방법을 사용한다면 org.springframework.security.web.authentication.AuthenticationFailureHandler 인터페이스를 구현한 클래스를 만든 뒤 이를 <bean> 태그를 이용하여 등록한 뒤 <http> 태그의 authentication-failure-handler-ref 속성에 해당 bean 클래스를 설정하는 것으로 구현할 수가 있다. 다음은 그런 설정의 예이다.


<form-login
	username-parameter="loginid"
	password-parameter="loginpwd" 
	login-page="/login.do"
	default-target-url="/main.do"
	authentication-failure-url="/login.do?fail=true"
	authentication-success-handler-ref="customAuthenticationSuccessHandler"
        authentication-failure-handler-ref="customAuthenticationFailureHandler"
/>

<beans:bean id="customAuthenticationSuccessHandler" class="com.terry.springsecurity.common.security.handler.CustomAuthenticationSuccessHandler">
</beans:bean>

<beans:bean id="customAuthenticationFailureHandler" class="com.terry.springsecurity.common.security.handler.CustomAuthenticationFailureHandler">
</beans:bean>


이전글에서의 보여준 예시에 추가한 것이다(어차피 일반적으로 구현하게 될 경우 로그인 성공과 실패 쌍으로 구현하는 경우가 다반사이기 때문에 로그인 성공시의 예시에 추가로 달아놓았다) 그러면 이제 org.springframework.security.web.authentication.AuthenticationFailureHandler 인터페이스에 대해 살펴볼 차례이다. 이전글에서 설명했던 org.springframework.security.web.authentication.AuthenticationSuccessHandler 인터페이스와 마찬가지로 이 인터페이스 또한 다음의 메소드 1개뿐이다.


public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException


이전글에서 설명했던 AuthenticationSuccessHandler 인터페이스의 onAuthenticationSuccess와 한가지만 빼고 동일한 구성으로 되어 있다. 눈치 빠른 사람은 바로 파악했겠지만 AuthenticationSuccessHandler 인터페이스의 경우 로그인을 성공한 뒤의 작업이기 때문에 onAuthenticationSuccess 메소드의 세번째 인자로 인증 정보 객체인 Authentication 인터페이스를 구현한 객체가 오지만 AuthenticationFailureHandler 인터페이스의 경우 로그인이 실패한 뒤의 작업이기 때문에 로그인이 어떤 이유로 실패했는지의 정보를 가지고 있는 AuthenticationException 클래스 객체가 오게 된다.


로그인을 실패했을때의 작업은 로그인 실패 이유를 보여주는 로그인 화면을 보여주기 때문에 이전글에서 했던 로그인 성공했을때와는 달리 이동해야 할 URL의 우선순위를 따지는 그런 작업도 없는 단순 작업으로 갈 것이다. 다만 여기서는 로그인 실패했을때 메시지 보여주는 부분을 다르게 갈 것이다. 예전 글이 생각날지 모르겠지만 인증(로그인) 실패했을때  관련 에러 메시지를 세션에 저장한 뒤 그것을 웹페이지에서 세션에 접근해서 보여줬던 것을 기억할 것이다. 에러 메시지를 세션에 저장하는건 우리가 그렇게 설정을 한게 아니라 Spring Security가 자동으로 그렇게 하고 있다. 이 부분을 세션을 이용하지 않고 request의 Attribute에 저장할 것이다. request의 Attribute에 저장하여 jsp 페이지에서는 request의 해당 Attribute에 접근해서 관련 메시지를 가져와서 화면에 출력할 것이다.


그렇게 구현한 코드가 다음의 코드이다.


import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;

/**
 * 인증 실패 핸들러
 * @author TerryChang
 *
 */
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {

	private String loginidname;			// 로그인 id값이 들어오는 input 태그 name
	private String loginpasswdname;		// 로그인 password 값이 들어오는 input 태그 name
	private String loginredirectname;		// 로그인 성공시 redirect 할 URL이 지정되어 있는 input 태그 name
	private String exceptionmsgname;		// 예외 메시지를 request의 Attribute에 저장할 때 사용될 key 값
	private String defaultFailureUrl;		// 화면에 보여줄 URL(로그인 화면)
	
	public CustomAuthenticationFailureHandler(){
		this.loginidname = "j_username";
		this.loginpasswdname = "j_password";
		this.loginredirectname = "loginRedirect";
		this.exceptionmsgname = "securityexceptionmsg";
		this.defaultFailureUrl = "/login.do";
	}
	
	
	public String getLoginidname() {
		return loginidname;
	}


	public void setLoginidname(String loginidname) {
		this.loginidname = loginidname;
	}


	public String getLoginpasswdname() {
		return loginpasswdname;
	}


	public void setLoginpasswdname(String loginpasswdname) {
		this.loginpasswdname = loginpasswdname;
	}

	public String getExceptionmsgname() {
		return exceptionmsgname;
	}

	public String getLoginredirectname() {
		return loginredirectname;
	}


	public void setLoginredirectname(String loginredirectname) {
		this.loginredirectname = loginredirectname;
	}


	public void setExceptionmsgname(String exceptionmsgname) {
		this.exceptionmsgname = exceptionmsgname;
	}

	public String getDefaultFailureUrl() {
		return defaultFailureUrl;
	}


	public void setDefaultFailureUrl(String defaultFailureUrl) {
		this.defaultFailureUrl = defaultFailureUrl;
	}


	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
		// TODO Auto-generated method stub
		
		// Request 객체의 Attribute에 사용자가 실패시 입력했던 로그인 ID와 비밀번호를 저장해두어 로그인 페이지에서 이를 접근하도록 한다
		String loginid = request.getParameter(loginidname);
		String loginpasswd = request.getParameter(loginpasswdname);
		String loginRedirect = request.getParameter(loginredirectname);
		
		request.setAttribute(loginidname, loginid);
		request.setAttribute(loginpasswdname, loginpasswd);
		request.setAttribute(loginredirectname, loginRedirect);
		
		
		// Request 객체의 Attribute에 예외 메시지 저장
		request.setAttribute(exceptionmsgname, exception.getMessage());
		
		request.getRequestDispatcher(defaultFailureUrl).forward(request, response);
	}

}


로그인 성공 했을때는 설명해야 할 내용이 많아서 코드를 나눠서 설명했지만 로그인 실패했을때는 하는 기능이 별로 없어서 한꺼번에 보여주고 설명하도록 하겠다. 이 클래스가 받아야 할 파라미터는 5개가 있다. 물론 생성자에서 기본 값으로 셋팅해주고 있지만 기본값을 사용하지 않을 경우엔 바꿀수가 있다. 먼저 설정해야 할 5개의 파라미터는 HttpServletRequest에서 로그인 아이디가 저장되어 있는 파라미터 이름, HttpServletRequest에서 로그인 패스워드가 저장되어 있는 파라미터의 이름, 로그인 성공시 redirect 해야 할 URL이 저장되어 있는 파라미터 이름, 로그인 페이지에서 jstl을 이용하여 에러 메시지를 가져올때 사용해야 할 변수 이름, 화면에 보여줘야 할 페이지의 URL(일반적으로 재로그인을 유도해야 하기 때문에 로그인 페이지 URL을 사용할 것이다) 이렇게 5가지이다. 각 변수에 대한 getter/setter 메소드가 대부분이니 핵심인 onAuthenticationFailure 메소드를 살펴보도록 하자


onAuthenticationFailure 메소드에서는 HttpServletRequest 객체를 인자값으로 받고 있기 때문에 우리가 이 bean 클래스에서 받는 파라미터 중 로그인 아이디가 저장되어 있는 파라미터 이름, 로그인 패스워드가 저장되어 있는 파라미터 이름, 로그인 성공시 redirect 해야 할 URL이 저장되어 있는 파라미터 이름을 이용해서 각각의 값을 가져올 수 있다. HttpServletRequest 클래스의 getParameter 메소드를 이용해서 가져온 것들이 바로 그것들이다. 이 값들을 HttpServletRequest 객체의 setAttribute 메소드를 이용해 가져온 값을 그대로 셋팅해줌으로써 나중에 보여줄 페이지에서 jstl을 이용해서 그대로 가져와 사용할 수가 있다. 인증을 실패했기 때문에 왜 실패했는지 메시지를 보여줘야 하는데 이 메시지 또한 setAttribute 메소드를 이용해서 메시지를 셋팅해주어 나중에 보여줄 페이지에서 jstl을 이용해 그대로 보여줄 수 있다. 마지막으로 HttpServletRequest 클래스의 getRequestDispatcher 메소드를 이용해서 보여줘야 할 화면으로 forward를 해주면 된다. forward로 해줘야 jstl을 이용해서 setAttribute로 저장한 값을 가져올 수 있다. HttpServletResponse 클래스의 sendRedirect 메소드를 이용해서 redirect를 하지 말기 바란다(그 이유에 대해서는 여기서는 설명하지 않겠다. forward와 redirect의 차이에 대해 공부하면 알 수 있는 내용이다)


이렇게 onAuthenticationFailure 메소드에서 작업을 하면 다음의 로그인 페이지 소스에서 관련 에러 메시지를 보여줄 수 있게 된다.


<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
<title></title>
<jsp:include page="/WEB-INF/views/include/jsInclude.jsp"></jsp:include> 
<script type="text/javascript">
$(document).ready(function (){
	
	$("#loginbtn").click(function(){
		if($("#loginid").val() == ""){
			alert("로그인 아이디를 입력해주세요");
			$("#loginid").focus();
		}else if($("#loginpwd").val() == ""){
			alert("로그인 비밀번호를 입력해주세요");
			$("#loginpwd").focus();
		}else{
			$("#loginfrm").attr("action", "<c:url value='/j_spring_security_check'/>");
			$("#loginfrm").submit();
		}
	});
		
});
</script>    
</head>
<body>
<div style="display:inline-block;">
    로그인 화면
    <form id="loginfrm" name="loginfrm" action="<c:url value='${ctx}/j_spring_security_check'/>" method="POST">
    <table>
    	<tr>
    		<td>아이디</td>
    		<td>
    			<input type="text" id="loginid" name="loginid" value="${loginid}" />
    		</td>
    		<td rowspan="2">
    			<input type="button" id="loginbtn" value="확인" />
    		</td>
    	</tr>
    	<tr>
    		<td>비밀번호</td>
    		<td>
    			<input type="text" id="loginpwd" name="loginpwd" value="${loginpwd}" />
    		</td>
    	</tr>
    	<c:if test="${not empty securityexceptionmsg}">
    	<tr>
    		<td colspan="2">
				<font color="red">
				<p>Your login attempt was not successful, try again.</p>
				<p>${securityexceptionmsg}</p>
				</font>
    		</td>
    	</tr>
    	</c:if>
    </table>
    <input type="hidden" name="loginRedirect" value="${loginRedirect}" />
    </form>
</div>
</body>
</html>


이전에 설명했던 로그인 화면 커스터마이징때 올렸던 로그인 화면의 소스와 위에 올린 소스를 보면 다른 부분이 몇가지 있다. 예전 소스의 경우는 로그인이 실패했더라도 사용자가 입력했던 로그인 아이디와 패스워드를 보여주질 못했다. 그러나 지금은 사용자가 입력한 값을 jstl을 이용해 다시 가져올 수가 있다. 그래서 로그인 아이디와 패스워드를 입력했던 <input> 태그를 보면 value부분에 jstl을 이용해서 값을 가져와서 화면에 다시 보여주게 되었다. 그리고 에러 메시지를 보여주는 부분에서 변화가 있다. 예전엔 세션에 에러메시지가 저장되었기 때문에 이를 세션에서 가져왔지만 지금은 세션에 저장하지 않기 때문에 jstl을 이용해서 바로 출력했다. 또 세션에 저장하지 않기 때문에 <c:remove>를 이용해서 세션에 저장된 에러메시지를 세션에서 삭제하는 부분도 없어졌다. 마지막으로 로그인 성공시 이동할 URL을 저장해 놓는 hidden 태그인 loginRedirect의 value로 기존 로그인 페이지에서 있던 값을 jstl을 이용해서 그대로 셋팅해주게 했다.


마지막으로 spring security 설정 xml에서 위의 내용들이 최종 반영된 설정 내용이다.


<form-login
	username-parameter="loginid"
	password-parameter="loginpwd" 
	login-page="/login.do"
	default-target-url="/main.do"
	authentication-success-handler-ref="customAuthenticationSuccessHandler"
	authentication-failure-handler-ref="customAuthenticationFailureHandler"
/>

<beans:bean id="customAuthenticationSuccessHandler" class="com.terry.springsecurity.common.security.handler.CustomAuthenticationSuccessHandler">
	<beans:property name="targetUrlParameter" value="loginRedirect" />
	<beans:property name="useReferer" value="false" />
	<beans:property name="defaultUrl" value="/main.do" />
</beans:bean>

<beans:bean id="customAuthenticationFailureHandler" class="com.terry.springsecurity.common.security.handler.CustomAuthenticationFailureHandler">
	<beans:property name="loginidname" value="loginid" />
	<beans:property name="loginpasswdname" value="loginpwd" />
	<beans:property name="loginredirectname" value="loginRedirect" />
	<beans:property name="exceptionmsgname" value="securityexceptionmsg" />
	<beans:property name="defaultFailureUrl" value="/login.do?fail=true" />
</beans:bean>


이 글을 처음 시작할때 먼저 보여주었지만 org.springframework.security.web.authentication.AuthenticationFailureHandler 인터페이스를 구현한 클래스를 태그를 이용해서 등록한 뒤 <form-login> 태그의 authentication-failure-ref 속성에 <bean> 태그로 등록한 클래스의 id 값을 넣어주었다. 그리고 <property> 태그를 이용해서 위에서 언급했던 필요 파라미터 값들을 셋팅해주었다. 여기서는 한가지 변경된 부분이 있는데 CustomAuthenticationFailureHandler 클래스에서 로그인 실패시 화면을 이동하기 때문에 예전에 <form-login> 태그 설정에서 로그인 실패시 보여줘야 할 화면 URL을 설정하는 부분인 authentication-failure-url 속성이 필요없게 되었다. 그래서 여기서 보여주는 <form-login> 태그 설정에서 authentication-failure-url 속성을 뺐다. 대신 authentication-failure-url 속성에 지정했던 값인 /login.do?fail=true 를 CustomAuthenticationFailureHandler 클래스의 defaultFailureUrl 프로퍼티에 사용함으로써 같은 기능을 하도록 했다.


이 글을 쓰면서 궁금한 점이 있어서 한번 테스트를 해보고 글을 써서 밝혀두도록 하겠다. 예전 글에서 보면 Spring Security는 에러 메시지를 세션에 저장한다고 언급했었다. 그러면 우리가 CustomAuthenticationFailureHandler 클래스를 만들어서 로그인 실패시 별도로 작업을 지정할 경우 에러 메시지를 세션에 저장한 뒤에 CustomAuthenticationFailureHandler 클래스의 onAuthenticationFailure 메소드를 호출하는 것일까? 아니다. 지금의 글과 같이 Spring Security에 인증 실패시의 핸들러를 별도로 지정한 상황이라면 Spring Security는 인증 과정에서 문제가 생겼을 경우 어떠한 선작업도 하지 않고 바로 인증 실패 핸들러의 onAuthenticationFailure 메소드를 호출하게 된다. 테스트 방법은 onAuthentication 메소드에서 HttpSession 객체를 가져온뒤(HttpServletRequest 객체를 파라미터로 받기 때문에 getSession(false) 메소드를 호출하면 가져올 수 있다) 세션에 있는 key 값중 SPRING_SECURITY_LAST_EXCEPTION 란 key가 있는지 확인하는 방법으로 테스트하면 된다.


또한 우리가 <form-login> 태그에서 authentication-success-handler-ref 속성과 authentication-failure-handler-ref 속성에 이러한 bean 클래스들을 정의하지 않았다고 해서 부가작업을 안하는 것이 아니다. 예를 들어 authentication-failure-handler-ref 속성에 bean 클래스를 정의하지 않는다면 Spring Security는 자체적으로 AuthenticationFailureHandler 인터페이스를 구현한 클래스인 org.springframework.security.web.authentication.ExceptionMappingAuthenticationFailureHandler 클래스를 이용해서 로그인 실패시의 부가작업을 하고 있다. 속성에 해당 bean 클래스를 지정하지 않았다고 해서 부가작업을 하지 않는것이 아니라 Spring Security가 자체적으로 만든 클래스를 등록해서 부가작업을 하고 있는 것이다.


이번 글에서는 로그인 실패시 부가적인 작업을 설정하는 내용으로 다루었다. 다음엔 로그인 시 비밀번호를 암호화 시켜서 적용하는 방법에 대한 내용을 설명하도록 하겠다.



  • 2015.04.05 19:11

    비밀댓글입니다

    1. BlogIcon 메이킹러브 2015.04.06 10:59 신고

      고맙습니다..제 글이 도움이 되셨다니 저도 좋네요..
      현재는 기존에 연재했던 내용들을 Java Config 방식으로 하는 법을 해보고 있습니다..
      아무래도 Spring의 설정 방법이 XML에서 Java Config 식으로 바뀌다 보니..
      이것도 정리되는대로 연재해보도록 할께요..

  • BlogIcon Hq? 2017.01.12 09:53 신고

    Spring 배우는 중인데, 좋은 글 잘 보고 갑니다!!

  • 2019.01.08 14:50

    비밀댓글입니다

    1. BlogIcon 메이킹러브 2019.01.12 21:52 신고

      안녕하세요..댓글을 지금에서야 보게 되어서 답이 늦었네요..죄송합니다..
      CustomAuthenticationFailure 핸들러에서의 loginRedirect의 의미는 다른게 아니라요..
      로그인을 실패하면 로그인 창을 다시 보여주죠? 이때 로그인 성공했을때의 redirect url을 같이 넘겨줘야 로그인 성공시에 redirect url로 넘어가게 되는겁니다..
      그리고 이것은 파라미터로 주게 되어 있어요..예를 들면..
      login.do?redirectURL=/main.do 요런식으로 주었다면
      CustomAccessFailureHandler의 loginredirectname에 redirectURL로 주면..
      CustomAccessFailureHandler의 onAuthenticationFailure 메소드에서 request.getParameter("redirectURL") 을 통해 로그인 성공시 redirect 할 URL을 읽어오게 하는거죠..
      질문의 의도에 맞게 답변이 되셨는지 모르겠네요..

    2. 2019.01.13 15:04

      비밀댓글입니다

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

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

다른 카테고리의 글 목록

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