Notice
«   2025/01   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
01-10 04:46
Today
Total
관리 메뉴

그날그날 공부기록

스프링 시큐리티 로그인 동작 따라가보기 본문

Spring 공부

스프링 시큐리티 로그인 동작 따라가보기

given_dragon 2023. 9. 25. 17:36

스프링 시큐리티 설정

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
            .formLogin().disable()
            .httpBasic().disable()
            .csrf().disable()
            .headers().frameOptions().disable()
            .and()

            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()

            .authorizeHttpRequests()
            .requestMatchers("/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**").permitAll()
            .requestMatchers("/sign-up").permitAll()
            .anyRequest().authenticated()
            .and()

                        // oauth 관련 설정
            .oauth2Login()
            .successHandler(oAuth2LoginSuccessHandler)
            .failureHandler(oAuth2LoginFailureHandler)
            .userInfoEndpoint().userService(customOAuth2UserService);

        http.addFilterAfter(jwtAuthenticationProcessingFilter(), LogoutFilter.class);
    http.addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), JwtAuthenticationProcessingFilter.class);

    return http.build();
}

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

@Bean
public AuthenticationManager authenticationManager() {
    DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
    provider.setPasswordEncoder(passwordEncoder());
    provider.setUserDetailsService(loginService);
    return new ProviderManager(provider);
}

@Bean
public CustomJsonUsernamePasswordAuthenticationFilter customJsonUsernamePasswordAuthenticationFilter () {
    CustomJsonUsernamePasswordAuthenticationFilter customLoginFilter = new CustomJsonUsernamePasswordAuthenticationFilter(objectMapper);
    customLoginFilter.setAuthenticationManager(authenticationManager());
    customLoginFilter.setAuthenticationSuccessHandler(loginSuccessHandler);
    customLoginFilter.setAuthenticationFailureHandler(loginFailureHandler);
    return customLoginFilter;
}

@Bean
public JwtAuthenticationProcessingFilter jwtAuthenticationProcessingFilter() {
    JwtAuthenticationProcessingFilter jwtAuthFilter = new JwtAuthenticationProcessingFilter(jwtService, userRepository);
    return jwtAuthFilter;
}

로그인 정보를 json으로 받음 → form login, http basic 기능 비활성화

서버에 인증정보 저장 안 함 → csrf공격 방지기능, session 비활성화(Stateless)

 

authorizeHttpRequests()로 요청에 대한 인가를 구성.

requestMatchers().permitAll()로 설정 경로에 대해서는 인증 없이 접근 가능

anyRequest().authenticated()로 나머지 요청들에 대해서 인증된 사용자만 접근 가능하도록 설정

 

http.addFilterAfter, http.addFilterBefore를 통해
LogoutFilter → JwtAuthenticationProcessingFilter → CustomJwonUserNamePasswordAuthentiactionFilter 순으로 필터가 작동되도록 설정.

 

  • 필터 실행순서가 왜이럴까?
http.addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class);
http.addFilterBefore(jwtAuthenticationProcessingFilter(), CustomJsonUsernamePasswordAuthenticationFilter.class);

LogoutFilter "이후"에 CustomJsonUsernamePasswordAuthenticationFilter 실행.
CustomJsonUsernamePasswordAuthenticationFilter "이전"에 JwtAuthenticationProcessingFilter 실행.
예상 = LogoutFilterJwtAuthenticationProcessingFilterCustomJsonUsernamePasswordAuthenticationFilter

하지만 실행결과를 보면 순서가 꼬인다. 왜 그럴까..

 

http.addFilterAfter(jwtAuthenticationProcessingFilter(), LogoutFilter.class);
http.addFilterAfter(customJsonUsernamePasswordAuthenticationFilter(), JwtAuthenticationProcessingFilter.class);

이렇게 적어주니 순서가 잘 적용되는 걸 확인 가능했다.


로그인 과정

디버깅으로 확인한 필터 호출 순서

POST /login으로 로그인 요청을 보냄.

LogoutFilter 이후 JWT 인증 과정을 위한 JwtAuthenticationProcessingFilter 호출.

JWT를 받기 위한 로그인 과정이므로 JwtAuthenticationProcessingFilter를 패스.

// JwtAuthenticationProcessingFilter의 doFilterInternal 일부
// NO_CHECK_URL = "/login"
if (request.getRequestURI().equals(NO_CHECK_URL)) {
        filterChain.doFilter(request, response);
        return;
}

 

filterChain.doFilter로 다음 필터인 AbstractAuthentiactionProcessingFilter를 호출하여 인증과정 진행.

AbstractAuthentiactionProcessingFilter의 doFilter메서드

해당 필터에서는 우선 requiresAuthentication메서드를 통해 인증이 필요한지 체크한다.

로직은 다음과 같다.

AbstractAuthentiactionProcessingFilter의 requiresAuthentication메서드

matcher를 통해 request가 사전에 설정했던 url과 http method와 일치하는지 확인한다.

 

일치하는 request라면 attemptAuthentication 메서드가 실행된다.

AbstractAuthentiactionProcessingFilter의 attemptAuthentication 메서드

이 메서드는 추상 메서드이다.

따라서 AbstractAuthentiactionProcessingFilter를 구현한 클래스에서 원하는 조건에 따라 구현하면 된다.

formLogin에서는 AbstractAuthentiactionProcessingFilter를 구현한 UsernamePasswordAuthenticationFilter가 사용된다.

 

이번 프로젝트에서는 json을 통해 로그인을 하므로 UsernamePasswordAuthenticationFilter를 참고하여 CustomJsonUsernamePasswordAuthenticationFilter를 만들었다.

사용자가 보낸 로그인 정보를 form에서 가져오냐, messageBody에서 가져오냐의 차이만 존재한다.

코드는 다음과 같다.

public class CustomJsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

    private static final String DEFAULT_LOGIN_REQUEST_URL = "/login";
    public static final String HTTP_METHOD = "POST";
    public static final String CONTENT_TYPE = "application/json";
    public static final String USERNAME_KEY = "email";
    public static final String PASSWORD_KEY = "password";
    public static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
    public final ObjectMapper objectMapper;

    private boolean postOnly = true;

    public CustomJsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
        super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
        this.objectMapper = objectMapper;
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException {
        if (postOnly && !request.getMethod().equals(HTTP_METHOD)) {
            log.error("로그인 요청의 HTTP_MOTHOD가 잘못되었습니다. : {}", request.getMethod());
            throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
        }
        if (request.getContentType()==null || !request.getContentType().equals(CONTENT_TYPE)) {
            log.error("로그인 요청의 ContentType이 잘못되었습니다. : {}", request.getContentType());
            throw new AuthenticationServiceException("Authentication Cont-Type not supported: " + request.getContentType());
        }

        ServletInputStream inputStream = request.getInputStream();
        String messageBody = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);

        Map<String, String> loginDataMap = objectMapper.readValue(messageBody, new TypeReference<>() {});

        String email = loginDataMap.get(USERNAME_KEY);
        String password = loginDataMap.get(PASSWORD_KEY);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);

        return this.getAuthenticationManager().authenticate(authRequest);
    }

    public void setPostOnly(boolean postOnly) {
        this.postOnly = postOnly;
    }
}

(코드가 길어 나눠서 확인)

 

 

앞서 설명한 AbstAbstractAuthentiactionProcessingFilter
이 필터를 적용할 url과 http method를 설정한다.

 

attemptAuthentication과정은 다음과 같음.

  1. http method와 content type을 체크
  2. request에서 message body를 추출
  3. ObjectMapper를 통해 json데이터를 Map형식으로 변환
  4. username(email)과 password를 통해 UsernamePasswordAuthenticationToken 생성
  5. getAuthentication()메서드를 통해 AuthenticationManager를 불러온 뒤
    authenticate() 메서드에 UsernamePasswordAuthenticationToken을 넘겨주고
    Authentication 객체 반환(객체는 아래 정보와 같이 되어있음)

 

 

그렇다면 AuthenticationManager.authentication은 어떻게 동작할까?

우선 해당 객체는 SecurityConfig 에서 setAuthenticationManager()메서드를 통해
ProviderManager를 주입해 줌.

해당 ProviderManager에는 DaoAuthenticationProvider가 들어가 있음.

CustomJsonUsernamePasswordAuthenticationFilter에 들어갈 ProviderManager를 생성하는 메서드 ProviderManager의 생성자에는 여러 AuthenricationProvider객체를 주입받고, 객체 내부에 리스트 형태로 저장한다.
SecurityConfig.customJsonUsernamePasswordAuthenticationFilter()

 

이제 getAuthenticationManager()ProviderManager가 불려 오는 걸 알았으니
ProviderManagerauthentication()메서드를 확인해 보자.

ProviderManager.authentication()

파라미터로 들어온 Authentication 객체를 처리할 수 있는 provider를 리스트에 찾고, 해당 provider의 authenticate() 메서드를 실행한다.

여기서 사용한 provider는 DaoAuthenticationProvider 이다.

DaoAuthenticationProviderAbstractUserDetailsAuthenticationProvider를 구현한 클래스이다.

 

authenticate()메서드는 AbstractUserDetailsAuthenticationProvider에 있고,
그 안에 들어가는 추상 메서드인 retrieveUser()DaoAuthenticationProvider에서 구현한다.

 

 

AbstractUserDetailsAuthenticationProvider 를 먼저 확인해 보자.

AbstractUserDetailsAuthenticationProvider.supports()

이 전에 Authentication으로 넘겨주었던 UsernamePasswordAuthenticationToken 을 지원하는 걸 확인할 수 있다.

 

AbstractUserDetailsAuthenticationProvider.authentication()

retrieveUser()를 통해 DB에서 유저 정보인 UserDetails를 가져오고,
additionalAuthenticationChecks()를 통해 사용자가 입력한 정보가 일치하는지 확인한다.
최종적으로 createSuccessAuthentication()를 통해 Authentication객체를 반환한다.

 

AbstractUserDetailsAuthenticationProvider.retrieveUser()

앞서 말했듯, 추상 메서드로 작성되어 있고 AbstractUserDetailsAuthenticationProvider의 구현체인 DaoAuthenticationProvider에서 이를 구현한다.

 

구현 내용을 확인하기 위해 잠깐 DaoAuthenticationProvider를 확인.

DaoAuthenticationProvider.retrieveUser()

UserDetailService의 loadUserByUsername()메서드로 DB에서 유저 정보를 가져와 반환한다.

loadUser에 담긴 정보

 

 

다시 AbstractUserDetailsAuthenticationProvider로 돌아와 createSuccessAuthentication()을 확인.

AbstractUserDetailsAuthenticationProvider.createSuccessAuthentication()

이 메서드에서는 UsernamePasswordAuthenricationToken을 생성해서 반환한다.

UsernamePasswordAuthenricationToken에 들어가는 값은 다음과 같음.

 

1. principal

authenticate()에서 forcePrincipalAsString값이 true가 아니라면 UserDetails 자체가 principal값으로 들어간다.

2. authentication.getCredentials()

 

3. user.getAuthorities()

 

 

반환되면 다시 AbstractAuthenticationProcessingFilter로 돌아와 인증의 성공/실패에 따른 분기를 처리한다.

 

 

미리 등록해 둔 성공/실패 핸들러가 실행된다.

LoginSuccessHandler
LoginFailureHandler


그림으로 정리하며 다시 한번 확인하자…

 

좀 더 보기 쉽게 다시 그렸다.

Comments