@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("jwt 필터 동작");
System.out.println(request.getRequestURI());
// 헤더에서 액세스 토큰 추출
Cookie[] cookies = request.getCookies();
// 쿠키 자체가 없으면 401 에러 발생
if (cookies == null) {
log.error("쿠키 없음.");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "쿠키가 존재하지 않습니다.");
return;
}
// 액세스 토큰 추출
String accessToken = Arrays.stream(cookies)
.filter(c -> "accessToken".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
// 액세스 토큰이 없는 경우
if (accessToken == null) {
log.error("액세스 토큰이 없습니다.");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "액세스 토큰이 존재하지 않습니다.");
return; // 여기서 처리 종료
}
// userId 토큰에서 꺼냄.
try {
TokenInfo tokenInfo = jwtService.getUserId(accessToken);
log.info("userId:{}", tokenInfo.getUserId());
// 토큰이 만료됐으면
if (tokenInfo.isExpired()) {
// 리프레시 토큰 탐색
String refreshToken = Arrays.stream(cookies)
.filter(c -> "refreshToken".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
// 만약에 null이면
if (refreshToken == null) {
log.error("리프레시 토큰이 없습니다.");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다.");
return;
}
String result = jwtService.checkRefreshToken(refreshToken);
if ("사용 불가".equals(result)) {
log.error("리프레시 토큰 문제 발생");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "리프레시 토큰 사용 불가(로그아웃 진행)");
return; // 여기서 처리 종료
} else {
String reAccessToken = jwtService.createAccessToken(tokenInfo.getUserId());
String reRefreshToken = jwtService.createRefreshToken(tokenInfo.getUserId(), true);
if ("유저 없음".equals(reRefreshToken)) {
log.error("유저 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 정보 조회 실패");
return; // 여기서 처리 종료
}
// 액세스 토큰을 위한 쿠키 생성
Cookie accessTokenCookie = new Cookie("accessToken", reAccessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setSecure(true); // HTTPS를 사용하는 경우에만 true로 설정
accessTokenCookie.setPath("/");
// 리프레시 토큰을 위한 쿠키 생성
Cookie refreshTokenCookie = new Cookie("refreshToken", reRefreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(true); // HTTPS를 사용하는 경우에만 true로 설정
refreshTokenCookie.setPath("/");
// 쿠키에 토큰 저장
response.addCookie(refreshTokenCookie);
response.addCookie(accessTokenCookie);
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "JWT 재발급 완료");
return; // 여기서 처리 종료
}
}
Optional<Member> userOptional = memberRepository.findByIdLogin(tokenInfo.getUserId());
if(userOptional.isEmpty()) {
log.info("유저 데이터 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 데이터 없음");
return;
}
Member member = userOptional.get();
List<MemberRole> memberRoles = memberRoleRepository.findByMember(member);
List<GrantedAuthority> authorities = memberRoles.stream()
.map(memberRole -> new SimpleGrantedAuthority(memberRole.getRole().name()))
.collect(Collectors.toList());
// 권한 부여
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(member.getId(), null, authorities);
// Detail을 넣어준다.
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
filterChain.doFilter(request, response);
} catch (Exception e) {
e.printStackTrace();
}
}
현재 위에 보이는 코드가 JWT 필터에 만들어놓은 전체 코드.
위에서 사용하는 기능은 다음과 같다.
- 쿠키에서 JWT 토큰 확인
- 액세스 토큰 추출
- 토큰 만료 검증
- 만료되면 리프레시 토큰 추출 및 액세스 재발급
- 만료되지 않았으면 시큐리티컨텍스트 홀더에 유저 인증 정보 저장.
그런데 현재 코드는 많이 길고 알아보기 어렵다는 느낌을 받아서 이번에 코드를 좀 간결하고, 가독성 좋게 변경해보려고 한다.
우선 메서드를 분리하기로 결정했다.
아래 4개의 메서드를 추가해서 로직을 묶었다.
findCookieToken
(기존 로직)
// 헤더에서 액세스 토큰 추출
Cookie[] cookies = request.getCookies();
// 쿠키 자체가 없으면 401 에러 발생
if (cookies == null) {
log.error("쿠키 없음.");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "쿠키가 존재하지 않습니다.");
return;
}
// 액세스 토큰 추출
String accessToken = Arrays.stream(cookies)
.filter(c -> "accessToken".equals(c.getName()))
.findFirst()
.map(Cookie::getValue)
.orElse(null);
기존에 있던 로직은 액세스 토큰 쿠키 파싱, 리프레시 토큰 쿠키 파싱이 따로 진행됐는데 거의 동일한 로직을 사용하기에 하나의 메서드로 묶었다.
(변경 로직)
// 쿠키에서 토큰 찾기
private Optional<String> findCookieToken(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
return cookies == null ? Optional.empty() : Arrays.stream(cookies)
.filter(cookie -> name.equals(cookie.getName()))
.findFirst()
.map(Cookie::getValue);
}
request와 name을 파라미터로 넘겨서 accessToken인지 refreshToken인지 구분시켜서 파싱하도록 변경했다.
reGenerateToken
(기존 로직)
String reAccessToken = jwtService.createAccessToken(tokenInfo.getUserId());
String reRefreshToken = jwtService.createRefreshToken(tokenInfo.getUserId(), true);
if ("유저 없음".equals(reRefreshToken)) {
log.error("유저 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 정보 조회 실패");
return; // 여기서 처리 종료
}
(수정 로직)
// 토큰 재생성
private boolean reGenerateToken(HttpServletResponse response, Long userId) throws IOException {
// 액세스 토큰을 위한 쿠키 생성
String accessToken = jwtService.createAccessToken(userId);
String refreshToken = jwtService.createRefreshToken(userId, true);
if ("유저 없음".equals(refreshToken)) {
log.error("유저 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 정보 조회 실패");
return false;
}
setCookie(response, "accessToken", accessToken);
setCookie(response, "refreshToken", refreshToken);
return true;
}
사실 여기는 별로 바뀐 건 없다.
아무래도 필터이고 스프링 스펙이 아니기에 글로벌 예외처리가 불가능했다. 그래서 response를 전달하기 위해 boolean 타입으로 true, false로 구분시켰다. 메인 비즈니스 로직에서 false가 나오면 바로 필터 빠져나오도록
setCookie
(기존 로직)
// 액세스 토큰을 위한 쿠키 생성
Cookie accessTokenCookie = new Cookie("accessToken", reAccessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setSecure(true); // HTTPS를 사용하는 경우에만 true로 설정
accessTokenCookie.setPath("/");
// 리프레시 토큰을 위한 쿠키 생성
Cookie refreshTokenCookie = new Cookie("refreshToken", reRefreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setSecure(true); // HTTPS를 사용하는 경우에만 true로 설정
refreshTokenCookie.setPath("/");
// 쿠키에 토큰 저장
response.addCookie(refreshTokenCookie);
response.addCookie(accessTokenCookie);
(수정 로직)
// 쿠키에 토큰 저장
private void setCookie(HttpServletResponse response, String name, String value) {
Cookie cookie = new Cookie(name, value);
cookie.setSecure(true);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
log.info("쿠키 생성 및 설정 완료");
}
사실 이 부분이 제일 지저분해보였다. 쿠키 저장 또한 동일한 로직이 반복되었기에 하나의 메서드로 묶었다.
authenticateUser
(기존 로직)
Optional<Member> userOptional = memberRepository.findByIdLogin(tokenInfo.getUserId());
if(userOptional.isEmpty()) {
log.info("유저 데이터 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 데이터 없음");
return;
}
Member member = userOptional.get();
List<MemberRole> memberRoles = memberRoleRepository.findByMember(member);
List<GrantedAuthority> authorities = memberRoles.stream()
.map(memberRole -> new SimpleGrantedAuthority(memberRole.getRole().name()))
.collect(Collectors.toList());
// 권한 부여
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(member.getId(), null, authorities);
// Detail을 넣어준다.
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
(수정 로직)
// 유저 인증
private boolean authenticateUser(HttpServletRequest request, HttpServletResponse response, Long userId) throws IOException {
Optional<Member> byIdLogin = memberRepository.findByIdLogin(userId);
if (byIdLogin.isEmpty()) {
log.info("유저 데이터 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 데이터 없음");
return false;
}
Member member = byIdLogin.get();
List<MemberRole> memberRoles = member.getMemberRoles();
List<SimpleGrantedAuthority> authorities = memberRoles.stream()
.map(memberRole -> new SimpleGrantedAuthority(memberRole.getRole().name()))
.collect(Collectors.toList());
// 유저 인증 객체
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(member.getId(), null, authorities);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
return true;
}
일단 memberRole을 찾기 위해 쿼리를 한 번 날렸는데, fetchJoin을 통해 user에서 한번의 쿼리로 수행하기 위해 fetchJoin을 적용시켜 N+1 문제를 방지했다.(왜 애초에 이렇게 냅뒀었는지??)
그리고 모든 로직을 메서드 하나에 몰아서 정리했다.
전체 로직
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("jwt 필터 동작");
Optional<String> accessTokenOptional = findCookieToken(request, "accessToken");
// 쿠키 자체가 없으면 401 에러 발생
if (!accessTokenOptional.isPresent()) {
log.error("쿠키 없음.");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "쿠키가 존재하지 않습니다.");
return;
}
// 액세스 토큰 추출
String accessToken = accessTokenOptional.get();
// userId 토큰에서 꺼냄.
try {
TokenInfo tokenInfo = jwtService.getUserId(accessToken);
log.info("userId:{}번 유저 토큰 추출 완료", tokenInfo.getUserId());
// 토큰이 만료됐으면
if (tokenInfo.isExpired()) {
// 리프레시 토큰 추출
Optional<String> refreshTokenOptional = findCookieToken(request, "refreshToken");
if (!refreshTokenOptional.isPresent()) {
log.error("리프레시 토큰 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "리프레시 토큰이 존재하지 않습니다.");
return;
}
// 리프레시 토큰 만료 확인
if (!jwtService.checkRefreshToken(refreshTokenOptional.get())) {
log.error("리프레시 토큰 문제 발생");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "리프레시 토큰 사용 불가(로그아웃 진행)");
return; // 여기서 처리 종료
}
// 토큰 재생성 및 쿠키 설정
if (!reGenerateToken(response, tokenInfo.getUserId())) return;
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "JWT 재발급 완료");
return; // 여기서 처리 종료
}
if (!authenticateUser(request, response, tokenInfo.getUserId())) return;
filterChain.doFilter(request, response);
} catch (Exception e) {
e.printStackTrace();
}
}
// 공통 응답 메시지
private void sendErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws
IOException {
response.setStatus(status.value());
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write(String.format("{\"error\": \"%s\"}", message));
}
// 쿠키에서 토큰 찾기
private Optional<String> findCookieToken(HttpServletRequest request, String name) {
Cookie[] cookies = request.getCookies();
return cookies == null ? Optional.empty() : Arrays.stream(cookies)
.filter(cookie -> name.equals(cookie.getName()))
.findFirst()
.map(Cookie::getValue);
}
// 토큰 재생성
private boolean reGenerateToken(HttpServletResponse response, Long userId) throws IOException {
// 액세스 토큰을 위한 쿠키 생성
String accessToken = jwtService.createAccessToken(userId);
String refreshToken = jwtService.createRefreshToken(userId, true);
if ("유저 없음".equals(refreshToken)) {
log.error("유저 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 정보 조회 실패");
return false;
}
setCookie(response, "accessToken", accessToken);
setCookie(response, "refreshToken", refreshToken);
return true;
}
// 쿠키에 토큰 저장
private void setCookie(HttpServletResponse response, String name, String value) {
Cookie cookie = new Cookie(name, value);
cookie.setSecure(true);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
log.info("쿠키 생성 및 설정 완료");
}
// 유저 인증
private boolean authenticateUser(HttpServletRequest request, HttpServletResponse response, Long userId) throws IOException {
Optional<Member> byIdLogin = memberRepository.findByIdLogin(userId);
if (byIdLogin.isEmpty()) {
log.info("유저 데이터 없음");
sendErrorResponse(response, HttpStatus.UNAUTHORIZED, "유저 데이터 없음");
return false;
}
Member member = byIdLogin.get();
List<MemberRole> memberRoles = member.getMemberRoles();
List<SimpleGrantedAuthority> authorities = memberRoles.stream()
.map(memberRole -> new SimpleGrantedAuthority(memberRole.getRole().name()))
.collect(Collectors.toList());
// 유저 인증 객체
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(member.getId(), null, authorities);
authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
return true;
}
이렇게 코드를 정리하고 로직을 변경했는데 가독성 측면에서는 확실히 좋아졌다고 생각이 든다.
근데 아직도 이게 잘 정리를 한 건지 모르겠다. 애초에 보기 좋게 만들었으면 됐을 텐데
'프로젝트 > RESTAPI 추천 서비스' 카테고리의 다른 글
Navbar를 어떻게 페이지마다 유동적으로 보여줄 수 있을까? (0) | 2024.05.15 |
---|---|
코드 리팩토링(1) (0) | 2024.05.04 |
웹소켓을 사용해서 간단하게 전체 채팅을 구현하자(1) (0) | 2024.04.11 |
어김없이 또 발생한 N+1 문제(요청 기록 조회 API) (0) | 2024.04.09 |