잊지 않겠습니다.

Spring Security를 이용한 REST API를 만들때, 일반적인 Spring Security를 이용하는 경우에는 다Login 성공 또는 실패시에 모두 302번 Redirect가 발생하게 됩니다. 또한 인증되지 않은 사용자가 API에 접근한 경우에도 마찬가지로 302번 Redirect로 Login Page로 이동하게 되는데, 이는 REST API 개발시에는 원하지 않는 결과입니다.

먼저, 개발할 API 서버는 Form 인증을 지원하는 것을 전제로 합니다. Basic/Digest 인증의 경우에는 다른 방법으로 개발이 되어야지 됩니다.

일반적인 Form 인증을 이용하는 경우에 Spring Security의 기본 Form인증은 다음 문제를 해결해야지 됩니다.

  1. 인증되지 않은 Request가 들어오는 경우, 인증 Page로 Redirect
  2. Login 성공시, 200 Response가 되지 않고, Success Page로 Redirect
  3. Login 실패시, 200 Response가 되지 않고, Failure Page로 Redirect

결국은 모두 Redirect문제가 발생하게 됩니다. 이에 조금 추가를 해서, 일반적으로는 Spring Security는 application이 시작할 때, 각각의 URL에 대한 ROLE을 설정하게 됩니다. 이러한 ROLE의 설정 없이, Request가 발생하였을때 인증된 사용자인 경우에 ROLE을 확인하는 기능을 추가해보도록 하겠습니다.

인증되지 않는 Request에 대한 문제해결

Spring Security는 ErrorHandling에 인증되지 않았을 때의 동작을 AuthenticationEntryPoint를 설정하여 제어할 수 있습니다. 기본적으로 Form인증을 사용하는 경우에는 org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint가 설정이 됩니다. Spring code를 보면 다음과 같이 구성되어 있습니다.

public class LoginUrlAuthenticationEntryPoint implements AuthenticationEntryPoint, InitializingBean {
    public LoginUrlAuthenticationEntryPoint(String loginFormUrl) {
        this.loginFormUrl = loginFormUrl;
    }


    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
            throws IOException, ServletException {

        String redirectUrl = null;

        if (useForward) {
            if (forceHttps && "http".equals(request.getScheme())) {
                // First redirect the current request to HTTPS.
                // When that request is received, the forward to the login page will be used.
                redirectUrl = buildHttpsRedirectUrlForRequest(request);
            }

            if (redirectUrl == null) {
                String loginForm = determineUrlToUseForThisRequest(request, response, authException);

                if (logger.isDebugEnabled()) {
                    logger.debug("Server side forward to: " + loginForm);
                }

                RequestDispatcher dispatcher = request.getRequestDispatcher(loginForm);

                dispatcher.forward(request, response);

                return;
            }
        } else {
            // redirect to login page. Use https if forceHttps true

            redirectUrl = buildRedirectUrlToLoginPage(request, response, authException);

        }

        redirectStrategy.sendRedirect(request, response, redirectUrl);
    }
}

보시면 최종적으로는 sendRedirect method를 통해서 login url로 redirect를 시켜주고 있는 것을 볼 수 있습니다. 이 부분에 대한 제어만을 변경시켜준다면 우리가 원하는 조건을 만들어줄 수 있습니다.

새롭게 만들어지는 RestAuthenticationHandler는 매우 단순한 코드를 갖습니다. 인증이 되지 않은 사용자들에게 401 status를 던질뿐이니까요.

@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

이제 이 부분을 Spring Security Configuration에 설정을 해줍니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .and()
            .logout()
                .logoutUrl("/auth/logout");
    }
}

exceptionHandling()에 구성된 RestAuthenticationEntryPoint 객체만을 설정해주는 것으로 이제 인증되지 않은 request에는 401 status를 보내줄 수 있게 되었습니다.

인증 성공시, 200 status 반환

Spring Security를 이용한 Form 인증을 성공하는 경우, 일반적으로 SuccessfulUrl로 Redirect가 됩니다. 그런데 REST API의 경우에는 이와 같은 구성이 문제가 됩니다. 우리는 인증을 정상적으로 했는지에 대한 확인이 필요한 것이지, 인증을 한 후에 특정 Page로의 Redirect를 원하는 것이 아니기 때문입니다.

Form 인증에서, 인증이 성공한 경우에는 Spring Security 는 기본적으로 org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler에서 동작을 담당합니다. code를 보면 다음과 같습니다.

public class SavedRequestAwareAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws ServletException, IOException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if (savedRequest == null) {
            super.onAuthenticationSuccess(request, response, authentication);

            return;
        }
        String targetUrlParameter = getTargetUrlParameter();
        if (isAlwaysUseDefaultTargetUrl() || (targetUrlParameter != null && StringUtils.hasText(request.getParameter(targetUrlParameter)))) {
            requestCache.removeRequest(request, response);
            super.onAuthenticationSuccess(request, response, authentication);

            return;
        }

        clearAuthenticationAttributes(request);

        // Use the DefaultSavedRequest URL
        String targetUrl = savedRequest.getRedirectUrl();
        logger.debug("Redirecting to DefaultSavedRequest Url: " + targetUrl);
        getRedirectStrategy().sendRedirect(request, response, targetUrl);
    }

    public void setRequestCache(RequestCache requestCache) {
        this.requestCache = requestCache;
    }
}

매우 단순한 code입니다. 여기서 중요한 것은 onAuthenticationSuccess method의 끝부분에 위치한 getRedirectStraegy()입니다. 이 부분만을 제거하고, 200 status만을 반환할 수 있다면 우리가 원하는 결과를 얻어낼 수 있습니다. 인증이 성공한 다음에 실행되기 때문에 매우 단순하게 만들 수 있습니다.

public class RestLoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

    private RequestCache requestCache = new HttpSessionRequestCache();

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        handle(request, response, authentication);
        clearAuthenticationAttributes(request);
    }

    protected void handle(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException {
        SavedRequest savedRequest = requestCache.getRequest(request, response);

        if (savedRequest == null) {
            clearAuthenticationAttributes(request);
            return;
        }
        String targetUrlParam = getTargetUrlParameter();
        if (isAlwaysUseDefaultTargetUrl() ||
                (targetUrlParam != null &&
                        StringUtils.hasText(request.getParameter(targetUrlParam)))) {
            requestCache.removeRequest(request, response);
            clearAuthenticationAttributes(request);
            return;
        }
        clearAuthenticationAttributes(request);
    }
}

기본적으로 매우 단순하게 clearAuthenticationAttribute만 해주고, 다른 처리는 해줄 필요가 없습니다. 아무런 Exception이 발생하지 않고, response에 특별히 status를 설정하지 않는 경우에는 200 status를 반환하게 되니까요.

이제 작성된 AuthenticationSuccessHandler를 설정하면 Spring Security는 다음과 같은 코드가 됩니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler()) //추가된 부분
                .and()
            .logout()
                .logoutUrl("/auth/logout")
            .authorizeRequests().anyRequest().authenticated();
    }
}

인증 실패 및 로그 아웃시 동작 설정

인증 실패시의 동작과 로그 아웃시의 동작 역시 인증 성공시와 동일하게 처리할 수 있습니다. 조금 차이가 있다면 인증 실패시에는 401번 UNAUTHENTICATION status를 보내야지 되고, 로그 아웃시에는 항시 200 status를 보내야지 되는 차이만이 있을 뿐입니다.

인증실패시의 Handler는 다음과 같이 구성될 수 있습니다.

/**
 * Created by ykyoon on 14. 4. 22.
 * when login failed, return http status code (401).
 */
public class RestLoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request,
                                        HttpServletResponse response,
                                        AuthenticationException exception) throws IOException, ServletException {
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
    }
}

그리고, 로그아웃시의 Handler 역시 다음과 같은 code로 구성가능합니다.

/**
 * Created by ykyoon on 14. 4. 21.
 * after Logouted, send http status ok code (200).
 */
public class RestLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String targetUrl = determineTargetUrl(request, response);
        if (response.isCommitted()) {
            logger.debug("Response has already been committed. Unable to redirect to " + targetUrl);
        }
    }
}

이제 구성된 두개의 Handler를 추가하면 Spring Security는 다음 code와 같이 변경됩니다.

@Configuration
@EnableWebSecurity
@ComponentScan(basePackages = "com.daesung.fms.api.securities")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler()) 
                .failureHandler(loginFailureHandler()) //추가2
                .and()
            .logout()
                .logoutUrl("/auth/logout")
                .logoutSuccessHandler(logoutSuccessHandler()) //추가2
            .authorizeRequests().anyRequest().authenticated();
    }
}

이렇게 구성하면 이제 Spring Security로 구성되는 REST API를 구성할 수 있습니다.

API URL별 권한 설정

REST API를 개발하면, 각 URL에 따른 사용 권한을 넣어주는 것이 일반적입니다. 또는 URL 단위로 권한을 묶어서 관리하게 되는데, 그럼 API가 하나하나 추가 될때마다 security의 설정에 매번 URL을 hard coding으로 넣어주게 된다면 이는 매우 큰 비용으로 발생되게 됩니다. 사용자에게 권한만 넘겨주고, 그 권한과 URL을 Request마다 체크를 해서 권한을 확인하면 Security에 대한 권한 코드를 좀더 동적으로 사용할 수 있습니다. (DB 값만을 변경시켜주면 URL에 따른 권한을 변경할 수 있습니다.)

먼저, Spring Security는 각각의 URL과 ROLE간의 Matching을 어떻게 하고 있는지에 대한 구조에 대한 이해가 필요합니다. Spring Security의 URL-ROLE간의 matching은 다음과 같이 진행됩니다.

  1. FilterInvocationSecurityMetadataSource을 통해 URL/METHOD를 이용한 접근권한이 어떻게 되는지 확인합니다. 이 과정이 매우 중요합니다. 우리가 Spring Security에 각각의 URL별 권한을 hard coding으로 넣어주는 경우에는, 이 MetadataSource가 hard coding된 것과 같은 역활을 하게 됩니다.
  2. FilterInvocationSecurityMetadataSource에서 얻어온 ROLE들과 사용자가 갖은 ROLE을 이용해서 AccessVote를 진행합니다.

이러한 두과정을 거치는 Filter가 존재를 하는데, 이 필터가 아래 그림의 최종적인 Filter인 FilterSecurityInterceptor입니다.

이러한 Filter Chain에서 새로운 Filter를 만들어서 Spring Security에 추가를 할 예정입니다. Spring Security의 FilterSecurityInterceptor에는 모든 인증된 Request만이 접근할 수 있도록 설정을 하고, 그 뒤에 우리가 새롭게 만든 Filter를 통해서 인증을 처리할 계획입니다.

먼저, 새로운 FilterInvocationSecurityMetaSource가 필요합니다. 이는 FilterInvocationSecurityMetadataSource interface를 상속하면 매우 간단하게 구성 가능합니다.

/**
 * Created by ykyoon on 14. 4. 21.
 * DB 기반의 인증 관리 시스템.
 */
public class FmsFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

    public FmsFilterInvocationSecurityMetadataSource(MenuRoleService menuRoleService) {
        this.menuRoleService = menuRoleService;
        parser = new FmsUrlParser();
        permissions = new Hashtable<>();
    }
    private final MenuRoleService menuRoleService;
    private final FmsUrlParser parser;
    private final Map<String, Permission> permissions;

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        final HttpServletRequest request = ((FilterInvocation) object).getRequest();
        final String httpMethod = request.getMethod().toUpperCase();
        final String url = parser.parse(request.getRequestURI());
        final String key = String.format("%s %s", httpMethod, url);

        final Permission permission;
        if(permissions.containsKey(key)) {
            permission = permissions.get(key);
        } else {
            permission = menuRoleService.findByMethodAndUrl(httpMethod, url);
            if(permission != null) {
                permissions.put(key, permission);
            }
        }

        String[] roles;
        if(permission == null) {
            roles = new String[] { "ROLE_ADMIN" };
        } else {
            roles = new String[] { "ROLE_ADMIN", permission.getName() };
        }
        return SecurityConfig.createList(roles);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return FilterInvocation.class.isAssignableFrom(clazz);
    }

    /**
     * url - permission hashmap clear
     */
    public void clear() {
        permissions.clear();
    }
}

작성된 code의 menuRoleService는 request URL과 http method를 이용해서 접근 가능한 ROLE을 반환합니다. 약간의 tip으로, DB를 계속해서 읽는 것보다는 Hashtable을 이용해서 한번 읽어들인 ROLE은 또다시 DB 조회를 하지 않도록 MAP을 가지고 있도록 구성하였습니다.

이제 AccessVote를 구현할 차례입니다. AccessVote는 FilterInvocationSecurityMetadataSource에서 넘겨준 ROLE과 사용자의 ROLE간의 비교를 해주게 됩니다.

/**
 * Created by ykyoon on 14. 4. 22.
 * Authorized Name 기반의 인증 Vote class
 */
@Slf4j
public class FmsAccessDecisionVoter implements AccessDecisionVoter<FilterInvocation> {

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return attribute instanceof SecurityConfig;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return clazz != null && clazz.isAssignableFrom(FilterInvocation.class);
    }

    @Override
    public int vote(Authentication authentication, FilterInvocation fi, Collection<ConfigAttribute> attributes) {
        assert authentication != null;
        assert fi != null;
        assert attributes != null;

        SecurityConfig securityConfig = null;

        boolean containAuthority = false;
        for(final ConfigAttribute configAttribute : attributes) {
            if(configAttribute instanceof SecurityConfig) {
                securityConfig = (SecurityConfig) configAttribute;
                for(GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
                    containAuthority = securityConfig.getAttribute().equals(grantedAuthority.getAuthority());
                    if(containAuthority) {
                        break;
                    }
                }
                if(containAuthority) {
                    break;
                }
            }
        }
        return containAuthority ? ACCESS_GRANTED : ACCESS_DENIED;
    }
}

AccessDecisionVoter는 하는 일이 매우 단순합니다. Authentication이 넘어왔고, 이에 대한 규칙을 이용해서 ROLE을 matching이 되는 것이 있는지 없는지를 확인하는 과정을 거치게 됩니다. 이 코드를 조금 더 수정을 한다면 권한 1, 권한 2, 권한 3 모두를 갖는 사용자만이 접근할 수 있는 규칙 역시 만드는 것이 가능합니다. 이건 Security에 대한 운영원칙과 BL에 따라 달라질 것입니다.

이제 구성된 FilterInvocationSecurityMetadataSource와 AccessDecisionVoter를 Spring Security에 추가해보도록 하겠습니다.

먼저 Bean들을 등록시켜줍니다.

    /**
     * FMS API 권한 Filter.
     * @return securityMetadataSource() 가 적용된 데이터.
     * @throws Exception
     */
    @Bean
    public FilterSecurityInterceptor filterSecurityInterceptor() throws Exception {
        FilterSecurityInterceptor filterSecurityInterceptor = new FilterSecurityInterceptor();
        filterSecurityInterceptor.setSecurityMetadataSource(securityMetadataSource());
        filterSecurityInterceptor.setAuthenticationManager(authenticationManager());
        filterSecurityInterceptor.setAccessDecisionManager(affirmativeBased());
        return filterSecurityInterceptor;
    }

    //여러 Voter를 추가하는 것이 가능. List로 index가 작은 Voter가 먼저 투표. 투표 포기(ACCESS_ABSTAIN)
    //가 되는 경우, 다음 Voter에게 넘어감.
    @Bean
    public AffirmativeBased affirmativeBased() {
        FmsAccessDecisionVoter voter = new FmsAccessDecisionVoter();
        List<AccessDecisionVoter> voters = new ArrayList<>();
        voters.add(voter);
        return new AffirmativeBased(voters);
    }

    @Bean
    public FilterInvocationSecurityMetadataSource securityMetadataSource() {
        return new FmsFilterInvocationSecurityMetadataSource(menuRoleService);
    }

그리고, FilterSecurityInterceptor 뒤에 우리가 만들어준 Filter를 등록시켜주면 됩니다.

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf().disable()
            .exceptionHandling()
                .authenticationEntryPoint(restAuthenticationEntryPoint())
                .and()
            .formLogin()
                .loginProcessingUrl("/auth/login")
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(loginSuccessHandler())
                .failureHandler(loginFailureHandler())
                .and()
            .logout()
                .logoutUrl("/auth/logout")
                .logoutSuccessHandler(logoutSuccessHandler())
                .and()
            .authorizeRequests().anyRequest().authenticated()
                .and()
            .addFilterAfter(filterSecurityInterceptor(), FilterSecurityInterceptor.class);
    }

Summary

REST API 서비스 구현시에, Spring Security를 이용한 인증처리를 만들었습니다. 기본적인 Form 인증과 구성된 Security 다음 동작이 다릅니다.

동작기본 Spring Security FormREST Security Form
인증성공시설정된 인증 성공 URL로 302 Redirect200 status return
인증실패시설정된 인증 실패 URL로 302 Redirect401 unauthentication status return
로그아웃시설정된 로그아웃 성공 URL로 302 Redirect200 status return

또한, API의 추가 또는 권한변경시 코드의 변경 없이, DB값을 조회해와서 동적인 ROLE을 설정하는 것이 가능합니다.

인증은 매우 중요하고, 인증은 회사의 운영원칙에 많은 변경이 오게 되는 것이 사실입니다. 특히 REST API를 구성한다면 이 인증에 대한 많은 고민들이 필요하실 것이라고 생각하며 이만 글을 줄입니다. 모두들 Happy Coding~

Posted by Y2K
,