[Spring Security] (1) 기본 구성

Park Yeongseo·2023년 8월 28일
1

Spring Security

목록 보기
1/13
post-thumbnail

[1] 스프링 시큐리티

1. 기초 예제

Spring Security를 Dependencies에 추가하고 아래와 같은 컨트롤러를 추가해보자.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

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

서버를 돌리고 콘솔을 확인하면 다음과 같은 결과가 표시된 것을 볼 수 있다.

Using generated security password: f0a16191-a13a-4e30-916b-949a9fd1489c

(현재 포트는 8080을 사용하고 있음)

이후 http://localhost:8080을 통해 접속하면 401이 반환되는데, 이는 인증을 위한 자격증명을 제공하지 않았기 때문이다.

브라우저로 http://localhost:8080에 접속하면, Username과 Password를 입력하는 로그인 창이 나타나는데, 이는 스프링 시큐리티에서 기본적으로 제공하는 폼 로그인 화면이다. Username에 “user”, Password에 위에서 나온 비밀번호를 입력하고 나면 “Hello”가 출력됨을 확인할 수 있다. 혹은
터미널에 curl -u user:f0a16191-a13a-4e30-916b-949a9fd1489c http://localhost:8080/hello 를 입력해도 마찬가지다.

이는 user:f0a16191-a13a-4e30-916b-949a9fd1489c를 다음과 같이

echo -n user:f0a16191-a13a-4e30-916b-949a9fd1489c | base64

base64로 인코딩해서 얻은 dXNlcjpmMGExNjE5MS1hMTNhLTRlMzAtOTE2Yi05NDlhOWZkMTQ4OWM= 를 요청 헤더에 “Authorization: Basic dXNlcjpmMGExNjE5MS1hMTNhLTRlMzAtOTE2Yi05NDlhOWZkMTQ4OWM=”의 형식으로 추가해서 얻은 효과이다.

2. 기본 구성

스프링 시큐리티는 기본적으로 다음과 같은 플로우를 가지고 있다.

  1. 인증 필터가 인증 요청을 가로채 인증 책임을 인증 관리자에 위임. 응답을 바탕으로 보안 컨텍스트 구성
  2. 인증 관리자 → 인증 논리를 구현하는 인증 공급자를 이용
  3. 공급자는 사용자 세부 정보 서비스로 사용자를 찾고 암호 인코더로 암호 검증
  4. 인증 결과가 필터에 반환
  5. 인증된 엔티티에 관한 세부 정보가 보안 컨텍스트에 저장
  6. 보안 컨텍스트는 인증 프로세스 후 인증 데이터를 유지

여기서 중요한 다음의 빈들이 자동으로 구성된다.

자동으로 구성되는 빈

  • UserDetailsService

사용자에 대한 세부 정보는 스프링 시큐리티에서 UserDetailsService 계약을 구현하는 객체가 관리한다. 기본 구현에서는 내부 메모리에 기본 자격 증명을 등록하는 일만 한다. 사용자 이름은 user, 기본 암호는 UUID형식으로 스프링 컨텍스트가 로드될 때 생성되고 콘솔에 출력된다.(위 예제 참고)

  • PasswordEncoder

암호를 인코딩하거나 인코딩된 암호가 기존 인코딩과 일치하는지 확인한다. UserDetailsService의 구현을 바꿀 때에는 PasswordEncoder도 지정해줘야 한다.

스프링 부트에서는 기본적으로 사용자명:암호를 base64로 인코딩하고 HTTP Authorization 헤더를 통해 보낸다.

  • AuthenticationProvider : 인증 논리를 정의하고, 사용자와 암호의 관리를 위임한다. 기본 구현은 UserDetailsService, PasswordEncoder에 제공된 기본 구현을 이용한다.

3. 기본 구성 재정의

위 빈들은 스타일에 따라 유연하게 재정의할 수 있다. 근데 여러 스타일을 섞어서 쓰는 건 좋지 않다. 코드가 복잡해지고 유지 관리하기도 어려워지기 때문이다.

UserDetailsSerivce 구성 요소 재정의

(1) InMemoryUserDetailsManager 구현을 이용한 구현

메모리에 자격 증명을 저장해서 스프링 시큐리티가 요청을 인증할 때 이용한다. 운영 단계 애플리케이션을 위한 건 아니다. config 패키지에 별도로 구성 클래스를 선언, 정의해보자.

@Configuration
public class SecurityConfig{
	@Bean
	public UserDetailsService userDetailsSerivce(){
		var userDetailsService = new InMemoryUserDetailsManager();
		return userDetailsService;	
	}
}

위를 쓰면 콘솔에 비밀번호가 뜨지 않게 되지만 엔드포인트에 접근할 수는 없게 된다.

  • 사용자가 없고
  • PasswordEncoder가 없기 때문

엔드포인트에의 접근을 가능하게 하려면 아래를 모두 해줘야 한다.

  1. 자격 증명이 있는 사용자를 만들어야 한다
  2. 사용자를 UserDetailsService에서 관리하도록 추가해야 한다
  3. 암호를 검증하는 PasswordEncoder 형식의 빈을 추가해야 한다
@Configuration
public class SecurityConfig{
	@Bean
	public UserDetailsService userDetailsSerivce(){
		var userDetailsService = new InMemoryUserDetailsManager();
        //지정한 이름, 패스워드, 권한을 가지는 유저 객체를 만들고
		var user = User.withUsername("john")
							.password("12345")
							.authorities("read")
							.build();
        //이를 userDetailsService에 추가.                       
        userDetailsService.createUser(user);
		return userDetailsService;	
	}

  //암호를 검증하는 `PasswordEncoder` 형식의 빈을 추가
  @Bean
  public PasswordEncoder passwordEncoder() {
      //NoOpPasswordEncoder는 암호화나 해시를 적용하지 않고 일반 텍스트처럼 암호를 처리.
      return NoOpPasswordEncoder.getInstance();
  }

서버를 켜고 기본 예제에서 했던 것처럼 사용자 명은 “john”, password는 “12345”로 로그인하면, “Hello”가 출력된 것을 확인할 수 있다.

엔드포인트 권한 부여 구성 재정의

HTTP Basic 인증은 대부분의 애플리케이션 아키텍처에 적합하지 않다. 이를 앱에 맞게 변경하고 싶을 때도 있을 수 있고, 모든 엔드포인트를 보호할 필요도, 모든 엔드포인트에 같은 권한 부여 규칙을 선택할 필요도 없기 때문이다.

«스프링 시큐리티 인 액션»에서는 다음과 같이 WebSecurityConfigurerAdapter클래스를 확장하고 configure(HttpSecurity http) 메서드를 오버라이드 해서 사용하고 있다. 하지만 스프링 시큐리티 6.0 버전 이후로 WebSecurityConfigurerAdapter는 deprecated 되었기 때문에, 아래의 configure()를 쓰는 방법은 사용할 수 없게 됐다.

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter{

  //생략  
  @Override
  protected void configure(HttpSecurity http) throws Exception{
      http:httpBasic();
      http:authorizeRequests()
          .anyRequest().authenticated();//모든 요청에 인증이 필요
          //authenticated() -> permitAll()로 변경하면 모든 엔드포인트를 인증없이 요청 가능.
  }
}

시큐리티 6.0 이후로는 다음과 같이 써야 한다.

@Configuration
@EnableWebSecurity
public class SecurityConfig {

	//생략
    @Bean
    public SecurityFilterChain httpFilterChain (HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authorizeRequests) -> authorizeRequests
                        .anyRequest().authenticated()
                )
	}
}

다른 방법의 구성 재정의

UserDetailsServicePasswordEncoder를 다른 빈으로 따로 등록하지 않고 동시에 등록하는 방법도 있다.

//생략
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
	var userDetailsService = new InMemoryUserDetailsManager();
  
	var user = User.withUsername("john")
                .password("12345")
                .authorities("read")
                .build();
  userDetailsService.createUser(user);
  
	auth.userDetailsService(userDetailsService)
			.passwordEncoder(NoOpPasswordEncoder.getInstance());
	//configure 메서드 안에서 UserDetailsService와 PasswordEncoder를 모두 등록
}

기본 구성 재정의에서와 같이 컨텍스트에 빈을 추가하는 방법은 기존 구현체를 다른 구현체로 교체하고 주입해야 하는 경우에 용이하다. 하지만 그럴 필요가 없는 경우에는 위의 방법도 마찬가지로 좋다. 하지만 섞어서 쓰지는 말자.

보통은 사용자 정보를 DB나 다른 시스템에서 가져와야 하지만, 인 메모리 방식으로 사용자를 구성해야 하는 경우는 아래와 같이 쓸 수도 있다. 책임 분리가 되지 않으므로 권장되지는 않는다.

//생략
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception{
	auth.inMemoryAuthentication()
			.withUser("john")
			.password("12345")
      		.authorities("read")
	.and()
			.passwordEncoder(NoOpPasswordEncoder.getInstance());
}

AuthenticationProvider 구현 재정의

AuthenticationProviderUserDetailsService, PasswordEncoder에 작업을 위임한다. 하지만 이것도 맞춤 구성을 할 수 있다.

@Component
public class CustomAuthenticationProvider implements AuthenticationProvider{
	
	@Override
	public Authentication authenticate(Authentication authentication) 
			throws AuthenticationException {
		//인증의 전체 논리.
		String username = authentication.getName();//Principal 인터페이스의 getName() 상속
		String password = String.valueOf(authentication.getCredentials());
		
		if ("john".equals(username) && "12345".equals(password)) {
			return new UsernamePasswordAuthenticationToken(username, password, Arrays.asList());
		}
		else {
			throw new AuthenticationCredentialsNotFoundException("Error!");
		}
	}

	@Override
	public boolean supports(Class<?> authenticationType){
		//Authentication 형식의 구현을 추가
	}

}

위에서는 UserDetailsSerivce, PasswordEncoder의 책임을 if-else절이 대체하도록 구현, 인증 논리를 대체하고 있다. 그러나 웬만하면 논리를 분리하는 것이 좋기에 좋지는 않다. 물론 이게 더 유용한 경우가 있을 수도 있다.

여러 구성 클래스 이용

하나의 구성 클래스만 이용하는 것보다 구성 클래스도 책임을 분리하는 것이 좋다. 운영 단계로 넘어갈 수록 복잡해질 것이기 때문이다. 단 항상 한 클래스가 하나의 책임을 맡도록 하는 것이 바람직하다.


참고문헌 : Laurentiu Splica, 스프링 시큐리티 인 액션, 위키북스, 2022

0개의 댓글