스프링 시큐리티(1) - URL 접근 보안하기

2024. 3. 8. 01:53spring/SECURITY

과제

대다수 웹 애플리케이션에는 특별히 보안에 신경 써야 할 민감한 URL이 있다. 이런 URL에 미인가 외부 유저가 제약 없이 접근할 수 있도록 보안하라

 

해결책

스프링 시큐리티는 HTTP 요청에 서블릿 필터를 적용해 보안을 처리한다.

 

AbstractSecurityWebApplicationInitializer라는 베이스 클래스를 상속하면 편리하게 필터들 등록하고 구성 내용이 자동 감지되게 할 수 있다.

 

WebSecurityConfigurerAdapter라는 구성 어댑터에 준비된 다양한 configure() 메서드를 이용하면 웹 애플리케이션 보안을 쉽게 구성할 수 있다. 간단하고 일반적인 보안 요건은 구성 파일을 건드리지 않아도 아래 기본 보안 설정을 바로 적용할 수 있다.

 

*폼 기반 로그인 서비스 : 유저가 애플리케이션에 로그인하는 기본 폼 페이지를 제공한다.

 

*HTTP 기본 인증 : 요청 헤더에 표시된 HTTP 기본 인증 크레덴셜을 처리한다.

 

*로그아웃 서비스 : 유저를 로그아웃시키는 핸들러를 기본 제공한다.

 

*익명 로그인 : 익명 유저도 주체를 할당하고 권한을 부여해서 마치 일반 유저처럼 처리한다.

 

*서블릿 API 연계 : HttpServletRequest.isUserInRole(), HttpServletRequest.getUserPrincipal() 같은 표준 API를 이용해 웹 애플리케이션에 위치한 보안 정보에 접근한다.

 

*CSRF : 사이트 간 요청 위조 방어용 토큰을 생성해 HttpSession에 넣는다.

 

*보안헤더 : 보안이 적용된 패키지에 대해서 캐시를 해제하는 식으로 XSS 방어, 전송 보안, X-Frame 보안 기능을 제공한다.

 

이러한 보안 서비스를 등록하면 특정 접근 권한을 요구하는 URL 패턴을 지정할 수 있다.

 

풀이

우선, 스프링 시큐리티가 사용하는 필터를 등록한다.

public class SecurityInitializer extends AbstractSecurityWebApplicationInitializer {

}

 

 

AbstractSecurityWebApplicationInitializer 생성자는 하나 이상의 구성 클래스를 인수로 받아 보안 기능을 가동한다.

스프링 시큐리티를 웹/서비스 레이어의 구성 클래스에 설정해도 되지만 SecurityConfig로 나눠 구성하는 편이 좋다.

 

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
}

 

public class WebAppInitializer implements WebApplicationInitializer {
    @Override
    public void onStartup(ServletContext servletContext) {

        System.out.println("chapter07");

        // Root Config 설정 -s
        AnnotationConfigWebApplicationContext rootContext = new AnnotationConfigWebApplicationContext();
        rootContext.register(RootConfig.class); //등록
        rootContext.register(SecurityConfig.class);

        ContextLoaderListener listener = new ContextLoaderListener(rootContext);
        servletContext.addListener(listener);
        // Root Context Config 설정 -e

        // DispatcherServlet 설정 -s
        // 1. DispatcherServlet WebApplicationContext 객체 생성
        AnnotationConfigWebApplicationContext webContext 
                            = new AnnotationConfigWebApplicationContext();
        webContext.register(ServletConfig.class);

        // 2. DispatcherServlet 객체 생성 및 추가
        DispatcherServlet dispatcherServlet = new DispatcherServlet(webContext);
        ServletRegistration.Dynamic servlet 
                            = servletContext.addServlet("dispatcher", dispatcherServlet);


        // 3. 서블릿 매핑 및 부가 설정
        servlet.addMapping("/");
        servlet.setLoadOnStartup(1);
        // DispatcherServlet 설정 -e


        // Filter 설정 -s
        FilterRegistration.Dynamic filter 
                = servletContext.addFilter("encodingFilter", CharacterEncodingFilter.class);
        filter.setInitParameter("encoding", "UTF-8");
        filter.addMappingForServletNames(null, false, "dispatcher");
        // Filter 설정 -e
    }
}

 

애플리케이션 배포 후 접속하면 스프링 시큐리티가 기본 제공하는 로그인 페이지가 나타난다.

 

 

 

 

URL 접근 보안하기

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
         //   http.authorizeHttpRequests()
         //       .anyRequest().authenticated()
         //       .and()
         //       .formLogin().and()
         //       .httpBasic();
    }
}

 

위 메서드는 기본적으로 anyRequest().authenticated() 해서 매번 요청이 들어올 때마다 인등을 받도록 강제한다.
또 HTTP 기본 인증 및 폼 기반 로그인 기능은 켜있는게 디폴트다.

 

- 보안규칙 작성

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final UserDetailsService userDetailsService;

    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        /** 데이터 액세스로 권한을 인증하고 할 경우**/
        auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
        
        /** 데이터 액세스로 권한을 인증하지 않고 하드코딩해서 시큐리티만 확인 할 경우**/
        auth.inMemoryAuthentication()
                .withUser("userId").password("userPwd").authorities("USER")
                .and()
                .withUser("adminId").password("adminPwd").authorities("ADMIN");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests()
                .antMatchers("/todos*").hasAuthority("USER")
                .antMatchers(HttpMethod.DELETE, "/todos*").hasAuthority("ADMIN")
                .and()
                    .formLogin()
                .and()
                    .csrf().disable();
    }
}

 

 

configure (HttpSecurity http) 메서드를 오버라이드하면 더 정교한 인가 규칙을 적용할 수 있다.

 

URL 접근 보안은 authorizeRequests() 부터 시작되며 여러 가지 매처를 이용해 규칙을 정할 수 있다. 예제에서는 유저가 어떤 권한을 가져야 하는지 andMatcher로 매치 규칙을 지정한다.

(URL 패턴 끝의 와일드카드를 빼면 쿼리 매개변수가 있는 URL은 걸리지 않는다.)

 

이로써 /todos로 시작하는 URL은 USER 권한 유저만 접근할 수 있고 HTTP 메서드가 DELETE인 요청은 ADMIN 권한 유저만 실행할 수 있게 보안 설정을 했다.

 

인증 서비스는 configure(AuthenticationManagerBuilderAuth auth) 메서드를 오버라이드해서 구성한다.

 

 

 

/** 데이터 액세스로 권한을 인증하고 할 경우(s)**/

************************************************************************************

UserDetailService.java

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class UserDetailService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {

        com.spring.study.chapter07.domain.user.entity.User user = userRepository.findByName(username);

        return User.builder()
                .username(username)
                .password(user.getPassword())
                .authorities(
                        user.getRoleTypes().stream()
                                .map(RoleType::getName)
                                .map(SimpleGrantedAuthority::new)
                                .collect(Collectors.toUnmodifiableSet())
                )
                .build();
    }
}

************************************************************************************

User.java

@SequenceGenerator(
        name = "USER_SEQ_GENERATOR",
        sequenceName = "USER_SEQ"
)
@ToString
@NoArgsConstructor
@Entity
@Table(name = "USER2")
@Getter @Setter
public class User {

    @Id @GeneratedValue(
            strategy = GenerationType.SEQUENCE,
            generator = "USER_SEQ_GENERATOR"
    )
    private Long id;

    @Column(unique = true)
    private String userName;
    private String password;

    @Convert(converter = UserRoleConverter.class)
    private Set<RoleType> roleTypes;

    @Builder
    public User(Long id, String userName, String password, Set<RoleType> roleTypes) {
        this.id = id;
        this.userName = userName;
        this.password = password;
        this.roleTypes = roleTypes;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof User user)) return false;
        return Objects.equals(getId(), user.getId()) &&
        Objects.equals(getUserName(), user.getUserName()) &&
        Objects.equals(getPassword(), user.getPassword()) &&
        getRoleTypes() == user.getRoleTypes();
    }

    @Override
    public int hashCode() {
        return Objects.hash(getId(), getUserName(), getPassword(), getRoleTypes());
    }
}

************************************************************************************

UserRoleConverter.java

@Slf4j
public class UserRoleConverter implements AttributeConverter<Set<RoleType>, String> {
    @Override
    public String convertToDatabaseColumn(Set<RoleType> roleTypes) {
        if (roleTypes.isEmpty()) return null;
        return roleTypes.stream()
                .map(RoleType::getName)
                .collect(Collectors.joining(","));
    }

    @Override
    public Set<RoleType> convertToEntityAttribute(String dbData) {
        if (dbData == null) return Collections.emptySet();
        try {
            return RoleType.fromCode(dbData);
        } catch (IllegalArgumentException e) {
            log.error("failure to convert cause unexpected code[{}]", dbData, e);
        }
        return Collections.emptySet();
    }
}
************************************************************************************

/** 데이터 액세스로 권한을 인증하고 할 경우(e)**/

 

CSRF 공격 방어

- csrf란?

https://www.imperva.com/learn/application-security/csrf-cross-site-request-forgery/

 

What is CSRF | Cross Site Request Forgery Example | Imperva

CSRF is a common attack vector that tricks a user into executing an unwanted action in a web application. While dangerous, the attack is easily preventable

www.imperva.com

 

CSRF 방어 기능이 작동하는 상태에서 스프링 시큐리티는 CsrfTokenRepository 인터페이스의 구현체를 이용해 토큰 값을 생성/보관하는 CsrfFilter를 보안 필터 목록에 추가한다.

 

기본 구현체인

HttpSessionCsrfTokenRepository 클래스는 그 이름 그대로 생성한 토큰을 HttpSession에 저장하고 CookiesrfTokenRepository 클래스는 쿠키에 토큰 정보를 보관한다.

 

아래 예제는 기본 구현체인 HttpSessionCsrfTokenRepository를 명시적으로 구성한 예제 코드이다.

 

@Override
protected void configure(HttpSecurity http) throws Exception {
    HttpSessionCsrfTokenRepository repo = new HttpSessionCsrfTokenRepository();
    repo.setSessionAttributeName("csrf_token");
    repo.setParameterName("csrf_token");
    
    http.csrf().csrfTokenRepository(repo)
}

 

CSRF 방어 기능이 켜진 상태에서 애플리케이션에 로그인 후 할 일을 완료 또는 삭제하려고 시도하면 CSRF 토큰이 없기 때문에 실패한다. 콘텐트를 수정하는 요청을 전송할 때 CSRF 토큰을 서버에 재전송하면 된다.

HttpSessionCsrfTokenRepository는 (구성하지 않고 기본적으로) _csrf라는 세션값으로 토큰을 보관한다.

폼에서는 _csrf의 parameterName, token 속성을 이용해 input 태그를 렌더링할 수 있다.

 

할 일을 완료하고 삭제하는 각 폼에 아래 한 줄을 각각 넣는다.

<input type="hidden" name="${csrf.parameterName}" value="${csrf.token}" />

 

다시 폼을 전송해보면 토큰과 함께 전송되고 정상 처리 된다.

스프링 MVC 폼 태그를 사용하면 CSRF 토큰은 자동으로 추가된다. 이 부분은 스프링이 CsrfRequestDataValueProcessor 클래스를 등록하고 이 클래스가 토큰을 알아서 폼에 넣어주기 때문이다.