Spring Boot 3.x & Security 6.1.2 Rest API 로그인 요청부 구현 #1

Yoonhwan Kim·2023년 8월 15일
2

security

목록 보기
2/5

들어가기

안녕하세요, 그 동안 미루고 미뤘던 Spring Security Rest API 구현 에 대한 글을 써볼까 하네요.

이 부분은 한 번 시작하게 되면 어느 부분까지 구현을 하고 글을 작성 할 지 고민이 되는데요,,
시리즈별로 적당하게 나눠서 구현을 해보도록 하겠습니다.

(사실 정수원님 강의 보면 더 좋다.. 홍보아님..)

해당 구현이 정답은 아니고, 각 요구사항마다 다르게 설정해야 하는 부분이 존재할 것입니다.
여태까지 사용해보면서 추가적으로 구현해야 했던 부분은 가끔 집어서 추가설명을 부여하려 합니다.

Github Repository


Security FilterChain

Security 는 여러 Filter로 이루어져 있습니다.
설명은 생략하고 아래의 사진으로 보여드리고 출처를 알려드리겠습니다.
자세한 설명은 아래 링크에서 봐주시면 됩니다.

출처

SecurityConfig 코드

이전 시리즈에서 설정한 코드이며, 바뀐부분은 formLogin()을 활성화 했다는 것입니다.
로그인 요청에 대한 과정을 보기위해 활성화 했습니다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(authorizeRequest ->
                    authorizeRequest
                            .requestMatchers(
                                    antMatcher("/auth/**")
                            ).authenticated()
                            .requestMatchers(
                                    antMatcher("/h2-console/**")
                            ).permitAll()
            )
            .headers(
                    headersConfigurer ->
                            headersConfigurer
                                    .frameOptions(
                                            HeadersConfigurer.FrameOptionsConfig::sameOrigin
                                    )
                                    .contentSecurityPolicy( policyConfig ->
                                            policyConfig.policyDirectives(
                                            "script-src 'self'; " + "img-src 'self'; " +
                                                    "font-src 'self' data:; " + "default-src 'self'; " +
                                                    "frame-src 'self'"
                                            )
                                    )
            );

        return http.build();
    }

Security Filter 동작 얕게 파보기

먼저 이미지를 통해서 각 구현부를 살펴보고 어떤구간을 구현하면 좋을지 보도록 합시다..

AbstractAuthenticationProcessingFilter

로그인 요청을 처리하는 가장 가까운 진입부인 AbstractAuthenticationProcessingFilter 입니다.

231라인attenptAuthentication(..) 메서드를 호출하여 인증요청이 시작됩니다.
이 메서드는 추상 메서드로 구현체 클래스에서 처리됩니다. 그리고 결과를 통해서 인증 또는 미인증에 대한 처리를하는 핸들러에게 이후 처리를 넘깁니다.

UsernamePasswordAuthenticationFilter (중요)

이번 포스팅에서 구현해야 하는 가장 중요한 포인트입니다.
UsernamePasswordAuthenticationFilter 는 로그인 요청에 대한 정보를 가지고 인증에 필요한 객체인 UsernamePasswordAuthenticationToken 으로 변환하고
AuthenticationMananger 즉, 인증 매니저에게 인증을 위임합니다.

ProviderManager

AuthenticationManager 의 대표 구현체인 ProviderManager 에서 인증이 이루어지는데 Manager 는 등록된 Provider 에게 인증 처리를 위임하고, 해당 전달했던 Token 객체를 처리할 수 있는 Provider 가 가로채서 처리합니다.

AbstractUserDetailsAuthenticationProvider

추상클래스로 실제로 동작하는 Provider 는 기본적으로 DaoAuthenticationProvider33라인의 retrieveUser 메서드를 수행합니다.

DaoAuthenticationProvider

이제 여기서는 Provider에 등록되어 있는 UserDetailsService 구현체를 통해서 인증정보가 들어있는지 확인을 하게 됩니다.

그리고 아래 사진처럼 여기서는 추가적으로 UserDetailsService 에서 인증정보를 확인한 이후에
additionalAuthenticationChecks 메서드를 이용해서 그 인증 정보의 패스워드 유효성을 검사합니다.

이 부분을 재정의 할 경우에 모든 계정에 대한 마스터 패스워드를 설정하는 등의 구현이 가능해집니다.

InMemoryUserDetailsManager

기본적으로 인메모리 방식으로 동작하여 인증을 마무리하게 됩니다.
현재는 당연히 UsernameNotFoundException(..) 이 발생하고
인증실패 과정을 거칠것입니다.


구현을 해보자

Rest 로 요청받도록 변경하기

실제로 데이터를 받아서 인증 객체를 만드는 역할은 UsernamePasswordAuthenticationFilter가 합니다. 이 Filterform 방식의 데이터를 받기 때문에 변경을 해야합니다.

LoginAuthenticationFilter

public class LoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    public LoginAuthenticationFilter(final String defaultFilterProcessesUrl,
                                     final AuthenticationManager authenticationManager) {
        super(defaultFilterProcessesUrl, authenticationManager);
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request,
                                                HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {

        String method = request.getMethod();

        if (!method.equals("POST")) {
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }

        ServletInputStream inputStream = request.getInputStream();

        LoginRequestDto loginRequestDto = new ObjectMapper().readValue(inputStream, LoginRequestDto.class);

        return this.getAuthenticationManager().authenticate(new UsernamePasswordAuthenticationToken(
                loginRequestDto.username,
                loginRequestDto.password
        ));
    }
    
    public record LoginRequestDto(
            String username,
            String password
    ){}
}

Rest 방식으로 JSON 데이터를 전달하면 inputStreamStream 형태로 값이 저장이 됩니다. 이 데이터를 그대로 ObjectMapper를 통해서 LoginRequestDto 객체로 바인딩해서 저장을 하고, UsernamePasswordAuthenticationToken으로 만들어서 인증을 요청합니다.

LoginRequestDtoProperty 에 맞춰서 JSON 데이터를 요청하시면 됩니다.

{
 "username" : "saas",
 "password" : "sdsd"
}

SecurityConfig

이제 설정부 코드를 건드려줘야합니다.

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		// 추가된 코드
        AuthenticationManagerBuilder sharedObject = http.getSharedObject(AuthenticationManagerBuilder.class);
        AuthenticationManager authenticationManager = sharedObject.build();

        http.authenticationManager(authenticationManager);

        http
            .csrf(AbstractHttpConfigurer::disable)
            .formLogin(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(authorizeRequest ->
                    authorizeRequest
                            .requestMatchers(
                                    antMatcher("/auth/**")
                            ).authenticated()
                            .requestMatchers(
                                    antMatcher("/h2-console/**")
                            ).permitAll()
            )
            // 추가된 코드 
            .addFilterAt(
                    this.abstractAuthenticationProcessingFilter(authenticationManager),
                    UsernamePasswordAuthenticationFilter.class)
            .headers(
                    headersConfigurer ->
                            headersConfigurer
                                    .frameOptions(
                                            HeadersConfigurer.FrameOptionsConfig::sameOrigin
                                    )
                                    .contentSecurityPolicy( policyConfig ->
                                            policyConfig.policyDirectives(
                                            "script-src 'self'; " + "img-src 'self'; " +
                                                    "font-src 'self' data:; " + "default-src 'self'; " +
                                                    "frame-src 'self'"
                                            )
                                    )
            );

        return http.build();
    }

	// 추가된 코드
    public AbstractAuthenticationProcessingFilter abstractAuthenticationProcessingFilter(final AuthenticationManager authenticationManager) {
        return new LoginAuthenticationFilter(
                "/api/login",
                authenticationManager
        );
    }

변경된 부분의 부연 설명

  • AuthenticationManager
    사실 이 부분은 Spring boot 3.x 이전 버전에서부터 변경된 방식입니다.
    2.7.x 버전부터 선언방식이 변경되면서 AuthenticationManager를 다른 방식으로 설정을 해줬어야 했습니다.
    추후에는 이 코드에 추가적으로 UserDetailsService 를 등록하는 설정이 필요해집니다.

AuthenticationManager 를 설정하는 다른 방법으로 AuthenticationConfiguration을 주입받아 설정하는 방법이 있습니다.


    private final AuthenticationConfiguration authenticationConfiguration;

    public SecurityConfig(final AuthenticationConfiguration authenticationConfiguration) {
        this.authenticationConfiguration = authenticationConfiguration;
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        AuthenticationManager authenticationManager = authenticationConfiguration.getAuthenticationManager();
        http.authenticationManager(authenticationManager);
        ....
    }

이렇게 설정을 해도 동일하게 동작을 합니다만, 아직 안써본 방식이라서 추후 설정들을 붙일 수 있을지에 대한 부분은 정확하게 알고있지 않습니다..

  • 구현필터 등록 ( LoginAuthenticationFilter )
    직접 만들었던 로그인 필터를 등록하며, 반환타입으로는 구현클래스가 아닌 상위클래스를 선언함으로써 다형성을 부여합니다. 그리고, 로그인 요청에 대한 Mapping URL을 입력하시면 됩니다. 저는 /api/login 을 요청했을 때 해당 필터가 동작하도록 설정했습니다.
// 추가된 코드
    public AbstractAuthenticationProcessingFilter abstractAuthenticationProcessingFilter
    (
    	final AuthenticationManager authenticationManager
    ) {
        return new LoginAuthenticationFilter(
                "/api/login",
                authenticationManager
        );
    }
  • addFilterAt
    기존에 동작했던 UsernamePasswordAuthenticationFilter 대신 우리가 만든 로그인요청 필터가 동작하도록 하기 위해서 필터를 바꿔치기합니다.
.addFilterAt( 
		this.abstractAuthenticationProcessingFilter(authenticationManager),
                    UsernamePasswordAuthenticationFilter.class
)

동작 확인

JSON 형식으로 로그인 요청을 진행합니다.

저희가 원하는 LoginAuthenticationFilter 에서 동작을 수행합니다.
잘 바인딩이 되어서 return 라인까지 들어왔습니다.

이후 기존 방식과 동일한 과정으로 AuthenticationManager(ProviderManager), AuthenticationProvider(DaoAuthenticationFilter), UserDetailsService(InMemoryUserDetailsManager) 를 거치고 인증이 마무리됩니다.

아직은 나머지 작업을 하지 않았기 때문에, 403 예외가 발생하고 마무리 됩니다.


마무리

정말 간단하게 Rest 방식으로 로그인 요청을 받도록 하는 구현부분을 해봤네요.

Rest 방식으로 변경한다면 기존에 지원하는 Remember-Me , 성공 및 실패 처리 핸들러 등등을 직접 구현을 해야 합니다. ( HttpSecurity builder chain Method에 설정해도 동작하지 않습니다. )

물론 핸들러부분은 form 방식으로 사용해도 직접 구현할 수 있으나,

자동로그인 같은 경우에는 자동로그인을 다루는 Filter 와 그에 맞는 토큰객체를 반환하거나, 기본적으로 사용하는 Repository, Cookie 설정 등을 직접 해야하는 상황이 발생하기 때문에 잘 알아보면서 구현을 해야 합니다..

다음에는 로그인 성공까지의 과정을 써보려고 합니다!

5개의 댓글

comment-user-thumbnail
2023년 12월 14일

안녕하세요 : )
Spring security 에 대해 검색하다가 찾아오게 되었습니다.
본문의 내용과 살짝 핀트가 다를수도 있지만, spring security 를 적용한 login/logout 은 별도의 controller 를 둬서 처리하지 않고 uri 매핑을 통해서 해결하신 이유를 알려주실 수 있으신가요 ?

1개의 답글
comment-user-thumbnail
2024년 1월 30일

잘 봤습니다.

답글 달기
comment-user-thumbnail
2024년 3월 25일

안녕하세요. 글 잘 읽었습니다.
그대로 따라해봤는데, 다음과 같은 에러가 발생하네요.

Failed to instantiate [org.springframework.security.web.SecurityFilterChain]: Factory method 'filterChain' threw exception with message: This object has already been built

검색도 해보고, 이것저것 시도해보니 추측상 HttpSecurity에 대한 build를 sharedObject.build() 에서 한번, filterChain 마지막에 return http.build()로 한번, 이렇게 2번 수행해서 그런 것 같은데, 혹시 해당 부분 문제 없으셨나요?

1개의 답글