[Spring] Spring Security를 이용한 로그인 구현 (스프링부트 3.X 버전) [3] - JSON로그인 필터 커스텀

Paek·2024년 1월 28일
0

Spring프로젝트

목록 보기
6/9
post-thumbnail
post-custom-banner

이 포스팅에서는 스프링 부트 3.2.2 버전을 사용하고, 스프링 시큐리티 6.2.1 버전을 사용합니다.

저번 포스팅에 이어, 이번에는 시큐리티 필터를 직접 커스터마이징 해보겠습니다.

JsonUsernamePasswordAuthenticationFilter

RESTFUL API 기반의 로그인을 구현할것이 때문에, Json을 처리할 수 있는 필터를 구현해보겠습니다.

public class JsonUsernamePasswordAuthenticationFilter extends AbstractAuthenticationProcessingFilter {

	private static final String DEFAULT_LOGIN_REQUEST_URL = "/login";  // /login/oauth2/ + ????? 로 오는 요청을 처리할 것이다
	private static final String HTTP_METHOD = "POST";    //HTTP 메서드의 방식은 POST 이다.
	private static final String CONTENT_TYPE = "application/json";//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); //=>   /login 의 요청에, POST로 온 요청에 매칭

	public JsonUsernamePasswordAuthenticationFilter(ObjectMapper objectMapper) {

		super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);   // 위에서 설정한  /oauth2/login/* 의 요청에, GET으로 온 요청을 처리하기 위해 설정

		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);//principal 과 credentials 전달

		return this.getAuthenticationManager().authenticate(authRequest);
	}
}
  • DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD, CONTENT_TYPE 를 통해 "/login"으로 들어오는 "Post"인 "JSON"형식의 요청에 대해서만 작동하도록 설정했습니다.
  • attemptAuthentication 메서드
    • username과 password를 받아와 FormLogin과 동일하게 UsernamePasswordAuthenticationToken을 사용했습니다. username과 password를 사용하여 로그인하는 전략은 폼로그인과 똑같기 때문에 굳이 따로 구현하지 않고 기존에 있는걸 사용하였습니다.
    • return값은 authenticationManagerauthenticate 메서드를 실행했습니다. 여기서 사용되는 AuthenticationManagerProviderManager입니다. 이후 SecurityConfig 파일에서 설정합니다.

SecurityConfig 설정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

	private final UserDetailsServiceImpl userDetailsService;
	private final ObjectMapper objectMapper;

	// 특정 HTTP 요청에 대한 웹 기반 보안 구성
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http	.csrf(AbstractHttpConfigurer::disable)
				.httpBasic(AbstractHttpConfigurer::disable)
				.formLogin(AbstractHttpConfigurer::disable)
				.addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class) // 추가 : 커스터마이징 된 필터를 SpringSecurityFilterChain에 등록
				.authorizeHttpRequests((authorize) -> authorize
						.requestMatchers("/user/signup", "/", "/login", "/album/init").permitAll()
						.anyRequest().authenticated())
//				.formLogin(formLogin -> formLogin
//						.loginPage("/login")
//						.defaultSuccessUrl("/home"))
				.logout((logout) -> logout
						.logoutSuccessUrl("/login")
						.invalidateHttpSession(true))
				.sessionManagement(session -> session
					.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		);
		return http.build();
	}

	// 인증 관리자 관련 설정
	@Bean
	public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
		DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();

		daoAuthenticationProvider.setUserDetailsService(userDetailsService);
		daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());

		return daoAuthenticationProvider;
	}

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

	@Bean
	public AuthenticationManager authenticationManager() throws Exception {//AuthenticationManager 등록
		DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
		provider.setPasswordEncoder(passwordEncoder());//PasswordEncoder로는 PasswordEncoderFactories.createDelegatingPasswordEncoder() 사용
		return new ProviderManager(provider);
	}

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

}

앞선 포스팅에서 작성한 코드에 추가하였습니다.

  1. 기존 SpringSecurityFilterChain에는 커스텀한 필터가 존재하지 않으므로, 필터체인의 http 설정에 .addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class) 메서드를 통해 Logout필터 이후에 커스텀 필터를 추가해줍니다.
  2. 이전 포스팅에서 사용하기로 한 PasswordEncoder를 AuthenticationProvider에 등록해줍니다. 폼 로그인 방식과 동일하게 DaoAuthenticationProvider를 사용합니다.
  3. AuthemticationManager는 폼 로그인과 동일한 ProviderManager를 사용합니다.
  4. 앞에서 생성한 JsonUsernamePasswordAuthenticationFilter을 빈으로 등록해줍니다. 이때 AuthenticationManager도 함께 등록해주지 않으면 오류가 발생합니다.

이제 전에 만들어 두었던 UserDetails를 반환하는 UserDetailsService 를 상속받은 클래스를 만들어 보겠습니다.

UserDetailsServiceImpl

@Service
@RequiredArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {

	private final UsersRepository usersRepository;

	@Override
	public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
		Users users = usersRepository.findByEmail(email)
				.orElseThrow(() -> new IllegalArgumentException(email));
		return new UserDetailsImpl(users);
	}
}

DB에서 email에 해당하는 유저를 찾아 반환해줍니다.

비밀 번호 검증은 DaoAuthenticationProvider에서 해줍니다. 더 정확히는 AbstractUserDetailsAuthenticationProvider 에서 진행합니다.

additionalAuthenticationChecks도 retriveUser와 마찬가지로 추상 메서드이므로 구현 클래스에게 처리를 맡깁니다.

구현클래스인 DaoAuthenticationProvider에서 구현 되어있는 것을 아래 코드에서 볼 수 있습니다.

비밀번호 (Credential)의 일치 여부를 판단합니다.

userDetailsUserDetailsService에서 만들어준 User 객체이고, 이것의 password 와JsonUsernamePasswordAuthenticationFilter (정확히는 AbstractAuthenticationProcessingFilter)에서 Request의 정보를 통해 인자로 전달해준 Authentication 객체(여기서는 UsernamePasswordAuthenticationToken)의  Credentials (우리는 password를 넣어주었다)를 비교합니다.

그렇기 때문에 저희는 DB에서 유저 정보만 조회해서 반환만하면 됩니다.

이제 성공 처리와 실패 처리를 할 Handler을 구현해보겠습니다.

LoginSuccessJWTProvideHandler

@Slf4j
public class LoginSuccessJWTProvideHandler extends SimpleUrlAuthenticationSuccessHandler {

	@Override
	public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
		UserDetails userDetails = (UserDetails) authentication.getPrincipal();
		log.info( "로그인 성공. JWT 발급. username: {}" ,userDetails.getUsername());


		response.getWriter().write("success");
	}
}

SimpleUrlAuthenticationSuccessHandler을 상속받아, 로그인 성공시 JWT를 발급하도록 합니다. (아직 JWT 발급 로직 미포함) 또한 로그를 출력하도록 합니다.

LoginFailureHandler

@Slf4j
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {

	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
		response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401 인증 실패
		response.getWriter().write("fail");
		log.info("로그인 실패");
	}
}

SimpleUrlAuthenticationFailureHandler을 상속받은 핸들러입니다.

상태 코드는 인증을 실패하였다는 의미로, 401 Unauthorized를 반환하도록 하였습니다.

이제 이 핸들러를 설정에 추가해주도록 하겠습니다.

SecurityConfig 수정

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

	private final UserDetailsServiceImpl userDetailsService;
	private final ObjectMapper objectMapper;

	// 특정 HTTP 요청에 대한 웹 기반 보안 구성
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http	.csrf(AbstractHttpConfigurer::disable)
				.httpBasic(AbstractHttpConfigurer::disable)
				.formLogin(AbstractHttpConfigurer::disable)
				.addFilterAfter(jsonUsernamePasswordLoginFilter(), LogoutFilter.class) // 추가 : 커스터마이징 된 필터를 SpringSecurityFilterChain에 등록
				.authorizeHttpRequests((authorize) -> authorize
						.requestMatchers("/user/signup", "/", "/login", "/album/init").permitAll()
						.anyRequest().authenticated())
//				.formLogin(formLogin -> formLogin
//						.loginPage("/login")
//						.defaultSuccessUrl("/home"))
				.logout((logout) -> logout
						.logoutSuccessUrl("/login")
						.invalidateHttpSession(true))
				.sessionManagement(session -> session
					.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
		);
		return http.build();
	}

	// 인증 관리자 관련 설정
	@Bean
	public DaoAuthenticationProvider daoAuthenticationProvider() throws Exception {
		DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();

		daoAuthenticationProvider.setUserDetailsService(userDetailsService);
		daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());

		return daoAuthenticationProvider;
	}

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

	@Bean
	public AuthenticationManager authenticationManager() throws Exception {//2 - AuthenticationManager 등록
		DaoAuthenticationProvider provider = daoAuthenticationProvider();//DaoAuthenticationProvider 사용
		return new ProviderManager(provider);
	}

    @Bean
    public LoginSuccessJWTProvideHandler loginSuccessJWTProvideHandler(){
        return new LoginSuccessJWTProvideHandler();
    }

    @Bean
    public LoginFailureHandler loginFailureHandler(){
        return new LoginFailureHandler();
    }

	@Bean
	public JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter() throws Exception {
		JsonUsernamePasswordAuthenticationFilter jsonUsernamePasswordLoginFilter = new JsonUsernamePasswordAuthenticationFilter(objectMapper);
		jsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
		jsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessJWTProvideHandler());
		jsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
		return jsonUsernamePasswordLoginFilter;
	}

}

다음 포스팅에서는 로그인 성공시 JWT를 발급하고, 인증이 필요한 요청에 대해서 AccessToken을 가지고 인증을 수행하는 코드를 작성해보겠습니다.

출처 및 참고

profile
티스토리로 이전했습니다. https://100cblog.tistory.com/
post-custom-banner

0개의 댓글