일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 윤성우 열혈자료구조
- Graph
- 혼자 공부하는 C언어
- 윤성우의 열혈 자료구조
- list 컬렉션
- Selection Sorting
- insertion sort
- Stack
- JSON
- R
- C 언어 코딩 도장
- buffer
- Algorithm
- stream
- 이것이 자바다
- s
- datastructure
- C programming
- 알기쉬운 알고리즘
- coding test
- Serialization
- 메모리구조
- 이스케이프 문자
- Today
- Total
Engineering Note
[Error Handling] Spring Security 로그아웃 버그 해결 본문
로그인한 상태에서 로그아웃 버튼을 클릭시 '/members/logout' API 요청을 통해 로그아웃 기능을 구현하는 도중 오류가 발생했다.
개발 환경
Spring Boot3, Java17
에러 화면
Whitelabel Error Page
This application has no explicit mapping for /error, so you are seeing this as a fallback.
리소스가 없다는 의미고 get 요청에 대한 오류가 발생했다는 뜻이다.
실제 로그아웃 요청을 하는 Navbar html 코드를 보면 <a> 태그를 통해서 GET 요청을 하도록 되어 있었다. (<a> 태그를 통한 링크 연결은 GET요청이다.)
<div th:fragment="header" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<nav class="navbar navbar-expand-sm bg-primary navbar-dark">
<button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbarTogglerDemo03" aria-controls="navbarTogglerDemo03"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/">Kmarket</a>
<div class="collapse navbar-collapse" id="navbarTogglerDemo03">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
<a class="nav-link" href="/admin/item/new">상품 등록</a>
</li>
<li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
<a class="nav-link" href="/admin/items">상품 관리</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/cart">장바구니</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/orders">구매내역</a>
</li>
<li class="nav-item" sec:authorize="isAnonymous()">
<a class="nav-link" href="/members/login">로그인</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/members/logout">로그아웃</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0" th:action="@{/}" method="get">
<input name="searchQuery" class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>
</div>
아래는 Security 환경설정 코드이다.
package com.shop.config;
import com.shop.contant.Role;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf
.ignoringRequestMatchers("/h2-console/**")// 특정 경로만 CSRF 비활성화
)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/", "/members/**", "/item/**", "/images/**", "/thymeleaf/**").permitAll() // 인증 없이 접근 허용
.requestMatchers("/h2-console/**","/css/**", "/js/**", "/img/**").permitAll() // H2 콘솔 접근 허용
.requestMatchers("/admin/**").hasRole(Role.ADMIN.name())
.anyRequest().authenticated() // 나머지는 인증 필요
)
.formLogin(form -> form
.loginPage("/members/login") // 커스텀 로그인 페이지 URL
.defaultSuccessUrl("/") // 로그인 성공 후 이동
.usernameParameter("email")
.failureUrl("/members/login/error")
)
.logout(logout -> logout
.logoutUrl("/members/logout")
.logoutSuccessUrl("/")
).exceptionHandling(exception -> exception
.authenticationEntryPoint(new CustomAuthenticationEntryPoint()) // 커스텀 인증 예외 처리
)
.headers(headers -> headers.frameOptions(frame -> frame.sameOrigin())); // H2 콘솔 사용 위해
;
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
login 요청은 MemberController에서 아래처럼 POST로 처리를 해뒀고, logout 처리는 시큐리티 설정에 logoutUrl에 "/members/logout"으로 등록을 해두었다. 이렇게 등록을 해두면 Spring Security는 "/members/logout" 이 요청에 대해 POST요청만 처리하도록 되어 있다.
package com.shop.controller;
import com.shop.dto.MemberFormDto;
import com.shop.entity.Member;
import com.shop.service.MemberService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@RequestMapping("/members")
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
private final PasswordEncoder passwordEncoder;
@GetMapping(value = "/new")
public String memberForm(Model model){
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberForm";
}
@PostMapping(value = "/new")
public String memberForm(@Valid MemberFormDto memberFormDto, BindingResult bindingResult, Model model){
if (bindingResult.hasErrors()){
return "member/memberForm";
}
try {
Member member = Member.createMember(memberFormDto, passwordEncoder);
memberService.saveMember(member);
} catch (IllegalStateException e){
model.addAttribute("errorMessage", e.getMessage());
return "member/memberForm";
}
return "redirect:/";
}
@GetMapping(value = "/login")
public String loginMember(Model model){
model.addAttribute("memberFormDto", new MemberFormDto());
return "member/memberLoginForm";
}
@GetMapping("/login/error")
public String loginError(Model model){
model.addAttribute("loginErrorMsg", "아이디 또는 비밀번호를 확인해주세요");
return "member/memberLoginForm";
}
}
해결책
Security 환결설정에서 logoutUrl GET요청도 허용
.logout(logout -> logout
.logoutUrl("/members/logout")
.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout", "GET")) // GET 요청도 허용
.logoutSuccessUrl("/")
)
Controller에서 GET요청으로 직접 로그아웃 구현
@Controller
public class MemberController {
@GetMapping("/members/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) {
new SecurityContextLogoutHandler().logout(request, response, auth);
}
return "redirect:/";
}
}
로그아웃 컨트롤러가 따로 없더라도 Security가 알아서 처리를 해준다. 그래서 해결방법은 GET 요청으로 로그아웃을 처리하는 Controller를 개발해서 Spring Security에 대한 로그아웃처리 비즈니스 로직 처리하거나, Security에서 GET 요청으로 만드는 방법이 있는데 보안상의 이유로 권장되는 방법은 아니다.
그래서 최종적으로 Navbar 에서 form요청을 post 처리를 통해 해결하는 방법을 찾아서 해결했다.
여기서도 방법이 여러가지가 있는데 직접 form 태그와 버튼으로 POST 요청을 처리하게 구현했더니 기존의 li 태그와 a태그로 구현된 로그아웃 버튼의 UI가 많이 깨져서 li 태그와 a태그를 유지하면서 POST 요청을 유지하는 방법을 찾아보았다.
이 방법에서도 여러가지 방법이 있었는데 JS의 fetch API를 통해 POST 요청하는 방법도 있지만, 브라우저 호환성 등을 고려하면 document.getElementById를 통해 form 태그를 직접 조작하는 방식이 가장 간단하면서도 장점이 많아서 dom 조작을 통해 해결했다.
중간에 생각한 fetch API를 통한 해결 코드
<a class="nav-link" onclick="logout(event)">로그아웃</a>
<script>
function logout(event) {
// a태그 기본 동작 제거
event.preventDefault();
const token = document.querySelector('meta[name="_csrf"]').content;
const header = document.querySelector('meta[name="_csrf_header"]').content;
fetch('/members/logout', {
method: 'POST',
headers: {
[header]: token,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: ''
}).then(res => {
if (res.ok) window.location.href = '/'; // 성공 시 홈으로 이동
else alert('로그아웃 실패');
}).catch(err => {
console.error(err);
alert('네트워크 오류');
});
}
</script>
그리고 로그아웃 a 태그의 href를 비활성화 'javascript:void(0)'를 통해 비활성화하고 onclick 속성으로 form태그의 submit()을 하도록 했는데 form 태그는 nav태그 바깥에 두어 UI가 깨지지 않게 했다.
<a class="nav-link" href="javascript:void(0)" onclick="document.getElementById('logout-form').submit();">로그아웃</a>
최종 해결 코드
<div th:fragment="header" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<nav class="navbar navbar-expand-sm bg-primary navbar-dark">
<button class="navbar-toggler" type="button" data-toggle="collapse"
data-target="#navbarTogglerDemo03" aria-controls="navbarTogglerDemo03"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand" href="/">Kmarket</a>
<div class="collapse navbar-collapse" id="navbarTogglerDemo03">
<ul class="navbar-nav mr-auto mt-2 mt-lg-0">
<li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
<a class="nav-link" href="/admin/item/new">상품 등록</a>
</li>
<li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
<a class="nav-link" href="/admin/items">상품 관리</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/cart">장바구니</a>
</li>
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="/orders">구매내역</a>
</li>
<li class="nav-item" sec:authorize="isAnonymous()">
<a class="nav-link" href="/members/login">로그인</a>
</li>
<!-- 수정: 로그아웃 링크를 JavaScript를 사용하여 POST 요청으로 처리 -->
<li class="nav-item" sec:authorize="isAuthenticated()">
<a class="nav-link" href="javascript:void(0)" onclick="document.getElementById('logout-form').submit();">로그아웃</a>
</li>
</ul>
<form class="form-inline my-2 my-lg-0" th:action="@{/}" method="get">
<input name="searchQuery" class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
<button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
</form>
</div>
</nav>
<!-- 수정: CSRF 토큰을 포함한 숨겨진 로그아웃 폼 -->
<form id="logout-form" th:action="@{/members/logout}" method="post" style="display: none;">
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
</form>
</div>
'Error Handling' 카테고리의 다른 글
[Error Handling] Thymeleaf Parsing Error, NotReadablePropertyException 예외 (0) | 2025.09.16 |
---|---|
[Error Handling] 파일 업로드 테스트 에러 해결 (0) | 2025.09.15 |
[Error Handling] DB 연결 설정 테스트 문제해결, ActiveProfile로 테스트 환경 관리 (0) | 2025.09.13 |
[Error Handling] Spring Security 로그인 테스트에서 userParameter 설정 이슈 해결 (0) | 2025.09.12 |
[Error Handling] EC2에서 Spring Boot 빌드 오류 해결 (0) | 2025.09.05 |