diff --git a/build.gradle b/build.gradle index 1d80701..8dd2bc9 100644 --- a/build.gradle +++ b/build.gradle @@ -135,6 +135,8 @@ dependencies { compileOnly group: 'com.fasterxml.jackson.dataformat', name: 'jackson-dataformat-xml', version: '2.13.1' + api "org.springframework.security:spring-security-jwt:1.1.1.RELEASE" + testRuntimeOnly 'com.h2database:h2:2.1.214' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.1' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.1' diff --git a/src/main/java/com/eactive/eai/authserver/jwt/PssJwtAccessTokenConverter.java b/src/main/java/com/eactive/eai/authserver/jwt/PssJwtAccessTokenConverter.java new file mode 100644 index 0000000..5d9e6ce --- /dev/null +++ b/src/main/java/com/eactive/eai/authserver/jwt/PssJwtAccessTokenConverter.java @@ -0,0 +1,123 @@ +package com.eactive.eai.authserver.jwt; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.security.oauth2.provider.OAuth2Authentication; +import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * PS256(RSA-PSS) JWT를 발급·검증하는 JwtAccessTokenConverter 확장 클래스. + * + * spring-security-jwt 1.1.1은 JwtAlgorithms에 PS256을 등록하지 않아 + * JwtHelper.encode/decodeAndVerify가 PS256 헤더를 처리하지 못한다. + * 따라서 encode/decode를 직접 구현하여 JwtHelper를 완전히 우회한다. + */ +public class PssJwtAccessTokenConverter extends JwtAccessTokenConverter { + + /** PS256 JWT 헤더 {"alg":"PS256","typ":"JWT"} 의 Base64URL 인코딩 (고정값) */ + private static final String ENCODED_HEADER = Base64.getUrlEncoder().withoutPadding() + .encodeToString("{\"alg\":\"PS256\",\"typ\":\"JWT\"}".getBytes(StandardCharsets.UTF_8)); + + private final RsaPssSigner pssSigner; + private final RsaPssVerifier pssVerifier; + private final String publicKeyPem; + private final ObjectMapper jackson = new ObjectMapper(); + + public PssJwtAccessTokenConverter(RSAPrivateKey privateKey, RSAPublicKey publicKey) { + this.pssSigner = new RsaPssSigner(privateKey); + this.pssVerifier = new RsaPssVerifier(publicKey); + this.publicKeyPem = "-----BEGIN PUBLIC KEY-----\n" + + Base64.getMimeEncoder(64, new byte[]{'\n'}).encodeToString(publicKey.getEncoded()) + + "\n-----END PUBLIC KEY-----"; + // 부모 setVerifierKey — afterPropertiesSet() 내부에서 참조하는 verifierKey 필드 초기화 + setVerifierKey(this.publicKeyPem); + // getKey() (/oauth/token_key 엔드포인트)가 동작하도록 부모의 verifier 필드를 주입 + try { + Field f = JwtAccessTokenConverter.class.getDeclaredField("verifier"); + f.setAccessible(true); + f.set(this, pssVerifier); + } catch (Exception ignored) { } + } + + @Override + public void afterPropertiesSet() throws Exception { + // 부모 afterPropertiesSet()은 signingKey 기반 초기화(JwtHelper 의존)를 시도하므로 건너뜀 + } + + /** + * PS256 JWT 발급. JwtHelper.encode를 사용하지 않고 직접 구성한다. + * signingInput = Base64URL(header) + "." + Base64URL(payload) + * JWT = signingInput + "." + Base64URL(PSS_sign(signingInput)) + */ + @Override + protected String encode(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { + Map claims = getAccessTokenConverter().convertAccessToken(accessToken, authentication); + String payload; + try { + payload = jackson.writeValueAsString(claims); + } catch (Exception e) { + throw new IllegalStateException("Access token을 JSON으로 변환 실패", e); + } + return buildJwt(payload); + } + + /** + * PS256 JWT 검증 및 클레임 추출. JwtHelper.decodeAndVerify를 사용하지 않고 직접 파싱한다. + */ + @Override + protected Map decode(String token) { + String[] parts = token.split("\\."); + if (parts.length != 3) { + throw new IllegalArgumentException("잘못된 JWT 형식"); + } + byte[] signingInput = (parts[0] + "." + parts[1]).getBytes(StandardCharsets.US_ASCII); + byte[] sig = Base64.getUrlDecoder().decode(padBase64Url(parts[2])); + pssVerifier.verify(signingInput, sig); // 실패 시 InvalidSignatureException + + byte[] payloadBytes = Base64.getUrlDecoder().decode(padBase64Url(parts[1])); + try { + @SuppressWarnings("unchecked") + Map result = jackson.readValue(payloadBytes, Map.class); + // JwtAccessTokenConverter 원본 동작 유지: exp 필드 Integer → Long 변환 + if (result.containsKey(EXP) && result.get(EXP) instanceof Integer) { + result.put(EXP, ((Integer) result.get(EXP)).longValue()); + } + return result; + } catch (Exception e) { + throw new IllegalArgumentException("JWT 페이로드 파싱 실패", e); + } + } + + /** /oauth/token_key 엔드포인트 응답 — alg와 PEM 공개키 반환 */ + @Override + public Map getKey() { + Map result = new LinkedHashMap<>(); + result.put("alg", "PS256"); + result.put("value", publicKeyPem); + return result; + } + + public String buildJwt(String payload) { + String encodedPayload = Base64.getUrlEncoder().withoutPadding() + .encodeToString(payload.getBytes(StandardCharsets.UTF_8)); + String signingInput = ENCODED_HEADER + "." + encodedPayload; + byte[] sig = pssSigner.sign(signingInput.getBytes(StandardCharsets.US_ASCII)); + return signingInput + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(sig); + } + + private static String padBase64Url(String s) { + int mod = s.length() % 4; + if (mod == 2) return s + "=="; + if (mod == 3) return s + "="; + return s; + } +} diff --git a/src/main/java/com/eactive/eai/authserver/jwt/RsaPssSigner.java b/src/main/java/com/eactive/eai/authserver/jwt/RsaPssSigner.java new file mode 100644 index 0000000..c0eeb33 --- /dev/null +++ b/src/main/java/com/eactive/eai/authserver/jwt/RsaPssSigner.java @@ -0,0 +1,46 @@ +package com.eactive.eai.authserver.jwt; + +import java.security.Signature; +import java.security.interfaces.RSAPrivateKey; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; + +import org.springframework.security.jwt.crypto.sign.Signer; + +/** + * PS256 (RSA-PSS + SHA-256) 서명 구현체. + * RFC 7518 §3.5 파라미터: Hash=SHA-256, MGF=MGF1/SHA-256, saltLen=32, trailer=0xBC + * JDK 11+ 내장 RSASSA-PSS 사용. JDK 8 환경은 BouncyCastle 프로바이더 등록이 필요하다. + * + * JwtHelper를 우회하는 PssJwtAccessTokenConverter와 함께 사용한다. + * (spring-security-jwt 1.1.1은 JwtAlgorithms에 PS256을 등록하지 않아 JwtHelper 직접 사용 불가) + */ +public class RsaPssSigner implements Signer { + + private static final PSSParameterSpec PSS_SPEC = + new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1); + + private final RSAPrivateKey privateKey; + + public RsaPssSigner(RSAPrivateKey privateKey) { + this.privateKey = privateKey; + } + + @Override + public byte[] sign(byte[] bytes) { + try { + Signature sig = Signature.getInstance("RSASSA-PSS"); + sig.setParameter(PSS_SPEC); + sig.initSign(privateKey); + sig.update(bytes); + return sig.sign(); + } catch (Exception e) { + throw new RuntimeException("RSA-PSS 서명 실패", e); + } + } + + @Override + public String algorithm() { + return "PS256"; + } +} diff --git a/src/main/java/com/eactive/eai/authserver/jwt/RsaPssVerifier.java b/src/main/java/com/eactive/eai/authserver/jwt/RsaPssVerifier.java new file mode 100644 index 0000000..8879083 --- /dev/null +++ b/src/main/java/com/eactive/eai/authserver/jwt/RsaPssVerifier.java @@ -0,0 +1,47 @@ +package com.eactive.eai.authserver.jwt; + +import java.security.Signature; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.PSSParameterSpec; + +import org.springframework.security.jwt.crypto.sign.InvalidSignatureException; +import org.springframework.security.jwt.crypto.sign.SignatureVerifier; + +/** + * PS256 (RSA-PSS + SHA-256) 서명 검증 구현체. + * RFC 7518 §3.5 파라미터: Hash=SHA-256, MGF=MGF1/SHA-256, saltLen=32, trailer=0xBC + */ +public class RsaPssVerifier implements SignatureVerifier { + + private static final PSSParameterSpec PSS_SPEC = + new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1); + + private final RSAPublicKey publicKey; + + public RsaPssVerifier(RSAPublicKey publicKey) { + this.publicKey = publicKey; + } + + @Override + public void verify(byte[] content, byte[] signature) { + try { + Signature sig = Signature.getInstance("RSASSA-PSS"); + sig.setParameter(PSS_SPEC); + sig.initVerify(publicKey); + sig.update(content); + if (!sig.verify(signature)) { + throw new InvalidSignatureException("RSA-PSS 서명 검증 실패"); + } + } catch (InvalidSignatureException e) { + throw e; + } catch (Exception e) { + throw new InvalidSignatureException("RSA-PSS 서명 검증 실패: " + e.getMessage()); + } + } + + @Override + public String algorithm() { + return "PS256"; + } +} diff --git a/src/test/java/com/eactive/eai/authserver/jwt/RsaPssSignerVerifierTest.java b/src/test/java/com/eactive/eai/authserver/jwt/RsaPssSignerVerifierTest.java new file mode 100644 index 0000000..9252125 --- /dev/null +++ b/src/test/java/com/eactive/eai/authserver/jwt/RsaPssSignerVerifierTest.java @@ -0,0 +1,260 @@ +package com.eactive.eai.authserver.jwt; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; +import java.util.Base64; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.security.jwt.JwtHelper; +import org.springframework.security.jwt.crypto.sign.InvalidSignatureException; +import org.springframework.security.jwt.crypto.sign.RsaSigner; + +/** + * RsaPssSigner / RsaPssVerifier 단위 테스트. + * Spring 컨텍스트 불필요 — KeyPairGenerator로 테스트용 RSA 2048 키쌍을 생성하여 검증한다. + */ +@SuppressWarnings("deprecation") +@TestMethodOrder(MethodOrderer.DisplayName.class) +class RsaPssSignerVerifierTest { + + private static RSAPrivateKey privateKey; + private static RSAPublicKey publicKey; + private static RsaPssSigner signer; + private static RsaPssVerifier verifier; + + private static final byte[] CONTENT = "header.payload".getBytes(); + + @BeforeAll + static void setUpClass() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair keyPair = gen.generateKeyPair(); + privateKey = (RSAPrivateKey) keyPair.getPrivate(); + publicKey = (RSAPublicKey) keyPair.getPublic(); + signer = new RsaPssSigner(privateKey); + verifier = new RsaPssVerifier(publicKey); + } + + // ========================================================================= + // 1. RsaPssSigner + // ========================================================================= + + @Test + @DisplayName("1-1. algorithm() — PS256 반환") + void testSignerAlgorithm() { + assertEquals("PS256", signer.algorithm()); + } + + @Test + @DisplayName("1-2. sign() — non-null, 256바이트 (RSA-2048 서명 크기)") + void testSignReturnsExpectedLength() { + byte[] sig = signer.sign(CONTENT); + assertNotNull(sig); + assertEquals(256, sig.length); + } + + @Test + @DisplayName("1-3. sign() — 비결정론적 (같은 입력이라도 랜덤 salt로 인해 서명이 달라야 한다)") + void testSignIsNonDeterministic() { + byte[] sig1 = signer.sign(CONTENT); + byte[] sig2 = signer.sign(CONTENT); + assertFalse(Arrays.equals(sig1, sig2), "PSS는 매번 다른 salt를 사용하므로 서명이 달라야 한다"); + } + + // ========================================================================= + // 2. RsaPssVerifier + // ========================================================================= + + @Test + @DisplayName("2-1. algorithm() — PS256 반환") + void testVerifierAlgorithm() { + assertEquals("PS256", verifier.algorithm()); + } + + @Test + @DisplayName("2-2. 정상 서명 → 검증 성공 (예외 없음)") + void testVerifyValidSignature() { + byte[] sig = signer.sign(CONTENT); + assertDoesNotThrow(() -> verifier.verify(CONTENT, sig)); + } + + @Test + @DisplayName("2-3. 내용 변조 → InvalidSignatureException") + void testVerifyTamperedContent() { + byte[] sig = signer.sign(CONTENT); + byte[] tampered = "tampered.payload".getBytes(); + assertThrows(InvalidSignatureException.class, () -> verifier.verify(tampered, sig)); + } + + @Test + @DisplayName("2-4. 서명 변조 (첫 바이트 반전) → InvalidSignatureException") + void testVerifyTamperedSignature() { + byte[] sig = signer.sign(CONTENT); + sig[0] ^= 0xFF; + assertThrows(InvalidSignatureException.class, () -> verifier.verify(CONTENT, sig)); + } + + @Test + @DisplayName("2-5. 다른 키쌍의 공개키로 검증 → InvalidSignatureException") + void testVerifyWithWrongPublicKey() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + RSAPublicKey otherPublicKey = (RSAPublicKey) gen.generateKeyPair().getPublic(); + RsaPssVerifier wrongVerifier = new RsaPssVerifier(otherPublicKey); + + byte[] sig = signer.sign(CONTENT); + assertThrows(InvalidSignatureException.class, () -> wrongVerifier.verify(CONTENT, sig)); + } + + // ========================================================================= + // 3. Signer + Verifier 통합 + // ========================================================================= + + @Test + @DisplayName("3-1. sign → verify 라운드트립 — 성공") + void testRoundTrip() { + byte[] sig = signer.sign(CONTENT); + assertDoesNotThrow(() -> verifier.verify(CONTENT, sig)); + } + + @Test + @DisplayName("3-2. 다른 키쌍의 서명을 원래 verifier로 검증 → 실패") + void testRoundTripCrossKeyFails() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair other = gen.generateKeyPair(); + RsaPssSigner otherSigner = new RsaPssSigner((RSAPrivateKey) other.getPrivate()); + + byte[] sigByOther = otherSigner.sign(CONTENT); + assertThrows(InvalidSignatureException.class, () -> verifier.verify(CONTENT, sigByOther)); + } + + @Test + @DisplayName("3-3. 원래 서명을 다른 키쌍의 verifier로 검증 → 실패") + void testRoundTripCrossVerifierFails() throws Exception { + KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA"); + gen.initialize(2048); + KeyPair other = gen.generateKeyPair(); + RsaPssVerifier otherVerifier = new RsaPssVerifier((RSAPublicKey) other.getPublic()); + + byte[] sig = signer.sign(CONTENT); + assertThrows(InvalidSignatureException.class, () -> otherVerifier.verify(CONTENT, sig)); + } + + // ========================================================================= + // 4. 시각 확인 — 콘솔 출력으로 RS256(결정론적) vs PS256(비결정론적) 비교 + // ========================================================================= + + @Test + @DisplayName("4-1. [시각 확인] RS256은 결정론적, PS256은 비결정론적 — 콘솔 hex 출력") + void testVisualComparison() { + RsaSigner rs256Signer = new RsaSigner(privateKey); + + byte[] rs256Sig1 = rs256Signer.sign(CONTENT); + byte[] rs256Sig2 = rs256Signer.sign(CONTENT); + byte[] ps256Sig1 = signer.sign(CONTENT); + byte[] ps256Sig2 = signer.sign(CONTENT); + + System.out.println(); + System.out.println("━━━ 서명 알고리즘 시각 비교 (동일 데이터: \"" + new String(CONTENT) + "\") ━━━"); + System.out.println(); + System.out.printf("[RS256 서명 1] %s%n", toHex(rs256Sig1)); + System.out.printf("[RS256 서명 2] %s%n", toHex(rs256Sig2)); + System.out.printf(" → 두 서명 일치 여부: %b (결정론적 — 항상 같음)%n", Arrays.equals(rs256Sig1, rs256Sig2)); + System.out.println(); + System.out.printf("[PS256 서명 1] %s%n", toHex(ps256Sig1)); + System.out.printf("[PS256 서명 2] %s%n", toHex(ps256Sig2)); + System.out.printf(" → 두 서명 일치 여부: %b (비결정론적 — 매번 다름, PSS 랜덤 salt 때문)%n", Arrays.equals(ps256Sig1, ps256Sig2)); + System.out.println(); + + assertTrue(Arrays.equals(rs256Sig1, rs256Sig2), "RS256은 결정론적이어야 한다"); + assertFalse(Arrays.equals(ps256Sig1, ps256Sig2), "PS256은 비결정론적이어야 한다"); + } + + // ========================================================================= + // 4-2. JWT 토큰 시각 확인 — 헤더 alg 필드 + 서명 파트 비교 + // ========================================================================= + + @Test + @DisplayName("4-2. [JWT 시각 확인] alg 헤더(RS256/PS256)와 서명 파트 변화를 JWT 문자열로 확인") + void testJwtVisualComparison() { + String claims = "{\"sub\":\"test-client\",\"scope\":\"read\"}"; + RsaSigner rs256Signer = new RsaSigner(privateKey); + PssJwtAccessTokenConverter pssConverter = new PssJwtAccessTokenConverter(privateKey, publicKey); + + String rs256Jwt1 = JwtHelper.encode(claims, rs256Signer).getEncoded(); + String rs256Jwt2 = JwtHelper.encode(claims, rs256Signer).getEncoded(); + String ps256Jwt1 = pssConverter.buildJwt(claims); + String ps256Jwt2 = pssConverter.buildJwt(claims); + + System.out.println(); + System.out.println("━━━ JWT 토큰 시각 비교 ━━━"); + System.out.println(); + printJwt("RS256 JWT 1", rs256Jwt1); + printJwt("RS256 JWT 2", rs256Jwt2); + System.out.printf(" → 두 JWT 완전 일치: %b (헤더·페이로드·서명 모두 동일)%n", + rs256Jwt1.equals(rs256Jwt2)); + System.out.println(); + printJwt("PS256 JWT 1", ps256Jwt1); + printJwt("PS256 JWT 2", ps256Jwt2); + System.out.printf(" → 두 JWT 완전 일치: %b (헤더·페이로드 동일, 서명만 다름)%n", + ps256Jwt1.equals(ps256Jwt2)); + System.out.println(); + System.out.println(" [서명 파트만 비교]"); + System.out.printf(" RS256 서명1: %s%n", signaturePart(rs256Jwt1)); + System.out.printf(" RS256 서명2: %s%n", signaturePart(rs256Jwt2)); + System.out.printf(" PS256 서명1: %s%n", signaturePart(ps256Jwt1)); + System.out.printf(" PS256 서명2: %s%n", signaturePart(ps256Jwt2)); + + assertEquals(rs256Jwt1, rs256Jwt2, "RS256 JWT는 동일해야 한다"); + assertNotEquals(ps256Jwt1, ps256Jwt2, "PS256 JWT는 서명 파트가 달라야 한다"); + } + + private static void printJwt(String label, String jwt) { + String[] parts = jwt.split("\\."); + String header = new String(Base64.getUrlDecoder().decode(padBase64Url(parts[0]))); + String payload = new String(Base64.getUrlDecoder().decode(padBase64Url(parts[1]))); + System.out.printf("[%s]%n", label); + System.out.printf(" 전체 : %s%n", jwt); + System.out.printf(" 헤더 : %s%n", header); // {"alg":"RS256","typ":"JWT"} 또는 PS256 + System.out.printf(" 페이로드: %s%n", payload); + System.out.printf(" 서명 : %s%n", parts[2]); + System.out.println(); + } + + private static String signaturePart(String jwt) { + return jwt.split("\\.")[2]; + } + + /** Base64URL 패딩 보정 (Java 표준 디코더는 패딩 필수) */ + private static String padBase64Url(String base64url) { + int mod = base64url.length() % 4; + if (mod == 2) return base64url + "=="; + if (mod == 3) return base64url + "="; + return base64url; + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < bytes.length; i++) { + if (i > 0 && i % 32 == 0) sb.append("\n "); + sb.append(String.format("%02X", bytes[i])); + } + return sb.toString(); + } +}