경계의 경계

Spring Security의 인증(Authentication)과 인가(Authorization) 본문

Spring Security

Spring Security의 인증(Authentication)과 인가(Authorization)

gigyesik 2024. 4. 24. 03:37

들어가며

Spring Security는 ID/Password, OAuth2 등 다양한 인증 체계를 지원한다.

유저가 인증되고 나면, Spring Security는 유저가 특정 리소스나 기능에 접근할 수 있는지를 확인한다.

계속 ‘인증’과 ‘인가’라는 단어를 사용하게 되는데, 좋은 포스팅을 만나 정리해보려 한다.

Spring Security

Spring으로 개발하다 보면 Auth를 추가하기 위해 Spring Security를 의존성에 추가하게 된다. 그리고 여러 문제점을 마주한다.

  • 로그인 페이지가 자동으로 생성되어 있다.
  • POST Request가 전송되지 않는다.
  • 어플리케이션 전체가 username, password를 요구하기 시작한다.

이런 여러 문제를 해결하기 위해서는 Spring Security가 무엇인지 알아야 한다.

쉽게 먼저 생각하자.

Spring Security는 인증, 인가 기능이 있는 서블릿 필터 덩어리이다. 다른 Spring 하위 프레임워크와 호함되고, OAuth2나 SAML등도 사용할 수 있고, 로그인 로그아웃 페이지도 기본으로 제공해주고, CSRF 공격도 방어해준다.

이제 자세히 알아보면 된다.

웹 어플리케이션 보안

Spring Security를 이해하고 싶지만, 핵심 개념을 모른다면 모래성에 불과하다.

  • Authentication : 인증
  • Authorization : 인가
  • Servlet Filter

Authentication

‘인증’은 유저를 식별하는 것이다. 가입된 유저가 자신의 신원을 증명하는 것을 ID와 Password로 검증하는 것이다.

유저 : 저는 G라는 유저입니다. 제 username은 gigyesik 입니다.
어플리케이션 : 안녕하세요 gigyesik님. password도 말씀해주세요.
유저 : 제 패스워드는 abcd 입니다.
어플리케이션 : 일치하는군요. 환영합니다!

Authorization

간단한 어플리케이션에서는 유저가 인증을 통과하면 모든 기능을 이용할 수 있도록 해주면 충분하다.

하지만 대부분의 어플리케이션에서는 고객이 접근 가능한 페이지와 관리자가 접근 가능한 페이지가 나누어져 있다.

유저와 관리자 모두 로그인이 필요하지만, 인증 만으로는 어플리케이션 내에서 할 수 있는 권한에 대해 화깅ㄴ할 수는 없다.

따라서 ‘인가’를 통해 인증된 유저가 어떤 권한을 허가받았는지 확인한다.

유저 : 저는 마이페이지를 확인하고 싶습니다.
어플리케이션 : 볼 수 있는 권한이 있군요. 허가하겠습니다.
유저 : 감사합니다.

Servlet Filters

Spring 어플리케이션은 HTTP 요청을 리다이렉트하는 DispatcherServlet이라는 서블릿을 가지고 있다. @Controller 또는 @RestContoller로 사용되는 컨트롤러들이 그것이다.

우리가 HTTP 요청을 보낼 때, 매번 Basic Authentication으로 구현된 헤더에서 계정 정보를 찾아와서 검증해야 할까? 어플리케이션은 컨트롤러에 요청이 도착하기 전에 인증, 인가 과정이 끝나있어야 한다.

그 방법을 서블릿 필터가 제공한다. 서플릿 필터는 Tomcat(Servlet Container 또는 Application Server)으로 들어와 Servlet에 접근하는 요청의 앞에서 SecurityFilter로 작용한다.

  • 간단한 서블릿 필터인 SecurityFilter는 다음과 같다.
public class SecurityServletFilter extends HttpFilter {
    @Override
    protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {

        UsernamePasswordAuthenticationToken token = extractUsernameAndPasswordFrom(request);

        if (notAuthenticated(token)) {
            // username 또는 password 불일치
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // HTTP 401.
            return;
        }

        if (notAuthorized(token, request)) {
            // 권한지 없는 경우
            response.setStatus(HttpServletResponse.SC_FORBIDDEN); // HTTP 403
            return;
        }

        // 요청을 DispatcherServlet 으로 보낸다
        chain.doFilter(request, response); // (4)
    }

    private UsernamePasswordAuthenticationToken extractUsernameAndPasswordFrom(HttpServletRequest request) {
        // Basic Auth 의 경우 HTTP 헤더에서 username:password 추출
        // 또는 Login 요청 Post Body 에서 추출
        return new UsernamePasswordAuthenticationToken(request.getUserPrincipal(), request.getSession().getAttribute("credentials"));
    }

    private boolean notAuthenticated(UsernamePasswordAuthenticationToken token) {
        // DB, memory, LDAP 등에서 계정정보 추출
        return false;
    }

    private boolean notAuthorized(UsernamePasswordAuthenticationToken token, HttpServletRequest request) {
        // 권한 판단
        return false;
    }
}

  1. 요청에서 username과 password를 추출한다. Basic Auth 방식에서는 HTTP 헤더에서 얻을 수 있고, 다른 방식에서는 페이지 필드나 쿠키 등에서 얻을 수 있다.
  2. 추출한 username과 password의 조합을 가지고 있는 계정 정보로 검증한다. 여기서 '가지고 있다'는 것은 DB이거나, 메모리일 수 있다.
  3. 계정 정보가 검증되어 '인증'에 성공한 후에는, 접근을 요청한 경로에 대해 권한이 있는지 '인가' 과정을 거친다.
  4. 인증과 인가를 모두 통과하였다면DispatcherServlet, 즉 컨트롤러로 요청을 넘긴다.
  • Filterchains

위와 같이 필터를 구현하여 컴파일하게 되면, 어플리케이션이 복잡해지면서 인증 과정에서 거대한 하나의 필터를 통과하는 방식으로 진화할 것이다.

그러므로 필터를 역할별로 여러 개로 분리하여 체이닝하는 방식으로 변경한다. 예를 들어

  • LoginMethodFilter 통과
  • AuthenticationFilter 통과
  • AuthorizationFilter 통과
  • Servlet 도달

이러한 개념을 FilterChain이라 한다. 필터를 서로 연결하는 것은 아래의 코드를 통해 동작시킬 수 있다.

chain.doFilter(request, response);

이런 필터를 통해 실제 어플리케이션의 컨트롤러 동작에는 영향을 미치지 않고 모든 인증, 인가 관련 로직을 처리할 수 있다.

FilterChain과 Security 설정 DSL(Doamin Specific Language)

Spring의 DefaultSecurityFilterChain

Spring Security가 어플리케이션에 주입되고 나면, 아래와 같은 로그를 만나게 된다. FilterChain이 16개의 필터로 구정되어 있다는 이야기이다.

[main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with 
[
org.springframework.security.web.session.DisableEncodeUrlFilter@6682e6a5, // 1
org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@ac4915e, // 2
org.springframework.security.web.context.SecurityContextHolderFilter@13e00016, // 3
org.springframework.security.web.header.HeaderWriterFilter@4bf4680c, // 4
org.springframework.web.filter.CorsFilter@10fb4575, // 5
org.springframework.security.web.csrf.CsrfFilter@5fb8dc01, // 6 
org.springframework.security.web.authentication.logout.LogoutFilter@3f866f50, //7
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter@2dac2e1b, // 8
org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter@72b2c5ed, // 9
org.springframework.security.web.authentication.ui.DefaultLogoutPageGeneratingFilter@7cc2c551, // 10
org.springframework.security.web.authentication.www.BasicAuthenticationFilter@64a1116a, // 11
org.springframework.security.web.savedrequest.RequestCacheAwareFilter@177ede17, // 12
org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@17176b18, // 13
org.springframework.security.web.authentication.AnonymousAuthenticationFilter@d84b3a2, // 14
org.springframework.security.web.access.ExceptionTranslationFilter@6615237, // 15
org.springframework.security.web.access.intercept.AuthorizationFilter@5e67a490 // 16
]

따라서 HTTP 요청이 들어오면 컨트롤러를 지나기 전에 16개의 필터를 지나게 된다. 순서 또한 중요한데, 자세한 순서는 나중에 각각 다뤄본다.

Spring FilterChain 분석

filter chain의 모든 필터에 대해 디테일하게 알아보기보다는 이곳에서는 몇 가지 중요한 필터에 대해 알아본다.

  • BasicAuthenticationFilter : Auth HTTP 헤더를 요청에서 찾고, 헤더의 username과 password를 가지고 인증을 시도한다.
  • UsernamePasswordAuthenticationFilter : username과 password를 request parameter 또는 POST body에서 찾아 인증을 시도한다.
  • DefaultLoginPageGeneratingFilter : 해당 필터를 비활성화하지 않는다면 Spring Security의 기본 로그인 화면을 활성화해준다. Spring Security 의존성을 주입한 것 만으로 로그인 화면을 볼 수 있는 것은 이 필터 때문이다.
  • DefaultLogoutPageGeneratingFilter : 명시적으로 비활성화하지 않는다면 로그인 페이지 필터와 마찬가지로 로그아웃 페이지를 노출한다.
  • FilterSecurityInterceptor : 인가 과정을 진행한다.

이런 필터들을 통해 Spring Security 로 로그인, 로그아웃을 구현할 수 있다. 이제 구현을 하기 위해 필요한 설정들을 알아봐야 한다.

Spring Security 설정 : SecurityConfig

@EnableWebSecurity 어노테이션 삽입한다. 예시 코드는 아래와 같다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    public DefaultSecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(r -> 
                        r.requestMatchers("/", "/home").permitAll()
                                .anyRequest().authenticated()
                )
                .formLogin(l -> l.loginPage("/login")
                        .permitAll())
                .logout(LogoutConfigurer::permitAll)
                .httpBasic(Customizer.withDefaults());
        
        return http.build();
    }
}
  1. @Configuration 과 @EnableWebSecurity 를 함께 사용하여 Spring Security 설정 활성화
  2. "/" 또는 "/home" 경로로 들어오는 요청 .antMatchers() 는 인증이 필요하지 않다 .permitAll()
  3. 다른 모든 요청 .anyRequest() 는 인증이 필요하다 .authenticated()
  4. 로그인 페이지 .loginPage() 는 Spring Security가 제공하는 기본 로그인 페이지가 아닌 "/login" 경로에 매핑된 커스텀 로그인 페이지를 사용한다. (로그인 페이지는 permitAll() 해두어야 한다. 로그인 페이지에 접근하는데 인증을 요구할 수는 없으니까)
  5. 로그아웃 페이지도 동일하다.
  6. Basic Auth를 허용하는 경우, .httpBasic() 을 통해 설정할 수 있다.

Spring Security Config DSL 사용방법

설정의 목적은 아래와 같다.

  1. 어떤 URL을 보호(authenticated())하고 허용(permitAll())할 것인가
  2. 어떤 인증 방법(로그인 화면, Basic Auth 사용 여부 등)을 사용할 것인가
  3. 전반적인 다른 Security 설정

위 예시와 같은 코드의 형태로 설정할 수 있다.

  1. 어떤 URL .anyRequest() 에 대한 인증 여부 설정 .authenticated()
  2. 로그인 화면 기본 설정 여부 .formLogin()
  3. HTTP Basic Auth 사용 여부 .httpBasic

이제 @Configuration 어노테이션을 활용한 Spring Security 기본 설정을 할 수 있게 되었다.

하지만 중요한 부분이 남아있다. 위의 AuthenticationFilter에서, 추출한 username과 password를 어떻게 검증할 것인지, 즉 Spring Security 인증을 어떻게 구현할 것인지이다.

Spring Security를 사용한 인증

Spring Security로 인증을 구현하려면 일반적으로 3가지 방식이 있다.

  1. 가장 보편적인 방식 : DB에 저장된 유저 정보로부터 username과 암호화된(hashed) password에 접근하는 방법
  2. 다른 방식 : 유저 정보를 제3자가 관리하는 프로덕트에 요청하여 접근하는 방법 (ex. Atlassian Crowd)
  3. 또 다른 방식 : OAuth2 + JWT를 활용하는 방법 (ex. Google ID를 사용해 로그인)

한 어플리케이션에서 여러 가지 방식을 사용해 인증을 구현할 수 있고, 이를 각각 다른 @Bean으로 정의해야 한다. 이 중 OAuth2 는 양이 방대하므로 위 2가지 방식에 대해서 알아본다.

1. UserDetailsService : 유저의 password에 접근하는 방법

유저 정보가 저장된 DB 테이블에 접근할 때 가장 중요한 column 은 암호화된 password가 저장된 column이다.

create table users (id int auto_increment primary key, username varchar(255), password varchar(255));

이 경우 Spring Security에서는 인증을 위해 2가지 bean이 필요하다.

  • UserDetailsService
  • PasswordEncoder

UserDetailsService의 기본 코드는 아래와 같다.

public class CustomUserDetailsService implements UserDetailsService {
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return new CustomUserDetails();
    }
}

public class CustomUserDetails implements UserDetails {
    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return null;
    }
    
    ...
}

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    ...
    
    @Bean
    public UserDetailsService userDetailsService() {
        return new CustomUserDetailsService();
    }
}
  • UserDetails 객체를 반환하는 loadByUsername() 메서드를 오버라이딩해야한다. 파라미터는 username 한가지이며, password는 받지 않는다.
  • UserDetails 객체를 유저의 username을 통해 DB에서 조회한다.
  • UserDetails 객체에는 조회한 계정의 활성화 여부나 password 만료 여부를 판단하는 다른 메서드들이 존재하지만, 여기서는 다루지 않는다.
  • loadByUsername() 메서드를 오버라이딩하지 않고 Spring Security가 제공하는 기본값을 사용해도 된다.

기본 제공되는 구현체 사용

UserDetailsService나 UserDetails 인터페이스는 위에 언급한대로 구현이 가능하지만, Spring Security가 제공하는 기성품 구현체를 사용해서 설정을 간소화할수도 있다.

  • JdbcUserDetailsManager : JDBC(DB) 기반 UserDetailsService. 자체 설계한 User 테이블의 column과 매핑할 수 있다.
  • InMemoryUserDetailsManager : 모든 UserDetails 객체가 메모리에 저장되어 있으며, 테스트용으로 적합하다.
  • org.springframework.security.core.userdetail.User : Spring 에서 기본 제공하는 UserDetails 구현체. 이 객체를 상속해서 UserDetails를 커스텀할 수 있다.

HTTP Basic Auth의 UserDetails 사용

HTTP Basic Auth 의 경우 로그인을 시도했을 때 UserDetailsService 에서 일어나는 일을 생각해보자.

  1. HTTP Basic Auth 헤더에서 username:password 를 추출해 필터로 보낸다.
  2. CustomUserDetailsService 에서 username을 사용해 유저 객체를 조회하고, 암호화된 password를 얻는다.
  3. 헤더에서 얻은 password를 암호화하여 UserDetails의 암호화된 password와 비교한다. 일치하면 인증에 성공한다.

여기서 3번 과정의 암호화는 어떻게 일어나는가?

PasswordEncoder

Spring Security는 어플리케이션에서 사용되는 password에 대한 암호화 알고리즘을 자동으로 예측해주지는 않는다. 그러므로 PasswordEncoder Bean을 정의해주어야 한다.

Spring Security에서 기본값으로 사용하고 있는 Bcrypt PasswordEncoder를 Bean으로 등록해준다.

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

하지만 만약 암호화 방식이 다른 레거시 유저 정보를 보유하고 있는 서비스라면? Bcrypt 방식뿐 아니라 다른 암호화 알고리즘도 필요하다.

  @Bean
    public PasswordEncoder passwordEncoder() {
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

이 delegating PasswordEncoder는 저장된 암호화된 비밀번호 앞에 붙은 접두사 {}로 암호화 알고리즘을 판단한다.

예를 들어 password가 {sha256}5ffa39f5757a0dad5dfada519d02c6b71b61ab1df51b4ed1f3beed6abe0ff5f6 이라면

  1. {} 안의 접두사를 보고 password 암호화 방식이 sha256 임을 판단
  2. DelegatingPasswordEncoder 가 알고리즘에 적합한 인코더인 SHA256Encoder 선택
  3. 제출된 password를 인코딩한 후 저장된 패스워드와 일치 여부 판단

2. AuthenticationProvider : 유저의 패스워드에는 접근하지 않음

이번에는 계정 정보가 보유하고 있는 DB 테이블이 아닌 제3자 프로덕트(ex. Atlassian Crawd)에 저장되어 있는 경우를 살펴본다.

이런 경우 아래와 같은 차이점이 있다.

  • 패스워드 정보를 가지고 있지 않고, 제3자 프로덕트에 패스워드를 달라고 요청할 수도 없다.
  • 따라서 요청받은 username, password를 가지고 제3자 프로덕트에 로그인 가능 여부를 요청하는 API를 전송해야 한다.

이 경우에는 위에서 구현했던 UserDetailsService가 아닌 AuthenticationProvider Bean을 사용하여야 한다.

public class CustomAuthenticationProvider implements AuthenticationProvider {
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        String username = authentication.getPrincipal().toString();
        String password = authentication.getCredentials().toString();
        Collection<? extends GrantedAuthority> grantedAuthorities = authentication.getAuthorities();

        User user = new User(username, password, grantedAuthorities); // external service
        if (user == null) {
            throw new AuthenticationException("could not login") {};
        }
        return new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), user.getAuthorities());
    }
		
		...
}

    
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    ...
    
		@Bean
    public AuthenticationProvider authenticationProvider() {
        return new CustomAuthenticationProvider();
    }
}
  1. 외부 서비스에서 UserDetails 응답을 받아온다.
  2. 인증에 실패한 경우 예외를 처리한다.
  3. 인증에 성공하면 UsernamePasswordAuthenticationToken을 반환한다. UsernamePasswordAuthenticationToken은 Authentication 인터페이스 구현체로, 인증 여부를 true 로 변경하는 역할을 한다.

HTTP Basic Auth의 AuthenticationProvider 사용

HTTP Basic Auth 의 경우 로그인을 시도했을 때 AuthenticationProvider에서는

  1. HTTP Basic Auth 헤더에서 username:password 를 추출해 필터로 보낸다.
  2. AuthenticationProvider를 호출하여 외부 서비스로부터 인증 여부를 반환받는다.

Spring Security의 인가

위에서는 Spring Security에서 username과 password를 검증하는 인증 과정을 살펴보았다.

이번에는 유저의 role이나 authority를 판단하는 인가 과정을 살펴본다.

인가 (Authorization) 란 무엇인가

쇼핑몰을 하나 운영하고 있다고 가정해보자. 쇼핑몰은 아래와 같은 구성요소들이 있다.

위 구성요소에 따르면 각 역할에는 제약이 따른다.

  • 고객은 콜센터나 어드민 영역에는 접근할 수 없고, 쇼핑몰 기능만 이용할 수 있다.
  • 콜센터 직원은 어드민 영역에 접근할 수 없다.
  • 관리자는 쇼핑몰, 콜센터, 어드민 페이지 모두 접근할 수 있다.

각 역할에 따라 접근할 수 있는 권한을 부여하는 것을 인가라고 한다.

Authority와 Role의 차이점

  • Authority는 user, admin과 같이 문자열 그 자체이다.
  • Role 은 ‘ROLE_’이라는 접두사가 붙는다. 즉 admin 이라는 Authority는 ROLE_ADMIN 이라는 Role이다.

둘의 차이점에 대해서는 StackOverflow에서 의견이 분분하지만, 명확하게 밝혀져 있지는 않다.

SimpleGrantedAuthority 사용

Spring Security는 Authority의 문자열을 그대로 사용하기 보다는 클래스 구현을 선호한다.

public final class SimpleGrantedAuthority implements GrantedAuthority {

	private final String role;

    @Override
	public String getAuthority() {
		return role;
	}
}

Spring Security에는 GrantedAuthority에 대한 더 많은 구현체가 있지만, 여기서 다루지는 않는다.

1. UserDetailsService에서의 유저 권한 얻기

앞서 UserDetailsService는 유저 정보를 DB에 저장한 경우에 사용한다고 언급하였다.

UserDetailsService에서 유저 권한을 얻으려면 User 테이블에 authority column을 추가하여야 한다.

authority column 은 문자열 타입이지만, 콤마(,)로 구분할 수 있는 문자열이다. (ex. ROLE_USER, ROLE_ADMIN. Spring Security는 ROLE_ 접두사가 붙은 authority를 role로 판단해준다)

유저에 대한 Authority를 column이 아닌 table로 설계할 수도 있지만, 여기서는 column 방식으로 조회해본다.

public class MyDatabaseUserDetailsService implements UserDetailsService {

  UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
     User user = userDao.findByUsername(username);
     List<SimpleGrantedAuthority> grantedAuthorities = user.getAuthorities().map(authority -> new SimpleGrantedAuthority(authority)).collect(Collectors.toList());
     return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(), grantedAuthorities);
  }

}

DB에서 유저의 Authorities 문자열을 조회하였고, UserDetails 기본 구현체를 사용했으므로 자동으로 매핑해준다.

2. AuthenticationProvider에서의 유저 권한 얻기

앞서 AuthenticationProvider는 제3자 제공 어플리케이션으로부터 인증 정보를 얻는 경우 사용한다고 언급하였다.

이 경우에는 제3자가 권한 처리를 제공하는 방법을 알아야 한다. 예시로 든 Atlassian Crawd에서는 Role의 의미를 Group 이라는 집합으로 관리한다.

인증을 제3자 프로덕트에 의존하므로, Spring Security와 매핑하는 과정이 필요하다.

public class CustomAuthenticationProvider implements AuthenticationProvider {

    Authentication authenticate(Authentication authentication)
            throws AuthenticationException {
        String username = authentication.getPrincipal().toString();
        String password = authentication.getCredentials().toString();

        User user = call3rdPartyRestService(username, password); 
        if (user == null) {
            throw new AuthenticationException("could not login");
        }
        return new UserNamePasswordAuthenticationToken(user.getUsername(), user.getPassword(), mapToAuthorities(user.getGroups())); 
    }
}

SecurityConfig Authority 설정

권한을 설정하였으므로 각 URI마다 권한 설정을 매핑하여야한다.

@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

	@Override
	protected void configure(HttpSecurity http) throws Exception {
        http
          .authorizeRequests()
            .antMatchers("/admin").hasAuthority("ROLE_ADMIN")
            .antMatchers("/callcenter").hasAnyAuthority("ROLE_ADMIN", "ROLE_CALLCENTER") 
            .anyRequest().authenticated()
            .and()
         .formLogin()
           .and()
         .httpBasic();
	}
}
  • /admin 페이지에 접근하기 위해서는 ‘인증’이 요구되고, ADMIN 권한을 가지고 있어야 한다.
  • /callcenter 페이지에 접근하기 위해서는 ‘인증’이 요구되고, ADMIN 또는 CALLCENTER 권한을 가지고 있어야 한다.
  • 다른 경로들은 특별한 권한을 필요로 하지는 않지만, 인증된 상태여야 한다.

hasAuthority() 대신 hasRole()을 사용할 수도 있다.

  http
    .authorizeRequests()
      .antMatchers("/admin").hasRole("ADMIN") 
      .antMatchers("/callcenter").hasAnyRole("ADMIN", "CALLCENTER") 

hasAccess()와 SpEL(Spring Expression Language)

인증, 인가와 더불어 더욱 커스터마이징된 인증 설정을 구성하고 싶다면 hasAccess() 와 SpEL을 사용한다.

http
    .authorizeRequests()
      .antMatchers("/admin").access("hasRole('admin') and hasIpAddress('192.168.1.0/24') and @myCustomBean.checkAccess(authentication,request)") 

위 코드에서는 아래와 같은 사항을 요구하고 있다.

  • ADMIN 권한을 가질 것
  • 접속 IP 주소가 192.168.1.0일 것
  • Custom Bean의 인증 체크 로직을 통과할 것

보안 공격 방어

Spring Security는 timing attack, cache control attack, content sniffing, click jacking, cross-site scripting 등 여러가지 보안 공격에 대한 방어 기능을 제공한다.

이곳에서 이 공격들이 무엇을 의미하는지 자세히 알아볼 수는 없지만, 가장 먼저 만나게 될 방어 방법인 CSRF에 대해서 살펴본다.

CSRF : Cross Site Request Forgery

사이트 간 요청 위조 공격인 CSRF에 대해 Spring Security가 제공하는 방어 방법은 기본값으로 모든 POST 요청에 대해 CSRF 토큰을 검증하는 것이다.

이것은 무엇을 의미하는가?

CSRF와 Server Side HTML

은행 어플리케이션의 송금 요청이나 일반 어플리케이션의 로그인 요청은 @Controller로 들어오는 form을 받게 된다.

<form action="/transfer" method="post">  
  <input type="text" name="amount"/>
  <input type="text" name="routingNumber"/>
  <input type="text" name="account"/>
  <input type="submit" value="Transfer"/>
</form>

Spring Security가 활성화되면, 이 form 으로 POST요청을 보낼 수 없게 된다.

Spring Security의 CSRFFilter가 POST또는 DELETE 요청에 대해 CSRF 토큰이 있는지 여부를 검사하기 때문이다.

Spring Security는 CSRF토큰을 세션 단위로 생성하여 세션에 저장하고, 모든 HTML요소에 들어있는지 검사한다.

CSRF와 Thymeleaf

Thymeleaf라는 Spring과 호환되는 템플릿 엔진을 사용하면, CSRF 토큰을 HTML에 아래와 같이 삽입할 수 있다.

  1. ${} 을 사용하여 CSRF 파라미터를 수동으로 추가하는 방법
  2. th: 키워드를 사용하여 자동으로 추가하는 방법
<form action="/transfer" method="post">  
  <input type="text" name="amount"/>
  <input type="text" name="routingNumber"/>
  <input type="text" name="account"/>
  <input type="submit" value="Transfer"/>
  <input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}"/>
</form>

<form th:action="/transfer" method="post">  
  <input type="text" name="amount"/>
  <input type="text" name="routingNumber"/>
  <input type="text" name="account"/>
  <input type="submit" value="Transfer"/>
</form>

CSRF와 다른 템플릿 엔진

그 밖에 다른 템플릿 엔진을 사용한다면, ServletRequest를 통하여 @Controller에 직접 CSRF 토큰을 주입할 수 있다.

@Controller
public class MyController {
    @GetMaping("/login")
    public String login(Model model, CsrfToken token) {
        return "/templates/login";
    }
}

CSRF와 Javascript App (React, Angular ..)

Javascript SPA(Single Page Application)의 경우 다른 방법으로 설정할 수 있다.

  1. Spring Security가 CookieCsrfTokenRepository를 사용하도록 설정한다.
    • Spring Security는 CSRF 토큰을 쿠키에 “XSRF-TOKEN” 이라는 이름으로 브라우저에 전송한다.
  2. Javascript App이 쿠키에서 XSRF-TOKEN 값을 얻어 모든 POST 요청마다 헤더에 첨부하도록 한다.

CSRF 비활성화

만약 어플리케이션이 Stateless 한 REST API만을 제공한다면, CSRF 토큰은 필요하지 않다.

그 경우 CSRF 옵션을 아래와 같은 방법으로 비활성화할 수 있다.

@EnableWebSecurity
@Configuration
public class WebSecurityConfig extends
   WebSecurityConfigurerAdapter {

  @Override
  protected void configure(HttpSecurity http) throws Exception {
    http
      .csrf().disable();
  }
}

Spring Integrations

Spring Framework에 Spring Security 적용하기

위에서 살펴본 Spring Framework에서의 Spring Security 적용은 엔드포인트(URL) 보안이었다.

이제 로직 자체, 즉 Bean(Controller, Service, Reposirtory, Component)에 대한 보안을 알아본다.

메서드 보안 (Method Security)

메서드 보안은 SecurityConfig에 @EnableGlobalMethodSecurity 어노테이션을 설정함으로서 전역 적용할 수 있다.

@Configuration
@EnableGlobalMethodSecurity(
  prePostEnabled = true,
  securedEnabled = true, 
  jsr250Enabled = true) 
public class YourSecurityConfig extends WebSecurityConfigurerAdapter{
}
  • prePostEnabled : @PreAuthorize와 @PostAuthorize 어노테이션이 붙은 로직을 검사한다.
  • securedEnabled : @Secured 어노테이션이 붙은 로직을 검사한다.
  • jsr250Enabled : @RolesAllowed 어노테이션이 붙은 로직을 검사한다.

@PreAuthorize, @Secured, @RolesAllowed 의 차이점

  • @Secured와 @RolesAllowed의 기능은 기본적으로 동일하지만, 전자는 Spring Security가 제공하는 어노테이션이고 후자는 Java 표준 어노테이션(javax.annotation.api)이라는 데 차이가 있다. 둘다 권한 판단에 사용한다.
  • @PreAuthorize와 @PostAuthorize는 Spring Security에서 제공하는 어노테이션으로, 권한 판단 뿐 아니라 SpEL을 사용할 수 있다.
  • 위 어노테이션 모두 권한 인가에 실패할 경우 AccessDeniedException을 발생시킨다.

작동하는 예시를 살펴보자.

@Service
public class SomeService {

    @Secured("ROLE_CALLCENTER") 
    // @RolesAllowed("ADMIN")
    public BankAccountInfo get(...) {

    }

    @PreAuthorize("isAnonymous()")
    // @PreAuthorize("#contact.name == principal.name")
    // @PreAuthorize("ROLE_ADMIN")
    public void trackVisit(Long id);

    }
}

Spring Web MVC에 Spring Security 적용하기

Spring MVC에 Spring Security를 적용하는 경우 다음 기능을 제공받을 수 있다.

  • antMatchers나 regexMatchers뿐 아니라 mvcMatchers를 사용할 수 있다.
    • mvcMatchers를 사용하면 URI 보안 설정에 @RequestMapping과 동일한 형식을 사용할 수 있다.
  • Controller에 인증된 Principal 객체를 주입할 수 있다.
    • Principal 객체는 UserDetailsService 또는 AuthenaticationProvider에서 제공된 인증된 유저 객체이다. 인증되지 않은 경우 null을 반환한다.
  • Controller에 CSRF 토큰을 주입할 수 있다.
  • 비동기 요청에 대한 보안 설정을 핸들링할 수 있다.
@Controller
public class MyController {

    @RequestMapping("/messages/inbox")
    public ModelAndView findMessagesForUser(@AuthenticationPrincipal CustomUser customUser, CsrfToken token) {
			...
    }
}

만약 @AuthenticationPrincipal 을 사용하지 않고 로그인 사용자 정보를 가져오려면, 아래와 같은 코드를 사용해야 한다.

@Controller
public class MyController {

    @RequestMapping("/messages/inbox")
    public ModelAndView findMessagesForUser(CsrfToken token) {
         SecurityContext context = SecurityContextHolder.getContext();
         Authentication authentication = context.getAuthentication();

         if (authentication != null && authentication.getPrincipal() instanceof UserDetails) {
             CustomUser customUser = (CustomUser) authentication.getPrincipal();
         }
    }
}

Spring Boot에 Spring Security 적용하기

Spring Boot에서 의존성으로 spring-boot-starter-security를 주입하면, 위의 설정을 전부 하지 않아도 기본값으로 SecurityConfig가 설정된다.

나머지는 커스터마이징하는 자의 몫이다.

Thymeleaf에 Spring Security 적용하기

thymeleaf는 Spring Security와의 호환을 제공하는 템플릿 엔진이다.

<div sec:authorize="isAuthenticated()">
  This content is only shown to authenticated users.
</div>
<div sec:authorize="hasRole('ROLE_ADMIN')">
  This content is only shown to administrators.
</div>
<div sec:authorize="hasRole('ROLE_USER')">
  This content is only shown to users.
</div>

마치며

이번 글은 Spring Security에 대한 전반적 내용을 정리하느라 많이 길어졌다.

마지막으로 Spring Security가 로그인을 어떻게 처리하는지 요약 코드를 보고 마무리한다.

UserDetails principal = userDetailsService.loadUserByUsername(username);
Authentication authentication = new UsernamePasswordAuthenticationToken(principal, principal.getPassword(), principal.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);

Resources

'Spring Security' 카테고리의 다른 글

JWT란 무엇인가  (0) 2024.04.27
Spring Security의 OAuth2  (0) 2024.04.26
Spring Security로 Basic Authentication 적용하기  (0) 2024.04.17
Spring Security란 무엇인가  (0) 2024.04.16