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 Boot 3.1 & Spring Security 6: Security Config 최적화 리팩토링 (12편)
이번에는 필요없는 필터는 없애고 최적화를 진행하기 위해 Security Config를 리팩토링 했다. 1. SecurityConfig 리팩토링 앞의 게시글을 확인해보면 작성한 코드가 있기에 변경된 메서드만 설명하겠다.
curiousjinan.tistory.com
Spring Boot 3.1 & Spring Security 6: 로그인 프로세스 및 JWT 토큰 동작 설명 (10편)
지금까지 만든 시큐리티의 동작을 설명하겠다. 1. 로그인 과정 로그인 페이지 접근: 사용자는 웹 브라우저에서 로그인 페이지(/login)에 접근한다. 로그인 페이지에는 사용자 이름과 비밀번호를
curiousjinan.tistory.com
반응형
'Spring > 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 |