잊지 않겠습니다.

Spring MVC를 이용한 개발에서 가장 멋진 일은 MockMvc를 이용한 테스트다. 특히 Controller의 사용자 Scenario를 짜고, 그 Sceneario의 결과를 테스트 해보는것은 너무 재미있는 일이다. 그런데, Spring MVC에 Spring Security를 적용한 후에 인증에 대한 테스트를 하기 위해서는 다음의 간단한 절차를 거쳐야지 된다. 


1. MockMvc에 Spring Security Filter를 적용해야지 된다.

2. Login 절차를 통과한 사용자를 만들어내야지 된다. - MockHttpSession을 이용한다. 


이와 같은 과정을 조금 단순화하기 위해서 간단한 TestSupport 객체를 만들어봤다. Utility 객체이기 때문에, 하는 일들은 매우 단순하다. 

1. MockMvc에 Spring Security Filter를 적용한 후, Return시킨다.

2. Digest 인증키값을 만들어낼 수 있다.

3. Basic 인증키값을 만들어낼 수 있다.

4. Form 인증이 반영된 MockHttpSession 값을 만들어낼 수 있다.


Helper class 코드는 다음과 같다.


/**
 * User: ykyoon
 * Date: 11/18/13
 * Time: 7:13 PM
 * Spring Security Filter 적용 및 인증 지원을 위한 Test Helper Class
 */
public class AuthorizedControllerHelper {

    /**
     * MockMvc 생성 코드
     * @param context WebApplicationContext
     * @return Spring Security Filter가 적용된 MockMvc 객체
     * @throws Exception
     */
    public static MockMvc getSecurityAppliedMockMvc(WebApplicationContext context) throws Exception {
        DelegatingFilterProxy delegateProxyFilter = new DelegatingFilterProxy();
        MockFilterConfig secFilterConfig = new MockFilterConfig(context.getServletContext(),
                BeanIds.SPRING_SECURITY_FILTER_CHAIN);
        delegateProxyFilter.init(secFilterConfig);

        return MockMvcBuilders.webAppContextSetup(context).addFilter(delegateProxyFilter).build();
    }

    public static final String AUTH_HEADER = "Authorization";

    /**
     * Basic 인증 문자열 생성
     * @param username 사용자 이름
     * @param password 비밀번호
     * @return Basic XXXX 형태의 Authorization 문자열
     * @throws Exception
     */
    public static final String buildBasicAuthHeaderValue(String username, String password) throws Exception {
        String authHeaderFormat = "Basic ";
        String encodingRawData = String.format("%s:%s", username, password);
        String encodingData = authHeaderFormat + new String(Base64.encode(encodingRawData.getBytes("utf-8")));
        return encodingData;
    }

    /**
     * Digest 인증 문자열 생성
     * @param mvc MockMvc. Digest의 경우, 한번의 Request를 통해 서버의 nonce값을 얻어내야지 된다.
     *            Spring Security의 EntryEndPoint의 설정이 DigestAuthenticationFilter로 되어있어야지 된다.
     * @param username 사용자 이름
     * @param password 비밀번호
     * @param uri 호출할 URI
     * @param method HttpRequestMethod : GET, POST, PUT, DELETE
     * @return Digest 인증 문자열
     * @throws Exception
     */
    public static String buildDigestAuthenticateion(MockMvc mvc, String username,
                                                    String password,
                                                    String uri, String method) throws Exception {
        MvcResult mvcResult = null;
        if(method.equals("GET")) {
            mvcResult = mvc.perform(get(uri)).andDo(print()).andReturn();
        } else if(method.equals("POST")) {
            mvcResult = mvc.perform(post(uri)).andDo(print()).andReturn();
        } else if(method.equals("PUT")) {
            mvcResult = mvc.perform(put(uri)).andDo(print()).andReturn();
        } else if(method.equals("DELETE")) {
            mvcResult = mvc.perform(delete(uri)).andDo(print()).andReturn();
        }
        String authHeader = mvcResult.getResponse().getHeader("WWW-Authenticate");
        String[] authHeaderItemStrings = authHeader.split(",\\s");
        Map<String, String> authItems = new HashMap<>();
        Pattern keyAndItemPattern = Pattern.compile("(Digest\\s)?(?<key>[^=]+)=\"(?<value>[^\"]+)\"");
        for(int i = 0 ; i < authHeaderItemStrings.length; i++) {
            Matcher matcher = keyAndItemPattern.matcher(authHeaderItemStrings[i]);
            assertThat(matcher.find(), is(true));
            String key = matcher.group("key");
            String value = matcher.group("value");
            authItems.put(key, value);
        }
        Assert.assertNotNull(authItems.get("realm"));
        Assert.assertNotNull(authItems.get("nonce"));
        Assert.assertNotNull(authItems.get("qop"));

        String ha1 = DigestUtils.md5DigestAsHex(String.format("%s:%s:%s", username, authItems.get("realm"), password).getBytes("UTF-8"));
        String ha2 = DigestUtils.md5DigestAsHex(String.format("%s:%s", method, uri).getBytes("UTF-8"));
        String cnonce = calculateNonce();
        String totalString = String.format("%s:%s:00000001:%s:%s:%s",
                ha1, authItems.get("nonce"), cnonce, authItems.get("qop"), ha2);
        String response = DigestUtils.md5DigestAsHex(totalString.getBytes("UTF-8"));

        String clientRequest = String.format("Digest username=\"%s\",", username) +
                String.format("realm=\"%s\",", authItems.get("realm")) +
                String.format("nonce=\"%s\",", authItems.get("nonce")) +
                String.format("uri=\"%s\",", uri) +
                String.format("qop=%s,", authItems.get("qop")) +
                "nc=00000001," +
                String.format("cnonce=\"%s\",", cnonce) +
                String.format("response=\"%s\"", response);

        return clientRequest;
    }

    /**
     * Form인증을 위한 MockHttpSession 반환 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return Spring Security Attribute가 적용된 MockHttpSession 값
     * @throws Exception
     */
    public static MockHttpSession buildSecuritySession(WebApplicationContext context, String username) throws Exception {
        MockHttpSession session = new MockHttpSession();
        SecurityContext securityContext = buildFormAuthentication(context, username);
        session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, securityContext);
        return session;
    }

    /**
     * Spring Security Context 얻어내는 내부 함수
     * @param context WebApplicationContext
     * @param username 사용자 이름
     * @return SecurityContext
     * @throws Exception
     */
    private static SecurityContext buildFormAuthentication(WebApplicationContext context, String username) throws Exception {
        UserDetailsService userDetailsService = (UserDetailsService) context
                .getBean(BeanIds.USER_DETAILS_SERVICE);
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
                userDetails,
                userDetails.getPassword(),
                userDetails.getAuthorities());
        SecurityContext securityContext = new SecurityContext() {
            private static final long serialVersionUID = 8611087650974958658L;
            private Authentication authentication;

            @Override
            public void setAuthentication(Authentication authentication) {
                this.authentication = authentication;
            }

            @Override
            public Authentication getAuthentication() {
                return this.authentication;
            }
        };
        securityContext.setAuthentication(authToken);
        return securityContext;
    }

    /**
     * Digest인증시에 사용되는 cnonce 값을 생성하는 내부 함수
     * @return
     * @throws UnsupportedEncodingException
     */
    private static String calculateNonce() throws UnsupportedEncodingException {
        Date d = new Date();
        SimpleDateFormat f = new SimpleDateFormat("yyyy:MM:dd:hh:mm:ss");
        String fmtDate = f.format(d);
        Random rand = new Random(100000);
        Integer randomInt = rand.nextInt();
        return DigestUtils.md5DigestAsHex((fmtDate + randomInt.toString()).getBytes("UTF-8"));
    }
}

Spring Security가 반드시 적용된 Test에서만 사용가능하다. Helper를 이용한 Test code는 다음과 같다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { DomainConfiguration.class, SecurityConfiguration.class, ControllerConfiguration.class })
@WebAppConfiguration
public class AdminNoticeControllerTest {
    @Autowired
    private WebApplicationContext context;
    private MockMvc mvc;
    @Autowired
    private ObjectMapper objectMapper;
    @Autowired
    private NoticeService noticeService;

    @Before
    public void setUp() throws Exception {
        assertThat(context, is(not(nullValue())));
        mvc = AuthorizedControllerHelper.getSecurityAppliedMockMvc(context);
    }

    @Test
    public void getAllNotices() throws Exception {
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(get(AdminNoticeController.API_ADMIN_NOTICES)
                .param("pageIndex", "0")
                .param("pageSize", "10").session(session))
                .andExpect(status().isOk())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void hideNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, false);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_HIDE)
                    .param("noticeId", notice.getId().toString())
                    .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    @Test
    public void editNotice() throws Exception {

    }

    @Test
    public void showNotice() throws Exception {
        final Page<Notice> notices = noticeService.getNotices(0, 10, true);
        Notice notice = notices.getContent().get(0);
        MockHttpSession session = AuthorizedControllerHelper.buildSecuritySession(context, "ykyoon");
        MvcResult result = mvc.perform(post(AdminNoticeController.API_ADMIN_NOTICE_SHOW)
                .param("noticeId", notice.getId().toString())
                .session(session))
                .andExpect(status().isOk())
                .andDo(print())
                .andReturn();
        checkResultData(result);
    }

    private void checkResultData(MvcResult result) throws Exception {
        ResultData r = objectMapper.readValue(result.getResponse().getContentAsString(), ResultData.class);
        assertThat(r.isOk(), is(true));
    }
}


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
,

개인 project를 진행하다가 이상한 에러가 계속해서 발생되었다. 


Transaction update가 발생될때와 Test Rollback 이 발생될 때마다 PlatformTransaction null point exception이 발생되었다.

특이하게 이 exception은 다음과 같은 상황에서만 발생된다. 


1. JUnit을 이용한 test인 경우

2. update, delete query인 경우


발생되는 곳에 @Transactional을 붙이건 뭘하건 계속해서 발생이 되는데.... 이 에러의 원인은 엉뚱하게도 다른곳에 있었다.


hibernate-core와 hibernate-entitymanager 간의 버젼이 다를 경우에 발생하는 에러라는것. -_-;;


gradle로 build script를 짜두고 hibernate version만으로 따로 project property로 관리하고 있었는데, 최신의 hibernate와 예전의 hibernate-entitymanager간의 버젼이 다르기 때문에 발생되는 에러였다. 


그래서 gradle script를 다음과 같이 변경한 후에는 잘 돌아간다.; hibernate에 관련된 library들은 같은 version으로 관리될 수 있도록 해야지 되겠다.;



ext {

    springVersion = '4.0.0.BUILD-SNAPSHOT'

    springDataVersion = '1.4.2.RELEASE'

    javaVersion = 1.7

    slf4jVersion = '1.7.2'

    querydslVersion = '3.2.4'

    hibernateVersion = "4.2.7.Final"

}


        compile "org.hibernate:hibernate-core:${rootProject.ext.hibernateVersion}"

        compile "org.hibernate:hibernate-entitymanager:${rootProject.ext.hibernateVersion}"

        compile "org.hibernate:${rootProject.ext.hibernateVersion}"



Posted by Y2K
,