Spring/Refactoring

[Spring] JWT 리팩토링 #1

sowith 2025. 3. 2. 21:30

 

전 프로젝트에서 JWT 기반 인증 방식을 구현했다.

하지만 코드를 다시 뜯어보면서 리팩토링이 필요한 부분들이 눈에 보여 이를 시작해보려고 한다.


리팩토링이 필요한 부분

1. JWT기반으로 적용할 것이기 때문에 formLogin, httpBasic을 명확히 disable 해주도록 하겠다.

2. 현재 로그인 방식은 Spring Security를 잘 이용하지 못했기 때문에 바꾸어주겠다.


 

현재 코드의 유저 동작 과정

 

1. 로그인을 하면

api/users/login 컨트롤러로 가서 서비스에서 이메일을 기준으로 DB 회원 정보의 비밀번호와 비교하여

일치한다면 Access Token과 Refresh Token을 생성해준다.

 

2. 그럼 응답으로 AccessToken이 온다.

 

3. 프론트에서는 해당 응답을 받아 세션 스토리지에 저장해준다.

(refresh는 이미 쿠키에 저장되었음)

 

4. 그 후 모든 API 요청은 JWT Filter로 인증해 동작한다.

 

5. JWT Filter에서는 헤더로 AccessToken을 받는다.

 

6. AccessToken의 유효성을 검사한다. (타입이 access인지, 만료되지는 않았는지..)

 

7. 유효하다면 SpringContext에 Authentication의 형태로 저장된다.

 

8. 그후 컨트롤러로 가서 SpringContext에 저장된 Authentication으로 원하는 유저의

정보를 가져와 응답해준다.

 

9. 프론트는 해당 응답을 받아 화면을 구성해준다.

 


 

코드 구성

 

1. User 엔티티는 UserDetails의 구현체로 만들어주었다.

2. username은 이메일로 설정해주었다. (sub)

3.  AuthTokenImpl은 key(시크릿키), Jwt토큰(key를 통해 서명됨) 으로 구성되어있다.

- createJwtToken을 통해 Jwt토큰이 만들어진다.

4. 토큰 유효 검사를 통해 유효하다면 인증용 객체(

return new UsernamePasswordAuthenticationToken(
                    principal,
                    authToken,
                    authorities
            );

)로 반환한다.

5. 해당 Autentication을 SecurityContextHolder에 담아준다.

 


 

Authentication 객체의 역할

 

Authentication 객체는 인증된 사용자의 정보를 담고 있으며, 주로 다음과 같은 정보를 포함한다.

  1. Principal: 인증된 사용자의 정보(ex UserDetails 객체나 사용자 이름)
  2. Credentials: 인증을 위한 자격 증명(ex 비밀번호)
  3. Authorities: 사용자에게 부여된 권한(ex GrantedAuthority)

그럼 Authentication을 SecurityContext에 담아주면

API요청 서비스에서 해당 autentication에서 getPrincipal을 통해 원하는 유저 정보를 획득할 수 있다.

 

 

Authentication은 인터페이스로서 인증하는 여러가지 상황에 따라 다양한 구현체로 표현된다.

- 주로 사용되는 ID/PW를 통한 인증에서 Authentication은 UsernamePasswordAuthenticationToken 구현체로 표현된다.


[현재 코드]

 

WebSecurityConfig

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    private final UserDetailsService userService;
    private final JwtFilter jwtFilter;

    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring() // 인증 및 인가 검사 제외 경로
                .requestMatchers(toH2Console())
                .requestMatchers("/static/**");
    }


    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler) throws Exception {
        return http.
                exceptionHandling(ex->ex
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler))
                .authorizeHttpRequests(auth -> auth
                		// 특정 경로에 대한 권한 설정
                         // requestMatchers() .. 생략 
                        .anyRequest().authenticated())
                .cors(cors -> cors.configurationSource(request -> {
                    var config = new CorsConfiguration();
                    config.setAllowedOrigins(List.of("https://jrqggzccfxaqcbkg.tunnel-pt.elice.io/","http://localhost:9000", "https://txqfegberfyqzheq.tunnel-pt.elice.io/")); // 허용할 도메인
                    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH")); // 허용할 메서드
                    config.setAllowCredentials(true); // 인증 정보 포함 여부
                    config.setAllowedHeaders(List.of("*")); // 허용할 헤더
                    return config;
                }))
                .csrf(csrf -> csrf.disable())
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }



    // 7. 인증 관리자 관련 설정
    @Bean
    public AuthenticationManager authenticationManager(
            HttpSecurity http,
            BCryptPasswordEncoder bCryptPasswordEncoder
    ) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder
                = http.getSharedObject(AuthenticationManagerBuilder.class);
        authenticationManagerBuilder
                .userDetailsService(userService)
                .passwordEncoder(bCryptPasswordEncoder);

        return authenticationManagerBuilder.build();
    }


    @Bean // 9. 패스워드 인코더로 사용할 빈 등록
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }


}

 

1. JWT 방식의 로그인을 진행할 것이기 때문에 fromLogin과  http basic 인증방식을 명확히 disable 해주자.

2. JWT 방식에서 중요한 것은 인증/인가를 위해 session을 stateless 상태로 관리하는 것이다. 이를 설정해주어야 한다.

 

fromLogin에서는 /login으로 POST요청을 보내면

UsernamePasswordAuthenticationFilter

가 자동으로 회원가입한 유저인지 비교해주는 해당 필터가 작동한다. (물론 원하는 경로로 설정도 가능하다)

 

하지만 formLogin에 대한 아무런 설정이 없는 지금 상황에서는

/api/users/login 경로로 POST 로그인 요청을 보내고 있기 때문에 해당 필터가 동작하지 않는다. 따라서 컨트롤러까지 로그인 요청이 도달할 수 있었다. (나는 직접 DB에서 비교하는 로직을 통해 회원가입 유저인지 확인했다.)

 

Spring Security를 잘 사용해보기 위해서 사용되고 있지 않은 AuthenticationManager은 사용하게 만들어보자.

-> 회원가입한 유저인지 확인해줄 것이다.

 

 

BcryptPasswordEncoder 

security를 통해서 회원 정보를 저장하고, 회원 가입하고 다시 검증할 때는 항상 비밀번호를 해쉬로 암호화시켜서 진행하게된다.

 

 

[리팩토링 코드]

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class WebSecurityConfig {
    private final UserDetailsService userService;
    private final JwtFilter jwtFilter;

    //1.스프링 시큐리티 비활성화
    @Bean
    public WebSecurityCustomizer webSecurityCustomizer() {
        return (web) -> web.ignoring()
                .requestMatchers(toH2Console())
                .requestMatchers("/static/**");
    }


    //2.특정 HTTP 요청에 대한 웹 기반 보안 구성
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler) throws Exception {
        return http.
                exceptionHandling(ex->ex
                        .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                        .accessDeniedHandler(jwtAccessDeniedHandler))
                .authorizeHttpRequests(auth -> auth
                        // 특정 경로에 대한 권한 설정
                         // requestMatchers() .. 생략 
                        .anyRequest().authenticated())
                .cors(cors -> cors.configurationSource(request -> {
                    var config = new CorsConfiguration();
                    config.setAllowedOrigins(List.of("https://jrqggzccfxaqcbkg.tunnel-pt.elice.io/","http://localhost:9000", "https://txqfegberfyqzheq.tunnel-pt.elice.io/")); // 허용할 도메인
                    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH")); // 허용할 메서드
                    config.setAllowCredentials(true); // 인증 정보 포함 여부
                    config.setAllowedHeaders(List.of("*")); // 허용할 헤더
                    return config;
                }))
                .csrf(csrf -> csrf.disable())
                
                // form 로그인 disable
                .formLogin((auth) -> auth.disable())
                
                // http basic 인증 방식 disable
                .httpBasic((auth) -> auth.disable())
                
                .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
                
                //세션 stateless
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                        
                .build();
    }



    // 7. 인증 관리자 관련 설정
    @Bean
    public AuthenticationManager authenticationManager(
            HttpSecurity http,
            BCryptPasswordEncoder bCryptPasswordEncoder
    ) throws Exception {
        AuthenticationManagerBuilder authenticationManagerBuilder
                = http.getSharedObject(AuthenticationManagerBuilder.class);
        authenticationManagerBuilder
                .userDetailsService(userService)
                .passwordEncoder(bCryptPasswordEncoder);

        return authenticationManagerBuilder.build();
    }


    @Bean // 9. 패스워드 인코더로 사용할 빈 등록
    public BCryptPasswordEncoder bCryptPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }


}