Backend/Spring

[Spring Security] 스프링 시큐리티 기본 API 및 Filter 이해 - 2

김세진 2022. 11. 23. 23:17
반응형

 

 

 

익명사용자 인증 필터 : AnonymousAuthenticationFilter


인증 Flow

Spring Security는 인증되지 않은 사용자의 접근에 대해 자동으로 익명 사용자 인증 객체를 만들어서 처리한다.

 

  • 익명사용자를 Authentication이 null인 상태로 인식하는 것이 아닌, 익명사용자 인증 객체를 만들어서 사용
  • 익명사용자와 인증사용자를 구분하여 처리하기 위함
  • 화면에서 인증 여부를 구현할 때 isAnonymous() isAuthenticated() 로 구분하여 사용 (logout을 할 때 등)
  • 인증 객체를 세션에 저장하지 않음 

 

익명사용자 객체를 통해 특별한 기능을 수행한다기 보다는, 스프링에서 익명사용자 또한 기존 인증 체계에 맞게 구조적으로 관리할 수 있도록 하는 것이 목표. 아무런 출입 권한이 없는 통행증을 발급하는 것과 같다.

 

보안상의 이점은 크게 없으나, 로그인하지 않은 사용자가 애플리케이션에서 제공하는 일부 기능을 사용할 수 있도록 허용하고자 하는 경우 유용할 수 있다.

 

 

동시 세션 제어, 세션 고정 보호, 세션 정책


동시 세션 제어

최대 세션 허용 개수 초과 시, 두 가지 정책 존재

  1. 이전 사용자 세션 만료
    - 다른 사용자가 동일 계정으로 로그인 시도 시, 이전 사용자의 세션을 만료
  2. 현재 사용자 인증 실패
    - 다른 사용자가 동일 계정으로 로그인 시도 시 인증 예외 발생
    - 처음 로그인 한 사용자의 세션을 지속적으로 유지

 

동시 세션 제어 Code

@Configuration
@EnableWebSecurity  // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .sessionManagement()
                .invalidSessionUrl("/invalid")    // 유효하지 않은 세션일 시 이동할 페이지 (잘못된 세션 요청 처리)
                .maximumSessions(1)               // 최대 허용 세션 개수
                .maxSessionsPreventsLogin(true)   // 최대 허용 개수 초과 시 정책, 
                                                  //   false: 기존 세션 만료(default), 
                                                  //   true: 인증 차단
                .expiredUrl("/expired")           // 세션이 만료된 경우 이동할 페이지 (동시 세션 제어로 처리)
        ;
    }
}

 

 

세션 고정 보호

세션 고정 공격 FLOW

 

  1. 공격자가 서버에서 발급받은 JSESSIONID(세션쿠키)를 사용자에게 심어 놓음
  2. 사용자가 로그인 할 때 해당 JSESSIONID로 로그인을 하게 되어 동일한 세션을 공유하게 됨
  3. 같은 세션을 공유하기 때문에 공격자는 사용자의 권한을 취득 (로그인 인증을 건너뜀)

 

세션 고정 보호란, 위같은 세션 고정 공격을 방지하기 위해 매 로그인 마다 새로운 JSESSIONID를 사용하는 세션을 생성하는 솔루션이다. 

 

세션 고정 보호 Code

@Configuration
@EnableWebSecurity  // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .sessionManagement()
                .sessionFixation().changeSessionId()  // 세션은 유지하고 세션 ID만 변경 (default)
                                                      // none, changeSessionId, migrateSession, newSession 4개 옵션 존재
        ;
    }
}

 

별도의 설정이 없어도 SpringSecurity가 초기화되며 sessionFixation().changeSessionId() 는 자동 적용된다.

 

  • none: 세션 고정 보호를 사용하지 않음
  • changeSessionId: 세션은 유지하고 세션 ID만 변경 (servlet 3.1 이상에서 기본값)
  • migrateSession: 세션과 세션 ID를 새로 발급받는다. 또한 이전 세션의 속성값(attributes)들이 유지된다. (servlet 3.1 미만에서 기본값)
  • newSession: 세션과 세션 ID를 새로 발급받고, 속성값들도 초기화된다.

 

세션 정책

세션을 사용하지 않는 인증 방식을 이용할 경우, 혹은 외부 인증 서비스를 사용할 때 스프링에서 세션을 생성하지 않아도 되는 경우가 있다. 이 경우 세션 정책을 통해 세션 관리 방법을 지정해줄 수 있다.

 

@Configuration
@EnableWebSecurity  // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
        ;
    }
}

 

  • SessionCreationPolicy. ALWAYS:  스프링 시큐리티가 항상 세션 생성
  • SessionCreationPolicy. IF_REQUIRED:  스프링 시큐리티가 필요 시 세션 생성 (기본값)
  • SessionCreationPolicy. NEVER:  스프링 시큐리티가 생성하지 않지만 이미 존재하면 사용
  • SessionCreationPolicy. STATELESS:  스프링 시큐리티가 생성하지 않고 존재해도 사용하지 않음
    - 세션을 사용하지 않는 JWT(JSON Web Tokens) 등의 방식을 사용할 때 이용

 

 

세션 제어 필터 : SessionManagementFilter, ConcurrentSessionFilter


SessionManagementFilter

  • 세션 관리
    - 인증 시 사용자의 세션정보를 등록, 조회, 삭제 등의 세션 이력을 관리
  • 동시적 세션 제어
    - 동일 계정으로 접속이 허용되는 최대 세션 수를 제한
  • 세션 고정 보호
    - 인증 할 때마다 세션쿠키를 새로 발급하여 공격자의 쿠키 조작을 방지
  • 세션 생성 정책
    - Always, If_Required, Never, Stateless

 

ConcurrentSessionFilter

  • 매 요청 마다 현재 사용자의 세션 만료 여부 체크
  • 세션이 만료되었을 경우 즉시 만료 처리
  • session.isExpired() == true
    - 로그아웃 처리
    - 즉시 오류 페이지 응답

 

세션 제어 필터 Flow (이전 사용자 세션 만료 정책)

 

  1. 새로운 사용자가 로그인을 시도하여 SessionManagementFilter 작동
  2. 최대 세션 허용 개수가 초과되었을 경우, 이전 사용자 세션을 만료한다. (session.expireNow())
  3. 이전 사용자가 서버로 요청을 보내 ConcurrentSessionFilter가 작동
  4. session.isExpired()를 통해 세션 만료 여부를 판단한다
    - 세션이 만료되었다면 Logout 및 오류 페이지 응답

 

세션 제어 필터 Sequence Diagram

ConcurrentSessionControlAuthenticationStrategy: 현재 사용자의 최대 세션 개수를 제어할 수 있는 클래스

 

 

 

권한설정과 표현식


권한 설정

선언적 방식

  • URL
    - http.antMatchers("/users/**").hasRole(“USER")
  • Method
    - @PreAuthorize(“hasRole(‘USER’)”)
       public void user(){ System.out.println(“user”)}

 

@PreAuthorize : 메소드 실행 전에 인가 처리를 수행한다. 인가 처리를 통해 현재 사용자가 해당 메소드를 실행할 수 있는 권한을 가지고 있는지 검사한다.

 

동적 방식 - DB 연동 프로그래밍

  • URL
  • Method

 

권한 설정 Code

@Configuration
@EnableWebSecurity  // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http
                .antMatcher("/shop/**") // 해당 경로와 매치되는 요청들을 검사
                .authorizeRequests() // 보안 검사 기능 시작
                .antMatchers("/shop/login", "/shop/users/**").permitAll() // 해당 경로에 대한 모든 접근 허용
                .antMatchers("/shop/mypage").hasRole("USER") // 해당 경로에 대해 USER 권한이 있는지 인가 심사
                .antMatchers("/shop/admin/pay").access("hasRole('ADMIN')") // access 내부의 표현식에 통과하는지 인가 심사
                .antMatchers("/shop/admin/**").access("hasRole('ADMIN') or hasRole('SYS')")
                .anyRequest().authenticated()
        ;
        
    }
}

※ 주의 사항

설정 시 구체적인 경로를 먼저 적고 그것 보다 큰 범위의 경로가 뒤에 오도록 해야 함.

만약 .antMatchers("/shop/admin/**").access("hasRole('ADMIN') or hasRole('SYS')") 구문이 .antMatchers("/shop/admin/pay").access("hasRole('ADMIN')") 보다 위에 존재할 경우, SYS 권한만 가진 사용자도 /shop/admin/pay 경로에 접근이 가능해짐

 

표현식

 authenticated()  인증된 사용자의 접근을 허용
 fullyAuthenticated()  인증된 사용자의 접근을 허용, rememberMe 인증 제외
 permitAll()  무조건 접근을 허용
 denyAll()  무조건 접근을 허용하지 않음
 anonymous()  익명사용자의 접근을 허용
 rememberMe()  기억하기를 통해 인증된 사용자의 접근을 허용
 access(String)  주어진 SpEL 표현식의 평가 결과가 true이면 접근을 허용
 hasRole(String)  사용자가 주어진 역할이 있다면 접근을 허용 
 hasAuthority(String)   사용자가 주어진 권한이 있다면
 hasAnyRole(String...)  사용자가 주어진 권한이 있다면 접근을 허용
 hasAnyAuthority(String...)  사용자가 주어진 권한 중 어떤 것이라도 있다면 접근을 허용
 hasIpAddress(String)  주어진 IP로부터 요청이 왔다면 접근을 허용

 

Config

@Configuration
@EnableWebSecurity  // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().withUser("user").password("{noop}1111").roles("USER");  // ({noop}: 암호화를 건너뜀)
        auth.inMemoryAuthentication().withUser("sys").password("{noop}1111").roles("SYS","USER"); // 여러 권한 지정 가능
        auth.inMemoryAuthentication().withUser("admin").password("{noop}1111").roles("ADMIN" ,"SYS" ,"USER");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {


        http
                .authorizeRequests() // 보안 검사 기능 시작
                .antMatchers("/shop/mypage").hasRole("USER") // 해당 경로에 대해 USER 권한이 있는지 인가 심사
                .antMatchers("/shop/admin/pay").access("hasRole('ADMIN')") // access 내부의 표현식에 통과하는지 인가 심사
                .antMatchers("/shop/admin/**").access("hasRole('ADMIN') or hasRole('SYS')")
                .anyRequest().authenticated();
        http
                .formLogin();
        
    }
}

 

Controller

@RestController
public class SecurityController {

    @GetMapping("/")
    public String index() {
        return "home";
    }

    @GetMapping("/user")
    public String user() {
        return "user";
    }

    @GetMapping("/admin/pay")
    public String adminPay() {
        return "adminPay";
    }

    @GetMapping("/admin/**")
    public String adminAll() {
        return "admin";
    }
}

 

* 주의 : 이와 같이 하드코딩으로 권한 관리가 되는 것은 범용 서비스에서 좋지 않은 방향임. DB와 연동하여 실시간 권한 관리가 되도록 방향을 잡아야 함

 

예외 처리 및 요청 캐시 필터 :
ExceptionTranslationFilter, RequestCacheAwareFilter


ExceptionTranslationFilter

Security Filter들 중 일반적으로 맨 마지막에 위치한 FilterSecurityInterceptor 라는 필터에서 발생하는 예외를 받아 처리한다. FilterSecurityInterceptor는 요청 정보, 인증 정보, 권한 정보 등을 이용하여 인가 처리를 하는 필터이다.

 

AuthenticationException : 인증 예외 처리

  1. AuthenticationEntryPoint 호출 (인증 처리 과정에서 예외가 발생한 경우 예외를 핸들링하는 인터페이스)
    • 로그인 페이지 이동, 401 오류 코드 전달 등
  2. 인증 예외가 발생하기 전의 요청 정보를 저장
    RequestCache - 사용자의 이전 요청 정보을 세션에 저장하고 이를 꺼내 오는 캐시 메커니즘
    SavedRequest - 사용자가 요청했던 request 파라미터 값들, 그 당시의 헤더값들 등이 저장
    - 사용 예: 미인증 상태로 요청했을 때, 로그인 화면이 뜨게 되고 로그인이 완료되면 기존 요청한 페이지로 이동

AccessDeniedException : 인가 예외 처리

  • AccessDeniedHandler 에서 예외 처리하도록 제공

 

Flow

 

익명 사용자가 접근한다고 가정

  1. 익명 사용자가 인증을 받지 않고 /user 로 요청을 보낸다.
  2. FilterSecurityInterceptor에서 해당 요청에 대해 인증 예외를 던진다.
    - 정확히는 익명 사용자의 경우 인증을 받지는 않았지만 익명 사용자로 Authentication 객체가 생성된 상태이므로 우선적으로 AccessDeniedException 인가 예외가 발생한다. 단, 이후 ExceptionTranslationFilter 내부에서 익명 사용자 검증 과정에서 인증 예외가 발생하여 AuthenticationEntryPoint가 호출된다.
    - Remeber Me 사용자의 경우 fullyAuthentication(), 혹은 쿠키 만료 등에 의해 인증 예외가 발생할 수 있다.

  3. 인증 실패 이후 처리
    - AuthenticationEntryPoint 의 구현체를 호출해서 보통은 사용자가 다시 인증할 수 있도록 Login 페이지로 redirect 한다.
    - SecurityContext를 null로 초기화 해주는 작업도 한다.
  4. 요청 관련 정보 저장
    - 인증 예외가 발생하기 전 사용자가 요청했던 정보(/user)를 HttpSessionRequestCache 클래스가 DefaultSavedRequest 객체에 담아  세션에 저장한다.

 

권한이 부족한 사용자가 접근한다고 가정

  1. 해당 유저는 USER 권한으로 인증이 되었다고 가정한다.
  2. /user에 필요한 권한이 ADMIN인데, 현재 유저 권한이 그보다 낮은 USER이기 때문에 인가 예외가 발생한다.
  3. AccessDeniedException에서 AccessDeniedHandler를 호출하여 인가 예외 처리를 수행한다.
  4. 보통은 /denied로 페이지를 redirect 한다. 

 

예제 Config

@Configuration
@EnableWebSecurity  // 웹 보안 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication().withUser("user").password("{noop}1111").roles("USER");
        auth.inMemoryAuthentication().withUser("sys").password("{noop}1111").roles("SYS","USER"); // 여러 권한 지정 가능
        auth.inMemoryAuthentication().withUser("admin").password("{noop}1111").roles("ADMIN" ,"SYS" ,"USER");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http // 인증 정책
                .formLogin()
                .successHandler(new AuthenticationSuccessHandler() {
                    @Override
                    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
                        RequestCache requestCache = new HttpSessionRequestCache();
                        SavedRequest savedRequest = requestCache.getRequest(request, response); // 요청 정보가 저장되어 있는 클래스
                        String redirectUrl = savedRequest.getRedirectUrl(); // 인증 전 사용자가 요청했던 url 정보
                        response.sendRedirect(redirectUrl); // 인증에 성공한 상태이므로 원래 가려 했던 url로 redirect
                    }
                });

        http
                .authorizeRequests() // 보안 검사 기능 시작
                .antMatchers("/login").permitAll() // 해당 경로에 대한 모든 접근 허용
                .antMatchers("/user").hasRole("USER") // 해당 경로에 대해 USER 권한이 있는지 인가 심사
                .antMatchers("/admin/pay").access("hasRole('ADMIN')") // access 내부의 표현식에 통과하는지 인가 심사
                .antMatchers("/admin/**").access("hasRole('ADMIN') or hasRole('SYS')")
                .anyRequest().authenticated();

        http
                .exceptionHandling()
                .authenticationEntryPoint(new AuthenticationEntryPoint() { // 인증 예외 처리
                    @Override
                    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
                        response.sendRedirect("/login");
                    }
                })
                .accessDeniedHandler(new AccessDeniedHandler() { // 인가 예외 처리
                    @Override
                    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {

                    }
                });
        
    }
}

 

예제 Controller

@RestController
public class SecurityController {

    @GetMapping("/")
    public String index() {
        return "home";
    }

    @GetMapping("/user")
    public String user() {
        return "user";
    }

    @GetMapping("/admin/pay")
    public String adminPay() {
        return "adminPay";
    }

    @GetMapping("/admin/**")
    public String adminAll() {
        return "admin";
    }

    @GetMapping("/denied")
    public String denied() {
        return "Access is denied";
    }

    @GetMapping("/login")
    public String login() {
        return "login";
    }
}

 

 

사이트 간 요청 위조 - CSRF, CsrfFilter


CSRF(Cross-Site Request Forgery) Flow

  1. 사용자가 인증 후 세션쿠키를 발급받는다.
  2. 공격자가 사용자에게 메일 등으로 공격용 링크를 전달한다.
  3. 사용자가 링크를 클릭하여 공격용 웹페이지에 접속한다.
  4. 공격용 페이지의 이미지를 불러오는 과정에서 이미지에 심어져 있던 공격용 URL이 실행된다.
    - 사용자 브라우저는 쇼핑몰(가정)에서 발급한 세션쿠키를 가지고 있기 때문에, 쇼핑몰은 인증된 브라우저에서 요청한다고 판단
  5. 사용자의 승인 없이 공격자의 임의대로 부당한 요청이 이루어진다.
  6. 위같은 보안 약점을 스프링 시큐리티에선 CsrfFilter를 제공하여 방지할 수 있도록 한다.

 

CsrfFilter

  • 페이지 렌더링 시 랜덤하게 생성된 CSRF토큰을 모든 요청에 Http 파라미터로 요구
    - X-CSRF-Token 라는 헤더 이름에 토큰 정보를 담아 요청해야 한다.
  • 요청 시 전달되는 토큰 값과 서버에 저장된 실제 값과 비교한 후 만약 일치하지 않으면 요청은 실패

 

Client

스프링 시큐리티에선 Thymeleaf 에서 POST 요청을 할 때, 혹은 스프링의 Form 태그를 사용할 때 자동으로 CSRF_Token을 발급해준다. 단, JSP의 경우 지원하지 않아 Form Tag에 아래와 같은 태그를 삽입해야 한다.

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

또한 Ajax로 통신할 때에도 CSRF 토큰을 포함해야 한다. 다음과 같이 CSRF 토큰 데이터를 불러와 $.ajaxSetup 메서드를 통해 ajax로 통신하기 전 헤더에 CSRF 토큰을 포함할 수 있다.

<script type="text/javascript">
    var csrf_token = "{{ csrf_token() }}";

    $.ajaxSetup({
        beforeSend: function(xhr, settings) {
            if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRF-Token", csrf_token);
            }
        }
    });
</script>

 

무조건 csrf 검사가 필요한 HTTP 메소드 : PATCH, POST, PUT, DELETE

 

Spring Security CSRF API

  • http.csrf() : CsrfFilter 기능을 활성화한다(default: 활성화)
  • http.csrf().disabled() : CsrfFilter 비활성화

 

 

 

반응형