diff --git a/sample/build.gradle b/sample/build.gradle index b719ed2..9e6e78b 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -48,4 +48,6 @@ dependencies { tasks.named('test') { useJUnitPlatform() + // Byte Buddy 에이전트 경고 및 JVM 부트스트랩 클래스 경고 해결 + // jvmArgs '-XX:+EnableDynamicAgentLoading', '-Xshare:off' } diff --git a/sample/src/main/java/com/temp/sample/config/auth/AuthUser.java b/sample/src/main/java/com/temp/sample/config/auth/AuthUser.java index 91d8b94..9120d1a 100644 --- a/sample/src/main/java/com/temp/sample/config/auth/AuthUser.java +++ b/sample/src/main/java/com/temp/sample/config/auth/AuthUser.java @@ -1,14 +1,15 @@ package com.temp.sample.config.auth; -import java.util.ArrayList; import lombok.Getter; import lombok.RequiredArgsConstructor; +import java.util.List; + @Getter @RequiredArgsConstructor public class AuthUser { private final Long userId; - private final ArrayList roles; + private final List roles; } diff --git a/sample/src/main/java/com/temp/sample/config/auth/JKeyLocator.java b/sample/src/main/java/com/temp/sample/config/auth/JKeyLocator.java index b093285..582eb38 100644 --- a/sample/src/main/java/com/temp/sample/config/auth/JKeyLocator.java +++ b/sample/src/main/java/com/temp/sample/config/auth/JKeyLocator.java @@ -2,6 +2,8 @@ import com.temp.sample.dao.SystemKeyRepository; import com.temp.sample.entity.SystemKey; +import io.jsonwebtoken.JweHeader; +import io.jsonwebtoken.JwsHeader; import io.jsonwebtoken.LocatorAdapter; import io.jsonwebtoken.ProtectedHeader; import io.jsonwebtoken.io.Decoders; @@ -22,7 +24,7 @@ protected Key locate(ProtectedHeader header) { // lookupKey(keyId) 는 키를 데이터베이스(DB), 키 저장소(Keystore), HSM(Hardware Security Module) // 등에서 조회하는 메서드 로 직접 구현해야 한다. - //JWS 서명 검증 시, 대칭키[HMAC (HS256, HS384, HS512)]는 SecretKey를 리턴해야한다. + // JWS 서명 검증 시, 대칭키[HMAC (HS256, HS384, HS512)]는 SecretKey를 리턴해야한다. // HSM은 provider 방식이 있음. String keyId = header.getKeyId(); @@ -33,5 +35,4 @@ protected Key locate(ProtectedHeader header) { } - } diff --git a/sample/src/main/java/com/temp/sample/config/auth/JwtProvider.java b/sample/src/main/java/com/temp/sample/config/auth/JwtProvider.java index b92c888..f66e3b7 100644 --- a/sample/src/main/java/com/temp/sample/config/auth/JwtProvider.java +++ b/sample/src/main/java/com/temp/sample/config/auth/JwtProvider.java @@ -3,13 +3,14 @@ import com.temp.sample.dao.SystemKeyRepository; import com.temp.sample.entity.SystemKey; import io.jsonwebtoken.Claims; -import io.jsonwebtoken.IncorrectClaimException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.MissingClaimException; +import io.jsonwebtoken.Jwts.SIG; import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.io.Encoders; import io.jsonwebtoken.security.Keys; import java.util.ArrayList; +import java.util.Calendar; import java.util.Date; import java.util.HashMap; import java.util.Map; @@ -23,62 +24,69 @@ @RequiredArgsConstructor public class JwtProvider { - private final SystemKeyRepository systemKeyRepository; - private final JKeyLocator jKeyLocator; + private final SystemKeyRepository systemKeyRepository; + private final JKeyLocator jKeyLocator; + public static final String ISSUER = "24-mall.com"; - public String createLoginToken(AuthUser authUser) { - Map claims = new HashMap<>(); - claims.put("userId", authUser.getUserId()); - claims.put("roles", authUser.getRoles()); + public String createAccessToken(AuthUser authUser) { + Map claims = new HashMap<>(); + claims.put("roles", authUser.getRoles()); + SystemKey lastSecretKey = systemKeyRepository.findLastSecretKey(); + SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(lastSecretKey.getEncKey())); + Date now = new Date(); + Calendar calendar = Calendar.getInstance(); + calendar.setTime(now); // 현재 시간 기준 + Date expiration = calendar.getTime(); + calendar.add(Calendar.MINUTE, 30); + + return Jwts.builder() + .issuer(ISSUER) + .subject(String.valueOf(authUser.getUserId())) + .header().keyId("24-mall").and() + .claims(claims) +// .audience().add(audience).and() + .issuedAt(now) + .expiration(expiration) + .signWith(secretKey) + .compact(); + } - SystemKey lastSecretKey = systemKeyRepository.findLastSecretKey(); - SecretKey secretKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode(lastSecretKey.getEncKey())); + public AuthUser readLoginToken(String token) { - return Jwts.builder() - .issuer("24-mall") - .subject("login") // sub (Subject) 클레임 설정 - .header().keyId("24-mall").and() // alg, frm, zip 은 생략 가능 - .claims(claims) // 주제 - .audience().add("24-mall").and() - .issuedAt(new Date()) // iat (Issued At) 클레임 설정 - .expiration(new Date(System.currentTimeMillis() + 3600000)) - .signWith(secretKey) // 서명 설정 (기본 HS256 사용) - .id(String.valueOf(lastSecretKey.getId())) - .compact(); // 최종 JWT 생성 + Jws jws = Jwts.parser() + .clockSkewSeconds(180) + .requireSubject("login") + .requireIssuer("24-mall") + .requireAudience("24-mall") + .keyLocator(jKeyLocator) + .build() + .parseSignedClaims(token); + + log.info("복호화 성공"); + Claims claims = jws.getPayload(); + + Date expiration = claims.getExpiration(); + Date now = new Date(); + if(expiration.before(now)) { + throw new RuntimeException("만료된 토큰입니다."); } - // https://web.archive.org/web/20230428094039/https://stormpath.com/blog/where-to-store-your-jwts-cookies-vs-html5-web-storage + return new AuthUser(claims.get("userId", Long.class), claims.get("roles", ArrayList.class)); + + } - public AuthUser readLoginToken(String token) { - // Dynamic Key Lookup(keyId) 는 키를 데이터베이스(DB), 키 저장소(Keystore), HSM(Hardware Security Module) - // 등에서 조회하는 메서드로 직접 구현할 수 있다. - - try { - Jws jws = Jwts.parser() - .clockSkewSeconds(180) // 시계오차 오차 허용 시간 설정 3분, 5분이상이면 심각한 문제 -> 생성서버와 검증서버 시간차 - .requireSubject("login") // 추가 검증. - .requireIssuer("24-mall") - .requireAudience("24-mall") - .keyLocator(jKeyLocator) // 동적키 - .build() - .parseSignedClaims(token); // JWT 파싱 - - Claims claims = jws.getPayload(); - log.info("성공"); - - return new AuthUser(claims.get("userId", Long.class), claims.get("roles", ArrayList.class) ); - - } catch (Exception e){ - log.error(e.getMessage()); - throw new RuntimeException(e); - } + public String generateSecretKey() { + SecretKey secretKey = SIG.HS256.key().build(); + + return Encoders.BASE64.encode(secretKey.getEncoded()); } + } diff --git a/sample/src/main/java/com/temp/sample/config/filter/JwtFilter.java b/sample/src/main/java/com/temp/sample/config/filter/JwtFilter.java index fcef1b5..631d9c2 100644 --- a/sample/src/main/java/com/temp/sample/config/filter/JwtFilter.java +++ b/sample/src/main/java/com/temp/sample/config/filter/JwtFilter.java @@ -27,11 +27,12 @@ public class JwtFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if(!request.getRequestURL().toString().endsWith("login")){ + // 로그인 요청이 아니면 authorization 검사 + if (!request.getRequestURL().toString().endsWith("login")) { String token = request.getHeader("Authorization"); - if (StringUtils.isEmpty(token)){ + if (StringUtils.isEmpty(token)) { throw new RuntimeException("Token is empty"); } @@ -44,6 +45,4 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } - - } diff --git a/sample/src/main/java/com/temp/sample/controller/ProductController.java b/sample/src/main/java/com/temp/sample/controller/ProductController.java index 88bd1c7..e763371 100644 --- a/sample/src/main/java/com/temp/sample/controller/ProductController.java +++ b/sample/src/main/java/com/temp/sample/controller/ProductController.java @@ -2,6 +2,7 @@ import com.temp.sample.service.ProductService; import com.temp.sample.service.request.ProductRequest; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -12,14 +13,17 @@ @RequiredArgsConstructor public class ProductController { - private final ProductService productService; + private final ProductService productService; - @PostMapping("/products") - ApiResponse getProducts(@RequestBody ProductRequest request){ + @PostMapping("/products") + ApiResponse getProducts(@RequestBody ProductRequest request, HttpServletRequest httpRequest) { - productService.read(request.getId()); + // 필터에서 설정된 속성 가져오기 + Long userId = (Long) httpRequest.getAttribute("userId"); - return ApiResponse.OK; - } + productService.read(request.getId()); + + return ApiResponse.OK; + } } diff --git a/sample/src/main/java/com/temp/sample/entity/SystemKey.java b/sample/src/main/java/com/temp/sample/entity/SystemKey.java index 79f3995..47e13c3 100644 --- a/sample/src/main/java/com/temp/sample/entity/SystemKey.java +++ b/sample/src/main/java/com/temp/sample/entity/SystemKey.java @@ -4,10 +4,8 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import java.time.LocalDateTime; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.ToString; + +import lombok.*; @Table(name = "system_key") @Getter @@ -26,4 +24,13 @@ public class SystemKey { private Boolean isActive; + public static SystemKey create(Long id, String encKey, LocalDateTime createdAt, Boolean isActive) { + SystemKey systemKey = new SystemKey(); + systemKey.id = id; + systemKey.encKey = encKey; + systemKey.createdAt = createdAt; + systemKey.isActive = isActive; + return systemKey; + } + } diff --git a/sample/src/main/java/com/temp/sample/service/LoginServiceImpl.java b/sample/src/main/java/com/temp/sample/service/LoginServiceImpl.java index 3187fff..7005917 100644 --- a/sample/src/main/java/com/temp/sample/service/LoginServiceImpl.java +++ b/sample/src/main/java/com/temp/sample/service/LoginServiceImpl.java @@ -6,7 +6,6 @@ import com.temp.sample.dao.UserRepository; import com.temp.sample.entity.User; import java.util.ArrayList; -import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -28,7 +27,10 @@ public String login(LoginReq req) { AuthUser authUser = new AuthUser(user.getId(), new ArrayList<>(List.of("bronze", "silver", "gold"))); - String loginToken = jwtProvider.createLoginToken(authUser); + // todo 사용자 등급별 접속가능 경로 설정 + // String audience = "/premium"; + + String loginToken = jwtProvider.createAccessToken(authUser); return loginToken; } diff --git a/sample/src/test/java/com/temp/sample/config/auth/JwtProviderTest.java b/sample/src/test/java/com/temp/sample/config/auth/JwtProviderTest.java new file mode 100644 index 0000000..9eb2b67 --- /dev/null +++ b/sample/src/test/java/com/temp/sample/config/auth/JwtProviderTest.java @@ -0,0 +1,127 @@ +package com.temp.sample.config.auth; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.BDDMockito.given; + +import com.temp.sample.dao.SystemKeyRepository; +import com.temp.sample.entity.SystemKey; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.ProtectedHeader; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import javax.crypto.SecretKey; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@Slf4j +@ExtendWith(MockitoExtension.class) +class JwtProviderTest { + + @Mock + SystemKeyRepository systemKeyRepository; + + @Mock + private ProtectedHeader protectedHeader; + + @InjectMocks + JwtProvider jwtProvider; + + @InjectMocks + JKeyLocator jKeyLocator; + + + @Test + @DisplayName("토큰 읽기 예외 테스트") + void exceptionTest() { + + given(systemKeyRepository.findLastSecretKey()) + .willReturn(createMockSystemKey()); + + Map claims = new HashMap<>(); + claims.put("userId", 1L); + claims.put("roles", "admin"); + + SecretKey secretKey = Keys.hmacShaKeyFor( + Decoders.BASE64.decode("MWE5ZjZiOGMzZDdlNGYyYTVjNmQ5ZTBiN2E0ZjNjMmUK")); + + String accessToken = jwtProvider.createAccessToken(new AuthUser(1L, + Arrays.asList("gold"))); + + System.out.println("accessToken = " + accessToken); + + + // subject 오류 + RuntimeException subjectException = assertThrows(RuntimeException.class, () -> { + Jwts.parser() + .clockSkewSeconds(180) + .requireSubject("wrongSubject") + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken); + }); + System.out.println("subject 오류 통과 = " + subjectException); + + // issuer 오류 + RuntimeException issuerException = assertThrows(RuntimeException.class, () -> { + Jwts.parser() + .clockSkewSeconds(180) + .requireSubject("1") + .requireIssuer("wrongIssuer") + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken); + }); + System.out.println("issuer 오류 통과 = " + issuerException); + + // audience 오류 + RuntimeException audienceException = assertThrows(RuntimeException.class, () -> { + Jwts.parser() + .clockSkewSeconds(180) + .requireSubject("1") + .requireIssuer("24-mall.com") + .requireAudience("wrongAudience") + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken); + }); + System.out.println("audience 오류 통과 = " + audienceException); + } + + + @Test + @DisplayName("automatic Reuse Detection") + void automaticReuseDetection() { + // 동일한 리프래쉬 토킁이 두번이상 사용되면 token family 무효화. + // 공격자가 탈취한 토큰을 사용하려 할 경우 모든 관련 토큰 무효화 접근 차단. + // localStorage x -> XSS 공격에 취약 + // replay attack 최소화 + // use https -> 네트워크 가로채기 방지 + // 쿠키기반 세션 유지 -> silent authentication 사용 -> ux 향상 + // 만료시간 적절히 설정 보안성과 사용자 경험 균형 유지 + } + + + private String createRefreshToken() { + return "refresh token"; + } + + private SystemKey createMockSystemKey() { + return SystemKey.create(1L, "MWE5ZjZiOGMzZDdlNGYyYTVjNmQ5ZTBiN2E0ZjNjMmUK", + LocalDateTime.now(), true); + } + +} \ No newline at end of file