diff --git a/src/main/java/org/websoso/WSSServer/auth/jwt/JWTUtil.java b/src/main/java/org/websoso/WSSServer/auth/jwt/JWTUtil.java index f10ba77f6..41038ee09 100644 --- a/src/main/java/org/websoso/WSSServer/auth/jwt/JWTUtil.java +++ b/src/main/java/org/websoso/WSSServer/auth/jwt/JWTUtil.java @@ -44,19 +44,27 @@ public Long getUserIdFromToken(String token) { public JwtValidationType validateJWT(String token) { try { final Claims claims = getClaim(token); - if (TokenType.from(claims.getSubject()) == TokenType.ACCESS) { + final TokenType tokenType = TokenType.from(claims.getSubject()); + if (tokenType == TokenType.ACCESS) { return JwtValidationType.VALID_ACCESS; } - return JwtValidationType.VALID_REFRESH; + if (tokenType == TokenType.REFRESH) { + return JwtValidationType.VALID_REFRESH; + } + return JwtValidationType.UNSUPPORTED_SUBJECT; } catch (SignatureException ex) { return JwtValidationType.INVALID_SIGNATURE; } catch (MalformedJwtException ex) { return JwtValidationType.INVALID_TOKEN; } catch (ExpiredJwtException ex) { - if (TokenType.from(ex.getClaims().getSubject()) == TokenType.ACCESS) { + final TokenType tokenType = TokenType.from(ex.getClaims().getSubject()); + if (tokenType == TokenType.ACCESS) { return JwtValidationType.EXPIRED_ACCESS; } - return JwtValidationType.EXPIRED_REFRESH; + if (tokenType == TokenType.REFRESH) { + return JwtValidationType.EXPIRED_REFRESH; + } + return JwtValidationType.UNSUPPORTED_SUBJECT; } catch (UnsupportedJwtException ex) { return JwtValidationType.UNSUPPORTED_TOKEN; } catch (IllegalArgumentException ex) { diff --git a/src/main/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilter.java index a860675a6..c75029492 100644 --- a/src/main/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilter.java @@ -53,7 +53,7 @@ protected void doFilterInternal(@NonNull HttpServletRequest request, writeError(response, CustomAuthError.WRONG_TOKEN_TYPE); return; } - case INVALID_TOKEN, INVALID_SIGNATURE, UNSUPPORTED_TOKEN, EMPTY_TOKEN -> { + case INVALID_TOKEN, INVALID_SIGNATURE, UNSUPPORTED_TOKEN, UNSUPPORTED_SUBJECT, EMPTY_TOKEN -> { writeError(response, CustomAuthError.INVALID_TOKEN); return; } diff --git a/src/main/java/org/websoso/WSSServer/auth/jwt/JwtValidationType.java b/src/main/java/org/websoso/WSSServer/auth/jwt/JwtValidationType.java index dd15fb9c1..13f57d734 100644 --- a/src/main/java/org/websoso/WSSServer/auth/jwt/JwtValidationType.java +++ b/src/main/java/org/websoso/WSSServer/auth/jwt/JwtValidationType.java @@ -8,5 +8,6 @@ public enum JwtValidationType { EXPIRED_ACCESS, EXPIRED_REFRESH, UNSUPPORTED_TOKEN, - EMPTY_TOKEN + EMPTY_TOKEN, + UNSUPPORTED_SUBJECT } diff --git a/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java b/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java index a20dc32b9..355f55fd0 100644 --- a/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java +++ b/src/test/java/org/websoso/WSSServer/auth/jwt/JWTUtilTest.java @@ -45,6 +45,22 @@ void validateJWT_expiredRefreshToken_returnsExpiredRefresh() { assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.EXPIRED_REFRESH); } + @DisplayName("서명이 유효해도 subject가 access/refresh가 아니면 UNSUPPORTED_SUBJECT를 반환한다") + @Test + void validateJWT_unknownSubjectToken_returnsUnsupportedSubject() { + String token = testTokenFactory.createTokenWithSubject(USER_ID, "unknown"); + + assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.UNSUPPORTED_SUBJECT); + } + + @DisplayName("만료된 토큰이어도 subject가 access/refresh가 아니면 UNSUPPORTED_SUBJECT를 반환한다") + @Test + void validateJWT_expiredUnknownSubjectToken_returnsUnsupportedSubject() { + String token = testTokenFactory.createExpiredTokenWithSubject(USER_ID, "unknown"); + + assertThat(jwtUtil.validateJWT(token)).isEqualTo(JwtValidationType.UNSUPPORTED_SUBJECT); + } + @DisplayName("다른 시크릿으로 서명된 Access Token은 INVALID_SIGNATURE를 반환한다") @Test void validateJWT_wrongSignatureAccessToken_returnsInvalidSignature() { diff --git a/src/test/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilterTest.java b/src/test/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilterTest.java index f26ebcfdb..eda988096 100644 --- a/src/test/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilterTest.java +++ b/src/test/java/org/websoso/WSSServer/auth/jwt/JwtAuthenticationFilterTest.java @@ -97,6 +97,18 @@ void tamperedOrUnsupportedToken_returnsInvalidToken() throws Exception { } } + @DisplayName("지원하지 않는 subject 토큰이면 401과 AUTH-001을 응답하고 체인을 중단한다") + @Test + void unsupportedSubjectToken_returnsInvalidTokenAndStopsChain() throws Exception { + given(jwtUtil.validateJWT(TOKEN)).willReturn(JwtValidationType.UNSUPPORTED_SUBJECT); + + MockHttpServletResponse response = doFilter(bearerRequest(TOKEN)); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()).contains("AUTH-001"); + verify(filterChain, never()).doFilter(any(), any()); + } + @DisplayName("Authorization 헤더가 없으면 익명 인증으로 설정하고 필터 체인을 계속 진행한다") @Test void noToken_setsAnonymousAuthenticationAndContinues() throws Exception { diff --git a/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java b/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java index 3fc100a7a..abb50ecfc 100644 --- a/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java +++ b/src/test/java/org/websoso/WSSServer/auth/jwt/TestTokenFactory.java @@ -1,5 +1,10 @@ package org.websoso.WSSServer.auth.jwt; +import io.jsonwebtoken.Header; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; + public class TestTokenFactory { public static final String TEST_SECRET = "test-only-jwt-secret-never-used-in-production-0123456789"; @@ -7,6 +12,7 @@ public class TestTokenFactory { public static final long ACCESS_TOKEN_EXPIRATION = 3_600_000L; public static final long REFRESH_TOKEN_EXPIRATION = 1_209_600_000L; + private final JwtKeyProvider jwtKeyProvider; private final JwtProvider jwtProvider; public TestTokenFactory() { @@ -14,7 +20,8 @@ public TestTokenFactory() { } public TestTokenFactory(String secret) { - this.jwtProvider = new JwtProvider(new JwtKeyProvider(secret), ACCESS_TOKEN_EXPIRATION, REFRESH_TOKEN_EXPIRATION); + this.jwtKeyProvider = new JwtKeyProvider(secret); + this.jwtProvider = new JwtProvider(jwtKeyProvider, ACCESS_TOKEN_EXPIRATION, REFRESH_TOKEN_EXPIRATION); } public String createAccessToken(Long userId) { @@ -40,4 +47,24 @@ public String createAccessTokenWithInvalidSignature(Long userId) { public String createRefreshTokenWithInvalidSignature(Long userId) { return new TestTokenFactory(OTHER_SECRET).createRefreshToken(userId); } + + public String createTokenWithSubject(Long userId, String subject) { + return signTokenWithSubject(userId, subject, ACCESS_TOKEN_EXPIRATION); + } + + public String createExpiredTokenWithSubject(Long userId, String subject) { + return signTokenWithSubject(userId, subject, -1_000L); + } + + private String signTokenWithSubject(Long userId, String subject, long expirationTime) { + long now = System.currentTimeMillis(); + return Jwts.builder() + .setHeaderParam(Header.TYPE, Header.JWT_TYPE) + .setSubject(subject) + .setIssuedAt(new Date(now)) + .setExpiration(new Date(now + expirationTime)) + .claim(JwtProvider.CLAIM_USER_ID, userId) + .signWith(jwtKeyProvider.getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } }