From 1d0890e24cd8860f208cf77a321bf7dc45bcb63c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EA=B8=B0=EC=84=9C?= <81108344+rlarltj@users.noreply.github.com> Date: Fri, 2 Aug 2024 10:30:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20JWT=20Filter=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4.=20(#14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: JWT Filter와 Error Handler를 추가한다. * feat: TokenService에 AccessToken을 validate하는 메소드를 추가한다. * feat: 인증 객체를 담는 dto(JwtAuthentication)를 추가한다. * feat: Jwt Filter를 Security FilterChain에 등록한다. --- .../auth/dto/jwt/JwtAuthentication.java | 38 +++++++++++ .../auth/dto/jwt/JwtAuthenticationToken.java | 56 ++++++++++++++++ .../domain/auth/service/TokenService.java | 24 +++++++ .../config/security/SecurityConfig.java | 12 ++++ .../global/filter/ExceptionHandlerFilter.java | 52 ++++++++++++++ .../filter/JwtAuthenticationEntryPoint.java | 46 +++++++++++++ .../filter/JwtAuthenticationFilter.java | 67 +++++++++++++++++++ 7 files changed, 295 insertions(+) create mode 100644 src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthentication.java create mode 100644 src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthenticationToken.java create mode 100644 src/main/java/com/dnd/accompany/global/filter/ExceptionHandlerFilter.java create mode 100644 src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationFilter.java diff --git a/src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthentication.java b/src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthentication.java new file mode 100644 index 0000000..660aab8 --- /dev/null +++ b/src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthentication.java @@ -0,0 +1,38 @@ +package com.dnd.accompany.domain.auth.dto.jwt; + +import java.util.Objects; + +import org.apache.commons.lang3.StringUtils; + +import com.dnd.accompany.domain.auth.exception.InvalidTokenException; +import com.dnd.accompany.global.common.response.ErrorCode; + +import lombok.Getter; + +@Getter +public class JwtAuthentication { + + private Long id; + private String accessToken; + + public JwtAuthentication(Long id, String accessToken) { + this.id = validateId(id); + this.accessToken = validateAccessToken(accessToken); + } + + private Long validateId(Long id) { + if (Objects.isNull(id) || id <= 0L) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + + return id; + } + + private String validateAccessToken(String accessToken) { + if (StringUtils.isEmpty(accessToken)) { + throw new InvalidTokenException(ErrorCode.INVALID_TOKEN); + } + + return accessToken; + } +} diff --git a/src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthenticationToken.java b/src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthenticationToken.java new file mode 100644 index 0000000..a60da0f --- /dev/null +++ b/src/main/java/com/dnd/accompany/domain/auth/dto/jwt/JwtAuthenticationToken.java @@ -0,0 +1,56 @@ +package com.dnd.accompany.domain.auth.dto.jwt; + +import java.util.Collection; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + + private final Object principal; + private String credentials; + + public JwtAuthenticationToken(String principal, String credentials) { + super(null); + super.setAuthenticated(false); + + this.principal = principal; + this.credentials = credentials; + } + + /** + * 인증이 완료될 경우 사용하는 생성자입니다. + */ + public JwtAuthenticationToken(Object principal, String credentials, + Collection authorities) { + super(authorities); + super.setAuthenticated(true); + + this.principal = principal; + this.credentials = credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } + + @Override + public String getCredentials() { + return credentials; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) { + if (isAuthenticated) { + throw new IllegalArgumentException("유효하지 않은 접근입니다."); + } + super.setAuthenticated(false); + } + + @Override + public void eraseCredentials() { + super.eraseCredentials(); + credentials = null; + } +} diff --git a/src/main/java/com/dnd/accompany/domain/auth/service/TokenService.java b/src/main/java/com/dnd/accompany/domain/auth/service/TokenService.java index 32b082a..0554086 100644 --- a/src/main/java/com/dnd/accompany/domain/auth/service/TokenService.java +++ b/src/main/java/com/dnd/accompany/domain/auth/service/TokenService.java @@ -1,18 +1,23 @@ package com.dnd.accompany.domain.auth.service; +import java.util.List; import java.util.UUID; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Service; import com.dnd.accompany.domain.auth.dto.AuthUserInfo; import com.dnd.accompany.domain.auth.dto.Tokens; +import com.dnd.accompany.domain.auth.dto.jwt.JwtAuthentication; +import com.dnd.accompany.domain.auth.dto.jwt.JwtAuthenticationToken; import com.dnd.accompany.domain.auth.entity.RefreshToken; import com.dnd.accompany.domain.auth.exception.ExpiredTokenException; import com.dnd.accompany.domain.auth.exception.RefreshTokenNotFoundException; import com.dnd.accompany.domain.auth.infrastructure.RefreshTokenRepository; import com.dnd.accompany.global.common.response.ErrorCode; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; @Service @@ -85,4 +90,23 @@ public void deleteRefreshToken(String refreshToken) { .ifPresent(refreshTokenRepository::delete); } + /** + * AccessToken에서 유효한 Authentication(인증 주체) 객체를 추출합니다. + */ + public JwtAuthenticationToken getAuthenticationByAccessToken(String accessToken) { + jwtTokenProvider.validateToken(accessToken); + + Claims claims = jwtTokenProvider.getClaims(accessToken); + + Long id = claims.get("userId", Long.class); + + JwtAuthentication principal = new JwtAuthentication(id, accessToken); + + /** + * 권한 분리가 필요할 경우 수정합니다. + */ + List authorities = List.of(new SimpleGrantedAuthority("user")); + + return new JwtAuthenticationToken(principal, null, authorities); + } } diff --git a/src/main/java/com/dnd/accompany/global/config/security/SecurityConfig.java b/src/main/java/com/dnd/accompany/global/config/security/SecurityConfig.java index a7208aa..02da04a 100644 --- a/src/main/java/com/dnd/accompany/global/config/security/SecurityConfig.java +++ b/src/main/java/com/dnd/accompany/global/config/security/SecurityConfig.java @@ -7,6 +7,11 @@ import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import com.dnd.accompany.global.filter.ExceptionHandlerFilter; +import com.dnd.accompany.global.filter.JwtAuthenticationEntryPoint; +import com.dnd.accompany.global.filter.JwtAuthenticationFilter; import lombok.RequiredArgsConstructor; @@ -15,6 +20,10 @@ @RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final ExceptionHandlerFilter exceptionHandlerFilter; + @Bean public SecurityFilterChain httpSecurity(HttpSecurity http) throws Exception { return http @@ -25,6 +34,9 @@ public SecurityFilterChain httpSecurity(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .anonymous(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) + .exceptionHandling(handler -> handler.authenticationEntryPoint(jwtAuthenticationEntryPoint)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(exceptionHandlerFilter, JwtAuthenticationFilter.class) .build(); } diff --git a/src/main/java/com/dnd/accompany/global/filter/ExceptionHandlerFilter.java b/src/main/java/com/dnd/accompany/global/filter/ExceptionHandlerFilter.java new file mode 100644 index 0000000..635f05d --- /dev/null +++ b/src/main/java/com/dnd/accompany/global/filter/ExceptionHandlerFilter.java @@ -0,0 +1,52 @@ +package com.dnd.accompany.global.filter; + +import java.io.IOException; + +import org.apache.commons.lang3.CharEncoding; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.dnd.accompany.domain.auth.exception.TokenException; +import com.dnd.accompany.global.common.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * TokenException이 발생할 경우 Handling합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ExceptionHandlerFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + filterChain.doFilter(request, response); + } catch (TokenException e) { + log.debug("[ExceptionHandler] token error message = {}", e.getMessage()); + generateErrorResponse(response, e); + } + } + + private void generateErrorResponse(HttpServletResponse response, TokenException e) throws IOException { + response.setStatus(e.getErrorCode().getStatus().intValue()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(CharEncoding.UTF_8); + response.getWriter() + .write(objectMapper.writeValueAsString( + ErrorResponse.from(e.getErrorCode()) + )); + } + +} diff --git a/src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationEntryPoint.java b/src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..5c3cff8 --- /dev/null +++ b/src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationEntryPoint.java @@ -0,0 +1,46 @@ +package com.dnd.accompany.global.filter; + +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import java.io.IOException; + +import org.apache.commons.lang3.CharEncoding; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.dnd.accompany.global.common.response.ErrorCode; +import com.dnd.accompany.global.common.response.ErrorResponse; +import com.fasterxml.jackson.databind.ObjectMapper; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * JwtAuthenticationFilter에서 사용자 인증에 실패한 경우 발생하는 401 예외를 핸들링합니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private static final String ERROR_LOG_MESSAGE = "[ERROR] {} : {}"; + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + log.info(ERROR_LOG_MESSAGE, authException.getClass().getSimpleName(), authException.getMessage()); + response.setStatus(UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding(CharEncoding.UTF_8); + response.getWriter() + .write(objectMapper.writeValueAsString( + ErrorResponse.from(ErrorCode.ACCESS_DENIED) + )); + } +} diff --git a/src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationFilter.java b/src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..aef931f --- /dev/null +++ b/src/main/java/com/dnd/accompany/global/filter/JwtAuthenticationFilter.java @@ -0,0 +1,67 @@ +package com.dnd.accompany.global.filter; + +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +import java.io.IOException; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.dnd.accompany.domain.auth.dto.jwt.JwtAuthenticationToken; +import com.dnd.accompany.domain.auth.service.TokenService; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * JWT 인증 필터로, accessToken을 검증합니다. + * 토큰이 유효할 경우 유효한 인증 객체를 SecurityContextHolder에 넣어줍니다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String BEARER_TYPE = "Bearer"; + private final TokenService tokenService; + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String accessToken = getAccessToken(request); + + if (accessToken != null) { + JwtAuthenticationToken authentication = tokenService.getAuthenticationByAccessToken(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return request.getRequestURI().endsWith("tokens"); + } + + private String getAccessToken(HttpServletRequest request) { + String authHeaderValue = request.getHeader(AUTHORIZATION); + if (authHeaderValue != null) { + return extractToken(authHeaderValue); + } + + return null; + } + + private String extractToken(String authHeaderValue) { + if (authHeaderValue.toLowerCase().startsWith(BEARER_TYPE.toLowerCase())) { + return authHeaderValue.substring(BEARER_TYPE.length()).trim(); + } + + return null; + } +}