반응형
JWT 필터 스프링 시큐리티 코드 리팩토링을 진행했다.
1. JwtAuthorizationFilter 클래스를 리팩토링 했다.
- 기존에는 한줄로 이루어져있던 코드를 extract method로 각각 역할별로 추출했다.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.recipia.web.config.security.jwt.TokenUtils;
import com.recipia.web.domain.user.constant.RoleType;
import com.recipia.web.exception.ErrorCode;
import com.recipia.web.exception.RecipiaApplicationException;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.*;
/**
* 지정한 URL별 JWT의 유효성 검증을 수행하며 직접적인 사용자 인증을 확인합니다.
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
private static final AntPathMatcher pathMatcher = new AntPathMatcher();
/**
* 문자열 리터럴은 상수로 선언하여 사용하는 것이 좋다. 이렇게 하면 코드의 가독성이 향상되며, 나중에 변경이 필요할 때 한 곳에서만 수정하면 된다.
*/
private static final String LOGIN_PATH = "/login";
private static final String ROOT_PAGE_PATH = "/main/rootPage";
private static final String JWT_COOKIE_NAME = "jwt";
// 예외 처리할 경로 리스트 선언
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/user/login",
"/login",
"/css/**",
"/js/**",
"/images/**",
"/signUp/form",
"/favicon.ico",
"/signUp/action/createUser",
"/resources/**" // 'src/main/resources'에 있는 모든 리소스에 대한 경로를 여기에 추가
);
/**
* 요청 경로와 HTTP 메서드를 확인하여 인증 및 인가 필터링을 건너뛸지 결정한다.
* 예외 처리 경로 목록(EXCLUDE_PATHS) 또는 HTTP OPTIONS 메서드의 경우 필터링을 건너뛴다.
*
* @param request 클라이언트로부터의 요청 객체
* @param response 서버의 응답 객체
* @param filterChain 필터 체인
*/
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
// log.info("JwtAuthorizationFilter 시작: " + request.getRequestURI()); // 로그 추가
String token = getTokenFromCookiesWithoutException(request.getCookies());
if (!StringUtils.hasText(token)) {
if (!request.getRequestURI().equals(LOGIN_PATH)) {
response.sendRedirect(LOGIN_PATH);
return;
}
} else if (request.getRequestURI().equals(LOGIN_PATH)) {
response.sendRedirect(ROOT_PAGE_PATH);
return;
}
if (shouldExclude(request)) {
filterChain.doFilter(request, response);
return;
}
try {
validateAndSetAuthentication(token);
filterChain.doFilter(request, response);
} catch (Exception e) {
handleException(response, e);
}
// log.info("JwtAuthorizationFilter 종료: " + request.getRequestURI()); // 로그 추가
}
// 예외를 발생시키지 않는 버전의 getTokenFromCookies 메서드
private String getTokenFromCookiesWithoutException(Cookie[] cookies) {
return Optional.ofNullable(cookies)
.flatMap(cookieList -> Arrays.stream(cookieList).filter(cookie -> JWT_COOKIE_NAME.equals(cookie.getName())).findFirst())
.map(Cookie::getValue)
.orElse(null);
}
/**
* 주어진 요청의 URI가 예외 처리 대상 경로에 포함되어 있는지, 또는 HTTP OPTIONS 메서드인지 확인한다.
* CORS preflight 요청 때문에 이런 처리가 필요하다.
*
* @param request 클라이언트로부터의 요청 객체
* @return 예외 처리 대상이면 true, 아니면 false
*/
private boolean shouldExclude(HttpServletRequest request) {
return EXCLUDE_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())) || "OPTIONS".equalsIgnoreCase(request.getMethod());
}
/**
* 주어진 JWT 토큰의 유효성을 검증하고, 유효하다면 해당 사용자의 인증 정보를 SecurityContextHolder에 설정한다.
* 이 메서드는 JWT 토큰에서 로그인 ID와 RoleType(사용자 권한)을 추출한다. 이후, RoleType을 바탕으로 권한 목록을 생성하고,
* 이를 포함하는 UsernamePasswordAuthenticationToken 인증 객체를 생성한다. 생성된 인증 객체는 SecurityContextHolder의 Context에 설정되며,
* 이로써 이후의 요청에서 해당 사용자가 인증된 것으로 처리된다.
*
* @param token 검증할 JWT 토큰
* @throws RecipiaApplicationException JWT 토큰이 유효하지 않을 경우 발생
*/
private void validateAndSetAuthentication(String token) {
log.info("validateAndSetAuthentication 시작: " + token); // 로그 추가
if (TokenUtils.isValidToken(token)) {
String loginId = TokenUtils.getUserIdFromToken(token);
RoleType roleType = TokenUtils.getRoleFromToken(token); // JWT 토큰에서 RoleType 정보를 추출합니다.
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(roleType.name())); // RoleType을 바탕으로 권한 목록을 생성합니다.
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
throw new RecipiaApplicationException(ErrorCode.TOKEN_NOT_VALID);
}
}
/**
* 예외 발생 시, 해당 예외를 로그에 기록하고 클라이언트에게 오류 메시지를 JSON 형태로 전송한다.
*
* @param response 서버의 응답 객체
* @param e 발생한 예외
*/
private void handleException(HttpServletResponse response, Exception e) throws IOException {
Map<String, Object> errorResponse = jsonResponseWrapper(e);
log.error((String) errorResponse.get("message"), e);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
try (PrintWriter printWriter = response.getWriter()) {
Map<String, Object> jsonObject = new HashMap<>();
jsonObject.put("error", true);
jsonObject.put("message", "로그인 에러");
printWriter.print(new ObjectMapper().writeValueAsString(jsonObject));
}
}
/**
* 주어진 예외에 따라 적절한 에러 메시지를 생성하고, 이를 JSON 객체로 변환하여 반환한다.
*
* @param e 발생한 예외
* @return 에러 메시지를 포함한 JSON 객체
*/
private Map<String, Object> jsonResponseWrapper(Exception e) {
String resultMessage;
if (e instanceof ExpiredJwtException) {
resultMessage = "JWT 토큰기간 만료";
} else if (e instanceof SecurityException) {
resultMessage = "허용된 JWT 토큰이 아닙니다.";
} else if (e instanceof JwtException) {
resultMessage = "토큰을 parsing하던중 예외가 발생했습니다.";
} else {
resultMessage = "JWT 토큰 에러입니다.";
}
Map<String, Object> jsonMap = new HashMap<>();
jsonMap.put("status", 401);
jsonMap.put("code", "9999");
jsonMap.put("message", resultMessage);
jsonMap.put("reason", e.getMessage());
return jsonMap;
}
}
2. 코드 설명
- 먼저 문자열들은 따로 상수로 선언했다. 이러면 코드의 가독성이 향상되고 나중에 변경하기도 쉽다.
/**
* 문자열 리터럴은 상수로 선언하여 사용하는 것이 좋다. 이렇게 하면 코드의 가독성이 향상되며, 나중에 변경이 필요할 때 한 곳에서만 수정하면 된다.
*/
private static final String LOGIN_PATH = "/login";
private static final String ROOT_PAGE_PATH = "/main/rootPage";
private static final String JWT_COOKIE_NAME = "jwt";
- 예외 처리할 경로 또한 리스트로 선언해 두었다.
// 예외 처리할 경로 리스트 선언
private static final List<String> EXCLUDE_PATHS = Arrays.asList(
"/user/login",
"/login",
"/css/**",
"/js/**",
"/images/**",
"/signUp/form",
"/favicon.ico",
"/signUp/action/createUser",
"/resources/**" // 'src/main/resources'에 있는 모든 리소스에 대한 경로를 여기에 추가
);
- 그리고 본 메서드인 doFilterInternal()를 선언했다.
/**
* 요청 경로와 HTTP 메서드를 확인하여 인증 및 인가 필터링을 건너뛸지 결정한다.
* 예외 처리 경로 목록(EXCLUDE_PATHS) 또는 HTTP OPTIONS 메서드의 경우 필터링을 건너뛴다.
*
* @param request 클라이언트로부터의 요청 객체
* @param response 서버의 응답 객체
* @param filterChain 필터 체인
*/
@Override
protected void doFilterInternal(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
String token = getTokenFromCookiesWithoutException(request.getCookies());
// 토큰이 없으면 로그인 페이지로 redirect시킨다.
if (!StringUtils.hasText(token)) {
if (!request.getRequestURI().equals(LOGIN_PATH)) {
response.sendRedirect(LOGIN_PATH);
return;
}
} else if (request.getRequestURI().equals(LOGIN_PATH)) {
response.sendRedirect(ROOT_PAGE_PATH);
return;
}
// 제외 경로에 존재하는 url이면 다음 filterChain으로 이동시킨다.
if (shouldExclude(request)) {
filterChain.doFilter(request, response);
return;
}
// 검증작업을 하고 다음 필터로 넘긴다.
try {
validateAndSetAuthentication(token);
filterChain.doFilter(request, response);
} catch (Exception e) {
handleException(response, e);
}
}
- 예외를 발생시키지 않고 토큰을 가져오는 메서드 (예외처리를 하면 필터를 탈때마다 예외로그가 너무 많이 남아서 알아볼수가 없었다.)
// 예외를 발생시키지 않는 버전의 getTokenFromCookies 메서드
private String getTokenFromCookiesWithoutException(Cookie[] cookies) {
return Optional.ofNullable(cookies)
.flatMap(cookieList -> Arrays.stream(cookieList).filter(cookie -> JWT_COOKIE_NAME.equals(cookie.getName())).findFirst())
.map(Cookie::getValue)
.orElse(null);
}
- 이제 주어진 요청의 URI로 예외 처리 대상 경로인지, HTTP OPTIONS 메서드인지 확인한다. 이건 CORS preflight 요청 때문에 이런 처리가 필요하다.
- EXCLUDE_PATHS는 상단에 선언한 예외 처리 대상 리스트이다.
/**
* 주어진 요청의 URI가 예외 처리 대상 경로에 포함되어 있는지, 또는 HTTP OPTIONS 메서드인지 확인한다.
* CORS preflight 요청 때문에 이런 처리가 필요하다.
*
* @param request 클라이언트로부터의 요청 객체
* @return 예외 처리 대상이면 true, 아니면 false
*/
private boolean shouldExclude(HttpServletRequest request) {
return EXCLUDE_PATHS.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())) || "OPTIONS".equalsIgnoreCase(request.getMethod());
}
- 주어진 JWT 토큰의 유효성을 검증한다.
/**
* 주어진 JWT 토큰의 유효성을 검증하고, 유효하다면 해당 사용자의 인증 정보를 SecurityContextHolder에 설정한다.
* 이 메서드는 JWT 토큰에서 로그인 ID와 RoleType(사용자 권한)을 추출한다. 이후, RoleType을 바탕으로 권한 목록을 생성하고,
* 이를 포함하는 UsernamePasswordAuthenticationToken 인증 객체를 생성한다. 생성된 인증 객체는 SecurityContextHolder의 Context에 설정되며,
* 이로써 이후의 요청에서 해당 사용자가 인증된 것으로 처리된다.
*
* @param token 검증할 JWT 토큰
* @throws RecipiaApplicationException JWT 토큰이 유효하지 않을 경우 발생
*/
private void validateAndSetAuthentication(String token) {
log.info("validateAndSetAuthentication 시작: " + token); // 로그 추가
if (TokenUtils.isValidToken(token)) {
String loginId = TokenUtils.getUserIdFromToken(token);
RoleType roleType = TokenUtils.getRoleFromToken(token); // JWT 토큰에서 RoleType 정보를 추출합니다.
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority(roleType.name())); // RoleType을 바탕으로 권한 목록을 생성합니다.
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginId, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
throw new RecipiaApplicationException(ErrorCode.TOKEN_NOT_VALID);
}
}
- 마지막으로 예외 처리 로직을 작성한다.
/**
* 예외 발생 시, 해당 예외를 로그에 기록하고 클라이언트에게 오류 메시지를 JSON 형태로 전송한다.
*
* @param response 서버의 응답 객체
* @param e 발생한 예외
*/
private void handleException(HttpServletResponse response, Exception e) throws IOException {
Map<String, Object> errorResponse = jsonResponseWrapper(e);
log.error((String) errorResponse.get("message"), e);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
try (PrintWriter printWriter = response.getWriter()) {
Map<String, Object> jsonObject = new HashMap<>();
jsonObject.put("error", true);
jsonObject.put("message", "로그인 에러");
printWriter.print(new ObjectMapper().writeValueAsString(jsonObject));
}
}
/**
* 주어진 예외에 따라 적절한 에러 메시지를 생성하고, 이를 JSON 객체로 변환하여 반환한다.
*
* @param e 발생한 예외
* @return 에러 메시지를 포함한 JSON 객체
*/
private Map<String, Object> jsonResponseWrapper(Exception e) {
String resultMessage;
if (e instanceof ExpiredJwtException) {
resultMessage = "JWT 토큰기간 만료";
} else if (e instanceof SecurityException) {
resultMessage = "허용된 JWT 토큰이 아닙니다.";
} else if (e instanceof JwtException) {
resultMessage = "토큰을 parsing하던중 예외가 발생했습니다.";
} else {
resultMessage = "JWT 토큰 에러입니다.";
}
Map<String, Object> jsonMap = new HashMap<>();
jsonMap.put("status", 401);
jsonMap.put("code", "9999");
jsonMap.put("message", resultMessage);
jsonMap.put("reason", e.getMessage());
return jsonMap;
}
3. JwtTokenInterceptor 리팩토링
- 기존 클래스의 로직에서 필요없는 부분을 제거했다.
import com.recipia.web.config.security.jwt.TokenUtils;
import com.recipia.web.exception.ErrorCode;
import com.recipia.web.exception.RecipiaApplicationException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
/**
* JwtTokenInterceptor는 JWT 토큰을 검증하는 역할을 수행한다.
* <p>
* HTTP 요청이 들어올 때, 쿠키에서 "jwt"라는 이름을 가진 토큰을 추출한다.
* 추출한 토큰이 유효한 경우, 요청을 그대로 진행한다.
* 토큰이 유효하지 않거나 존재하지 않는 경우, 예외를 발생시킨다.
* </p>
* 특이사항으로는 /favicon.ico 요청에 대해서는 토큰 검증을 수행하지 않는다.
*/
@Slf4j
@Component
public class JwtTokenInterceptor implements HandlerInterceptor {
/**
* preHandle 메서드는 HTTP 요청이 Controller로 들어가기 전에 수행된다.
*
* @return 토큰이 유효한 경우 true, 그렇지 않으면 예외를 발생시킨다.
*/
@Override
public boolean preHandle(
HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler
) {
// favicon.ico 요청에 대한 JWT 토큰 검증을 건너뛰기
if (request.getRequestURI().equals("/favicon.ico")) {
return true;
}
String token = null;
// 쿠키에서 JWT 토큰 가져오기
jakarta.servlet.http.Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (jakarta.servlet.http.Cookie cookie : cookies) {
if (cookie.getName().equals("jwt")) {
token = cookie.getValue();
break;
}
}
}
if (token != null) {
if (TokenUtils.isValidToken(token)) {
String userId = TokenUtils.getUserIdFromToken(token);
if (userId == null) {
log.debug("token isn't userId");
throw new RecipiaApplicationException(ErrorCode.AUTH_TOKEN_NOT_MATCH);
}
return true;
} else {
throw new RecipiaApplicationException(ErrorCode.AUTH_TOKEN_INVALID);
}
} else {
throw new RecipiaApplicationException(ErrorCode.AUTH_TOKEN_IS_NULL);
}
}
}
4. 변경사항 정리
1. JwtFilter 관련해서 필요없어진 코드는 제거했다. (삭제처리함)
2. JwtTokenInterceptor 코드도 검증 로직을 간략화 했다.
3. 전체적인 코드를 extract method로 보기쉽게 분리했다. (필요없는 로직은 제거 및 리팩토링으로 고도화)
- 지금까지 만든 패키지 구성 이미지
반응형
'Spring Security' 카테고리의 다른 글
Spring Security와 CORS: 크로스 도메인 요청 처리 기법 알아보기 (0) | 2023.10.21 |
---|---|
Spring Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편) (0) | 2023.09.04 |
Spring Security 6 이해하기: 동작 원리와 보안 기능 탐구 (0) | 2023.09.04 |
Spring Boot 3.1 & Spring Security 6: 로그인 프로세스 및 JWT 토큰 동작 설명 (10편) (0) | 2023.08.08 |
Spring Boot 3.1 & Spring Security 6: 로그인 & 메인 페이지 컨트롤러 (9편) (0) | 2023.08.08 |