본문 바로가기
Framework/Spring

[Spring / ToyProject] Spring Security 설정 - 2_1

by wo__ongii 2025. 1. 14.
728x90

1. JsonUsernamePasswordAuthenticationFilter.java

Spring Security의 기본 폼 로그인 방식 대신 AbstractAuthenticationProcessingFilter를 상속받아 JSON 데이터를 처리하여 인증을 수행하는 JSON 기반 로그인 요청을 처리 커스텀 필터이다. (RESTFUL API 기반의 로그인을 구현할것이 때문에, Json을 처리할 수 있는 필터를 구현)

 

[작동 흐름]

  1. 클라이언트가 /login 경로로 POST 요청을 보낸다
  2. 요청 본문에 JSON 형태로 email과 password를 포함한다.
  3. 필터가 요청을 인터셉트한다.
    • Content-Type이 application/json인지 확인한다.
    • JSON 본문에서 아이디와 비밀번호를 추출한다.
  4. 추출된 정보로 UsernamePasswordAuthenticationToken을 생성한다.
  5. AuthenticationManager를 사용하여 인증을 수행한다.
    • 성공 시 인증 객체를 반환하고, 실패 시 예외를 던진다.

1) 필드

private static final String DEFAULT_LOGIN_REQUEST_URL = "/login"; 
private static final String HTTP_METHOD = "POST";
private static final String CONTENT_TYPE = "application/json";
private final ObjectMapper objectMapper;
private static final String USERNAME_KEY = "email";
private static final String PASSWORD_KEY = "password";

private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(
            DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
  • DEFAULT_LOGIN_REQUEST_URL = "/login" : 필터가 처리할 로그인 URL이며 "/login/*"으로 오는 요청 처리
  • HTTP_METHOD = "POST" : HTTP 메서드 방식 중 POST 메서드만 허용
  • CONTENT_TYPE = "application/json" : 요청의 Content-Type이 application/json이어야 하며, json 타입의 데이터로만 로그인 진행
  • ObjectMapper : JSON 데이터를 읽고 Java 객체로 변환하기 위해 Jackson 라이브러리의 ObjectMapper를 사용
  • USERNAME_KEY, PASSWORD_KEY : JSON 요청 본문에서 사용할 키(email과 password)를 정의
ObjectMapper : Jackson 라이브러리에서 제공하는 클래스로, JSON과 Java 객체 간의 직렬화(Serialization) 및 역직렬화(Deserialization)를 처리한다.
  1) JSON → Java 객체 변환: JSON 문자열을 Java 객체로 변환.
  2) Java 객체 → JSON 변환: Java 객체를 JSON 문자열로 변환.

 

2) 요청 매처 설정

private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = 
    new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
  • AntPathRequestMatcher: 특정 URL과 HTTP 메서드를 매칭
  • 이 필터는 /login 경로로 들어오는 POST 요청만 처리하도록 제한된다.

3) 생성자

public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {
    super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
    this.objectMapper = objectMapper;
}
  • super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER) : 부모 클래스(AbstractAuthenticationProcessingFilter)에 요청 매처를 전달하여 필터를 초기화
  • this.objectMapper = objectMapper : ObjectMapper를 주입받아 JSON 데이터 처리 할 준비

4) 메서드 단계별 설명

@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
        throws AuthenticationException, IOException, ServletException {
    // 1. 요청의 Content-Type 확인
    if (request.getContentType() == null || request.getContentType().equals((CONTENT_TYPE))) {
        throw new AuthenticationServiceException(
                "Authentication Content-Type not supported" + request.getContentType());
    }

    // 2. JSON 본문 읽기
    String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);

    // 3. JSON 데이터 파싱
    Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);

    // 4. 사용자 아이디와 비밀번호 추출
    String username = usernamePasswordMap.get(USERNAME_KEY);
    String password = usernamePasswordMap.get(PASSWORD_KEY);

    // 5. 인증 토큰 생성
    UsernamePasswordAuthenticationToken authRequest = 
        new UsernamePasswordAuthenticationToken(username, password);

    // 6. 인증 시도
    return this.getAuthenticationManager().authenticate(authRequest);
}
  • 요청 Content-Type 확인 : 요청의 Content-Type이 application/json이 아니면 인증 실패 예외를 던져 JSON 기반 요청만 처리하도록 필터 동작을 제한한다.
  • JSON 본문 읽기 : StreamUtils.copyToString() 을 사용해 HTTP 요청 본문(InputStream)을 문자열로 변환하고, UTF_8로 인코딩을 지정해준다.
  • JSON 데이터 파싱 : ObjectMapper 사용하여 JSON 문자열을 Java Map 객체로 변환한다.
public <T> T readValue(String content, Class<T> valueType) throws IOException, JsonProcessingException
 1) 파라미터
- content : JSON 문자열 (여기서는 massageBody)
- valueType : 변환할 Java 객체의 클래스 (여기서는 Map.class로 이렇게 전달하면 JSON 데이터를 Map<String, Object> 형태로 변환해준다.)
 2) 반환값
- JSON 데이터를 변환한 Java 객체 (T 타입)
  • 사용자 아이디와 비밀번호 추출 : Map에서 email(아이디)와 password를 추출한다.
  • 인증 토큰 생성 : UsernamePasswordAuthenticationToken 객체를 생성하여 인증 요청을 준비하며, 생성된 토큰은 principal(사용자 ID) 과 credentials(비밀번호)를 포함한다.
  • 인증 시도 : 부모 클래스의 getAuthenticationManager() 를 호출하여 인증 매니저를 가져오고, 생성된 authRequest를 authenticate() 메서드에 전달하여 인증을 시도한다. 인증 성공 시 인증 객체가 반환되며, 실패 시 예외가 발생한다.

5) 전체 로직

public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
    private static final String DEFAULT_LOGIN_REQUEST_URL = "/login";
    private static final String HTTP_METHOD = "POST";
    private static final String CONTENT_TYPE = "application/json";
    private final ObjectMapper objectMapper;
    private static final String USERNAME_KEY = "email";
    private static final String PASSWORD_KEY = "password";

    private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(
            DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);

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

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException, IOException, ServletException {
        if (request.getContentType() == null || request.getContentType().equals((CONTENT_TYPE))) {
            throw new AuthenticationServiceException(
                    "Authentication Content-Type not supported" + request.getContentType());
        }

        String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
        Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);

        String username = usernamePasswordMap.get(USERNAME_KEY);
        String password = usernamePasswordMap.get(PASSWORD_KEY);

        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

}

 

2. UserDetailsServiceImpl.java 구현

Spring Security의 사용자 인증을 처리하는 핵심 컴포넌트로, Spring Security의 UserDetailsService 인터페이스의 메서드를 구현한다. 사용자 정보를 로드하고, 이를 Spring Security가 이해할 수 있는 형식으로 반환하는 역할을 한다.

 

[동작 과정]

  • 클라이언트가 인증 요청을 보낸다.
  • Spring Security는 사용자 정보를 로드하기 위해 loadUserByUsername 메서드를 호출한다.
  • email을 기반으로 userRepository에서 사용자를 조회한다.
  • 조회한 사용자 정보를 UserDetailsImpl 객체로 변환한다.
  • 반환된 UserDetailsImpl 객체는 Spring Security의 인증 프로세스에서 사용된다.
@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        UserEntity users = userRepository.findByUserEmail(email)
                .orElseThrow(() -> new IllegalArgumentException());
        return new UserDetailsImpl(users);
    }
}
  • 매개변수 email은 클라이언트에서 전달된 사용자 ID(이 경우 이메일)이며, 반환값은 Spring Security에서 인증 과정을 처리하기 위해 필요한 UserDetails 객체이다.
  • 이메일을 기반으로 사용자 정보를 데이터베이스에서 조회하고, 해당 이메일에 대한 사용자가 없는 경우 예외를 던진다.
  • 조회한 UserEntity 객체를 Spring Security에서 사용하는 UserDetails 인터페이스로 변환한다.

3. WebSecurityConfig.java 수정

1) SecurityFliterChain 추가

addFilterAfter(jsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class)

커스텀 인증 필터인 JsonUsernamePasswordAuthenticationFilter를 추가

 

2) 메서드 추가

@Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        DaoAuthenticationProvider provider = daoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter() throws Exception {
        JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter = new JsonUsernamePasswordAuthenticationFilter(
                objectMapper);
        jsonUsernamePasswordAuthenticationFilter.setAuthenticationManager(authenticationManager());
        return jsonUsernamePasswordAuthenticationFilter;
    }
  • DaoAuthenticationProvider 설정
    • AbstractUserDetailsAuthenticationProvider를 상속받아, 실제 인증 로직을 구현한 클래스로 Spring Security에서 데이터베이스에서 사용자 정보를 조회하고, 비밀번호를 비교하여 인증을 처리한다.
    • setUserDetailsService(userDetailsService)를 통해 사용자 정보를 로드할 UserDetailsService를 설정한다. 여기서는 userDetailsService가 UserDetailsServiceImpl 인스턴스이다.
    • setPasswordEncoder(passwordEncoder())를 통해 비밀번호 인코딩 방식을 설정하여 비밀번호를 암호화하고 안전하게 비교할 수 있도록 한다.
  • AuthenticationManager 설정
    • 인증을 관리하는 핵심 인터페이스로 DaoAuthenticationProvider를 사용하여 ProviderManager 인스턴스를 반환한다. ProviderManager는 여러 인증 제공자를 사용할 수 있게 해준다.
  • JsonUsernamePasswordAuthenticationFilter 설정
    • 사용자가 JSON 형식으로 로그인 정보를 제출할 때 처리한다. 일반적인 폼 기반 로그인과 달리 JSON을 처리하는 커스텀 필터JSON 기반 인증 요청을 처리하는 커스텀 필터를 생성한다.
    • setAuthenticationManager(authenticationManager())를 통해 앞서 설정한 AuthenticationManager를 필터에 연결한다. 이 필터는 사용자가 입력한 JSON 데이터를 기반으로 인증을 시도한다.
AbstractUserDetailsAuthenticationProvider

 

[동작 과정]

  • 사용자가 로그인 폼에 이메일과 비밀번호를 입력하고 제출한다.
  • JsonUsernamePasswordAuthenticationFilter가 요청을 처리하며, 이메일과 비밀번호를 받아 AuthenticationManager를 통해 인증을 시도한다.
  • AuthenticationManager는 DaoAuthenticationProvider를 사용하여 사용자 정보를 UserDetailsService에서 로드한다.
  • UserDetailsServiceImpl은 이메일을 사용해 사용자 정보를 데이터베이스에서 조회하고, 사용자가 존재하면 UserDetailsImpl 객체를 반환한다.
  • 반환된 UserDetailsImpl을 사용하여 인증을 완료하고, 로그인 성공 여부를 결정한다.

3) 전체 로직

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

	private final UserDetailsServiceImpl userDetailsService;
	private final ObjectMapper objectMapper;

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        httpSecurity
                .csrf(AbstractHttpConfigurer::disable)
                .cors((cors) -> cors.configurationSource(corsConfigurationSource()))
                .httpBasic(AbstractHttpConfigurer::disable)
                .formLogin(AbstractHttpConfigurer::disable)
                .addFilterAfter(jsonUsernamePasswordAuthenticationFilter(), LogoutFilter.class) // 필터 추가
                .authorizeHttpRequests((authorize) -> authorize
                        .requestMatchers("/", "/movie/**")
                        .permitAll()
                        .anyRequest().authenticated())
                .exceptionHandling((exception) -> exception
                        .authenticationEntryPoint(unauthorizedEntryPoint).accessDeniedHandler(accessDeniedHandler))
                .logout((logout) -> logout
                        .logoutSuccessUrl("/movie/login")
                        .invalidateHttpSession(true))
                .sessionManagement((session) -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS));

        return httpSecurity.build();
    }
    
    // Cors 설정
    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration(); // 허용할 origin 설정
        config.addAllowedOrigin("http://localhost:3000");
        config.setAllowCredentials(true); // 쿠키 허용
        config.addAllowedHeader("*"); // 모든 헤더 허용
        config.addAllowedMethod("*"); // 모든 http 메소드 허용

        config.setExposedHeaders(Arrays.asList("Access-Control-Allow-Headers",
                "Authorization, x-xsrf-token, Access-Control-Allow-Headers, Origin, Accept, X-Requested-With, ",
                "Content-Type, Access-Control-Request-Method, Access-Control-Request-Headers")); // 헤더 요청 열어둠
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config); // 모든 경로에 대해 위 설정 적용

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

    // 인증 관리자 관련 설정
    @Bean
    public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
        return daoAuthenticationProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager() throws Exception {
        DaoAuthenticationProvider provider = daoAuthenticationProvider();
        provider.setPasswordEncoder(passwordEncoder());
        return new ProviderManager(provider);
    }

    @Bean
    public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter() throws Exception {
        JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordAuthenticationFilter = new JsonUsernamePasswordAuthenticationFilter(
                objectMapper);
        jsonUsernamePasswordAuthenticationFilter.setAuthenticationManager(authenticationManager());
        return jsonUsernamePasswordAuthenticationFilter;
    }

}

 

 

 

 

[출처: https://velog.io/@dh1010a/Spring-Spring-Security%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84-%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-3.X-%EB%B2%84%EC%A0%84-3]

728x90
반응형