springboot

기존 SSR(Thymeleaf + SpringBoot) 방식의 프로젝트를 CSR(React + SpringBoot)로 바꾸다가 발생한 에러이다.

 

 

로그인 방식

기존에 폼 로그인과 세션을 사용해서 인증하던 방식을 JWT를 사용하도록 변경하고 있었다..

 

그 과정에서 로그인은 UsernamePasswordAuthenticationFilter를 상속한 클래스를 만들어서 처리할 수 있도록 했다.

 

@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
	private final AuthenticationManager authenticationManager;
	private final JwtTokenProvider jwtTokenProvider;

	@SneakyThrows
	@Override
	public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
		ObjectMapper om = new ObjectMapper();
		LoginRequestDto dto = om.readValue(request.getInputStream(), LoginRequestDto.class);


		UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
			dto.username(),
			dto.password());

		return authenticationManager.authenticate(authToken);
	}

	@Override
	protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
		FilterChain chain, Authentication authResult) throws ServletException, IOException {

		String accessToken = jwtTokenProvider.generateAccessToken(authResult);
		String refreshToken = jwtTokenProvider.generateRefreshToken(authResult);

		response.setHeader("Authorization", "Bearer " + accessToken);
		response.setHeader("Refresh", refreshToken);

		this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
	}
}

 

 

 

 

해당 클래스를 SecurityConfiguration 클래스에서 사용하여 인증을 처리할 수 있도록 했다.

 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
	private final JwtTokenProvider jwtTokenProvider;

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		...
		http
			.apply(new JwtSecurityConfig());

		return http.build();
	}

	public class JwtSecurityConfig extends AbstractHttpConfigurer<JwtSecurityConfig, HttpSecurity> {
		@Override
		public void configure(HttpSecurity builder) throws Exception {
			AuthenticationManager am = builder.getSharedObject(AuthenticationManager.class);

			JwtAuthenticationFilter jwtAuthFilter = new JwtAuthenticationFilter(am, jwtTokenProvider);
			jwtAuthFilter.setFilterProcessesUrl("/auth/login");
			jwtAuthFilter.setAuthenticationSuccessHandler(new CustomAuthenticationSuccessHandler());
			jwtAuthFilter.setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler());

			JwtFilter jwtFilter = new JwtFilter(jwtTokenProvider);
			builder
				.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
				.addFilter(jwtAuthFilter);
		}
	}
}

 

 

 

 

추가적으로 CORS에 대해서는 CorsFilter로 설정해주었다.

 

@Component
public class CorsFilter implements Filter {
	private static final String CLIENT_URL = "http://localhost:3000";

	@Override
	public void init(FilterConfig filterConfig) throws ServletException {
		Filter.super.init(filterConfig);
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws
		IOException,
		ServletException {

		HttpServletRequest req = (HttpServletRequest)request;
		HttpServletResponse res = (HttpServletResponse)response;

		// 요청을 허용하는 출처
		res.setHeader("Access-Control-Allow-Origin", CLIENT_URL);
		// 요청을 허용하는 HTTP 메소드
		res.setHeader("Access-Control-Allow-Methods", "*");
		// 쿠키 요청 허용
		res.setHeader("Access-Control-Allow-Credentials", "true");
		// 요청을 허용하는 헤더 이름
		res.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization");
		// 클라이언트에서 preflight 요청 결과를 저장할 시간
		res.setHeader("Access-Control-Max-Age", "3600");

		if ("OPTIONS".equalsIgnoreCase(req.getMethod())) {
			res.setStatus(HttpServletResponse.SC_OK);
		} else {
			chain.doFilter(request, response);
		}
	}

	@Override
	public void destroy() {
		Filter.super.destroy();
	}
}

 

 

 

에러 발생

위와 같이 서버 코드가 작성되었고, 클라이언트 측에서 로그인 요청을 하면 아래와 같은 에러가 발생한다.

 

 

CORS 정책에 막혔다고 하는 것을 볼 수 있다.

 

다른 url 요청에 대해서는 문제가 없지만 login 요청에 대해서만 거부된다.

 

서버 측에서는 아래와 같은 에러가 발생한다.

 

com.fasterxml.jackson.databind.exc.MismatchedInputException: No content to map due to end-of-input
at [Source: (org.apache.catalina.connector.CoyoteInputStream); line: 1, column: 0]
at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ObjectMapper._initForReading(ObjectMapper.java:4916) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4818) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3809) ~[jackson-databind-2.15.2.jar:2.15.2]
at com.e.commerce.global.security.jwt.JwtAuthenticationFilter.attemptAuthentication(JwtAuthenticationFilter.java:30) ~[main/:na]
...

 

확인해보면 JwtAuthenticationFilter 클래스에서 사용한 ObjectMapper에서 null을 받아 발생한 에러이다.

 

 

문제점 파악

Postman을 사용한 로그인 시에는 정상적으로 로그인이 되지만, 리액트앱을 사용했을 때만 에러가 발생한다.

 

 

그리고 구글링하던 중 다음과 같은 글을 보았다.

하지만 UsernamePasswordAuthenticationFilter는 특징이 있어 이 특징을 잘 처리해줘야한다.
- 해당 FIlter를 거치고 다음 Filter로 가지 않는다. 그렇기 때문에 인증 성공 여부에 따른 메서드 successAuthenticaiton/unSuccessfulAuthentication을 구현해야한다.

 

출처 : https://jaewoo2233.tistory.com/72

 

Spring Security - JWT 설정

해당 url로 { "email":"user", "password":"1111" } 데이터를 바디에 담아 요청하면 토큰이 발행되도록 만들려고 한다. Spring 2.7.0 기준 일단 먼저일반적으로 Spring Security에 로그인 요청을 날리면 UsernamePassword

jaewoo2233.tistory.com

 

 

문제 해결

이에 UsernamePasswordAuthenticationFilter를 사용한 로그인 방식이 CorsFilter를 거치지 않기 때문에

 

CORS 정책에서 거부당했다고 생각하여 CorsFilter를 CorsConfig를 설정해주는 형식으로 변경하였다.

 

 

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfiguration {
	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		// cors 설정
		http
			.cors(cors -> cors.configurationSource(
				request -> {
					CorsConfiguration config = new CorsConfiguration();
					config.addAllowedOrigin("http://localhost:3000");
					config.addAllowedMethod("*");
					config.setAllowCredentials(true);
					config.setAllowedHeaders(List.of("Origin", "X-Requested-With", "Content-Type", "Accept",
						"Authorization", "Refresh"));
					config.setExposedHeaders(List.of("Authorization", "Refresh"));
					config.setMaxAge(3600L);

					return config;
				}
			));
		...
		return http.build();
	}
}

 

 

 

이제 로그인이 성공적으로 된다.

 

 

서버 측에서도 성공적으로 인증이 된 것을 볼 수 있다.

CustomAuthenticationSuccessHandler : ### 1. UsernamePasswordAuthenticationToken [Principal=jwanna, Credentials=[PROTECTED], Authenticated=true, Details=null, Granted Authorities=[ROLE_USER]]
CustomAuthenticationSuccessHandler : ### 2. jwanna
CustomAuthenticationSuccessHandler : # Authenticated Successful

 

Spring Security를 통한 세션 방식 로그인 구현

 

SpringBoot 3.1.3 버전을 사용했습니다.

 

 

1. SecurityConfiguration 구현

@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

	// 정적 자원에 대한 Security Ignoring 처리
	@Bean
	public WebSecurityCustomizer webSecurityCustomizer() {
		return web -> web.ignoring()
			.requestMatchers(PathRequest.toStaticResources().atCommonLocations())
			.requestMatchers(HttpMethod.POST, "/registers");
	}

	@Bean
	public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
		http
			.csrf(AbstractHttpConfigurer::disable)
			.authorizeHttpRequests(auth -> {
				auth.requestMatchers("*").permitAll();
				auth.requestMatchers("auths/**").permitAll();
				auth.requestMatchers("members/**").hasRole("USER");
			});

		http
			.formLogin(form -> form
				.usernameParameter("username")
				.passwordParameter("password")
				.loginPage("/auths/login-form")
				.loginProcessingUrl("/process_login")
				.failureUrl("/auths/login-form?error"));

		http
			.logout(logout -> logout
				.logoutUrl("/logout")
				.logoutSuccessUrl("/"));

		http
			.exceptionHandling(handle -> handle
				.accessDeniedPage("/auths/access-denied"));
		
		http.sessionManagement(session ->
			session
				.sessionCreationPolicy(SessionCreationPolicy.ALWAYS)
				.maximumSessions(1)
				.maxSessionsPreventsLogin(false)
				.expiredUrl("/session-expired"));

		return http.build();
	}

	// 만료된 세션 정리
	@Bean
	public static ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() {
		return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher());
	}

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

 

정적 자원과 로그인 템플릿에 대한 권한을 모두에게 허용하고, 커스텀한 로그인 페이지를 통해 로그인을 할 수 있도록 함

 

템플릿 페이지는 Thymeleaf 3.1을 사용하여 구성하였으며, header ・ menu ・ footer fragmentslayouts을 구성한 뒤

레이아웃에 content를 생성하여 구성하도록 함

 

<!-- 메인 페이지 레이아웃 -->
<!DOCTYPE html>
<html lang="ko"
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<body>
<head>
    <title>Home</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
    <!-- CSS Stylesheet -->
</head>
<body>
<div class="container-lg">
    <div th:replace="~{fragments/header::header}"></div>
    <hr>

    <div th:replace="~{fragments/menu::menu}"></div>
    <hr>

    <div layout:fragment="content"></div>
    <hr>

    <div th:replace="~{fragments/footer::footer}"></div>
    <hr>
</div>
<!-- Script -->
</body>

</body>
</html>

 

 

 

CustomAuthorityUtils를 통한 User Role 생성

@Slf4j
@Component
public class CustomAuthorityUtils {
	// 사이드 프로젝트니까 그냥 안 숨기고 admin으로 설정
	private final String ADMIN_USERNAME = "admin";

	private final List<GrantedAuthority> ADMIN_ROLES =
		AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_OWNER", "ROLE_USER");

	private final List<GrantedAuthority> OWNER_ROLES =
		AuthorityUtils.createAuthorityList("ROLE_OWNER", "ROLE_USER");

	private final List<GrantedAuthority> USER_ROLES =
		AuthorityUtils.createAuthorityList("ROLE_USER");

	private final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "OWNER", "USER");
	private final List<String> USER_ROLES_STRING = List.of("USER");

	// DB에 저장된 Role 기반으로 권한 정보 생성
	public List<GrantedAuthority> createAuthorities(List<String> roles) {
		List<GrantedAuthority> authorities = roles.stream()
			.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
			.collect(Collectors.toList());

		log.info("Create Authorities : {}", authorities);

		return authorities;
	}

	public List<String> createRoles(String username) {
		if (username.equals(ADMIN_USERNAME)) {
			return ADMIN_ROLES_STRING;
		}

		return USER_ROLES_STRING;
	}
}

 

Role은 ADMIN, OWNER, USER 3가지로 구성하고,

OWNER는 Member Entity에 updateRoles() 메서드를 생성한 뒤 AdminService에서 추가 및 제거하는 로직을 생성할 예정

 

public class Member {
	...
    
	@ElementCollection(fetch = FetchType.EAGER)
	private List<String> roles = new ArrayList<>();
}

 

Roles는 별도의 클래스를 생성하지 않고 @ElementCollection을 사용해서 별도의 테이블을 생성해서 관리

 

 

 

기타 사항

회원가입 폼 및 컨트롤러 생성

Exception 처리를 위한 Exception Controller 및 GlobalExceptionAdvice(Handler) 생성

 

 

 

현재 발생한 문제점

서버를 종료 후 재 실행 시에 로그아웃이 풀리지 않고 그대로 로그인 되어있는 상태로 남아있는 문제가 있음

로그아웃 시에도 쿠키가 삭제되지 않아 쿠키에 세션 ID를 가지고 있는 것을 확인할 수 있음

 

 

 

사용하면서 공부한 기술 정리

Spring Security

 

Thymeleaf

 

 

더 자세한 코드는 여기에서 확인이 가능합니다.

+ Recent posts