1014 스프링mvc formatter

Formatter ?

  • 문자열을 객체로 변환시키거나 객체를 문자열로 변환해주는 일을 한다.

Person.java

@Getter
@Setter
public class Person {
  private String name;
}

SampleController.java

@RestController
public class SampleController {

    @GetMapping("/hello/{name}")
    public String helloPathVariable(@PathVariable("name") Person person) {
        return "hello this is pathVar. "+person.getName();
    }

    @GetMapping("/hello")
    public String helloRequestParam(@RequestParam("name") Person person) {
        return "hello this is reqParam. "+person.getName();
    }
}

위의 예제는 Formatter 설정이 없으면 에러난다. name 을 적절한 객체로 변환시켜주지 못했기 때문이다.

샘플코드를 제대로 동작시키기위해서 Formatter 설정을 해보자.

PersonFormatter.java

public class PersonFormatter implements Formatter<Person> {
    @Override
    public Person parse(String s, Locale locale) throws ParseException {
        Person person = new Person();
        person.setName(s);
        return person;
    }

    @Override
    public String print(Person person, Locale locale) {
        return person.toString();
    }
}

Formatter를 구현해야한다. 제네릭타입은 문자열을 객체로, 객체를 문자열로 변환시켜 줄 객체타입이 들어가야된다.

public Person parse(String s, Locale locale) throws ParseException { ... } 은 문자열을 객체로 변환시켜 출력할 때 사용되고, public String print(Person person, Locale locale) {...} 은 객체를 문자열로 변환시켜줄 때 필요하다.

Formatter 작성이 끝났으면, 스프링MVC에 달아줘야한다. 어떻게? WebMvcConfigure 를 구현함으로써. 다음 예제를 보자.

WebConfig.java

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addFormatter(new PersonFormatter());
    }
}
  • 스프링레거시를 쓸 때는 이와 같이 public void addFormatters(FormatterRegistry registry) { ... } 메소드를 이용하면된다.

  • 스프링 부트를 쓸 때는 이런 설정없이 Formatter 를 빈으로 등록시켜주면 된다.

    PersonFormatter.java

    @Component
    public class PersonFormatter implements Formatter<Person> {
        @Override
        public Person parse(String s, Locale locale) throws ParseException {
            Person person = new Person();
            person.setName(s);
            return person;
        }
    
        @Override
        public String print(Person person, Locale locale) {
            return person.toString();
        }
    }

테스트코드 작성

SampleControllerTest.java

@RunWith(SpringRunner.class)
@WebMvcTest
public class SampleControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    public void hello() throws Exception {
        mockMvc.perform(get("/hello/eunmo"))
                .andExpect(status().isOk())
                .andDo(print())
                .andExpect(content().string("hello this is pathVar. eunmo"));
    }

    @Test
    public void hello2() throws Exception {
        mockMvc.perform(get("/hello?name=eunmo"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("hello this is reqParam. eunmo"));
    }

    @Test
    public void hello3() throws Exception {
        mockMvc.perform(get("/hello")
                    .param("name","eunmo"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("hello this is reqParam. eunmo"));
    }
}

이 테스트코드가 성공하기 위해선 앞서말한 addFormatter메서드를 이용해야한다. 왜냐하면 @WebMvcTest 는 웹과 관련된 빈들만 만들어주기 때문이다.

Formatter 자체를 @Component 를 사용해 빈으로 등록시켜줬다면 @Controller 와 같은 웹과 관련된 빈이 아니므로 테스트가 깨진다. 다음과 같은 방법을 사용하자.

  1. 명시적으로 빈으로 등록한 후 테스트를 한다.

  2. @WebMvcTest 어노테이션 대신 @SpringBootTest 를 사용해 모든 빈을 등록시켜서 테스트를 한다. (이 때는 MockMvc를 주입받을 수 없으므로 @AutoConfigureMockMvc 또한 붙여줘야한다.)

SampleControllerTest.java

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class SampleControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    public void hello() throws Exception {
        mockMvc.perform(get("/hello/eunmo"))
                .andExpect(status().isOk())
                .andDo(print())
                .andExpect(content().string("hello this is pathVar. eunmo"));
    }

    @Test
    public void hello2() throws Exception {
        mockMvc.perform(get("/hello?name=eunmo"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("hello this is reqParam. eunmo"));
    }

    @Test
    public void hello3() throws Exception {
        mockMvc.perform(get("/hello")
                    .param("name","eunmo"))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(content().string("hello this is reqParam. eunmo"));
    }
}

Authentication과 SecurityContextHolder

위에서 AuthenticationManager가 인증을 마친 뒤 Authentication 리턴해준다고했다.

그렇다면 리턴된 Authentication은 어디로 가는걸까?

  • UsernamePasswordAuthenticationFilter
    • 폼인증을 처리하는 시큐리티필터.
    • 인증된 Authentication 객체를 SecurityContextHolder에 넣어주는 필터.
      • SecurityContextHolder.getContext().setAuthentication(authentication)

Filter와 FilterChainProxy.

FilterChainProxy가 Filter들을(UsernamePasswordAuthenticationFilter 등등) 호출한다.

우리가 정의한 SecurityConfig이 FilterChinaProxy의 FilterChain(타입은 어레이리스트)이 된다.

(SecurityConfig 두 개 정의하면 FilterChain size가 2다.)

DelegatingFilterProxy와 FilterChainProxy

어떠한 요청을 보내면 서블릿 컨테이너(톰캣)가 받는다.

서블릿 컨테이너는 서블릿스펙을 지원한다.

스펙에는 filter가 있는데, 요청의 앞 뒤로 특수한 행위를 할 수 있다 인터셉터와 비슷.

delegatingfilterProxy

위임을 하는 필터 프록시. 누구에게?

스프링 컨테이너에 들어있는 특정한 빈에게.

자기가 해야할 일을 위임한다.

어떤 빈에다 위임할 지 구체적인 명시가 필요하다 (빈의 이름으로)

AbstractSecurityWebApplicationInitializer을 사용해서 명시. 또는 web.xml에 명시.

부트를 쓴다면 자동으로 등록된다. (SecurityFilterAutoConfiguration)

스프링 시큐리티 관점에서는

delegatingFilterProxy는 FilterChainProxy에 위임한다.

위임을 하기 위해선 빈의 이름으로 구체적인 명시를 해야한다.

FilterChainProxy의 이름은 보통 SpringSecurityFilterChain다.

(SecurityFilterAutoConfiguration -> DEFAULT_FILTER_NAME 상수에 있다 )

인증은 했는데 인가는 어떻게 하는거지?


AccessDecisionManager

AccessControll 결정을 내리는 인터페이스. 구현체 3가지를 기본으로 제공.

  • AffirmativeBased : 여러 Voter 중 하나라도 허용하면 허용. 기본전략이다

    (디버거 걸어서 확인해보자 AffirmativeBased.java)

  • ConsensusBased : 다수결

  • UnanimousBased : 만장일치

리소스에 접근할 때 허용할 것인가? 를 확인함.

인증을 할때는 AuthenticationManager.

인가를 할때는 AccessDecisionManager.

여러개의 Voter를 가질 수 있다.

  • 해당 Autentication이 특정한 Object에 접근할 때 필요한 ConfigAttributes를 만족하는지 확인(ROLE_USER 등)

커스터마이징 하는 법

  1. UserDetailsService에서 리턴하는 UserDetails 에서 권한 두개 주기.

    return User.builder()
      .username(account.getUsername())
      .password(account.getPassword())
      .roles(account.getRole, "USER")
      .build()
  1. 계층형 구조

    SecurityConfig.java

    @Configuration
    @EnableWebSecurity
    public class SecurityConfig extends WebSecurityConfigurerAdapter {
      private AccessDecisionManager accessDecisionManager() {
            RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
            roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");
    
            DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
            handler.setRoleHierarchy(roleHierarchy);
    
            WebExpressionVoter webExpressionVoter = new WebExpressionVoter();
            webExpressionVoter.setExpressionHandler(handler);
    
            List<AccessDecisionVoter<? extends Object>> voters = Arrays.asList(webExpressionVoter);
            AccessDecisionManager accessDecisionManager = new AffirmativeBased(voters);
    
            return accessDecisionManager;
        }
    
            @Override
        protected void configure(HttpSecurity http) throws Exception {
    
            http
                        .authorizeRequests()
                        .mvcMatchers("/","/info","/account/**").permitAll()
                        .mvcMatchers("/admin").hasRole("admin")
                        .mvcMatchers("/user").hasRole("USER")
                        .anyRequest().authenticated()
                        .accessDecisionManager(accessDecisionManager());
            http.formLogin();
            http.httpBasic();
    
        }
    
    

}


   또는

   ````java
   @Configuration
   @EnableWebSecurity
   public class SecurityConfig extends WebSecurityConfigurerAdapter {

       public SecurityExpressionHandler expressionHandler() {
           RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl();
           roleHierarchy.setHierarchy("ROLE_ADMIN > ROLE_USER");

           DefaultWebSecurityExpressionHandler handler = new DefaultWebSecurityExpressionHandler();
           handler.setRoleHierarchy(roleHierarchy);

           return handler;
       }

       @Override
       protected void configure(HttpSecurity http) throws Exception {
           http.authorizeRequests()
                   .mvcMatchers("/", "/info", "/account/**").permitAll()
                   .mvcMatchers("/admin").hasRole("ADMIN")
                   .mvcMatchers("/user").hasRole("USER")
                   .anyRequest().authenticated()
                   .expressionHandler(expressionHandler());
           http.formLogin();
           http.httpBasic();
       }

   }

그렇다면 AccessDecisionManager는 어디서 사용할까

FilterSecurityInterceptor

역시 FiterChainProxy가 들고있는 것 중 하나다.

인증을 다 거친 뒤 AccessDecisionManager를 사용하여 마지막으로 AccessControl(인가)을 한다.

AbstranctSecurityInterceptor.java 디버거 걸어보자.

ExceptionTranslationFilter

필터체인에서 발생하는 AccessDeniendException과 AuthentcationException을 처리하는 필터

AuthenticationException 발생시

  • AuthenticationEntryPoint 실행.
  • AbstractSecurityInterceptor 하위 클래스 ( ex . FilterSecurityInterceptor ) 에서 발생하는 예외만 처리한다.
  • 그렇다면 UsernamePasswordAuthenticationFilter에서 발생한 인증 에러는 ? (로그인할 때 일어나는 익셉션)
    • 상위 클래스인 AbstractAuthenticationProcessingFilter에서 처리한다.

AccessDeniedException 발생시

  • 익명사용자라면 AuthenticationEntryPoint 실행.(form인 경우 로그인페이지로 이동할 것.)
  • 익명 사용자가 아니라면 AccessDeniedHandler에게 위임.

정리

서블릿 컨테이너에 요청이 들어온다.

서블릿 필터 중 델리게이팅필터프록시(부트 쓸 땐 자동 설정, 레거시는 AbstractSecurityWebApplicationInitilizer를 사용해서 등록하자) 가 요청을 받는다.

델리게이팅필터프록시는 스프링의 필터체인프록시에 위임한다.(위임할 땐 빈의 이름을 명시적으로 설정해야하는데, 보통 springSecurityFilterChin이란 이름으로 등록된다.)

필터체인프록시는 필터체인을 가지고 있다

필터체인프록시는 WebSecurity와 HttpSecurity로 만들어진다.(WebSecurityConfigureAdapter)

인증 관련해서는 AuthenticationManager를 사용한다

  • 구현체로는 ProviderManager를 사용한다.
    • 내부적으로 여러 AuthenticationProvider를 사용한다.
      • 그 중하나가 DaoAuthenticationProvider다.
        • UserDetailsService를 사용한다.
    • 인증이 성공한다면 SecurityContextHolder에 set 해준다.

인가 관련해서는 AcessDecisionManager를 사용한다.

  • SecurityContextHolder에 저장돼있는 Authentication이 접근하는 리소스에 적절한 configAttributes(ROLE)을 가지고있는지 확인한다.
    • 확인하는 방법은 세가지가 있는데 AffrimativeBased를 기본전략으로 사용한다.
      • 여러 AccessDesionVoter를 가지고 있다
        • 그 중 하나가 WebExpressVoter다
          • SecurityExpressionHandler

SecurityContextHolder

  • SecurityContextHolder - SecurityContext - Authentication
  • SecurityContextHolder는 쓰레드로컬.

Authentication

  • principal

    • 누구에 해당하는 정보
    • UserDetailsService에서 리턴한 객체
    • Object 타입이긴 하지만, UserDetailsService에서 리턴한 값이기 때문에 사실상 UserDetails 타입.
  • authrities

    • "ROLE_USER", "ROLE_ADMIN" 등 Principal이 가지고 있는 '권한'
    • 인증 이후 인가 및 권한 확인할 때 이 정보를 참조한다.
  • UserDetailsService

    • 유저정보를 UserDetails 타입으로 가져오는 DAO인터페이스.
  • AuthenticationManager

    • UserDetailsService를 이용해서 인증한다.

AuthenticationManager와 Authentication의 관계.

  • 스프링 시큐리티에서 인증(Authentication)은 AuthenticationManager가 한다.
  • AuthenticationManager는 하나의 메서드선언부를 가진 인터페이스다.
    • Authentication authenticate(Authentication authentication) throws AuthenticationExeption;
      • 인자로 받은 authentication이 유효한 인증인지 확인하고 Authenticaion객체를 리턴한다.
      • 인증 확인 과정에서 비활성계정, 잘못된 비밀번호, 잠긴계정등의 에러를 던질 수 있다.
    • 인자로 받은 Authentication
      • formLogin할 때 입력한 정보.
        • Principal : "username"
        • Credential : "password"
    • 리턴된 Authentication
      • 인증된 Authentication.
        • UsernamePasswordToken
        • Principal : UserDetailsService에서 리턴하는 UserDetails.
        • Credential : 인증됐으므로 Credential은 비어있다.
        • GrantedAuthorities : 권한
    • 보통 구현체로 ProviderManager를 사용한다.

ThreadLocal

  • java.lang 패키지에서 제공하는 쓰레드 범위 변수. 즉 쓰레드 수준의 데이터저장소.
  • 같은 쓰레드 내에서만 공유.
  • 따라서 같은 쓰레드 내라면 위치상관없이 호출가능. 매개변수로 넘겨줄 필요 없다.
  • SecurityContextHolder의 기본전략.

AuthenticationManager를 통해 Authentication을 만들고, 만든 Authentication을 SecurityContext에 set해준다.
그렇다면 언제 set해주는 걸까? 그 공부는 내일!

SPRING SECURITY.


준비

먼저 maven을 통해 spring-security-test 모듈을 받아오자.

        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-test</artifactId>
            <version>5.1.6.RELEASE</version>
        </dependency>

SecurityConfig.java

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .mvcMatchers("/", "/info", "/account/**").permitAll()
                .mvcMatchers("/admin").hasRole("ADMIN")
                .anyRequest().authenticated()

                .and()
                .formLogin()

                .and()
                .httpBasic();
    }
...
}
  1. 웹루트이하 "/", "/info", "/account/**" 에는 인증안된 사용자포함 모든 사용자가 접근 가능하다.
  2. "/admin" 페이지는 "ADMIN" 권한을 가진 사용자만 접근이 가능하다.
  3. 그 외 페이지는 인증된 사용자만 접근이 가능하다.
  4. formLogin()을 사용할 것이고, httpBasic()을 사용할 것이다.

SecurityTest(1).java

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class SecurityTest {
    @Autowired
    MockMvc mockMvc;

    @Test
    public void index_anonymous() throws Exception {
        mockMvc.perform(get("/").with(anonymous()))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    public void index_user() throws Exception {
        mockMvc.perform(get("/").with(user("grace").roles("USER")))
                .andDo(print())
                .andExpect(status().isOk());
    }
  • mockMvc.perform(get("/").with(anonymous()))
    • annonymous(), 그러니까 인증이 안된 사용자가 접근할 때의 테스트다.
  • mockMvc.perform(get("/").with(user("grace").roles("USER")))
    • username = "grace", roles = "USER" 를 가진 계정이 '/'에 접근할 때의 테스트다.

SecurityTest(2).java

    @Test
    @WithAnonymousUser
    public void index_anonymous_annotatedAnonymousUser() throws Exception {
        mockMvc.perform(get("/"))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "grace", roles = "ADMIN")
    public void index_user_annotatedMockUser() throws Exception {
        mockMvc.perform(get("/"))
                .andDo(print())
                .andExpect(status().isOk());
    }
  • 위의 SecyrityTest(1).java 와 같다. 'with(...)'를 어노테이션으로 대체한 것.
  • 그런데 @WithMockUser 어노테이션을 보면 알다시피 너무 길다. 따라서 @WithMockUser를 메타에노테이션으로 활용해서 더 짧게 바꿔보자.

WithAdmin.java

@WithMockUser(username = "grace", roles = "ADMIN")
@Retention(RetentionPolicy.RUNTIME)
public @interface WithAdmin {
}

SecurityTest(3).java

    @Test
    @WithAdmin
    public void admin_admin_customAnnotated() throws Exception {
        mockMvc.perform(get("/admin"))
                .andDo(print())
                .andExpect(status().isOk());
    }
  • @WithAdmin 어노테이션을 붙이면 @WithMockUser(username = "grace", roles = "ADMIN") 을 사용하는 거나 마찬가지.

폼로그인에 대한 테스트.

SecurityTest(4).java

@Autowired
    AccountService accountService;

    @Test
    @Transactional
    public void login_success() throws Exception{
        String username = "grace";
        String password = "123";
        Account user = this.createUser(username, password);


//        mockMvc.perform(formLogin().user(user.getUsername()).password(user.getPassword())) //안된다! bycrypt로 암호화된 비밀번호가 들어오기 때문에.
        mockMvc.perform(formLogin().user(user.getUsername()).password(password))
                .andExpect(authenticated());
    }

    @Test
    @Transactional
    public void login_fail() throws Exception{
        String username = "grace";
        String password = "123";
        Account user = this.createUser(username, password);
        mockMvc.perform(formLogin().user(user.getUsername()).password("1234"))
                .andExpect(unauthenticated());
    }

    private Account createUser(String username, String password) {
        Account account = new Account();
        account.setUsername(username);
        account.setPassword(password);
        account.setRole("USER");
        return accountService.createNew(account);
    }
  • @Transactinal 어노테이션을 붙여서 DB에 영향이 없도록 하자.

+ Recent posts