springboot
-
[Spring] 로그인 시 Cors 에러 해결2023.11.14
-
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현2023.08.29
[Spring] 로그인 시 Cors 에러 해결
기존 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' 카테고리의 다른 글
[Spring] Thymeleaf를 통한 페이지네이션 구현 (0) | 2023.09.07 |
---|---|
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정 (0) | 2023.09.07 |
[Thymeleaf] layout fragment로 변수 넘겨주기 (0) | 2023.09.07 |
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현 (0) | 2023.08.29 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring/Thymeleaf] 세션 방식 로그인 및 템플릿 구현
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 fragments와 layouts을 구성한 뒤
레이아웃에 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' 카테고리의 다른 글
[Thymeleaf] th:href 경로에 변수를 사용한 경로 설정 (0) | 2023.09.07 |
---|---|
[Thymeleaf] layout fragment로 변수 넘겨주기 (0) | 2023.09.07 |
[Spring/Error] WebSecurityCustomizer를 통해 정적 자원에 대한 Ignore가 안될때 (0) | 2023.08.28 |
[Spring] 다중 칼럼 인덱스를 활용한 쿼리 속도 개선 (0) | 2023.05.14 |
[Spring] SimpleJdbcInsert 사용 시 테이블이나 칼럼을 제대로 불러오지 못할 때 해결 방법 (0) | 2023.04.08 |