From e9e78dfcc2a5537e7a13347fa36fbf5c227eaeae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:21:05 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat(JwtParser):=20JWT=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/jwt/parser/DefaultJwtParser.java | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java b/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java index e493db1a3..c7c453da6 100644 --- a/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java +++ b/src/main/java/com/somemore/auth/jwt/parser/DefaultJwtParser.java @@ -1,8 +1,12 @@ package com.somemore.auth.jwt.parser; import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.SignatureException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -14,11 +18,21 @@ public class DefaultJwtParser implements JwtParser { private final SecretKey secretKey; + @Override public Claims parseToken(EncodedToken token) { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token.value()) - .getPayload(); + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token.value()) + .getPayload(); + + } catch (SignatureException e) { + throw new JwtException(JwtErrorType.INVALID_TOKEN); + } catch (ExpiredJwtException e) { + throw new JwtException(JwtErrorType.EXPIRED_TOKEN); + } catch (Exception e) { + throw new JwtException(JwtErrorType.UNKNOWN_ERROR); + } } } From 35a6795c173e778107c845d8f0a4acf40f01fbfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:22:21 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat(JwtRefresher):=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=EC=99=80=20=ED=98=91=EC=9D=98?= =?UTF-8?q?=ED=95=9C=20JWT=20=EA=B0=B1=EC=8B=A0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=99=84=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/refresh/refresher/DefaultJwtRefresher.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java b/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java index 1c9f23473..59ed670aa 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java @@ -1,5 +1,6 @@ package com.somemore.auth.jwt.refresh.refresher; +import com.somemore.auth.cookie.SetCookieUseCase; import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.domain.TokenType; import com.somemore.auth.jwt.generator.JwtGenerator; @@ -25,23 +26,16 @@ public class DefaultJwtRefresher implements JwtRefresher { @Override public EncodedToken refreshAccessToken(EncodedToken accessToken) { RefreshToken refreshToken = refreshTokenManager.findRefreshToken(accessToken); - validateToken(refreshToken); + EncodedToken refreshTokenValue = new EncodedToken(refreshToken.getRefreshToken()); + jwtValidator.validateToken(refreshTokenValue); - Claims claims = jwtParser.parseToken(accessToken); + Claims claims = jwtParser.parseToken(refreshTokenValue); refreshToken.updateAccessToken(generateAccessToken(claims)); refreshTokenManager.save(refreshToken); return new EncodedToken(refreshToken.getAccessToken()); } - private void validateToken(RefreshToken refreshToken) { - if (jwtValidator.validateToken(new EncodedToken(refreshToken.getAccessToken()))) { - // TODO Security Context (JwtFilter) 구현 시 예외 처리 구체화 - log.error("리프레시 토큰이 만료되었습니다. 로그인을 다시 해야합니다"); - throw new RuntimeException(); - } - } - private EncodedToken generateAccessToken(Claims claims) { return jwtGenerator.generateToken( claims.get("id", String.class), From 4ecb1a49b21503d6a3eaa6fc0331cefe5326cf6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:23:34 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat(JwtValidator):=20=EB=AA=85=ED=99=95?= =?UTF-8?q?=ED=95=9C=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?boolean=20=EB=B0=98=ED=99=98=EC=97=90=EC=84=9C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EB=B0=98=ED=99=98=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/validator/DefaultJwtValidator.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java b/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java index 97903f35e..1b313dbfa 100644 --- a/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java +++ b/src/main/java/com/somemore/auth/jwt/validator/DefaultJwtValidator.java @@ -1,7 +1,10 @@ package com.somemore.auth.jwt.validator; import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import com.somemore.auth.jwt.parser.DefaultJwtParser; +import io.jsonwebtoken.Claims; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -13,10 +16,15 @@ public class DefaultJwtValidator implements JwtValidator { private final DefaultJwtParser defaultJwtParser; - public boolean validateToken(EncodedToken token) { - return defaultJwtParser.parseToken(token) - .getExpiration() - .toInstant() - .isAfter(Instant.now()); + @Override + public void validateToken(EncodedToken token) { + Claims claims = defaultJwtParser.parseToken(token); + validateExpiration(claims); + } + + private void validateExpiration(Claims claims) { + if (claims.getExpiration() == null || claims.getExpiration().toInstant().isBefore(Instant.now())) { + throw new JwtException(JwtErrorType.EXPIRED_TOKEN); + } } } From 7cd389e71d6df7f51fb03fbfc8bc3548c7bc76f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:29:19 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat(JwtGenerator):=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=20=EC=8B=9C=EC=A0=90=20=EC=83=9D=EC=84=B1=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9D=84=20JTI=EB=A1=9C=20=EA=B5=AC=EB=B6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java b/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java index 304d103a3..1d25e5bca 100644 --- a/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java +++ b/src/main/java/com/somemore/auth/jwt/generator/HmacJwtGenerator.java @@ -11,6 +11,7 @@ import javax.crypto.SecretKey; import java.time.Instant; import java.util.Date; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -23,9 +24,11 @@ public EncodedToken generateToken(String userId, String role, TokenType tokenTyp Claims claims = buildClaims(userId, role); Instant now = Instant.now(); Instant expiration = now.plusMillis(tokenType.getPeriod()); + String uniqueId = UUID.randomUUID().toString(); // JTI return new EncodedToken(Jwts.builder() .claims(claims) + .id(uniqueId) .issuedAt(Date.from(now)) .expiration(Date.from(expiration)) .signWith(secretKey, ALGORITHM) From 830d42f22cd0f373c930d6ec881bc50f738d3846 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:35:10 +0900 Subject: [PATCH 05/19] =?UTF-8?q?feat(JwtService):=20JWT=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verifyToken 메서드 제거 후 processAccessToken 메서드로 통합. - 만료된 AccessToken을 처리하고 갱신된 토큰을 Set-Cookie 헤더에 추가하도록 구현. - HttpServletResponse를 활용해 갱신된 AccessToken 설정 로직 추가. - 만료된 AccessToken에 대한 예외 처리 로직(handleJwtExpiredException) 추가. - JwtException을 확인하고 만료된 토큰만 갱신 처리. - SetCookieUseCase 의존성 주입을 통해 클라이언트에 새로운 AccessToken 전달. - JwtRefresher와 SetCookieUseCase를 활용하여 클라이언트 응답에 새로운 AccessToken을 설정하는 책임 분리. - JwtValidator와 JwtParser를 활용한 검증 및 클레임 추출 구조 간소화. --- .../somemore/auth/jwt/service/JwtService.java | 29 ++++++++++++++----- .../somemore/auth/jwt/usecase/JwtUseCase.java | 7 +++-- 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/service/JwtService.java b/src/main/java/com/somemore/auth/jwt/service/JwtService.java index 9e9ce8ca6..dd84f0b20 100644 --- a/src/main/java/com/somemore/auth/jwt/service/JwtService.java +++ b/src/main/java/com/somemore/auth/jwt/service/JwtService.java @@ -1,12 +1,17 @@ package com.somemore.auth.jwt.service; +import com.somemore.auth.cookie.SetCookieUseCase; import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.domain.TokenType; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import com.somemore.auth.jwt.generator.JwtGenerator; import com.somemore.auth.jwt.parser.JwtParser; import com.somemore.auth.jwt.refresh.refresher.JwtRefresher; import com.somemore.auth.jwt.usecase.JwtUseCase; import com.somemore.auth.jwt.validator.JwtValidator; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -19,6 +24,7 @@ public class JwtService implements JwtUseCase { private final JwtParser jwtParser; private final JwtValidator jwtValidator; private final JwtRefresher jwtRefresher; + private final SetCookieUseCase setCookieUseCase; @Override public EncodedToken generateToken(String userId, String role, TokenType tokenType) { @@ -26,16 +32,25 @@ public EncodedToken generateToken(String userId, String role, TokenType tokenTyp } @Override - public void verifyToken(EncodedToken token) { - if (jwtValidator.validateToken(token)) { - return; + public void processAccessToken(EncodedToken accessToken, HttpServletResponse response) { + try { + jwtValidator.validateToken(accessToken); + } catch (JwtException e) { + handleJwtExpiredException(e, accessToken, response); } - EncodedToken accessToken = jwtRefresher.refreshAccessToken(token); - // TODO Security Context (JwtFilter) 구현 시 setCookie(accessToken) 구체화 } @Override - public String getClaimByKey(EncodedToken token, String key) { - return jwtParser.parseToken(token).get(key, String.class); + public Claims getClaims(EncodedToken token) { + return jwtParser.parseToken(token); + } + + private void handleJwtExpiredException(JwtException e, EncodedToken accessToken, HttpServletResponse response) { + if (e.getErrorType() == JwtErrorType.EXPIRED_TOKEN) { + EncodedToken refreshedToken = jwtRefresher.refreshAccessToken(accessToken); + setCookieUseCase.setToken(response, refreshedToken.value(), TokenType.ACCESS); + return; + } + throw e; } } diff --git a/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java b/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java index 21c9a3a99..8c43d4e99 100644 --- a/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java +++ b/src/main/java/com/somemore/auth/jwt/usecase/JwtUseCase.java @@ -2,11 +2,14 @@ import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.domain.TokenType; +import io.jsonwebtoken.Claims; +import jakarta.servlet.http.HttpServletResponse; public interface JwtUseCase { EncodedToken generateToken(String userId, String role, TokenType tokenType); - void verifyToken(EncodedToken token); + void processAccessToken(EncodedToken token, HttpServletResponse response); + + Claims getClaims(EncodedToken token); - String getClaimByKey(EncodedToken token, String key); } From 50c9db8abf70a75367a2a9ff7d3a52c6e09e8ddb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:35:59 +0900 Subject: [PATCH 06/19] =?UTF-8?q?feat(JwtValidator):=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=20=ED=83=80=EC=9E=85=20=EB=B3=80=EA=B2=BD,=20=EB=82=B4?= =?UTF-8?q?=EB=B6=80=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java b/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java index 6f25bbf8e..9f0d6b08b 100644 --- a/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java +++ b/src/main/java/com/somemore/auth/jwt/validator/JwtValidator.java @@ -3,5 +3,5 @@ import com.somemore.auth.jwt.domain.EncodedToken; public interface JwtValidator { - boolean validateToken(EncodedToken token); + void validateToken(EncodedToken token); } From 3b0586a169c41139ca297205c601862337bc91cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 12:38:17 +0900 Subject: [PATCH 07/19] =?UTF-8?q?feat(JwtException):=20Jwt=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A5=BC=20=EC=9C=84=ED=95=9C=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EC=99=80=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../somemore/auth/jwt/exception/JwtErrorType.java | 15 +++++++++++++++ .../somemore/auth/jwt/exception/JwtException.java | 14 ++++++++++++++ 2 files changed, 29 insertions(+) create mode 100644 src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java create mode 100644 src/main/java/com/somemore/auth/jwt/exception/JwtException.java diff --git a/src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java b/src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java new file mode 100644 index 000000000..92a624305 --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/exception/JwtErrorType.java @@ -0,0 +1,15 @@ +package com.somemore.auth.jwt.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum JwtErrorType { + MISSING_TOKEN("JWT 토큰이 없습니다."), + INVALID_TOKEN("JWT 서명이 유효하지 않습니다."), + EXPIRED_TOKEN("JWT 토큰이 만료되었습니다."), + UNKNOWN_ERROR("알 수 없는 JWT 처리 오류가 발생했습니다."); + + private final String message; +} diff --git a/src/main/java/com/somemore/auth/jwt/exception/JwtException.java b/src/main/java/com/somemore/auth/jwt/exception/JwtException.java new file mode 100644 index 000000000..406935484 --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/exception/JwtException.java @@ -0,0 +1,14 @@ +package com.somemore.auth.jwt.exception; + +import lombok.Getter; + +@Getter +public class JwtException extends RuntimeException { + private final JwtErrorType errorType; + + public JwtException(JwtErrorType errorType) { + super(errorType.getMessage()); + this.errorType = errorType; + } + +} From 3063e8a9a1b74a4adc122274b1c5e1284531a700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:38:45 +0900 Subject: [PATCH 08/19] =?UTF-8?q?feat(RefreshTokenManager):=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=ED=83=80=EC=9E=85,=20=EB=A9=94=EC=8B=9C=EC=A7=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jwt/refresh/manager/RedisRefreshTokenManager.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java b/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java index e53292c60..0a2d57e64 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/manager/RedisRefreshTokenManager.java @@ -1,9 +1,10 @@ package com.somemore.auth.jwt.refresh.manager; import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; import com.somemore.auth.jwt.refresh.domain.RefreshToken; import com.somemore.auth.jwt.refresh.repository.RefreshTokenRepository; -import jakarta.persistence.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -16,7 +17,7 @@ public class RedisRefreshTokenManager implements RefreshTokenManager { @Override public RefreshToken findRefreshToken(EncodedToken accessToken) { return refreshTokenRepository.findByAccessToken(accessToken.value()) - .orElseThrow(EntityNotFoundException::new); + .orElseThrow(() -> new JwtException(JwtErrorType.EXPIRED_TOKEN)); } @Override @@ -24,11 +25,10 @@ public void save(RefreshToken refreshToken) { refreshTokenRepository.save(refreshToken); } - // TODO 로그아웃에 사용 @Override public void removeRefreshToken(EncodedToken accessToken) { RefreshToken refreshToken = refreshTokenRepository.findByAccessToken(accessToken.value()) - .orElseThrow(EntityNotFoundException::new); + .orElseThrow(() -> new JwtException(JwtErrorType.EXPIRED_TOKEN)); refreshTokenRepository.delete(refreshToken); } From f92c8f2c077e8eac6191fd21cf7a8f00b627e432 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:47:28 +0900 Subject: [PATCH 09/19] =?UTF-8?q?feat(JwtAuthenticationToken):=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=9D=B8=EC=A6=9D=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=EB=A5=BC=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자 정보를 담는 principal(userId)과 credentials(accessToken) 필드 추가 - Spring Security의 AbstractAuthenticationToken을 상속받아 SecurityContext와의 호환성 유지 - setAuthenticated(true)를 통해 인증 상태 설정 (생선 전에 검증함) - 이후 SecurityContextHolder에서 principal 및 권한 정보를 추출하여 사용 가능 --- .../jwt/filter/JwtAuthenticationToken.java | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java new file mode 100644 index 000000000..e2260bec4 --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java @@ -0,0 +1,30 @@ +package com.somemore.auth.jwt.filter; + +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; + +public class JwtAuthenticationToken extends AbstractAuthenticationToken { + private final Object principal; + private final Object credentials; + + public JwtAuthenticationToken(Object principal, + Object credentials, + Collection authorities) { + super(authorities); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return credentials; + } + + @Override + public Object getPrincipal() { + return principal; + } +} From a7c91e765a483414b5d74f2478b90e57ff3e5195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:48:28 +0900 Subject: [PATCH 10/19] =?UTF-8?q?feat(JwtAuthFilter):=20JWT=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=EC=9D=B8=EC=A6=9D=20=ED=95=84=ED=84=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HTTP 요청의 Authorization 헤더에서 JWT 추출 - 추출한 JWT를 검증하고 만료 시 리프레시 처리 (JwtUseCase 활용) - JWT Claims에서 사용자 ID와 권한을 추출하여 JwtAuthenticationToken 생성 - SecurityContextHolder에 Authentication 객체 등록하여 인증 상태 관리 - shouldNotFilter 메서드로 특정 요청 URI에 대해 필터링 제외 가능 (현재는 개발 중 모든 요청 허용) --- .../auth/jwt/filter/JwtAuthFilter.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java new file mode 100644 index 000000000..1c2e8f85d --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java @@ -0,0 +1,68 @@ +package com.somemore.auth.jwt.filter; + +import com.somemore.auth.UserRole; +import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; +import com.somemore.auth.jwt.usecase.JwtUseCase; +import io.jsonwebtoken.Claims; +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; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUseCase jwtUseCase; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return true; // 개발 중 모든 요청 허용 +// return httpServletRequest.getRequestURI().contains("token"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + EncodedToken accessToken = getAccessToken(request); + jwtUseCase.processAccessToken(accessToken, response); + + Claims claims = jwtUseCase.getClaims(accessToken); + Authentication auth = createAuthenticationToken(claims, accessToken); + + SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + } + + private JwtAuthenticationToken createAuthenticationToken(Claims claims, EncodedToken accessToken) { + String userId = claims.get("id", String.class); + UserRole role = claims.get("role", UserRole.class); + + return new JwtAuthenticationToken( + userId, + accessToken, + List.of(new SimpleGrantedAuthority(role.name())) + ); + } + + private EncodedToken getAccessToken(HttpServletRequest request) { + String accessToken = request.getHeader("Authorization"); + if (accessToken == null || accessToken.isEmpty()) { + throw new JwtException(JwtErrorType.MISSING_TOKEN); + } + return new EncodedToken(accessToken); + } + +} From 9271e29ec5af94570c0ff6e90b82e7e746d70dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:49:41 +0900 Subject: [PATCH 11/19] =?UTF-8?q?feat(JwtExceptionFilter):=20JWT=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=ED=95=84=ED=84=B0=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JWT 처리 중 발생하는 JwtException을 전용 필터에서 처리 (스프링 컨텍스트가 아니므로) - HTTP 상태 코드 401(Unauthorized)와 함께 ProblemDetail 형식의 JSON 응답 생성 - ProblemDetail 응답에 오류 제목, 유형, 상세 메시지, 타임스탬프 포함 --- .../auth/jwt/filter/JwtExceptionFilter.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java b/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java new file mode 100644 index 000000000..4371fa6c6 --- /dev/null +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java @@ -0,0 +1,52 @@ +package com.somemore.auth.jwt.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.somemore.auth.jwt.exception.JwtException; +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; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URI; + +@RequiredArgsConstructor +@Slf4j +@Component +public class JwtExceptionFilter 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 (JwtException e) { + ProblemDetail problemDetail = buildUnauthorizedProblemDetail(e); + configureUnauthorizedResponse(response); + + objectMapper.writeValue(response.getWriter(), problemDetail); + } + } + + private void configureUnauthorizedResponse(HttpServletResponse response) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + } + + private ProblemDetail buildUnauthorizedProblemDetail(JwtException e) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.getMessage()); + problemDetail.setTitle("Authentication Error"); + problemDetail.setType(URI.create("http://프론트엔드주소/errors/unauthorized")); + problemDetail.setProperty("timestamp", System.currentTimeMillis()); + return problemDetail; + } +} From aa3ab9eeb9072b0b5190fa186f2688990e14cbbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:51:39 +0900 Subject: [PATCH 12/19] =?UTF-8?q?feat(SecurityConfig):=20JwtAuthFilter,=20?= =?UTF-8?q?JwtExceptionFilter=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JwtAuthFilter: 요청 헤더에서 JWT를 검증하고, 인증 정보를 SecurityContext에 설정 - JwtExceptionFilter: JWT 처리 중 발생한 예외를 캡처하고 ProblemDetail 형식으로 응답 반환 --- .../global/configure/SecurityConfig.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/somemore/global/configure/SecurityConfig.java b/src/main/java/com/somemore/global/configure/SecurityConfig.java index bc7edb98c..5951294ed 100644 --- a/src/main/java/com/somemore/global/configure/SecurityConfig.java +++ b/src/main/java/com/somemore/global/configure/SecurityConfig.java @@ -1,5 +1,7 @@ package com.somemore.global.configure; +import com.somemore.auth.jwt.filter.JwtAuthFilter; +import com.somemore.auth.jwt.filter.JwtExceptionFilter; import com.somemore.auth.oauth.handler.failure.CustomOAuthFailureHandler; import com.somemore.auth.oauth.handler.success.CustomOAuthSuccessHandler; import com.somemore.auth.oauth.service.CustomOAuth2UserService; @@ -13,6 +15,7 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @RequiredArgsConstructor @Configuration @@ -24,10 +27,12 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final CustomOAuthSuccessHandler customOAuthSuccessHandler; private final CustomOAuthFailureHandler customOAuthFailureHandler; + private final JwtAuthFilter jwtAuthFilter; + private final JwtExceptionFilter jwtExceptionFilter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception { - return httpSecurity + httpSecurity .csrf(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) @@ -57,13 +62,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws userInfoEndpointConfig.userService(customOAuth2UserService)) .failureHandler(customOAuthFailureHandler) .successHandler(customOAuthSuccessHandler) - ).build(); + ); -// TODO JWT 인증 필터가 인증 요청 처리, JWT 인증 필터를 UsernamePasswordAuthenticationFilter 앞에 추가 -// return httpSecurity -// .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) -// .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class) -// .build(); + return httpSecurity + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtExceptionFilter, JwtAuthFilter.class) + .build(); } } From e26c5658a478be0e74896ad2f93914e4a32f32fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 14:53:57 +0900 Subject: [PATCH 13/19] =?UTF-8?q?test(JwtServiceTest):=20=EB=8B=A4?= =?UTF-8?q?=EC=96=91=ED=95=9C=20JWT=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JWT 토큰 생성 및 검증 로직 테스트: - 토큰이 올바르게 생성되고, 예상한 클레임(`id`, `role`, `expiration`)을 포함하는지 확인. - 동일 사용자에 대해 여러 토큰 생성 시 서로 다른 값이어야 함을 검증. - JWT 만료 및 갱신 로직 테스트: - 만료된 AccessToken이 RefreshToken이 유효한 경우 갱신되는지 확인. - RefreshToken이 유효하지 않거나 존재하지 않을 때 예외 발생을 검증. - RefreshToken 관련 테스트: - 기존 RefreshToken이 올바르게 갱신되는지 확인. - RefreshToken이 없거나 잘못된 경우 예외 발생을 검증. - JWT 예외 처리 테스트: - 잘못된 토큰 및 만료된 토큰이 적절한 `JwtException`을 던지는지 확인. - RefreshToken 저장 및 유효성 확인 테스트: - RefreshToken 저장 로직 검증. - 갱신된 AccessToken이 쿠키에 올바르게 설정되는지 확인. --- .../auth/jwt/service/JwtServiceTest.java | 291 ++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java diff --git a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java new file mode 100644 index 000000000..aa3b0a372 --- /dev/null +++ b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java @@ -0,0 +1,291 @@ +package com.somemore.auth.jwt.service; + +import com.somemore.IntegrationTestSupport; +import com.somemore.auth.jwt.domain.EncodedToken; +import com.somemore.auth.jwt.domain.TokenType; +import com.somemore.auth.jwt.domain.UserRole; +import com.somemore.auth.jwt.exception.JwtErrorType; +import com.somemore.auth.jwt.exception.JwtException; +import com.somemore.auth.jwt.refresh.domain.RefreshToken; +import com.somemore.auth.jwt.refresh.manager.RefreshTokenManager; +import com.somemore.auth.jwt.validator.JwtValidator; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.mock.web.MockHttpServletResponse; + +import javax.crypto.SecretKey; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; + + +class JwtServiceTest extends IntegrationTestSupport { + + @Autowired + private JwtService jwtService; + @Autowired + private JwtValidator jwtValidator; + @Autowired + private SecretKey secretKey; + @Autowired + private RefreshTokenManager refreshTokenManager; + + @DisplayName("토큰이 올바르게 생성된다") + @Test + void generateAndValidateToken() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + TokenType tokenType = TokenType.ACCESS; + + // when + EncodedToken token = jwtService.generateToken(userId, role.name(), tokenType); + + // then + Claims claims = jwtService.getClaims(token); + assertThat(claims.get("id", String.class)).isEqualTo(userId); + assertThat(claims.get("role", String.class)).isEqualTo(role.name()); + assertThat(claims.getExpiration()).isNotNull(); + } + + @DisplayName("토큰 만료 기간이 정확히 설정되어야 한다") + @Test + void tokenExpirationPeriodIsExact() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + // when + EncodedToken accessToken = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + EncodedToken refreshToken = jwtService.generateToken(userId, role.name(), TokenType.REFRESH); + + // then + Claims accessClaims = jwtService.getClaims(accessToken); + Claims refreshClaims = jwtService.getClaims(refreshToken); + + long accessTokenDuration = accessClaims.getExpiration().getTime() - accessClaims.getIssuedAt().getTime(); + long refreshTokenDuration = refreshClaims.getExpiration().getTime() - refreshClaims.getIssuedAt().getTime(); + + assertThat(accessTokenDuration).isEqualTo(TokenType.ACCESS.getPeriod()); + assertThat(refreshTokenDuration).isEqualTo(TokenType.REFRESH.getPeriod()); + } + + @DisplayName("동일한 사용자로 여러 토큰 생성 시 서로 다른 값이어야 한다") + @Test + void multipleTokensForSameUserAreDifferent() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + // when + EncodedToken token1 = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + EncodedToken token2 = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + + // then + assertThat(token1.value()).isNotEqualTo(token2.value()); + } + + @DisplayName("만료된 엑세스 토큰은 리프레시 토큰이 유효하다면 갱신된다") + @Test + void verifyAndRefreshExpiredToken() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + createAndSaveRefreshToken(userId, expiredAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + // when + jwtService.processAccessToken(expiredAccessToken, mockResponse); + + // then + assertRefreshedAccessToken(mockResponse); + } + + @DisplayName("만료된 엑세스 토큰은 리프레시 토큰이 유효하지 않다면 갱신되지 않고 예외가 발생한다") + @Test + void throwExceptionWhenRefreshTokenIsInvalid() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + EncodedToken expiredRefreshToken = createExpiredToken(userId, role); + RefreshToken refreshToken = new RefreshToken(userId, expiredAccessToken, expiredRefreshToken); + refreshTokenManager.save(refreshToken); + + // when & then + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, mockResponse)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + @DisplayName("만료된 엑세스 토큰은 리프레시 토큰이 존재하지 않는다면 갱신되지 않고 예외가 발생한다") + @Test + void throwExceptionWhenRefreshTokenIsMissing() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + // when & then + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, mockResponse)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + @DisplayName("리프레시된 AccessToken은 쿠키에 올바르게 저장된다") + @Test + void refreshedAccessTokenIsSetInCookie() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + createAndSaveRefreshToken(userId, expiredAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + MockHttpServletResponse mockResponse = new MockHttpServletResponse(); + + // when + jwtService.processAccessToken(expiredAccessToken, mockResponse); + + // then + String cookieHeader = mockResponse.getHeader("Set-Cookie"); + assertThat(cookieHeader).contains("ACCESS="); + assertThat(cookieHeader).contains("HttpOnly"); + assertThat(cookieHeader).contains("Secure"); + } + + @DisplayName("기존 RefreshToken이 갱신된다") + @Test + void refreshTokenIsUpdated() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + RefreshToken oldRefreshToken = createAndSaveRefreshToken(userId, expiredAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + EncodedToken newAccessToken = jwtService.generateToken(userId, role.name(), TokenType.ACCESS); + RefreshToken newRefreshToken = createAndSaveRefreshToken(userId, newAccessToken, Instant.now().plusMillis(TokenType.REFRESH.getPeriod())); + + // when + // then + assertThatThrownBy(() -> refreshTokenManager.findRefreshToken(expiredAccessToken)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + + assertThat(newRefreshToken.getAccessToken()).isEqualTo(newAccessToken.value()); + assertThat(newRefreshToken.getRefreshToken()).isNotEqualTo(oldRefreshToken.getRefreshToken()); + } + + + @DisplayName("잘못된 JWT 토큰은 예외가 발생한다") + @Test + void invalidTokenThrowsJwtException() { + // given + String invalidToken = "invalid.token.value"; + EncodedToken encodedToken = new EncodedToken(invalidToken); + + // when + // then + assertThatThrownBy(() -> jwtValidator.validateToken(encodedToken)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.UNKNOWN_ERROR.getMessage()); + } + + @DisplayName("만료된 JWT 토큰은 예외가 발생한다") + @Test + void expiredTokenThrowsJwtException() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + // when + // then + assertThatThrownBy(() -> jwtValidator.validateToken(expiredAccessToken)) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + @DisplayName("RefreshToken이 존재하지 않으면 예외가 발생한다") + @Test + void refreshTokenNotFoundThrowsJwtException() { + // given + String userId = UUID.randomUUID().toString(); + UserRole role = UserRole.VOLUNTEER; + EncodedToken expiredAccessToken = createExpiredToken(userId, role); + + // when + // then + assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, new MockHttpServletResponse())) + .isInstanceOf(JwtException.class) + .hasMessage(JwtErrorType.EXPIRED_TOKEN.getMessage()); + } + + private EncodedToken createExpiredToken(String userId, UserRole role) { + Claims claims = buildClaims(userId, role); + + Instant now = Instant.now(); + Instant expiration = now.plusMillis(-1); // 과거 + + return new EncodedToken(Jwts.builder() + .claims(claims) + .issuedAt(Date.from(now)) + .expiration(Date.from(expiration)) + .signWith(secretKey, Jwts.SIG.HS256) + .compact()); + } + + private RefreshToken createAndSaveRefreshToken(String userId, EncodedToken accessToken, Instant expiration) { + Claims claims = buildClaims(userId, UserRole.VOLUNTEER); + Instant now = Instant.now(); + Instant refreshExpiration = now.plusMillis(TokenType.REFRESH.getPeriod()); + String uniqueId = UUID.randomUUID().toString(); // jti + + RefreshToken refreshToken = new RefreshToken( + userId, + accessToken, + new EncodedToken(Jwts.builder() + .claims(claims) + .id(uniqueId) + .issuedAt(Date.from(now)) + .expiration(Date.from(refreshExpiration)) + .signWith(secretKey, Jwts.SIG.HS256) + .compact())); + + refreshTokenManager.save(refreshToken); + + return refreshToken; + } + + private Claims buildClaims(String userId, UserRole role) { + return Jwts.claims() + .add("id", userId) + .add("role", role) + .build(); + } + + private void assertRefreshedAccessToken(MockHttpServletResponse mockResponse) { + String cookie = mockResponse.getHeader("Set-Cookie"); + assertThat(cookie).isNotNull(); + assertThat(cookie).contains(TokenType.ACCESS.name()); + + EncodedToken refreshedAccessToken = new EncodedToken( + cookie.split(";")[0].substring("ACCESS=".length())); + assertThatCode(() -> jwtValidator.validateToken(refreshedAccessToken)) + .doesNotThrowAnyException(); + } + +} \ No newline at end of file From d620164c0677300ddde03007b5f3a518bcba6251 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:23:02 +0900 Subject: [PATCH 14/19] =?UTF-8?q?feat(JwtAuthenticationToken):=20=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EC=A7=80=EC=9B=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - principal 필드를 직렬화 가능하도록 수정 - credentials 필드는 인증 이후에 사용하지 않으니 직렬화 제외 - 세션 저장 및 분산 서버 환경에서의 호환성 확보 --- .../somemore/auth/jwt/filter/JwtAuthenticationToken.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java index e2260bec4..d25a13aee 100644 --- a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthenticationToken.java @@ -3,13 +3,14 @@ import org.springframework.security.authentication.AbstractAuthenticationToken; import org.springframework.security.core.GrantedAuthority; +import java.io.Serializable; import java.util.Collection; public class JwtAuthenticationToken extends AbstractAuthenticationToken { - private final Object principal; - private final Object credentials; + private final Serializable principal; + private final transient Object credentials; - public JwtAuthenticationToken(Object principal, + public JwtAuthenticationToken(Serializable principal, Object credentials, Collection authorities) { super(authorities); From 2cf3323e7693fda387316ec7b2598d03ead28374 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:23:54 +0900 Subject: [PATCH 15/19] =?UTF-8?q?refactor:=20unused=20import=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java b/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java index 59ed670aa..262479eac 100644 --- a/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java +++ b/src/main/java/com/somemore/auth/jwt/refresh/refresher/DefaultJwtRefresher.java @@ -1,6 +1,5 @@ package com.somemore.auth.jwt.refresh.refresher; -import com.somemore.auth.cookie.SetCookieUseCase; import com.somemore.auth.jwt.domain.EncodedToken; import com.somemore.auth.jwt.domain.TokenType; import com.somemore.auth.jwt.generator.JwtGenerator; From 375cf9ea0fd36c15d8bbfc2250d93fe8546b3c1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 15:58:35 +0900 Subject: [PATCH 16/19] =?UTF-8?q?style(=EA=B0=9C=ED=96=89):=20git=20?= =?UTF-8?q?=EC=9E=A0=EC=9E=AC=EC=A0=81=EC=9D=B8=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=EC=98=88=EB=B0=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java index aa3b0a372..952c6fa99 100644 --- a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java +++ b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java @@ -288,4 +288,4 @@ private void assertRefreshedAccessToken(MockHttpServletResponse mockResponse) { .doesNotThrowAnyException(); } -} \ No newline at end of file +} From 0f38b462bc174165f7e8441032278770a180eac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:27:01 +0900 Subject: [PATCH 17/19] =?UTF-8?q?style:=20when,=20then=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=ED=96=89=20=EB=A7=9E=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/somemore/auth/jwt/service/JwtServiceTest.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java index 952c6fa99..509c513ce 100644 --- a/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java +++ b/src/test/java/com/somemore/auth/jwt/service/JwtServiceTest.java @@ -120,7 +120,8 @@ void throwExceptionWhenRefreshTokenIsInvalid() { RefreshToken refreshToken = new RefreshToken(userId, expiredAccessToken, expiredRefreshToken); refreshTokenManager.save(refreshToken); - // when & then + // when + // then MockHttpServletResponse mockResponse = new MockHttpServletResponse(); assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, mockResponse)) @@ -136,7 +137,8 @@ void throwExceptionWhenRefreshTokenIsMissing() { UserRole role = UserRole.VOLUNTEER; EncodedToken expiredAccessToken = createExpiredToken(userId, role); - // when & then + // when + // then MockHttpServletResponse mockResponse = new MockHttpServletResponse(); assertThatThrownBy(() -> jwtService.processAccessToken(expiredAccessToken, mockResponse)) From fcf21c663180370e3e097cb8b6ae033c902df1e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:27:29 +0900 Subject: [PATCH 18/19] =?UTF-8?q?feat(ProblemDetail):=20type=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java b/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java index 4371fa6c6..80ac8b5ee 100644 --- a/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtExceptionFilter.java @@ -15,7 +15,6 @@ import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; -import java.net.URI; @RequiredArgsConstructor @Slf4j @@ -45,7 +44,6 @@ private void configureUnauthorizedResponse(HttpServletResponse response) { private ProblemDetail buildUnauthorizedProblemDetail(JwtException e) { ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, e.getMessage()); problemDetail.setTitle("Authentication Error"); - problemDetail.setType(URI.create("http://프론트엔드주소/errors/unauthorized")); problemDetail.setProperty("timestamp", System.currentTimeMillis()); return problemDetail; } From 45409b755872d93cf2ff7a561b0f1df07141839d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=9E=AC=EC=A4=91?= <126754298+m-a-king@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:28:03 +0900 Subject: [PATCH 19/19] =?UTF-8?q?refactor(JwtAuthFilter):=20private=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9C=84=EC=B9=98=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../somemore/auth/jwt/filter/JwtAuthFilter.java | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java index 1c2e8f85d..3f9a2c677 100644 --- a/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java +++ b/src/main/java/com/somemore/auth/jwt/filter/JwtAuthFilter.java @@ -46,6 +46,14 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterChain.doFilter(request, response); } + private EncodedToken getAccessToken(HttpServletRequest request) { + String accessToken = request.getHeader("Authorization"); + if (accessToken == null || accessToken.isEmpty()) { + throw new JwtException(JwtErrorType.MISSING_TOKEN); + } + return new EncodedToken(accessToken); + } + private JwtAuthenticationToken createAuthenticationToken(Claims claims, EncodedToken accessToken) { String userId = claims.get("id", String.class); UserRole role = claims.get("role", UserRole.class); @@ -56,13 +64,4 @@ private JwtAuthenticationToken createAuthenticationToken(Claims claims, EncodedT List.of(new SimpleGrantedAuthority(role.name())) ); } - - private EncodedToken getAccessToken(HttpServletRequest request) { - String accessToken = request.getHeader("Authorization"); - if (accessToken == null || accessToken.isEmpty()) { - throw new JwtException(JwtErrorType.MISSING_TOKEN); - } - return new EncodedToken(accessToken); - } - }