잊지 않겠습니다.

'Security'에 해당되는 글 2건

  1. 2013.12.02 Code Base Spring Security 기본
  2. 2013.11.23 Spring Security를 이용한 CORS 적용 (1) 5

Spring 3.0에서 처음 도입된 Code Base Configuration은 이제 Spring 설정의 대세가 되어가는 기분이 든다. 

그런데, 이상하게도 Spring Security의 경우에는 Code Base Configuration이 적용되는 것이 매우 늦어지고 있다는 생각이 든다. 최신 3.2.0RC2에서의 Code Base Security를 구현하는 방법에 대해서 간단히 알아보기로 한다. 


1. @EnableWebSecurity Annotation

Spring Security를 사용하는 @Configuration은 @EnableWebSecurity annotation을 반드시 가져야지 된다.


2. WebSecurityConfigurerAdapter의 상속

@Configuration을 구성한 객체는 기본적으로 WebSecurityConfigurerAdapter를 상속받아야지 된다. 이 상속은 기본적인 method의 stub을 제공한다. 


@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

3. configurer 구현

configurer method를 override시켜서, configuration을 구성한다. 기본적으로 configuration xml에서 구성된 내용들은 configurer method에서 구현된다. 


    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

기본적으로 구성할 수 있는 login form이다. /login에서 username, password를 이용해서 로그인 하게 되며, login success와 failure를 모두 처리하도록 되어있다. 

4. AuthenticationProvider의 구성

AuthenticationProvider는 UserDetailsService와 PasswordEncoder를 반드시 구성해준다. AuthenticationProvider는 인증을 위한 사용자 Pool과 username, password간의 matching을 하는 방법을 제공한다.  주의점은 UserDetailsService이다. 반드시 BeanIds.USER_DETAILS_SERVICE bean name으로 등록이 되어야지 된다. Spring 내부에서 이 Bean Name을 이용해서 UserDetailsService를 구성하기 때문이다.


    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }


5. AuthenticationManager 구성

AuthenticationProvider를 이용해서 AuthenticationProvider를 구성해준다. 코드는 매우 단순하다. 다만 개인적으로는 Form Login 부분을 customizing을 시키는 경우가 많기 때문에 AuthenticationManager를 Bean으로 등록시켜두면 두고두고 편한 일들이 많다. 


    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }


전체 코드는 다음과 같다. 


/**
 * Created with IntelliJ IDEA.
 * User: ykyoon
 * Date: 11/18/13
 * Time: 1:12 AM
 * To change this template use File | Settings | File Templates.
 */

@EnableWebSecurity
@Configuration
@Slf4j
@ComponentScan("co.kr.daesung.app.center.api.web.cors")
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {
    public static final String CORS_SUPPORT_FILTER = "corsSupportFilter";
    public static final String ADMIN_ROLE = "ADMIN";

    @Autowired
    private ApplicationContext context;
    @Autowired
    private CorsSupportLoginUrlAuthenticationEntryPoint corsEntryPoint;

    @Bean
    public DigestAuthenticationFilter digestAuthenticationFilter() throws Exception {
        DigestAuthenticationFilter digestAuthenticationFilter = new DigestAuthenticationFilter();
        digestAuthenticationFilter.setAuthenticationEntryPoint(corsEntryPoint);
        digestAuthenticationFilter.setUserDetailsService(userDetailsServiceBean());
        return digestAuthenticationFilter;
    }

    @Bean
    public LoginProcessHandler loginProcessHandler() {
        LoginProcessHandler loginProcessHandler = new LoginProcessHandler();
        loginProcessHandler.setObjectMapper(objectMapper());
        return loginProcessHandler;
    }

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper;
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .formLogin()
                    .loginPage("/login")
                    .usernameParameter("username")
                    .passwordParameter("password")
                    .defaultSuccessUrl("/main")
                    .failureUrl("/login?error=loginFailed")
                .and()
                .authorizeRequests()
                    .antMatchers("/api/admin/**").hasRole(ADMIN_ROLE)
                    .antMatchers("/api/apiKey/**").authenticated()
                    .antMatchers("/api/message/**").authenticated()
                    .antMatchers("/api/auth/**").authenticated();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(daoAuthenticationProvider());
    }

    /**
     * AuthenticationManager를 LoginController에서 사용하기 위해서는 반드시, @Bean으로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

    /**
     * UserDetailService와 PasswordEncoder를 이용해서, AuthenticationProvider를 구성한다.
     * @return
     * @throws Exception
     */
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsServiceBean());
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    /**
     * UserDetailsService를 구성해준다. Bean Name은 반드시 BeanIds.USER_DETAILS_SERVICE로 등록되어야지 된다.
     * @return
     * @throws Exception
     */
    @Override
    @Bean(name = BeanIds.USER_DETAILS_SERVICE)
    public UserDetailsService userDetailsServiceBean() throws Exception {
        CommonAppUserDetailService userDetailsService = new CommonAppUserDetailService();
        userDetailsService.setUserService(context.getBean(UserService.class));
        return userDetailsService;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new PasswordEncoder() {
            @Override
            public String encode(CharSequence rawPassword) {
                    return rawPassword.toString();
            }

            @Override
            public boolean matches(CharSequence rawPassword, String encodedPassword) {
                return rawPassword.equals(encodedPassword);
            }
        };
    }

    @Bean(name = CORS_SUPPORT_FILTER)
    public Filter corsSupportFilter() {
        CorsSupportFilter corsSupportFilter = new CorsSupportFilter();
        return corsSupportFilter;
    }
}

6. no-web.xml인 경우 등록하는 방법


Servlet 3.0에서 지원되는 web.xml이 없는 개발에서 매우 유용한 방법이다. Spring Security는 Web.xml이 없을때, 아주 쉬운 코드로 WebXmlInitializer를 구성해뒀는데, AbstractSecurityWebApplicationInitializer를 상속받은 Class가 class path에 있는 경우, 자동으로 springSecurityFilterChain 이름의 Bean을 이용하는 DelegateProxyFilter를 등록한다. web.xml인 경우에는 기존과 동일하게 사용하면 된다. 다만 root context에 @Configuration이 로드가 먼저 선행되어야지 된다. 


public class SecurityWebXmlInitializer extends AbstractSecurityWebApplicationInitializer {
}


Posted by Y2K
,

Spring Security를 이용한 REST API를 개발할 때, 외부 사이트와의 데이터 연동을 위해 CORS를 적용하는 것도 매우 좋은 선택이다. 

일반적으로 Cross domain 문제를 해결하기 위해서, JSONP를 이용하는 경우가 많지만 이 방법은 두가지 이유때문에 개인적으로는 추천하지 않는다. 


1. GET method만을 이용 가능 - 데이터를 많이 보내야지 되는 경우가 발생할 수 있고, 무엇보다 REST한 API를 만드는데 제약사항이 발생하게 된다.

2. 인증 문제 - 인증을 받아서 처리를 해줘야지 되는 API의 경우에는 JSONP를 이용할 수 없다. 모든 정보를 API서버에만 위치하고, HTML + javascript로만 동작하는 web application을 작성하는 것이 목적이라면 인증을 처리하기 위해서라도 JSONP가 아닌 CORS를 적용해야지 된다. 


그러나 CORS 역시 많은 제약사항을 가지고 있다. 기본적인 제약사항들은 다음과 같다.  


1. GET, HEAD, POST 만 사용 가능하다.

2. POST의 경우에는 다음과 같은 조건이 경우에만 사용가능하다.

  1) content-type이 application/x-www.form-urlencoded, multipart/form-data, text/plain의 경우에만 사용 가능하다.

  2) customer Header가 설정이 된 경우에는 사용 불가하다. (X-Modified etc...)

3. Server에서 Access-Control-Allow-Origin 안에 허용여부를 결정해줘야지 된다. 


큰 제약사항은 위 3가지지만, 세부적으로는 preflight 문제가 발생하게 된다. preflight란, POST로 외부 site를 call 할때, OPTIONS method를 이용해서 URL에 접근이 가능한지를 다시 한번 확인하는 절차를 거치게 된다. 이때, 주의할 점이 WWW에서 제약한 사항은 분명히 content-type이 application/xml, text/xml의 경우에만 preflight가 발생한다고 되어있으나, firefox나 chrome의 경우에는 text/plain, application/x-www-form-urlencoded, multipart/form-data 모두에서 prefligh가 발생하게 된다. 


위 이론을 먼저 알고, Spring Security를 적용한 REST API Server를 구축하기 위해서는 다음과 같은 절차가 우선 필요하다.


1. Spring Security Form authentication endPoint의 변경

: Spring Security Form authentication은 인증되지 않은 Request가 접근한 경우, login page로 302 redirect를 발생시킨다. 이렇게 되면 API를 이용해서 로그인의 실패등을 확인하기 힘들기 때문에, 인증되지 않은 Request를 처리하는 방법을 달리 해줘야지 된다. 기본적으로 Digest Authentication 역시 www는 지원하기 때문에 Digest 인증 방식의 end point를 이용해서 인증되지 않은 request가 접근한 경우 302가 아니라 401(NotAuthenticated)를 반환할 수 있도록 Spring Configuration을 변경하도록 한다. 


2. CSRF disable

: 다른 domain에서의 API call이 발생하기 때문에 CSRF salt cookie값을 얻어내는 것은 불가능하다. 따라서, CSRF를 disable시켜야지 된다. (이 부분은 해결방법이 다른 것이 있는지 확인이 필요)


3. Login Processing을 재구현

: Spring Security를 이용한 Form Authentication의 경우, login success의 경우에도 마찬가지로 redirect가 발생하게 된다. AuthenticationSuccessHandler interface를 이용해서 변경시켜주거나, Login / Logout을 아애 새로 만들어주는 것이 필요하다. 개인적으로는 Login / Logout을 새로 만들어주는 것을 선호하는 편인데, Controller에 대한 Test code 역시 만들어주는 것이 가능하고, 좀더 깔끔해보이는 느낌이 든다.


4. CORS Filter의 적용

: 모든 response 에 Allow-Origin header를 삽입해주는 Filter 객체가 반드시 필요하다. 


Spring Security의 Form 인증을 새로 만들어주기 위해서는 다음과 같이 Controller를 만들 필요가 있다.


@Controller

public class LoginController {

    public static final String API_LOGIN = "/api/login";

    public static final String API_LOGOUT = "/api/logout";

    @Autowired

    private AuthenticationManager authenticationManager;


    @Autowired

    private UserService userService;


    @RequestMapping(value= API_LOGIN, method = {RequestMethod.GET, RequestMethod.OPTIONS})

    @ResponseBody

    @ResultDataFormat

    public Object getAuthenticationStatus() {

        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth != null && !auth.getName().equals("anonymousUser") && auth.isAuthenticated()) {

            User user = userService.findByUsername(auth.getName());

            return new LoginStatus(true, auth.getName(), user.getName());

        } else {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value= API_LOGIN, method = RequestMethod.POST)

    @ResponseBody

    @ResultDataFormat

    public Object login(@RequestParam("username") String username, @RequestParam("password") String password) {

        try {

            UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);

            User user = userService.findByUsername(username);

            token.setDetails(user);

            Authentication auth = authenticationManager.authenticate(token);

            SecurityContextHolder.getContext().setAuthentication(auth);

            return new LoginStatus(auth.isAuthenticated(), user.getUsername(), user.getName());

        } catch (Exception e) {

            return new LoginStatus(false, null, null);

        }

    }


    @RequestMapping(value = API_LOGOUT)

    @ResponseBody

    @ResultDataFormat

    public Object logout(HttpServletRequest request) {

        HttpSession session = request.getSession(false);

        if(session != null) {

            session.invalidate();

        }

        SecurityContextHolder.clearContext();

        return new LoginStatus(false, null, null);

    }


    @Getter

    public class LoginStatus {

        private final boolean isAuthenticated;

        private final String username;

        private final String name;

        public LoginStatus(boolean loggedIn, String username, String name) {

            this.isAuthenticated = loggedIn;

            this.username = username;

            this.name = name;

        }

    }

}




다음에는 CORS Filter 적용 및 Spring Security Configuration을 이용해서 REST Server 구성을 더 해보도록 하겠다.




Posted by Y2K
,