문제 상황
리프레시 토큰, 액세스 토큰을 발급 받는 로직은 모두 정상적으로 동작하고,
포스트맨에서도 일회적이지만 처음 로그인 할 때 헤더에 토큰 값이 들어왔다.
하지만 사진과 같이 다른 api 요청을 호출하면 헤더에서 토큰들이 감쪽같이 사라지는 오류가 발생한다.
당연히 웹에서도 동일한 오류가 발생한다....
그나마 포스트맨은 자체적으로 세션이랑 쿠키를 관리해서 망정이지.. 이마저도 아니었으면 테스트 하나도 못 했음
//SecurityConfig
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
//JwtAuthorizationFilter
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.resolveToken(request);
if (token != null && jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
var authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
시큐리티 설정에 해당 필터를 추가하면 매 요청마다 토큰이 유효한지 검증할 수 있다.
그래서 해당 오류가 발생했을 때 doFilterInternal에 로그로 token 값을 찍어 보면 null 파티가 신나게 발생한다^^!
해당 필터에 로그를 찍은 덕에 로그인 직후 바로 헤더에서 토큰이 사라지는 걸 알 수 있었고,
프론트에서 직접 헤더에 토큰을 넣어 주는 방식을 시도해 봤다.
Try 1. Setting Token at front-end
document.addEventListener('DOMContentLoaded', function () {
function fetchWithAuth(url, options = {}) {
const headers = new Headers(options.headers || {});
const accessToken = localStorage.getItem('accessToken');
const refreshToken = localStorage.getItem('refreshToken');
if (accessToken) {
headers.append('Authorization', 'Bearer ' + accessToken);
console.log('Access Token attached to header:', accessToken);
} else {
console.log('No access token found in localStorage');
}
if (refreshToken) {
headers.append('Refresh-Token', 'Bearer ' + refreshToken);
console.log('Refresh Token attached to header:', refreshToken);
} else {
console.log('No refresh token found in localStorage');
}
options.headers = headers;
return fetch(url, options);
}
window.fetchWithAuth = fetchWithAuth;
if (document.getElementById('user-info')) {
fetchWithAuth('/api/user/info')
.then(response => response.json())
.then(data => {
const userInfoDiv = document.getElementById('user-info');
if (userInfoDiv) {
userInfoDiv.innerHTML = `<p>사용자: ${data.username}</p>`;
}
})
.catch(error => {
console.error('Error:', error);
const userInfoDiv = document.getElementById('user-info');
if (userInfoDiv) {
userInfoDiv.innerHTML = `<p>사용자 정보를 가져오지 못했습니다.</p>`;
}
});
}
window.logout = function() {
const refreshToken = localStorage.getItem('refreshToken');
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
fetchWithAuth('/api/user/logout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: refreshToken })
}).then(response => {
if (response.ok) {
window.location.href = '/';
} else {
console.error('Logout failed');
}
});
};
});
해당 js 함수를 모든 html 파일에 추가해봤다.
결과: 실패❌
가장 일반적인 방법이었는데,, 이상하게 자꾸 실패를 한다.
Try 2. 쿠키 사용 ....
프론트 쪽은 잘 모르기 때문에 왜 자바스크립트가 적용이 안 되는지 모르겠다,,
아무튼 이렇게 된 거 백엔드에서 직접 토큰을 일일이 넣어 주면 되는 거 아닌가? 라는 생각이 들었다.
그래서 임시 해결 방법으로 토큰을 쿠키에 저장해서 매 요청마다 자동으로 전송하도록 하였다.
1️⃣ 로그인 시 토큰을 쿠키에 저장
public LoginResponseDto login(LoginRequestDto loginRequestDto, HttpServletResponse response) {
String username = loginRequestDto.getUsername();
String password = loginRequestDto.getPassword();
try {
Member member = memberRepository.findByUsername(username);
if (!passwordEncoder.matches(password, member.getPassword())) {
throw new IllegalArgumentException("회원 정보가 일치하지 않습니다.");
}
String accessToken = jwtUtil.generateAccessToken(username);
String refreshToken = jwtUtil.generateRefreshToken(username);
refreshTokenRepository.save(new RefreshToken(username, refreshToken));
Cookie accessTokenCookie = new Cookie("AccessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
//accessTokenCookie.setSecure(true); // HTTPS만 사용하도록 설정
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(60 * 60 * 1000);
Cookie refreshTokenCookie = new Cookie("RefreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
//refreshTokenCookie.setSecure(true); // HTTPS만 사용하도록 설정
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(7 * 24 * 60 * 60 * 1000);
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
return new LoginResponseDto(username, accessToken, refreshToken);
} catch (Exception e) {
log.error("Error authenticating user", e);
throw new IllegalArgumentException("회원 정보가 일치하지 않습니다.");
}
}
- 로그인 요청의 username으로 액세스, 리프레시 토큰 발급하고 저장
- 각각 AccessToken, RefreshToken 이름으로 토큰을 쿠키에 저장
- HttpServletResponse에 쿠키 추가
아직 작은 프로젝트 단계이므로 HTTPS 옵션을 비활성화 했다.
2️⃣ JwtAuthorizationFilter에서 쿠키로부터 토큰 가져오기
private String resolveTokenFromCookies(HttpServletRequest request) {
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("AccessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = resolveTokenFromCookies(request);
if (token != null && jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if (userDetails != null) {
var authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
- 요청에 있는 쿠키를 꺼내서 AccessToken을 찾으면 쿠키의 토큰 값을 반환
- 쿠키에서 꺼낸 토큰이 유효한지 검증
결과: 해결✅
헤더의 마이페이지, 장바구니, 로그아웃은 로그인한 사용자에게만 보이는 메뉴로 구현한 것이다.
로그인을 하니 회원 메뉴가 정상적으로 보이고 헤더의 쿠키에 토큰 값이 정상적으로 들어온 걸 볼 수 있다.
❌ 헤더에 리프레시 토큰이 들어오면 보안 의미가 없음 ❌ (추후 수정할 것..)
하지만 ...
쿠키를 사용하는 것은 편의성 측면에선 좋을 수는 있다.
그러나 쿠키는 모든 요청에 자동으로 전송되기 때문에 CSRF 공격에 취약하다.
그리고 모든 요청에 대해 쿠키를 확인하고 토큰을 검증해야 하므로 서버 부하가 증가할 수 있다.
CSRF 공격 방지 설정
위에서 비활성화한 HTTPS 설정도 활성화 해야 한다!
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtUtil jwtUtil;
private final UserDetailsServiceImpl userDetailsService;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
JwtAuthorizationFilter jwtAuthorizationFilter = new JwtAuthorizationFilter(jwtUtil, userDetailsService);
// CSRF 방어
http.csrf(csrf -> csrf
.csrfTokenRepository(csrfTokenRepository())
);
http.sessionManagement((sessionManagement) ->
sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
http.authorizeHttpRequests((authorizeHttpRequests) ->
authorizeHttpRequests
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.anyRequest().authenticated()
);
http.addFilterBefore(jwtAuthorizationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public CsrfTokenRepository csrfTokenRepository() {
HttpSessionCsrfTokenRepository repository = new HttpSessionCsrfTokenRepository();
repository.setSessionAttributeName("_csrf");
return repository;
}
}
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>title</title>
<!--CSRF 메타 태그 추가 -->
<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
</head>
</html>
document.addEventListener('DOMContentLoaded', function () {
function fetchWithAuth(url, options = {}) {
const headers = new Headers(options.headers || {});
const token = localStorage.getItem('accessToken');
const csrfToken = document.querySelector('meta[name="_csrf"]').getAttribute('content');
const csrfHeader = document.querySelector('meta[name="_csrf_header"]').getAttribute('content');
if (token) {
headers.append('Authorization', 'Bearer ' + token);
}
headers.append(csrfHeader, csrfToken);
options.headers = headers;
return fetch(url, options);
}
아무래도 단점이 마음에 걸리는 부분..☹
프론트에서 처리하는 방법을 알게 되면 후다닥 수정해야겠다.
어쨌든.. 해결..~~
'Trouble Shooting' 카테고리의 다른 글
[Spring Boot 프로젝트] 주문 중 재고 부족 오류 (0) | 2024.05.24 |
---|---|
[EC2] SSH - Host key verification failed. (0) | 2024.05.20 |
[Java, Spring] 주문 로직 중 NullPointerException 해결 (0) | 2024.05.19 |
[Spring Security 오류] 권한 접근 설정이 제대로 적용되지 않는 오류 (0) | 2024.05.19 |