[Spring] Spring Security

첫걸음 - 18

Posted by owin2828 on 2019-12-30 17:08 · 42 mins read

0. 기본 보안 개념


인증(Authentication)은 주체(Principal)의 신원(Identity)을 증명하는 과정

  • 주체(Principal): 유저, 기기, 시스템 등
    • 주로 유저(사용자)
  • 크레덴셜(Credential): 주체가 인증을 위해 제시하는 신원 증명 정보
    • 주체가 유저일 경우 주로 Password
  • 인가(Authorization, 권한부여): 인증을 마친 유저에게 권한을 부여하여 특정 리소스에 접근을 허용
    • 반드시 인증 이후에 수행
  • 접근 통제(Access control, 접근 제어): 어플리케이션 리소스에 접근하는 행위를 제어
    • 접근 통제 결정(Access control decision, 접근 제어 결정)이 뒤따름

1. URL 접근


  • Spring Security는 HTTP 요청에 서블릿 필터를 적용해 보안을 처리
    • AbstractSecurityWebApplicationInitializer 베이스 클래스를 상속해 구현
  • WebSecurityConfigurerAdapter 구성 어댑터의 configure() 매서드를 이용해 다음과 같은 기본 보안 설정 적용 가능
    • 폼 기반 로그인 서비스
    • HTTP 기본 인증
    • 로그아웃 서비스
    • 익명 로그인
    • 서블릿 API 연계
    • CSFR
    • 보안 헤더
        // TodoSecurityInitalizer.java
        public class TodoSecurityInitializer extends AbstractSecurityWebApplicationInitializer {
      
        public TodoSecurityInitializer() {
            super(TodoSecurityConfig.class);
        }
        }
      


1-1. URL 접근 보안

  • WebSecurityConfigurerAdapter 클래스의 configure(HttpSecurity http) 매서드는 기본적으로 다음의 특징을 지님
    1. anyRequest().authenticated()해서 매번 요청이 들어올 때마다 반드시 인증을 받도록 강제함
    2. HTTP 기본인증(httpBasic()) 및 폼 기반 로그인(formLogin()) 기능은 기본적으로 킴
    3. 로그인 페이지를 만들지 않으면 기본 로그인 페이지를 보이도록 구성
  • configure(HttpSecurity http) 매서드를 오버라이딩하여 더 정교한 인가 규칙을 적용 가능

      // TodoSecurityConfig.java
      @Configuration
      @EnableWebSecurity
      public class TodoSecurityConfig extends WebSecurityConfigurerAdapter {
    
          @Override
          protected void configure(AuthenticationManagerBuilder auth) throws Exception {
              auth.inMemoryAuthentication()
                      .withUser("user").password("{noop}user") // .password("user")하면 인코딩 에러발생
                      .authorities("USER")
                      .and()
                      .withUser("admin").password("{noop}admin") // .password("admin")하면 인코딩 에러발생
                      .authorities("USER", "ADMIN");
          }
    
          @Override
          protected void configure(HttpSecurity http) throws Exception {
              http.authorizeRequests() // URL 접근 보안의 시작
                      .antMatchers("/todos*").hasAuthority("USER")
                      .antMatchers(HttpMethod.DELETE, "/todos*").hasAuthority("ADMIN")
                  .and()
                      .formLogin()
                  .and()
                      .csrf().disable();
          }
      }
    
  • URL 접근 보안은 authorizeRequests()부터 시작되며, 여러가지 매처를 통해 규칙을 지정가능
    • 앞선 코드에서는 antMatchers로 규칙을 지정

      이때 “/todos*” 처럼 마지막에 와일드카드를 넣어줘야 쿼리 매개변수가 있는 URL도 걸림
      그렇지 않다면, 해커들이 아무 쿼리 변수나 덧붙여 URL 관문을 통과할 수 있음

  • configure(AuthenticationManagerBuilder auth) 매서드를 통해 메모리에 유저 정보를 저장
    • 이때, Spring5 부터 유저 정보를 메모리에 저장하면 PasswordEncoder를 찾지 못해 예외가 발생
    • 이러한 예외를 방지하기 위해 앞선 코드처럼 {noop}를 붙여줌으로써, 내부적으로 NoOpPasswoedEncoder를 사용하겠다는 선언
    • 위 클래스도 비권장 클래스라 좋은 방법은 아니지만, 실제 메모리에 정보를 저장하는 일은 거의 없으므로 임시사용
  • 기본 접근 규칙 및 로그인 구성을 오버라이딩 했으므로, formLogin 기능을 다시 활성화 시켜줘야 함
  • 앞선 코드처럼 구성하고, CSFR방어 기능을 키게 되면, 폼이 제대로 동작하지 않으므로 비활성화(활성방법 후술)

1-2. CSRF 공격 방어

  • CSRF 방어기능은 스프링 시큐리티 기본 설정 그대로 켜두면 되지만, 필요시 csrf().disable() 로 해제 가능

    CSRF 공격(Cross Site Request Forgery)은 웹 어플리케이션 취약점 중 하나로
    인터넷 사용자(희생자)가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)를 특정 웹사이트에 요청하게 만드는 공격

  • Spring Security는 CsrfTokenRepository 인터페이스의 구현체를 이용해 토큰 값을 생성/보관하는 CsrfFilter를 추가
  • CSRF 방어기능이 켜진 상태로, 할 일을 완료하거나 삭제하려면 토큰이 없어서 실패
    • 콘텐츠를 수정하는 요청을 할 때, 다음처럼 hidden input에 CSRF 토큰을 심어 서버에 재전송
        <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
      


2. 웹 어플리케이션 로그인


다음의 로그인 서비스는 HTTP 자동 구성을 활성화하면 자동 등록 되지만, 직접 커스터마이징 하는 경우 명시적으로 구성해야 함

  • 예외 처리보안 컨텍스트 연계등 Spring Security 필수 기능
      protected void configure(HttpSecurity http){
          http.securityContext()
              .and()
              .exceptionHandling();
      }
    
  • 서블릿 API 연계 기능
      protected void configure(HttpSecurity http){
          http.servletApi();
      }
    


2-1. HTTP 기본 인증

  • HTTP 기본 인증은 httpBasic()으로 구성
  • HTTP 기본 인증을 적용하면 대부분 브라우저는 로그인 대화상자를 띄우거나, 특정 로그인 페이지로 이동시킴
      http
          ...
          .httpBasic();
    


2-2. 폼 기반 로그인

HTTP 기본 인증과 폼 기반 로그인을 동시에 활성화 할 경우, 폼 기반 로그인이 우선시 됨

  • formLogin() 매서드로 로그인 서비스를 구현하면, 유저가 로그인 정보를 입력하는 폼 페이지가 자동 랜더링
      http
          ...
          .formLogin();
    
  • 기본 로그인 페이지가 아닌 다른 로그인 페이지를 사용하고 싶다면, login.jsp 파일을 작성 후 wepapp 루트디렉토리에 위치시킴
    • WEB-INF 안에 넣으면, USER는 접근조차 할 수 없음
  • 커스텀 로그인 페이지의 URL을 다음처럼 loginPage()의 인수로 전달하고, 기본 리다이렉트 페이지 및 에러처리
      http
          ...
          .formLogin()
          .loginPage("/login.jsp")
          .defaultSuccessUrl("/messageList")
          .failureUrl("login.jsp?error=true");
    
  • 요청 URL에 쿼리 매개변수 error이 존재하는지 체크하고 있다면 세션 속성값을 이용해 다음처럼 최근 에러 메세지를 표시
      <form>
          ...
          <c:if test="${not empty parma.error}">
              Reason: ${sessionScope["SPRING_SECURITY_LAST_EXCEPTION"].message}
          ...
      </form>
    


2-3. 로그아웃 서비스

  • 로그아웃 기능은 logout() 매서드로 구현
      http
          ...
          .logout();
    
  • 기본 URL은 /logout 이며, POST 요청일 경우에만 작동하며 로그아웃한 유저는 기본 경로(초기화면)으로 이동
  • 다른 URL로 보내고 싶다면, 다음처럼 logoutSuccessUrl() 매서드에 지정
      http
          ...
          .logout().logoutSuccessUrl("/logout.success.jsp")
          .and()
          .headers();
    
  • 로그아웃 이후 뒤로가기 버튼을 눌렀을 때, 브라우저가 페이지를 캐싱함으로 인해 로그인된 페이지로 이동하는 문제 발생
    • headers() 매서드로 보안 헤더를 활성화하여 브라우저의 페이지 캐싱을 방지
    • 캐싱 뿐 아니라, content sniffing 방지 및 X-Frame 방어를 활성화하는 역할도 수행

      content sniffing: 바이트 스트림을 읽어 그 안의 데이터 형식을 추론하는 해킹, MIME 스니핑이라고도 불림
      X-Frame: 클릭 재킹은 웹 사용자를 클릭하여 사용자가 클릭 한 것으로 보이는 것과 다른 것을 클릭하여
      기밀 정보를 공개하거나 자신의 컴퓨터를 제어하는 악의적인 기법이다.

2-4. 익명 로그인 구현

  • 익명 로그인 서비스는 anonymous() 매서드에 유저명과 익명유저의 권한을 주어 다음과 같이 지정
      http
          ...
          .and()
          .anonymous().principal("quest").authorities("ROLE_GUEST");
          // 간단하게 .anonymous()로 동작가능
    


2-5. Remember Me 구현

  • Remember Me 기능은 rememberMe() 매서드를 통해 구현
    • 유저명, 패스워드, 리맴버 미 만료 시각, 개인키를 하나의 토큰으로 인코딩해 유저 브라우저 쿠키로 저장
    • 재접속시 이 토큰값을 가져와 유저를 자동 로그인 시킴
        http
        ...
        .rememberMe();
      

      그러나 정적인 리맴버 미 토큰은 해커가 얼마든지 빼낼 수 있어 잠재적인 보안 이슈가 존재
      이에 Spring Security는 토큰을 rolling 시키는 기술을 지원하며 토큰 저장을 위한 별도의 DB가 필요

3. 유저 인증하기


  • Spring Security에서는 연쇄적으로 연결되 하나 이상의 인증 공급자를 이용해 인증을 수행하며, 모든 공급자의 인증 과정을 통과해야 함
  • 대부분의 인증 공급자는 유저 세부 내용을 저장소(메모리, RDBMS, LDAP 저장소)등에서 가져온 결과와 대조하여 인증을 수행
  • 유저 세부 내용을 저장할 때 패스워드는 해커의 공격을 당할 수 있어 주로 단방향 해시 함수를 이용해 암호화하여 저장
    • Spring Security는 패스워드 인코딩 알고리즘으로 MD5, SHA등을 지원
  • 유저가 로그인할 때마다 저장소에서 조회하면 성능 저하를 유발하므로, Spring Security는 로컬 메모리와 저장 공간에 캐싱하는 기능 제공

3-1. In memory 방식으로 유저 인증

  • 유저가 적고, 정보를 수정할 일이 없을 경우
      // TodoSecurityConfig.java
      @Configuration
      @EnableWebSecurity
      public class TodoSecurityConfig extends WebSecurityConfigurerAdapter {
          // 한 사람씩 withUser 매서드를 이용하여 추가
          @Override
          protected void configure(AuthenticationManagerBuilder auth) throws Exception {
              auth.inMemoryAuthentication()
                      .withUser("admin@ya2do.io").password("secret").authorities("ADMIN","USER").and()
                      .withUser("marten@@ya2do.io").password("{noop}user").authorities("USER").and()
                      .withUser("jdoe@does.net").password("unknown").disabled(true).authorities("USER");
          }
          ...
    


3-2. DB 조회 결과에 따라 유저 인증

  • 유저의 세부 내용은 DB에 저장하는 경우가 압도적
  • Spring Security는 다음과 같은 두 매서드를 통해 SQL을 이용하는 조회 기능을 제공
    • usersByUserNameQuery()
    • authoritiesByUsernameQuery()
        // TodoSecurityConfig.java
        ...
        @Override
        protected void configure(AuthenticationManagerBuilder auth) throws Exception {
            auth
                .jdbcAuthentication()
                    .dataSource(dataSource())
                    .usersByUsernameQuery("SELECT username, password, 'true' as enabled FROM member WHERE username = ?")
                    .authoritiesByUsernameQuery(
                            "SELECT member.username, member_role.role as authorities " +
                                    "FROM member, member_role " +
                                    "WHERE  member.username = ? AND member.id = member_role.member_id");
        }
      


3-3. 패스워드 암호화

  • In memory 방식의 경우, passwordEncoder() 매서드에 패스워드 인코더를 지정해 암호화하여 저장 가능
      // TodoSecurityConfig.java
          ...
          @Bean
          public BCryptPasswordEncoder passwordEncoder() {
              return new BCryptPasswordEncoder();
          }
    
          @Override
          protected void configure(AuthenticationManagerBuilder auth) throws Exception {
              auth
                  .jdbcAuthentication()
                      .passwordEncoder(passwordEncoder())
                      .dataSource(dataSource());
          }
    

    BCrypt: 단방향 해시 알고리즘으로 설계시 부터 패스워드 저장을 목적으로 설계되어 강력함

3-4. LDAP 저장소 조회 결과에 따라 유저 인증

LDAP: LDAP는 조직이나, 개체, 그리고 인터넷이나 기업 내의 인트라넷 등
네트웍 상에 있는 파일이나 장치들과 같은 자원 등의 위치를 찾을 수 있게 해주는 소프트웨어 프로토콜

  • LDAP의 저장소 구성은 ldapAuthentication() 매서드가 담당
  • 유저 및 그룹을 검색하는 필터와 베이스는 콜백 매서드로 지정
  • Spring Security는 각 그룹마다 ROLE_ 접두어를 붙혀 권한으로 사용
    // TodoSecurityConfig.java
      ...
      @Override
      protected void configure(AuthenticationManagerBuilder auth) throws Exception {
          auth
              .ldapAuthentication()
                  .contextSource()
                      .ldif("")
                      .url("ldap://ldap-server:389/dc=springrecipes,dc=com")
                      .managerDn("cn=Directory Manager").managerPassword("ldap")
              .and()
                  .userSearchFilter("uid={0}").userSearchBase("ou=people")
                  .groupSearchFilter("member={0}").groupSearchBase("ou=groups")
                  .passwordCompare()
                      .passwordEncoder(new BCryptPasswordEncoder())
                      .passwordAttribute("userPassword");
      }
    
  • LDAP을 지원하는 자바 기반 오픈소스 디렉토리 서비스 엔진인 OpenDS는 기본적으로 SSHA를 사용하므로,
    LdapShaPasswordEncoder를 지정해야 함

    SSHA: Salted Secure Hash Algorithm로서, 기존의 해싱의 단점(동일한 문자열에 대해 같은 해쉬값)을 극복하는 방법으로
    무작위 문자열을 추가해 같은 문자열에 대해 매번 다른 해쉬값을 가지도록 하여 보안성을 높이는 기법

4. 접근 통제 결정


  • 접근 통제 결정은 유저가 리소스에 접근 가능한지 판단하는 행위로서 유저 인증 상태리소스 속성에 따라 좌우되며,
    Spring Security에서는 AccessDecisionManager 인터페이스 구현을 통해 이를 판단

  • 필요 시 직접 인터페이스를 구현할 수도 있지만, Spring Security는 거수 방식으로 동작하는 다음 3가지 결정 관리자를 기본 제공
    1. AffirmativeBased: 하나의 거수기만 거수해도 접근 허용
    2. ConsensusBased: 거수기 전원이 만장일치해야 접근 허용
    3. UnanimousBased: 거수기 전원이 기권 또는 찬성해야 접근 허용(적어도 반대는 없어야 함)
  • 위의 결정 관리자를 이용하기 위해, 접근 통제 결정에 대한 거수기 그룹을 구성해야 하며
    각 거수기는 AccessDecisionVoter 인터페이스를 구현하고, 유저의 리소스 접근에 대해 찬성, 기권, 반대중 하나의 의사를 표명

  • 별도의 결정 관리자를 명시하지 않으면, AffirmativeBased를 기본 접근 통제 결정 관리자로 임명하고 다음 두 거수기를 구성
    1. RolVoter: 유저 롤을 기준으로 접근 허용 여부를 거수, ROLE_ 접두어(다른 접두어도 가능)로 시작하는 접근 속성만 처리
    2. AuthenticatedVoter: 유저 인증 레벨을 기준으로 접근 허용 여부를 거수하며, 다음 세 가지 접근 속성만 처리
    1. IS_AUTHENTICATED_FULLY
    2. IS_AUTHENTICATED_REMEMBERED
    3. IS_AUTHENTICATED_ANONYMOUSLY

      위 3가지는 순서대로 인증레벨이 정해지며(1번이 가장 높음), 유저의 인증 레벨이 리소스 접근 레벨보다 높으면 찬성

4-1. 표현식을 이용해 접근 통제 결정

  • Spring식 표현 언어인 SpEL(Spring Expression Language)를 사용
  • and, or, not등을 조합해 강력하고 유연한 표현식 사용 가능
      // TodoSecurityConfig.java
          ...
          @Override
          protected void configure(HttpSecurity http) throws Exception {
          /*
              * 유저가 ADMIN 롤을 가지고 있거나, 로컬 머신에서 로그인한 유저일 경우 삭제 권한 부여
              * 유저 IP주소에 따라 허용 여부를 결정  
              * 주소가 127.0.0.1 또는 0:0:0:0:0:0:0:1 일때 찬성, 그렇지 않으면 반대
              */
              http.authorizeRequests()
                      .antMatchers("/todos*").hasAuthority("USER")
                      .antMatchers(HttpMethod.DELETE, "/todos*").hasAuthority("ADMIN")
                      .antMatchers("/messageDelete*)
                  .access("hasRole('ROLE_ADMIN') or hasIpAddress('127.0.0.1') or  
                      hasIpAddress('0:0:0:0:0:0:0:1')")
                  ...
          }
    
  • Spring Security는 앞선 내장 함수를 제공하지만, SecurityExpressionOperations 인터페이스를 구현해 직접 등록해 사용 가능

      // ExtendWebSecurityExpressionRoot.java
      /*
      * 이 클래스가 상속받은 WebSecurityExpressionRoot 클래스의 
      * 상위 클래스인 SecurityExpressionRoot 클래스가 
      * SecurityExpressionOperations 인터페이스 구현 클래스
      */
      public class ExtendedWebSecurityExpressionRoot extends WebSecurityExpressionRoot {
            
          public ExtendedWebSecurityExpressionRoot(Authentication a, FilterInvocation fi) {
              super(a, fi);
          }
    
      /*
          * 로컬 머신 로그인 여부를 체크하는 매서드를 구현해 추가
          * 커스텀 표현식으로 원하는 표현식 유연하게 추가 가능 
          */
          public boolean localAccess() {
              return hasIpAddress("127.0.0.1") || hasIpAddress("0:0:0:0:0:0:0:1");
    
          }
      }
    
  • 위와 같은 커스텀 표현식을 등록해 사용하기 위해서, SecurityExpressionHandler 인터페이스 구현체를 생성해야 함
      // ExtendWevSecurityExpressionHandler.java
      public class ExtendedWebSecurityExpressionHandler extends DefaultWebSecurityExpressionHandler {
    
          private AuthenticationTrustResolver trustResolver = new AuthenticationTrustResolverImpl();
    
          @Override
          protected SecurityExpressionOperations 
          createSecurityExpressionRoot(Authentication authentication, FilterInvocation fi) {
              // 앞선 코드에서 생성한 ExtendedWebSecurityExpressionRoot 인스턴스 생성
              ExtendedWebSecurityExpressionRoot root =
                      new ExtendedWebSecurityExpressionRoot(authentication, fi);
              root.setPermissionEvaluator(getPermissionEvaluator());
              root.setTrustResolver(trustResolver);
              root.setRoleHierarchy(getRoleHierarchy());
              return root;
          }
          ...
      }
    
  • createSecurityExpressionRoot() 매서드를 오버라이드해 구현한 인스턴스를 생성
  • 커스텀 표현식 핸들러를 다음처럼 expressionHandler() 매서드에 지정하여 커스텀 표현식을 활용
      // TodoSecurityConfig.java
          ...
          @Override
          protected void configure(HttpSecurity http) throws Exception {
    
              http.authorizeRequests()
                      .expressionHandler(new ExtendedWebSecurityExpressionHandler())
                      .antMatchers("/todos*").hasAuthority("USER")
                      .antMatchers(DELETE, "/todos*")
                      .access("hasRole('ROLE_ADMIN') or localAccess()") // 커스텀 표현식 사용
                  .and()
          ...
    


4-2. Sprig Bean을 표현식에 넣어 접근 통제 결정

  • 앞선 방식으로 Spring Security 클래스를 상속후 매서드를 오버라이드해 사용할 수 있지만, 비권장
  • 표현식 내부에 커스텀 클래스를 만들어 사용하여, @ 어노테이션을 통해 Spring Bean으로 사용
      // AccessChecker.java
      public class AccessChecker {
    
          public boolean hasLocalAccess(Authentication authentication) {
              boolean access = false;
              if (authentication.getDetails() instanceof WebAuthenticationDetails) {
                  WebAuthenticationDetails details = (WebAuthenticationDetails) authentication.getDetails();
                  String address = details.getRemoteAddress();
                  access = address.equals("127.0.0.1") || address.equals("0:0:0:0:0:0:0:1");
              }
              return access;
          }
      }
    
  • AccessChecker 클래스는 앞선 커스텀 표현식 핸들러와 동일한 기능을 제공하지만, Spring Security 클래스를 상속하지 않음
  • 다음과 같은 방법으로 @accessChecker.hasLocalAccess(authentication) 표현식으로 호출 가능
      // TodoSecurityConfig.java
          ...
          // 사용할 표현식 클래스를 Spring Bean으로 등록
          @Bean
          public AccessChecker accessChecker() {
              return new AccessChecker();
          }
    
          @Override
          protected void configure(HttpSecurity http) throws Exception {
    
              http.authorizeRequests()
                      .antMatchers("/todos*").hasAuthority("USER")
                      .antMatchers(HttpMethod.DELETE, "/todos*")
                      // 커스텀 매서드를 어노테이션을 통해 Bean을 부름으로써 사용  
                      .access("hasAuthority('ADMIN') or @accessChecker.hasLocalAccess(authentication)")
                  .and()
          ...
    


5. 매서드 호출 보안


Spring Security는 빈 인터페이스나 구현 클래스에서 대상 매서드에 다음의 어노테이션을 선언함으로 매서드 호출을 보안

  • @Secured
  • @PreAuthorize / @PostAuthorize
  • @PreFilter / @PostFilter

위와 같은 어노테이션을 붙인 후, 구성 클래스 레벨에 @EnableGlobalMethodSecurity를 붙이면 보안 모드로 작동

5-1. 어노테이션을 붙어 매서드 보안

  • @Secured 어노테이션을 매서드에 붙이면 보안이 적용
      // TodoServiceImpl.java
      @Service
      @Transactional
      class TodoServiceImpl implements TodoService {
          ...
          @Override
          @Secured("USER")
          public List<Todo> listTodos() {...}
    
          @Override
          @Secured("USER")
          public void save(Todo todo) {...}
    
          @Override
          @Secured("USER")
          public void complete(long id) {...}
    
          @Override
          @Secured({"USER", "ADMIN"})
          public void remove(long id) {...}
    
          @Override
          @Secured("USER")
          public Todo findById(long id) {...}
      }
    
  • 각 매서드에 @Secured 어노테이션을 붙인 후, String[] 타입의 access 속성에 매서드별 접근 허용 권한 설정
  • 매서드 보안을 활성화하기 위해 구성 클래스에 다음처럼 @EnableGlobalMethodSecurity를 붙이고, 속성값 설정
      @Configuration
      @EnableGlobalMethodSecurity(securedEnable = true)
      public class TodoWebconfiguration{...}
    


5-2. 어노테이션 + 표현식으로 매서드 보안(@PreAuthorize, @PostAuthorize)

  • 조금 더 정교한 보안 규칙은 @PreAuthorize, @PostAuthorize 같은 어노테이션에 SpEL 기반의 보안 표현식을 적용
  • 이 두 가지 어노테이션을 사용하기 위해 @EnableGlobalMethodSecurityprePostEnabled 속성을 true로 설정
      @Configuration
      @EnableGlobalMethodSecurity(prePostEnabled = true)
      public class TodoWebconfiguration{...}
    
      // TodoServiceImple.java
      @Service
      @Transactional
      class TodoServiceImpl implements TodoService {
          ...
          @Override
          @PreAuthorize("hasAuthority('USER')")
          public List<Todo> listTodos() {...}
    
          @Override
          @PreAuthorize("hasAuthority('USER')")
          public void save(Todo todo) {...}
    
          @Override
          @PreAuthorize("hasAuthority('USER')")
          public void complete(long id) {...}
    
          @Override
          @PreAuthorize("hasAnyAuthority('USER', 'ADMIN')")
          public void remove(long id) {...}
    
      /*
          * returnObject 표현식으로 결과를 처리
          * 할 일을 등록한 유저 이외의 다른 유저가 호출해 Todo 객체에 접근하면 보안 예외를 던짐
          */
          @Override
          @PreAuthorize("hasAuthority('USER')")
          @PostAuthorize("returnObject.owner == authentication.name")
          public Todo findById(long id) {...}
      }
    
  • @PreAuthorize는 매서드 호출 직전, @PostAuthorize는 매서드 호출 직후 각각 동작
  • 보안 표현식을 사용하거나, returnObject 표현식으로 매서드 호출 결과를 받아올 수 있음

5-3. 어노테이션 + 표현식으로 거르기(@PreFilter, @PostFilter)

  • 앞선 두 어노테이션은 보안 규칙에 맞지 않을 경우 에러를 던지지만, @PreFilter/@PostFilter는 단순히 접근 권한을 체크
  • TodoList에 구현된 전체 데이터는 ADMIN만 볼 수 있고, USER는 본인의 일만 열람 가능하도록 다음처럼 제한을 둠
      // TodoServiceImple.java
          ...
          @Override
          @PreAuthorize("hasAuthority('USER')")
          @PostFilter("hasAnyAuthority('ADMIN') or filterObject.owner == authentication.name")
          public List<Todo> listTodos() {
              return todoRepository.findAll();
          }
    

    @PreFilter/@PostFilter는 매서드 입출력을 필터링하는 편리한 수단이지만,
    결과가 대용량일 경우 성능저하를 심각하게 유발하므로 주의

6. 뷰에서 보안 처리


Spring Security가 제공하는 보안 처리용 JSP 태그 라이브러리를 사용

JSP 스크립트릿 (<% … %>)을 사용하여 Spring Security API를 호출할 수 있지만, 좋은 방법이 아니라 비권장

6-1. 인증 정보 표시

  • 뷰 페이지 헤더에 주체명과 허용 권한을 설정
      <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
      <%@ taglib prefix="sec" uri="http://www.springframework.org/security/tags" %>
    
  • <sec:authentication> 태그를 이용하면, 현재 유저의 Authentication 객체를 가져올 수 있어
    뷰에서 원하는 프로퍼티를 property 속성명시하는 방법으로 랜더링 가능
      <!-- 유저 주체명은 name 프로퍼티로 가져와 표시가능 -->
      <h4>To-dos for <sec:authentication property="name" /></h4>
    
  • 위와 같이 직접 가져와 랜더링해도 되지만, JSP 변수에 프로퍼티 값을 옮겨담아 var 속성에 이름을 지정 가능
      <!-- 허용 권한 목록을 authorities 변수에 담고, <c:forEach> 태그로 하나씩 꺼내 랜더링  -->
      <sec:authentication property="authorities" var="authorities" />
      <ul>
          <c:forEach items="${authorities}" var="authority">
              <li>${authority.authority}</li>
          </c:forEach>
      </ul>
    


6-2. 뷰 콘텐트를 조건부 랜더링

  • <sec:authorize> 태그를 이용해 유저 권한에 따라 뷰 콘텐트를 다음 세 방법으로 조건부 표시 가능
  1. 전부 지니는 경우
     <!-- ROLE_ADMIN, ROLE_USER 권한을 모두 지녔을 경우만 태그로 감싼 부분을 랜더링 -->
     <td>
         <sec:authorize access="hasRole('ROLE_ADMIN') and hasRole('ROLE_USER')">
             ${todo.owner}
         </sec:authorize>
     </td>
    
  2. 하나만 지녀도 되는 경우
     <!-- 두 권한 중 하나만 가져도 랜더링하려면 다음처럼 hasAnyRole 사용 -->
     <td>
         <sec:authorize access="hasAnyRole('ROLE_ADMIN', 'ROLE_USER')">
             ${todo.owner}
         </sec:authorize>
     </td>
    
  3. 지니지 않을 경우
     <!-- 주어진 권한을 모두 갖고 있지 않은 유저에게만 보이게 하는 경우 -->
     <td>
         <sec:authorize access="!hasRole('ROLE_ADMIN') and !hasRole('ROLE_USER')">
             ${todo.owner}
         </sec:authorize>
     </td>
    


7. 도메인 객체 보안 처리


Spring Security는 자체로 ACL(Access Control List, 접근 통제 목록)을 설정하는 전용 모듈을 지원하며,
ACL에는 도메인 객체와 연결하는 ID를 비롯해 여러 개의 ACE(Access Control Entry, 접근 통제 엔트리)가 포함되며 ACE는 다음처럼 구성

  1. 퍼미션(Permission, 인가받은 권한): 각 비트 값은 특정 퍼미션을 의미하는 비트 마스크이며 각 비트는
    (READ: 0 or 1) (WRITE: 1 or 2) (CREATE: 2 or 4) (DELETE: 3 or 8) (ADMINISTRATION: 4 or 16)으로 구성

    다섯 가지 기본 퍼미션과 해당하는 비트의 쌍으로 표현
    이 중 안쓰는 나머지 비트를 이용해 퍼미션을 임의로 지정 가능

  2. 보안 식별자(SID, Security Identity): 각 ACE는 특정 SID에 대한 퍼미션을 지님

7-1. ACL 서비스 설정

  • Spring은 JDBC로 RDBMS에 접속하여 ACL 데이터저장/조회하는 기능을 기본 지원
  • Spring Security는 테이블에 저장된 ACL 데이터에 접근할 수 있는 고성능 JDBC 구현체API를 지원하며,
    ACL은 도메인 객체마다 별도로 둘 수 있어 ACL 객체를 캐싱하는 기능을 제공
    • Class path root에 있는 ehcache.xml 파일에 설정
        <!-- ehcache.xml -->
        ...
        <cache name="aclCache"
            maxElementsInMemory="1000"
            eternal="false"
            timeToIdleSeconds="600"
            timeToLiveSeconds="3600"
            overflowToDisk="true"
                />
      
  • 어플리케이션에서 사용할 ACL 모듈은 Java로 구성이 안되므로, Bean 그룹으로 구성후 등록해야 하므로 설정 파일을 작성하고 위치 등록
      public class TodoSecurityInitializer extends AbstractSecurityWebApplicationInitializer {
    
          public TodoSecurityInitializer() {
              super(TodoSecurityConfig.class, TodoAclConfig.class); // TodoAclConfig 파일 위치 등록
          }
      }
    
  • Spring Security에서 ACL 서비스 작업은 AclService, MutableAclService인터페이스로 정의
  • AclService는 읽기 작업을, 그 하위 인터페이스 MutableAcleService는 나머지(생성, 수정, 삭제)를 각각 기술

      // TodoAclConfig.java
      @Configuration
      public class TodoAclConfig {
    
          private final DataSource dataSource;
    
          public TodoAclConfig(DataSource dataSource) {
              this.dataSource = dataSource;
          }
    
          @Bean
          public AclEntryVoter aclEntryVoter(AclService aclService) {
              return new AclEntryVoter(aclService, "ACL_MESSAGE_DELETE", new Permission[]{BasePermission.ADMINISTRATION, BasePermission.DELETE});
          }
    
          @Bean
          public EhCacheCacheManager ehCacheManagerFactoryBean() {
              return new EhCacheCacheManager();
          }
    
          @Bean
          public AuditLogger auditLogger() {
              return new ConsoleAuditLogger();
          }
    
          // 자신이 가지고 있는 Permission 값으로 주어진 SID에 ACL 접근을 허용할지를 결정
          @Bean
          public PermissionGrantingStrategy permissionGrantingStrategy() {
              return new DefaultPermissionGrantingStrategy(auditLogger());
          }
    
      /* 
          * 각 프로퍼티 카테고리별로 필요한 권한을 지정하는 방식으로  
          * 주체가 특정 ACL 프로퍼티를 변경할 권한을 갖고 있는지 판단  
          * 해당 코드에서는 ADMIN 권한을 지닌 유저만 ACL 소유권, 감사 세부등 ACL/ACE 정보 수정 가능
          */
          @Bean
          public AclAuthorizationStrategy aclAuthorizationStrategy() {
              return new AclAuthorizationStrategyImpl(new SimpleGrantedAuthority("ADMIN"));
          }
    
          @Bean
          public AclCache aclCache(CacheManager cacheManager) {
              return new SpringCacheBasedAclCache(cacheManager.getCache("aclCache"), permissionGrantingStrategy(), aclAuthorizationStrategy());
          }
    
      /*
          * 룩업 성능을 높이고자 고급 DB 기능을 사용하려면 
          * 직접 LookupStrategy 인스턴스를 구현해 룩업 전략을 만들어 사용
          */
          @Bean
          public LookupStrategy lookupStrategy(AclCache aclCache) {
              return new BasicLookupStrategy(this.dataSource, aclCache, aclAuthorizationStrategy(), permissionGrantingStrategy());
          }
            
      /*
          * ACL 구성 파일의 핵심이 되는 JdbcMutableAclService 인스턴스
          * 첫 번째 인수는 ACL 데이터를 저장할 DB에 사용하는 데이터 소스  
          * 두 번째 인수는 표준/호환 SQL문으로 기본 룩업을 수행하는 LookupStrategy 인터페이스 구현체
          * 세 번째 인수는 ACL에 적용할 캐시 인스턴스  
          */
          @Bean
          public AclService aclService(LookupStrategy lookupStrategy, AclCache aclCache) {
              return new JdbcMutableAclService(this.dataSource, lookupStrategy, aclCache);
          }
    
          @Bean
          public AclPermissionEvaluator permissionEvaluator(AclService aclService) {
              return new AclPermissionEvaluator(aclService);
          }
      }
    


7-2. 도메인 객체에 대한 ACL 관리

  • Back-end 서비스와 DAO에서는 DI를 이용해 앞서 정의한 ACL 서비스를 이용해 도메인 객체용 ACL을 관리

    Todo-List에서는 할 일을 등록/삭제할 때마다 각각 ACL을 생성/삭제해야 함

      // TodoServiceImple.java
      @Service
      @Transactional
      class TodoServiceImpl implements TodoService {
          ...
      /*
          * 유저가 할 일을 등록하면 할 일 ID와 ACL 객체의 ID를 이용해 ACL을 생성함
          * 생성한 ACL에 대해서는 해당 USER와 ADMIN이 READ, WRITE, DELETE를 할 수 있도록 삽입
          */
          @Override
          @PreAuthorize("hasAuthority('USER')")
          public void save(Todo todo) {
    
              this.todoRepository.save(todo);
    
              ObjectIdentity oid = new ObjectIdentityImpl(Todo.class, todo.getId());
              MutableAcl acl = mutableAclService.createAcl(oid);
              acl.insertAce(0, READ, new PrincipalSid(todo.getOwner()), true);
              acl.insertAce(1, WRITE, new PrincipalSid(todo.getOwner()), true);
              acl.insertAce(2, DELETE, new PrincipalSid(todo.getOwner()), true);
    
              acl.insertAce(3, READ, new GrantedAuthoritySid("ADMIN"), true);
              acl.insertAce(4, WRITE, new GrantedAuthoritySid("ADMIN"), true);
              acl.insertAce(5, DELETE, new GrantedAuthoritySid("ADMIN"), true);
    
          }
    
          // 유저가 등록한 할 일을 삭제하면 해당 ACL도 함께 삭제
          @Override
          @PreAuthorize("hasAnyAuthority('USER','ADMIN')")
          public void remove(long id) {
              todoRepository.remove(id);
    
              ObjectIdentity oid = new ObjectIdentityImpl(Todo.class, id);
              mutableAclService.deleteAcl(oid, false);
          }
          ...
      }
    
  • JdbcMutableAclService 입장에서는 호출 매서드 쪽에서 트랜잭션을 걸어놓아야 그 트랜잭션 안에서 SQL문 수행 가능

    따라서, ACL을 관리하는 앞선 매서드의 @Transactional 어노테이션을 붙임

  • WebMvcConfigurer 구현 클래스에서는 다음처럼 트랜잭션 관리자를 추가하고, @EnableTransactionManagement를 붙임
      @Configuration
      ...
      @EnableTransactionManagement
      public class TodoWebConfig implements WebMvcConfigurer {
          ...
          @Bean
          public DataSourceTransactionManager transactionManager(DataSource dataSource) {
              return new DataSourceTransactionManager(dataSource);
          }
    
      }
    


7-3. 표현식을 이용해 접근 통제 결정

  • 도메인 객체마다 ACL을 부착했으므로, 이 객체에 속한 매서드마다 접근 통제 결정을 내리는 것이 가능

    유저가 할 일을 삭제하려고 하면 ACL을 보고 그 유저가 정말 삭제할 권한이 있는지 체크 가능

  • ACL을 직접 구현할 필요 없이 다음 애너테이션으로 간편하게 구현 가능
    1. @PreAuthorize/@PreFilter로 유저의 매서드 실행, 특정 매서드 인수의 사용 권한이 있는지 체크 가능
    2. @PostAuthorize/@PostFilter로 유저가 매서드 실행 결과에 접근하거나,
      ACL에 따라 그 결과를 필터링 할 수 있는지 체크 가능
  • 이러한 애너테이션은 @EnableGlobalMethodSecurityprePostEnabled 속성을 true로 설정해 사용 가능
      @EnableGlobalMethodSecurity(prePostEnabled = true)
    
  • 표현식을 사용하여 ACL로 매서드를 보안하려면 커스텀 퍼미션 평가기를 다음처럼
    전역 레벨에서 매서드 보안을 활성화하는 구성 클래스에 반드시 설정
      // TodoWebConfig.java
      @Configuration
      ...
      public class TodoWebConfig implements WebMvcConfigurer {
          ...
          @Bean
          public AclPermissionEvaluator permissionEvaluator(AclService aclService) {
              return new AclPermissionEvaluator(aclService);
          }
      }
    
  • 애너테이션과 표현식을 함께 사용한 최종 클래스는 다음과 같다.
      @Service
      @Transactional
      class TodoServiceImpl implements TodoService {
          ...
      /*
          * @PreAuthorize는 유저가 매서드를 수행할 퍼미션을 갖고 있는지 체크  
          * #message는 message라는 이름의 매서드 인수를 지칭  
          * hasPermission은 Spring Security의 기본 표현식  
          * @PostFilter는 컬렉션 중 현재 유저가 읽을 권한이 없는 원소를 제거, 표현식에 사용된 filterOject가 컬렉션의 원소  
          * @PostAuthorize는 하나의 결과값이 사용 가능한지(유저가 그에 맞는 권한을 지니는지)를 체크, 반환값은 returnObject 키워드로 사용
          */
          @Override
          @PreAuthorize("hasAuthority('USER')")
          @PostFilter("hasAnyAuthority('ADMIN') or hasPermission(filterObject, 'read')")
          public List<Todo> listTodos() {...}
    
          @Override
          @PreAuthorize("hasAuthority('USER')")
          public void save(Todo todo) {...}
    
          @Override
          @PreAuthorize("hasPermission(#id, 'com.apress.springrecipes.board.Todo', 'write')")
          public void complete(long id) {...}
    
          @Override
          @PreAuthorize("hasPermission(#id, 'com.apress.springrecipes.board.Todo', 'delete')")
          public void remove(long id) {...}
    
          @Override
          @PostFilter("hasPermission(filterObject, 'read')")
          public Todo findById(long id) {...}
      }