Apache Shiro를 이용한 웹 애플리케이션 보안 구성

Apache Shiro는 인증, 권한 부여, 암호화, 세션 관리 등의 기능을 제공하는 강력하면서도 통합이 용이한 오픈소스 보안 프레임워크입니다. 인증과 권한 부여는 보안의 핵심 요소로, 간단히 말해 '인증'은 사용자가 누구인지 증명하는 과정이며, 웹 애플리케이션에서는 일반적으로 폼을 통해 사용자 이름과 비밀번호를 제출하여 인증을 수행합니다. '권한 부여'는 인증된 사용자가 보호된 리소스에 접근할 수 있는지 여부를 결정합니다. Shiro의 다양한 특징과 장점에 대해서는 여러 문서에서 다루고 있으므로, 이 문서에서는 Shiro를 웹 애플리케이션에 적용하여 캡차 인증과 싱글 사인온(SSO)을 구현하는 방법에 초점을 맞춥니다.

사용자 권한 모델

Shiro를 이해하기 전에 사용자 권한 모델을 먼저 알아야 합니다. 여기서 사용자 권한 모델이란 사용자 정보와 사용자 권한 정보를 표현하는 데이터 모델을 의미하며, "당신은 누구인가?"와 "보호된 리소스에 얼마나 접근할 수 있는가?"를 나타냅니다. 보다 유연한 사용자 권한 데이터 모델을 구현하기 위해 일반적으로 사용자 정보를 하나의 엔터티로, 사용자 권한 정보를 두 개의 엔터티로 표현합니다.

  1. 사용자 정보는 LoginAccount로 표현되며, 가장 간단한 사용자 정보는 loginName과 password 두 속성만 포함할 수 있습니다. 실제 애플리케이션에서는 사용자 비활성화 여부, 사용자 정보 만료 여부 등의 정보를 포함할 수 있습니다.
  2. 사용자 권한 정보는 Role과 Permission으로 표현되며, Role과 Permission은 다대다 관계를 형성합니다. Permission은 리소스에 대한 작업으로 이해할 수 있고, Role은 Permission의 집합으로 간단히 이해할 수 있습니다.
  3. 사용자 정보와 Role은 다대다 관계입니다. 동일한 사용자가 여러 Role을 가질 수 있고, 하나의 Role은 여러 사용자가 가질 수 있습니다.

인증과 권한 부여

Shiro 인증 및 권한 부여 처리 과정

  • Shiro에 의해 보호되는 리소스만 인증 및 권한 부여 과정을 거칩니다. URL 보호에 Shiro를 사용하는 방법은 "Spring과 통합" 섹션을 참조하십시오.
  • 사용자가 보호된 URL(예: http://host/security/action.do)에 접근합니다.
  • Shiro는 먼저 사용자가 이미 인증되었는지 확인합니다. 인증되지 않은 경우 로그인 페이지로 리디렉션하고, 그렇지 않으면 권한 부여를 확인합니다. 인증 과정은 Realm을 통해 사용자 및 비밀번호 정보를 가져오며, 일반적으로 JDBC Realm을 구현하여 데이터베이스에서 사용자 인증 정보를 가져옵니다. 캐시를 사용하는 경우 첫 번째 이후에는 캐시에서 사용자 정보를 가져옵니다.
  • 인증이 완료되면 Shiro는 권한 부여 확인을 수행하며, 이때도 Realm을 통해 사용자 권한 정보를 가져옵니다. Shiro에 필요한 사용자 권한 정보에는 Role이나 Permission이 포함되며, 보호된 리소스의 구성에 따라 하나 또는 둘 다 필요할 수 있습니다. 사용자 권한 정보에 Shiro가 요구하는 Role이나 Permission이 포함되지 않은 경우 권한 부여가 실패합니다. 권한 부여가 성공해야만 보호된 URL에 해당하는 리소스에 접근할 수 있으며, 그렇지 않으면 "권한 없음 페이지"로 리디렉션됩니다.

Shiro Realm

Shiro 인증 및 권한 부여 과정에서 Realm이 언급되었습니다. Realm은 사용자 정보, 역할 및 권한을 읽어오는 DAO로 이해할 수 있습니다. 대부분의 웹 애플리케이션은 관계형 데이터베이스를 사용하므로 JDBC Realm을 구현하는 것이 일반적인 방법이며, 뒤에서 CAS Realm에 대해서도 설명합니다.

public class MyShiroRealm extends AuthorizingRealm {
    private BusinessManager businessManager;

    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        String username = (String) principals.fromRealm(getName()).iterator().next();
        if (username != null) {
            Collection<String> permissions = businessManager.queryPermissions(username);
            if (permissions != null && !permissions.isEmpty()) {
                SimpleAuthorizationInfo info = new SimpleAuthorizationInfo();
                for (String perm : permissions) {
                    info.addStringPermission(perm);
                }
                return info;
            }
        }
        return null;
    }

    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();
        if (username != null && !username.isEmpty()) {
            LoginAccount account = businessManager.get(username);
            if (account != null) {
                return new SimpleAuthenticationInfo(account.getLoginName(), account.getPassword(), getName());
            }
        }
        return null;
    }
}

코드 설명:

  1. businessManager는 데이터베이스에서 사용자 정보와 권한 정보를 가져오는 비즈니스 클래스입니다. 실제 상황에서는 사용자 권한 모델이나 영속성 프레임워크 선택에 따라 다를 수 있으므로 예제 코드는 제공되지 않았습니다.
  2. doGetAuthenticationInfo 메서드는 사용자 정보를 가져옵니다. 사용자 권한 모델에 따르면 LoginAccount 엔터티를 가져오는 것입니다. 최종적으로 Shiro에 AuthenticationInfo 객체를 제공해야 합니다.
  3. doGetAuthorizationInfo 메서드는 사용자 권한 정보를 가져옵니다. 코드는 사용자 Permission을 가져오는 예제를 보여주며, Role을 가져오는 코드도 유사합니다. Shiro에 제공되는 사용자 권한 정보는 AuthorizationInfo 객체 형태로 반환됩니다.

왜 Shiro인가?

Spring을 사용하고 있고 애플리케이션의 보안 컴포넌트로 Spring Security를 이미 선택했는데 왜 Shiro가 필요한지 의문이 들 수 있습니다. 물론 Spring Security도 훌륭한 보안 제어 컴포넌트입니다. 이 문서의 목적은 반드시 Shiro를 선택하고 Spring Security를 포기하도록 하는 것이 아닙니다. 객관적인 관점에서 두 가지를 간략히 비교해 보겠습니다.

  1. 단순성: Shiro는 Spring Security보다 사용하기 쉽고 이해하기 쉽습니다.
  2. 유연성: Shiro는 Web, EJB, IoC, Google App Engine 등 다양한 애플리케이션 환경에서 실행될 수 있으며, 이러한 환경에 의존하지 않습니다. 반면 Spring Security는 Spring과 함께만 통합하여 사용할 수 있습니다.
  3. 플러그 가능성: Shiro의 깔끔한 API와 디자인 패턴 덕분에 다른 많은 프레임워크 및 애플리케이션과 쉽게 통합할 수 있습니다. Shiro는 Spring, Grails, Wicket, Tapestry, Mule, Apache Camel, Vaadin과 같은 타사 프레임워크와 원활하게 통합됩니다. Spring Security는 이 측면에서 다소 부족합니다.

Spring과 통합

Java 웹 애플리케이션 개발에서 Spring은 널리 사용되며, EJB와 비교했을 때 Spring이 주류입니다. Shiro는 Spring과의 훌륭한 지원을 자체적으로 제공하므로 애플리케이션에 Spring을 통합하는 것은 매우 쉽습니다.

앞서 언급한 사용자 권한 데이터 모델을 만들고 자체 Realm을 구현했다면, 이제 Shiro를 통합하여 애플리케이션을 보호할 수 있습니다.

Shiro 설치

Shiro 설치는 매우 간단합니다. Shiro 공식 웹사이트에서 shiro-all-1.2.0.jar, shiro-cas-1.2.0.jar(SSO에 필요)를 다운로드하고, SLF4J 웹사이트에서 Shiro가 의존하는 로깅 컴포넌트 slf4j-api-1.6.1.jar를 다운로드합니다. Spring 관련 JAR 파일은 여기서 나열하지 않습니다. 이 JAR 파일들을 웹 프로젝트의 /WEB-INF/lib/ 디렉토리에 배치하면 됩니다. 이후에는 설정만 남았습니다.

필터 구성

먼저 요청이 Shiro의 필터 처리를 거치도록 필터를 구성합니다. 이는 다른 필터 사용과 유사합니다.

<filter>
    <filter-name>shiroFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>shiroFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

Spring 설정

이제 Spring 컨테이너에서 관리하는 일련의 Bean만 구성하면 통합이 완료됩니다. 각 Bean의 기능은 코드 설명을 참조하십시오.

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <property name="loginUrl" value="/login.do"/>
    <property name="successUrl" value="/welcome.do"/>
    <property name="unauthorizedUrl" value="/403.do"/>
    <property name="filters">
        <util:map>
            <entry key="authc" value-ref="formAuthenticationFilter"/>
        </util:map>
    </property>
    <property name="filterChainDefinitions">
        <value>
            /=anon
            /login.do*=authc
            /logout.do*=anon

            # 보안 설정 예제
            /security/account/view.do=authc,perms[SECURITY_ACCOUNT_VIEW]

            /** = authc
        </value>
    </property>
</bean>

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="realm" ref="myShiroRealm"/>
</bean>

<bean id="myShiroRealm" class="xxx.packagename.MyShiroRealm">
    <property name="businessManager" ref="businessManager"/>
    <property name="cacheManager" ref="shiroCacheManager"/>
</bean>

<bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/>

<bean id="shiroCacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
    <property name="cacheManager" ref="cacheManager"/>
</bean>

<bean id="formAuthenticationFilter" class="org.apache.shiro.web.filter.authc.FormAuthenticationFilter"/>

코드 설명:

  1. shiroFilter의 loginUrl은 로그인 페이지 주소, successUrl은 로그인 성공 페이지 주소(먼저 보호된 URL에 접근하여 로그인 성공 시 실제 접근 페이지로 리디렉션), unauthorizedUrl은 인증 실패 시 접근하는 페이지(앞서 언급한 "권한 없음 페이지")입니다.
  2. shiroFilter의 filters 속성, formAuthenticationFilter는 폼 기반 인증 필터로 구성됩니다.
  3. shiroFilter의 filterChainDefinitions 속성, anon은 익명 접근(인증 및 권한 부여 불필요), authc는 인증 필요, perms[SECURITY_ACCOUNT_VIEW]는 사용자가 "SECURITY_ACCOUNT_VIEW" 값을 가진 Permission 정보를 제공해야 함을 의미합니다. 따라서 URL이 authc 또는 perms[XXX]로 구성되면 보호된 리소스임을 나타냅니다.
  4. securityManager의 realm 속성은 직접 구현한 Realm으로 구성됩니다. Realm에 대한 자세한 내용은 앞서 "Shiro Realm" 섹션을 참조하십시오.
  5. myShiroRealm은 직접 구현해야 하는 Realm 클래스이며, 데이터베이스 부하를 줄이기 위해 캐시 메커니즘이 추가되었습니다.
  6. shiroCacheManager는 캐시 프레임워크 EhCache에 대한 Shiro의 구성입니다.

캡차 인증 구현

캡차는 무차별 대입 공격을 효과적으로 방지하는 수단입니다. 일반적인 방법은 서버에서 현재 사용자 세션과 연결된 임의의 문자열을 생성하고(일반적으로 세션에 저장), "왜곡된" 이미지를 사용자에게 표시한 후, 사용자가 입력한 내용이 서버에서 생성된 내용과 일치할 때만 다음 단계를 진행하도록 하는 것입니다.

캡차 생성

데모 목적으로 오픈소스 캡차 컴포넌트 kaptcha를 선택합니다. Servlet 하나만 간단히 구성하면 페이지에서 IMG 태그를 통해 그래픽 캡차를 표시할 수 있습니다.

<!-- captcha servlet -->
<servlet>
    <servlet-name>kaptcha</servlet-name>
    <servlet-class>com.google.code.kaptcha.servlet.KaptchaServlet</servlet-class>
</servlet>
<servlet-mapping>
    <servlet-name>kaptcha</servlet-name>
    <url-pattern>/images/kaptcha.jpg</url-pattern>
</servlet-mapping>

UsernamePasswordToken 확장

Shiro 폼 인증에서 페이지에서 제출된 사용자 이름과 비밀번호 등의 정보는 UsernamePasswordToken 클래스로 수신됩니다. 페이지에서 캡차 입력을 수신하려면 이 클래스를 확장해야 합니다.

public class CaptchaUsernamePasswordToken extends UsernamePasswordToken {
    private String captcha;

    // getter 및 setter 메서드 생략

    public CaptchaUsernamePasswordToken(String username, char[] password,
                                        boolean rememberMe, String host, String captcha) {
        super(username, password, rememberMe, host);
        this.captcha = captcha;
    }
}

FormAuthenticationFilter 확장

다음으로 FormAuthenticationFilter 클래스를 확장합니다. 먼저 createToken 메서드를 재정의하여 CaptchaUsernamePasswordToken 인스턴스를 가져오고, 그 다음 doCaptchaValidate 검증 메서드를 추가하고, 마지막으로 Shiro의 인증 메서드 executeLogin을 재정의하여 원래의 폼 인증 로직이 처리되기 전에 캡차 검증을 수행합니다.

public class CaptchaFormAuthenticationFilter extends FormAuthenticationFilter {
    public static final String DEFAULT_CAPTCHA_PARAM = "captcha";
    private String captchaParam = DEFAULT_CAPTCHA_PARAM;

    public String getCaptchaParam() {
        return captchaParam;
    }

    public void setCaptchaParam(String captchaParam) {
        this.captchaParam = captchaParam;
    }

    protected String getCaptcha(ServletRequest request) {
        return WebUtils.getCleanParam(request, getCaptchaParam());
    }

    @Override
    protected CaptchaUsernamePasswordToken createToken(ServletRequest request, ServletResponse response) {
        String username = getUsername(request);
        String password = getPassword(request);
        String captcha = getCaptcha(request);
        boolean rememberMe = isRememberMe(request);
        String host = getHost(request);

        return new CaptchaUsernamePasswordToken(username, password, rememberMe, host, captcha);
    }

    protected void doCaptchaValidate(HttpServletRequest request, CaptchaUsernamePasswordToken token) {
        String captcha = (String) request.getSession().getAttribute(com.google.code.kaptcha.Constants.KAPTCHA_SESSION_KEY);
        if (captcha != null && !captcha.equalsIgnoreCase(token.getCaptcha())) {
            throw new IncorrectCaptchaException("캡차 오류!");
        }
    }

    @Override
    protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception {
        CaptchaUsernamePasswordToken token = createToken(request, response);
        try {
            doCaptchaValidate((HttpServletRequest) request, token);
            Subject subject = getSubject(request, response);
            subject.login(token);
            return onLoginSuccess(token, subject, request, response);
        } catch (AuthenticationException e) {
            return onLoginFailure(token, e, request, response);
        }
    }
}

코드 설명:

  1. captchaParam 변수를 추가하여 페이지 폼에서 제출하는 캡차 파라미터 이름을 유연하게 구성할 수 있습니다.
  2. doCaptchaValidate 메서드에서 캡차 검증은 KAPTCHA 프레임워크가 제공하는 API를 사용합니다.

IncorrectCaptchaException 추가

앞서 캡차 검증이 실패하면 IncorrectCaptchaException 예외를 발생시킵니다. 이 클래스는 AuthenticationException을 상속합니다. 새로운 예외 클래스를 확장해야 하는 이유는 페이지에서 더 정확하게 오류 메시지를 표시하기 위해서입니다.

public class IncorrectCaptchaException extends AuthenticationException {
    public IncorrectCaptchaException() {
        super();
    }

    public IncorrectCaptchaException(String message, Throwable cause) {
        super(message, cause);
    }

    public IncorrectCaptchaException(String message) {
        super(message);
    }

    public IncorrectCaptchaException(Throwable cause) {
        super(cause);
    }
}

페이지에서 캡차 오류 메시지 표시

<%
    Object obj = request.getAttribute(org.apache.shiro.web.filter.authc.FormAuthenticationFilter.DEFAULT_ERROR_KEY_ATTRIBUTE_NAME);
    AuthenticationException authExp = (AuthenticationException) obj;
    if (authExp != null) {
        String expMsg = "";
        if (authExp instanceof UnknownAccountException || authExp instanceof IncorrectCredentialsException) {
            expMsg = "잘못된 사용자 계정 또는 비밀번호!";
        } else if (authExp instanceof IncorrectCaptchaException) {
            expMsg = "캡차 오류!";
        } else {
            expMsg = "로그인 예외: " + authExp.getMessage();
        }
        out.print("<div class=\"error\">" + expMsg + "</div>");
    }
%>

싱글 사인온(SSO) 구현

앞서 Shiro의 인증 및 권한 부여를 살펴보고 Spring과 통합했습니다. 현실에서는 여러 업무 시스템이 있는 경우가 많습니다. 앞서의 접근 방식대로 각 시스템에 접근할 때마다 인증을 수행해야 한다면 사용자 경험이 좋지 않을 것입니다. 한 번만 인증하고 원하는 대상 시스템에 접근할 수 있는 메커니즘은 없을까요?

이러한 시나리오를 싱글 사인온(SSO)이라고 합니다. Shiro는 1.2 버전부터 CAS를 지원하며, CAS는 SSO의 한 구현입니다.

Shiro CAS 인증 흐름

  • 사용자가 처음으로 보호된 리소스(예: http://casclient/security/view.do)에 접근합니다.
  • 인증되지 않았으므로 Shiro는 먼저 요청 주소(http://casclient/security/view.do)를 캐시합니다.
  • 그런 다음 CAS 서버로 리디렉션하여 로그인 인증을 수행합니다. CAS 서버에서 인증이 완료되면 요청한 CAS 클라이언트로 돌아와야 하므로, 요청 시 파라미터에 반환 주소(Shiro에서는 CAS Service)를 추가해야 합니다. 예: http://casserver/login?service=http://casclient/shiro-cas
  • CAS 서버에서 인증이 성공하면 CAS 서버는 반환 주소에 ticket을 추가합니다. 예: http://casclient/shiro-cas?ticket=ST-4-BWMEnXfpxfVD2jrkVaLl-cas
  • 다음으로 Shiro는 ticket이 유효한지 확인합니다. CAS 클라이언트는 직접 인증을 제공하지 않으므로 Shiro는 CAS 서버에 ticket 검증을 요청하고, 서버가 성공을 반환할 때만 Shiro가 인증된 것으로 간주합니다.
  • 인증이 완료되면 권한 부여 확인을 진행합니다. Shiro의 권한 부여 확인은 앞서 설명한 것과 동일합니다.
  • 마지막으로 권한 부여 확인이 완료되면 사용자는 http://casclient/security/view.do에 정상적으로 접근할 수 있습니다.

CAS Realm

Shiro는 CasRealm이라는 클래스를 제공합니다. 앞서 언급한 JDBC Realm과 유사하게 이 클래스는 인증 및 권한 부여 기능을 모두 포함합니다. 인증은 CAS 서버에서 반환된 ticket의 유효성을 확인하는 것이고, 권한 부여는 사용자 권한 정보를 가져오는 것입니다.

싱글 사인온 기능을 구현하려면 CasRealm 클래스를 확장해야 합니다.

public class MyCasRealm extends CasRealm {
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // ... 앞서 MyShiroRealm과 동일
    }

    @Override
    public String getCasServerUrlPrefix() {
        return "http://casserver/login";
    }

    @Override
    public String getCasService() {
        return "http://casclient/shiro-cas";
    }
}

코드 설명:

  1. doGetAuthorizationInfo는 앞서 "자체 JDBC Realm 구현" 섹션과 동일하게 권한 정보를 가져옵니다.
  2. 인증 기능은 Shiro 자체에서 제공하는 CasRealm에 의해 구현됩니다.
  3. getCasServerUrlPrefix 메서드는 CAS 서버 주소를 반환하며, 실제 사용 시 일반적으로 파라미터를 통해 구성됩니다.
  4. getCasService 메서드는 CAS 클라이언트 처리 주소를 반환하며, 실제 사용 시 일반적으로 파라미터를 통해 구성됩니다.
  5. 인증 과정에는 keystore가 필요하며, 그렇지 않으면 예외가 발생할 수 있습니다. 시스템 속성을 설정하여 지정할 수 있습니다(예: System.setProperty("javax.net.ssl.trustStore","keystore-file")).

CAS Spring 설정

싱글 사인온을 위한 Spring 설정은 앞서 설명한 것과 유사하며, 차이점은 코드 설명을 참조하십시오.

<bean id="shiroFilter" class="org.apache.shiro.spring.web.ShiroFilterFactoryBean">
    <property name="securityManager" ref="securityManager"/>
    <property name="loginUrl" value="http://casserver/login?service=http://casclient/shiro-cas"/>
    <property name="successUrl" value="/welcome.do"/>
    <property name="unauthorizedUrl" value="/403.do"/>
    <property name="filters">
        <util:map>
            <entry key="authc" value-ref="formAuthenticationFilter"/>
            <entry key="cas" value-ref="casFilter"/>
        </util:map>
    </property>
    <property name="filterChainDefinitions">
        <value>
            /shiro-cas*=cas
            /logout.do*=anon
            /casticketerror.do*=anon

            # 보안 설정 예제
            /security/account/view.do=authc,perms[SECURITY_ACCOUNT_VIEW]

            /** = authc
        </value>
    </property>
</bean>

<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
    <property name="realm" ref="myShiroRealm"/>
</bean>

<bean id="lifecycleBeanPostProcessor" class="org.apache.shiro.spring.LifecycleBeanPostProcessor"/>

<!-- CAS Realm -->
<bean id="myShiroRealm" class="xxx.packagename.MyCasRealm">
    <property name="cacheManager" ref="shiroCacheManager"/>
</bean>

<bean id="shiroCacheManager" class="org.apache.shiro.cache.ehcache.EhCacheManager">
    <property name="cacheManager" ref="cacheManager"/>
</bean>

<bean id="formAuthenticationFilter" class="org.apache.shiro.web.filter.authc.FormAuthenticationFilter"/>

<!-- CAS Filter -->
<bean id="casFilter" class="org.apache.shiro.cas.CasFilter">
    <property name="failureUrl" value="casticketerror.do"/>
</bean>

코드 설명:

  1. shiroFilter의 loginUrl 속성은 CAS 서버 주소로, service 파라미터는 서버의 반환 주소입니다.
  2. myShiroRealm은 이전 섹션에서 언급한 CAS Realm입니다.
  3. casFilter의 failureUrl 속성은 Ticket 검증 실패 시 표시할 오류 페이지입니다.

태그: Apache Shiro CAS SSO Spring 캡차

6월 12일 01:29에 게시됨