From 9702c4b332bd5500218d0c58214d81349eb14574 Mon Sep 17 00:00:00 2001 From: Junsoo Choi <78118588+jschoi-96@users.noreply.github.com> Date: Wed, 28 Feb 2024 22:26:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=97=91=EC=84=B8=EC=8A=A4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=20=EB=A6=AC=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=8B=9C=20=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=95=A1=EC=84=B8=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유효한 자격증명을 제공하지 않을 때 401 에러 리턴하는 기능 구현 * feat: 유효한 권한이 존재하지 않는 경우 403 에러 리턴하는 기능 구현 * feat: 401, 403에러 exception handling * refactor: 토큰 필드를 TokenDto를 생성하여 따로 분리 * feat: accessToken 만료시 refreshToken 재발급 하는 기능 구현 * feat: 에러 코드 추가 * refactor: yml 파일 config로 이동 * refactor: JwtTokenProvider 에서 AuthenticationManager 삭제하여 순환 참조 에러 해결 --- .../global/config/SecurityConfig.java | 8 +++++ .../global/exception/ErrorCode.java | 9 +++++- .../global/jwt/JwtAccessDeniedHandler.java | 18 +++++++++++ .../jwt/JwtAuthenticationEntryPoint.java | 18 +++++++++++ .../global/jwt/JwtAuthenticationFilter.java | 14 +++++---- .../global/jwt/JwtTokenProvider.java | 30 +++++++++++++------ .../member/application/MemberService.java | 8 ++--- .../module/member/dto/LoginSuccessDto.java | 4 +-- .../module/member/dto/TokenDto.java | 14 +++++++++ .../member/application/MemberServiceTest.java | 2 +- 10 files changed, 101 insertions(+), 24 deletions(-) create mode 100644 src/main/java/balancetalk/global/jwt/JwtAccessDeniedHandler.java create mode 100644 src/main/java/balancetalk/global/jwt/JwtAuthenticationEntryPoint.java create mode 100644 src/main/java/balancetalk/module/member/dto/TokenDto.java diff --git a/src/main/java/balancetalk/global/config/SecurityConfig.java b/src/main/java/balancetalk/global/config/SecurityConfig.java index 0b86f6fae..a8d5c36c7 100644 --- a/src/main/java/balancetalk/global/config/SecurityConfig.java +++ b/src/main/java/balancetalk/global/config/SecurityConfig.java @@ -1,5 +1,7 @@ package balancetalk.global.config; +import balancetalk.global.jwt.JwtAccessDeniedHandler; +import balancetalk.global.jwt.JwtAuthenticationEntryPoint; import balancetalk.global.jwt.JwtAuthenticationFilter; import balancetalk.global.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; @@ -20,6 +22,8 @@ public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @Bean public BCryptPasswordEncoder passwordEncoder() { @@ -35,6 +39,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.disable()) + .exceptionHandling(exception -> { + exception.authenticationEntryPoint(jwtAuthenticationEntryPoint); + exception.accessDeniedHandler(jwtAccessDeniedHandler); + }) // h2 콘솔 사용 .headers(header -> header.frameOptions(frameOption -> frameOption.disable()).disable()) // 세션 사용 X (jwt 사용) diff --git a/src/main/java/balancetalk/global/exception/ErrorCode.java b/src/main/java/balancetalk/global/exception/ErrorCode.java index 20fbe6308..bf1aaa39f 100644 --- a/src/main/java/balancetalk/global/exception/ErrorCode.java +++ b/src/main/java/balancetalk/global/exception/ErrorCode.java @@ -18,11 +18,14 @@ public enum ErrorCode { MIME_TYPE_NULL(BAD_REQUEST, "MIME 타입이 null입니다."), FILE_UPLOAD_FAILED(BAD_REQUEST, "파일 업로드에 실패했습니다."), FILE_SIZE_EXCEEDED(BAD_REQUEST, "파일 크기가 초과되었습니다."), - + EXPIRED_JWT_TOKEN(BAD_REQUEST, "만료된 토큰 입니다."), + INVALID_JWT_TOKEN(BAD_REQUEST, "유효하지 않은 토큰입니다."), + INVALID_REFRESH_TOKEN(BAD_REQUEST, "RefreshToken이 존재하지 않습니다."), // 401 MISMATCHED_EMAIL_OR_PASSWORD(UNAUTHORIZED, "이메일 또는 비밀번호가 잘못되었습니다."), AUTHENTICATION_ERROR(UNAUTHORIZED, "인증 오류가 발생했습니다."), + BAD_CREDENTIAL_ERROR(UNAUTHORIZED, "로그인에 실패했습니다."), // 403 FORBIDDEN_COMMENT_MODIFY(FORBIDDEN, "댓글 수정 권한이 없습니다."), // TODO : Spring Security 적용 후 적용 필요 @@ -45,6 +48,10 @@ public enum ErrorCode { ALREADY_LIKE_COMMENT(CONFLICT, "이미 추천을 누른 댓글입니다."), ALREADY_LIKE_POST(CONFLICT, "이미 추천을 누른 게시글입니다."), + // 500 + REDIS_CONNECTION_FAIL(INTERNAL_SERVER_ERROR, "Redis 연결에 실패했습니다."); + + // 500 DUPLICATE_EMAIL(INTERNAL_SERVER_ERROR, "이미 존재하는 이메일 입니다. 다른 이메일을 입력해주세요"), AUTHORIZATION_CODE_MISMATCH(INTERNAL_SERVER_ERROR, "인증 번호가 일치하지 않습니다."); diff --git a/src/main/java/balancetalk/global/jwt/JwtAccessDeniedHandler.java b/src/main/java/balancetalk/global/jwt/JwtAccessDeniedHandler.java new file mode 100644 index 000000000..440bc41d6 --- /dev/null +++ b/src/main/java/balancetalk/global/jwt/JwtAccessDeniedHandler.java @@ -0,0 +1,18 @@ +package balancetalk.global.jwt; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } +} diff --git a/src/main/java/balancetalk/global/jwt/JwtAuthenticationEntryPoint.java b/src/main/java/balancetalk/global/jwt/JwtAuthenticationEntryPoint.java new file mode 100644 index 000000000..67706cb7d --- /dev/null +++ b/src/main/java/balancetalk/global/jwt/JwtAuthenticationEntryPoint.java @@ -0,0 +1,18 @@ +package balancetalk.global.jwt; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED); + } +} diff --git a/src/main/java/balancetalk/global/jwt/JwtAuthenticationFilter.java b/src/main/java/balancetalk/global/jwt/JwtAuthenticationFilter.java index 224fb46f0..14d1fa9b7 100644 --- a/src/main/java/balancetalk/global/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/balancetalk/global/jwt/JwtAuthenticationFilter.java @@ -1,18 +1,21 @@ package balancetalk.global.jwt; +import balancetalk.global.exception.BalanceTalkException; +import balancetalk.global.exception.ErrorCode; +import io.jsonwebtoken.ExpiredJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.RedisConnectionFailureException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.GenericFilterBean; - import java.io.IOException; - +@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends GenericFilterBean { @@ -29,9 +32,10 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } } catch (RedisConnectionFailureException e) { SecurityContextHolder.clearContext(); - throw new IllegalAccessError("Redis 연결 실패"); - } catch (Exception e) { - throw new IllegalArgumentException("JWT가 유효하지 않음."); + throw new BalanceTalkException(ErrorCode.REDIS_CONNECTION_FAIL); + } catch (ExpiredJwtException e) { + log.error(e.getMessage()); + throw new BalanceTalkException(ErrorCode.EXPIRED_JWT_TOKEN); } chain.doFilter(request, response); } diff --git a/src/main/java/balancetalk/global/jwt/JwtTokenProvider.java b/src/main/java/balancetalk/global/jwt/JwtTokenProvider.java index 9d07467b3..1d8b36feb 100644 --- a/src/main/java/balancetalk/global/jwt/JwtTokenProvider.java +++ b/src/main/java/balancetalk/global/jwt/JwtTokenProvider.java @@ -1,21 +1,20 @@ package balancetalk.global.jwt; +import balancetalk.global.exception.BalanceTalkException; +import balancetalk.global.exception.ErrorCode; import balancetalk.global.redis.application.RedisService; -import balancetalk.module.member.domain.Role; +import balancetalk.module.member.dto.TokenDto; import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import lombok.extern.log4j.Log4j2; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Component; -import java.security.Key; import java.time.Duration; import java.util.Date; @@ -76,9 +75,7 @@ public String createRefreshToken(Authentication authentication) { public Authentication getAuthentication(String token) { String userPrincipal = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject(); UserDetails userDetails = userDetailsService.loadUserByUsername(userPrincipal); - return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); - } // http 헤더로부터 bearer 토큰 가져옴 @@ -97,10 +94,25 @@ public boolean validateToken(String token) { return true; } catch (ExpiredJwtException e) { log.error(e.getMessage()); - throw new IllegalArgumentException("토큰 만료"); + throw new BalanceTalkException(ErrorCode.EXPIRED_JWT_TOKEN); } catch (JwtException e) { log.error(e.getMessage()); - throw new IllegalArgumentException("유효하지 않은 JWT"); + throw new BalanceTalkException(ErrorCode.INVALID_JWT_TOKEN); + } + } + public TokenDto reissueToken(String refreshToken) { + validateToken(refreshToken); + Authentication authentication = getAuthentication(refreshToken); + + // redis에 저장된 RefreshToken 값을 가져옴 + String redisRefreshToken = redisService.getValues(authentication.getName()); + if (!redisRefreshToken.equals(refreshToken)) { + throw new BalanceTalkException(ErrorCode.INVALID_REFRESH_TOKEN); } + TokenDto tokenDto = new TokenDto( + createAccessToken(authentication), + createRefreshToken(authentication) + ); + return tokenDto; } } diff --git a/src/main/java/balancetalk/module/member/application/MemberService.java b/src/main/java/balancetalk/module/member/application/MemberService.java index 73eda7095..210b3a8c7 100644 --- a/src/main/java/balancetalk/module/member/application/MemberService.java +++ b/src/main/java/balancetalk/module/member/application/MemberService.java @@ -3,7 +3,6 @@ import balancetalk.global.exception.BalanceTalkException; import balancetalk.global.exception.ErrorCode; import balancetalk.global.jwt.JwtTokenProvider; -import balancetalk.global.redis.application.RedisService; import balancetalk.module.member.domain.Member; import balancetalk.module.member.domain.MemberRepository; import balancetalk.module.member.dto.*; @@ -48,16 +47,15 @@ public LoginSuccessDto login(final LoginDto loginDto) { Authentication authentication = authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword()) ); - + TokenDto tokenDto = new TokenDto(jwtTokenProvider.createAccessToken(authentication), jwtTokenProvider.createRefreshToken(authentication)); return LoginSuccessDto.builder() .email(member.getEmail()) .password(member.getPassword()) .role(member.getRole()) - .accessToken(jwtTokenProvider.createAccessToken(authentication)) - .refreshToken(jwtTokenProvider.createRefreshToken(authentication)) + .tokenDto(tokenDto) .build(); } catch (BadCredentialsException e) { - throw new BadCredentialsException("credential 오류!!"); + throw new BalanceTalkException(ErrorCode.BAD_CREDENTIAL_ERROR); } } diff --git a/src/main/java/balancetalk/module/member/dto/LoginSuccessDto.java b/src/main/java/balancetalk/module/member/dto/LoginSuccessDto.java index 4d032b6d9..09217bfa0 100644 --- a/src/main/java/balancetalk/module/member/dto/LoginSuccessDto.java +++ b/src/main/java/balancetalk/module/member/dto/LoginSuccessDto.java @@ -20,7 +20,5 @@ public class LoginSuccessDto { @Schema(description = "회원 역할", example = "USER") private Role role; - private String accessToken; - - private String refreshToken; + private TokenDto tokenDto; } diff --git a/src/main/java/balancetalk/module/member/dto/TokenDto.java b/src/main/java/balancetalk/module/member/dto/TokenDto.java new file mode 100644 index 000000000..0d62b76d1 --- /dev/null +++ b/src/main/java/balancetalk/module/member/dto/TokenDto.java @@ -0,0 +1,14 @@ +package balancetalk.module.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TokenDto { + private String accessToken; + private String refreshToken; +} diff --git a/src/test/java/balancetalk/module/member/application/MemberServiceTest.java b/src/test/java/balancetalk/module/member/application/MemberServiceTest.java index 5b9f6a6a8..1255cf385 100644 --- a/src/test/java/balancetalk/module/member/application/MemberServiceTest.java +++ b/src/test/java/balancetalk/module/member/application/MemberServiceTest.java @@ -62,7 +62,7 @@ void LoginMember_Success() { Member member = joinDto.toEntity(); when(memberRepository.findByEmail(any())).thenReturn(Optional.of(member)); - when(jwtTokenProvider.createToken(member.getEmail(), member.getRole())).thenReturn("token"); + //when(jwtTokenProvider.createToken(member.getEmail(), member.getRole())).thenReturn("token"); // TODO: jwt 토큰 null -> 인증 오류 // when