티스토리 뷰

지난 포스팅까지해서 Form을 통한 로그인은 구현을 해봤습니다. 완벽하다고는 할 수 없으나 사용하기엔 충분하리라 생각합니다. 하지만 저의 최종 Goal은 Rest Application이므로 로그인도 Rest 방식으로 변경을 해보겠습니다.


우선 SecurityConfig.java를 아래와 같이 수정합니다.
package com.cusonar.example.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.session.web.http.HeaderHttpSessionStrategy;
import org.springframework.session.web.http.HttpSessionStrategy;

import com.cusonar.example.user.service.UserService;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
     @Autowired UserService userService;
    
     @Override
     protected void configure(HttpSecurity http) throws Exception {
          http
               .csrf().disable()
               .authorizeRequests()
                    .antMatchers("/user/login").permitAll()
                    .antMatchers("/user").hasAuthority("USER")
                    .antMatchers("/admin").hasAuthority("ADMIN")
                    .anyRequest().authenticated()
                    .and()
//               .formLogin()
//                    .and()
               .logout()
               ;              
     }
    
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
          auth.userDetailsService(userService)
               .passwordEncoder(userService.passwordEncoder());
     }
    
     @Bean
     @Override
     public AuthenticationManager authenticationManagerBean() throws Exception {
          return super.authenticationManagerBean();
     }
}
     . formLogin은 더이상 사용하지 않기 때문에 제외 시켰습니다.
     . /user/login 경로를 누구나 접근 가능하도록 설정했습니다.
     . authenticationManagerBean 메소드의 경우에는 SpringSecurity에서 사용되는 인증객체를 Bean으로 등록할 때 사용합니다. @Bean을 붙여서 Bean으로 등록해줍니다.

간단하게 위와 같이 설정은 끝내구요. 그럼 위에서 설정한것을 바탕으로 UserController 클래스를 작성해봅시다. UserController에서 사용할 domain도 같이 추가해줍니다. 인증요청을 받는 클래스인 AuthenticationRequest 클래스와 인증을 완료한 후 token을 리턴해줄 때 사용하는 AuthenticationToken 클래스입니다.
package com.cusonar.example.user.controller;

import javax.servlet.http.HttpSession;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import com.cusonar.example.user.domain.AuthenticationRequest;
import com.cusonar.example.user.domain.AuthenticationToken;
import com.cusonar.example.user.domain.User;
import com.cusonar.example.user.service.UserService;

@RestController
@RequestMapping("/user")
public class UserController {
    
     @Autowired AuthenticationManager authenticationManager;
     @Autowired UserService userService;
    
     @RequestMapping(value="/login", method=RequestMethod.POST)
     public AuthenticationToken login(
               @RequestBody AuthenticationRequest authenticationRequest,
               HttpSession session
               ) {
          String username = authenticationRequest.getUsername();
          String password = authenticationRequest.getPassword();
         
          UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password);
          Authentication authentication = authenticationManager.authenticate(token);
          SecurityContextHolder.getContext().setAuthentication(authentication);
          session.setAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY,
                    SecurityContextHolder.getContext());
         
          User user = userService.readUser(username);
          return new AuthenticationToken(user.getName(), user.getAuthorities(), session.getId());
     }
}
     . AuthenticationManager는 위의 설정에서 Bean으로 등록했기 때문에 @Autowired를 통해 Injection 해줍니다.
     . URL 경로는 /user/login으로 하고, method는 POST 방식으로 합니다. Body에 데이터를 넣어서 호출할 때는 POST 방식을 사용합니다. 왜냐구요? login을 url을 통해 노출시킬 순 없으니깐요.
     . @RequestBody를 통해 Body의 데이터를 해당 객체로 받습니다. JSON 형태로 받는 경우 객체로 자동 매핑됩니다.
     . 처음에 username과 passwor를 통해 UsernamePasswordAuthenticationToken을 만듭니다. (반드시 이거일 필요는 없지만 Authentication 인터페이스를 구현한 것이어야 합니다.)
     . 다음은 AuthenticationManager의 authenticate 메소드에 위에서 만든 token을 파라미터로 하여 인증을 진행합니다. 이 때 SpringSecurity 설정한 인증이 적용됩니다.
     . 인증받은 결과를 SecurityContextHolder에서 getContext()를 통해 context를 받아온 후, 이 context에 인증 결과를 set 해줍니다. 이로써 서버의 SecurityContext에는 인증값이 설정됩니다.
     . Controller에서 HttpSession session을 파라미터로 지정하면 session을 받아올 수 있습니다. session 속성값에 SecurityContext 값을 넣어줍니다. 이 때 속성키는 HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY로 넣어주면 됩니다.
     . 인증을 완료한 후 session에도 셋팅을 완료했으면 user를 조회해서 사용자 계정, 권한, session id로 AuthenticationToken 객체를 만들어 리턴해줍니다.

package com.cusonar.example.user.domain;

public class AuthenticationRequest {
    
     private String username;
     private String password;
    
     public String getUsername() {
          return username;
     }
     public void setUsername(String username) {
          this.username = username;
     }
     public String getPassword() {
          return password;
     }
     public void setPassword(String password) {
          this.password = password;
     }
}
     . 단순히 ID와 PW를 받아오는 객체입니다. client에서 인증에 요청하는 값을 저장하는 클래스입니다.

package com.cusonar.example.user.domain;

import java.util.Collection;

import org.springframework.security.core.GrantedAuthority;

public class AuthenticationToken {
    
     private String username;
     private Collection authorities;
     private String token;
    
     public AuthenticationToken(String username, Collection collection, String token) {
          this.username = username;
          this.authorities = collection;
          this.token = token;
     }
    
     public String getUsername() {
          return username;
     }
     public void setUsername(String username) {
          this.username = username;
     }
     public Collection getAuthorities() {
          return authorities;
     }
     public void setAuthorities(Collection authorities) {
          this.authorities = authorities;
     }
     public String getToken() {
          return token;
     }
     public void setToken(String token) {
          this.token = token;
     }   
}
     . client에 리턴해줄 값을 모아놓은 클래스입니다. 접속한 사용자는 누구며, 권한은 이런것이 있다. 앞으로 요청할때는 token 값을 가지고 요청하라, 이런 의미입니다.

이렇게 하면 완료되었습니다. 참 쉽죠? 그러면 테스트는 어떻게 해볼까요? 서버를 띄어놓고 curl을 통해 요청을 해보는 방법이 있을거구요. 또 다른 하나는 controller를 테스트 할 수 있는 코드를 작성해보는 것입니다. curl은 잠시 제쳐두고 controller 테스트 코드를 작성해보겠습니다.
package com.cusonar.example.user;

import static org.hamcrest.CoreMatchers.hasItem;
import static org.hamcrest.CoreMatchers.is;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.http.MediaType;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.test.context.web.WebAppConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;

import com.cusonar.example.ExampleApplication;
import com.cusonar.example.user.domain.AuthenticationRequest;
import com.fasterxml.jackson.databind.ObjectMapper;

@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = ExampleApplication.class)
@WebAppConfiguration
public class UserControllerTest {
    
     @Autowired private WebApplicationContext wac;
     private MockMvc mvc;
    
     @Before
     public void setup() {
          mvc = MockMvcBuilders
                    .webAppContextSetup(wac)
                    .build();
     }
    
     @Test
     public void loginTest() throws Exception {
          AuthenticationRequest request = new AuthenticationRequest();
          request.setUsername("user1");
          request.setPassword("pass1");
         
          ObjectMapper om = new ObjectMapper();
         
          mvc.perform(post("/user/login")
                    .contentType(MediaType.APPLICATION_JSON_UTF8)
                    .content(om.writeValueAsString(request)))
               .andExpect(status().isOk())
               .andExpect(content().contentType(MediaType.APPLICATION_JSON_UTF8))
               .andExpect(jsonPath("$.username", is(request.getUsername().toUpperCase())))
               .andExpect(jsonPath("$.authorities[*].authority", hasItem("USER")))
               ;
     }
}
     . 설정은 위에서 보시는 바와 같습니다. WebApplicationContext를 injection 받아서 이를 MockMvc에 셋팅해줍니다.
     . MockMvc 객체를 위에 보시는 바와 같이 사용하시면 됩니다. (너무 무성의한가요?ㅎㅎ;)
     . ObjectMapper는 Object -> JSON String으로 만들기 위한 것입니다. 위에서 @RequestBody는 Json에 매핑이 된다고 했죠? 따라서 json을 content 메소드에 넣어줘야 합니다.
     . jsonPath는 받아온 리턴 값을 확인할 때 사용할 수 있습니다. json 값의 탐색을 편리하게 할 수 있습니다. 사용법은 https://github.com/jayway/JsonPath 참조하세요.

이러면 테스트가 정상적으로 나오는 것을 보실 수 있습니다. 실제로 Rest 방식으로 사용하기 위해서는 몇가지를 더 수정해줘야 합니다. 우선 SecurityConfig를 아래와 같이 수정해줍니다.
package com.cusonar.example.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.session.web.http.HeaderHttpSessionStrategy;
import org.springframework.session.web.http.HttpSessionStrategy;

import com.cusonar.example.user.service.UserService;

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    
     @Autowired UserService userService;
    
     @Override
     protected void configure(HttpSecurity http) throws Exception {
          http
               .csrf().disable()
               .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER)
                    .and()
               .authorizeRequests()
                    .antMatchers("/user/login").permitAll()
                    .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                    .antMatchers("/user").hasAuthority("USER")
                    .antMatchers("/admin").hasAuthority("ADMIN")
                    .anyRequest().authenticated()
                    .and()
//               .formLogin()
//                    .and()
               .logout()
               ;              
     }
    
     @Override
     protected void configure(AuthenticationManagerBuilder auth) throws Exception {
          auth.userDetailsService(userService)
               .passwordEncoder(userService.passwordEncoder());
     }
    
     @Bean
     @Override
     public AuthenticationManager authenticationManagerBean() throws Exception {
          return super.authenticationManagerBean();
     }
    
     @Bean
     public HttpSessionStrategy httpSessionStrategy() {
               return new HeaderHttpSessionStrategy();
     }
}
     . sessionManagement().sessionCreationPolicy(SessionCreationPolicy.NEVER) : 사용자의 쿠키에 세션을 저장하지 않겠다는 옵션입니다. Rest 아키텍처는 Stateless를 조건으로 하기 때문에 session 정책을 NEVER로 적용합니다. (하지만 이는 완벽하지 않은 정책입니다. NEVER 대신 STATELESS를 써야 완벽한 무상태가 됩니다. NEVER의 경우에는 Security 등 내부적으로 세션을 만드는것을 허용합니다.)

    . antMatchers(HttpMethod.OPTIONS, "/**").permitAll() : 크롬과 같은 브라우저에서는 실제 GET, POST 요청을 하기 전에 OPTIONS를 preflight 요청합니다. 이는 실제 서버가 살아있는지를 사전에 확인하는 요청입니다. Spring에서는 OPTIONS에 대한 요청을 막고 있으므로 해당 코드를 통해서 OPTIONS 요청이 왔을 때도 오류를 리턴하지 않도록 해줍니다.

     . return new HeaderHttpSessionStrategy() : HttpSession 전략으로 쿠키의 세션을 사용하는 대신 header에 'x-auth-token' 값을 사용할 수 있게 해줍니다.

 

마지막으로 rest 방식으로 할 경우 Cross Domain 문제가 발생할 수 있습니다. 이는 기본적으로 타 도메인간(포트만 다를지라도)에는 javascript 요청을 할 수 없도록 되어 있기 때문입니다. 이를 해결하기 위한 표준으로 CORS가 있습니다. CORS Filter를 적용함으로써 해결을 할 수 있습니다. Filter를 구현해서 SimplCorsFilter 클래스를 만들어 줍니다.

package com.doosan.ddms.filter;

import java.io.IOException;

import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletResponse;

import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class SimpleCorsFilter implements Filter {

	public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
		HttpServletResponse response = (HttpServletResponse) res;
		
		response.setHeader("Access-Control-Allow-Origin", "*");
		response.setHeader("Access-Control-Allow-Methods", "POST, GET, PUT, OPTIONS, DELETE");
		response.setHeader("Access-Control-Max-Age", "3600");
		response.setHeader("Access-Control-Allow-Headers", "x-auth-token, content-type");
		
		chain.doFilter(req, res);
	}

	public void init(FilterConfig filterConfig) {}

	public void destroy() {}

}

   . Filter는 스프링에서 제공하는 기능은 아니고 Servlet에서 정의한 명세입니다. 이를 @Component로 정의해서 Bean으로 등록해줍니다.

   . 허용되는 Origin(도메인)과 메소드 방식, Header를 정의해서 리턴해줍니다. 위와 같은 코드를 작성함으로써 모든 도메인에 모든 요청을 받을 수 있게 해줍니다.


이렇게 수정해주면 최종적으로 rest login이 가능해집니다. 그리고 리턴받은 token 값을 client에서는 http header에 x-auth-token에 assign한뒤 요청하면 권한에 맞는 URL을 호출할 수 있게 됩니다.
오늘은 여기까지 해서 마무리를 하도록 하겠습니다. 다들 즐코딩하세요^^

 

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함