From f64b81c0125c4c13260bf3117c08b86a7548b687 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Fri, 24 Jul 2026 17:45:03 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9D=B8=EC=A6=9D=20=EC=84=B8=EC=85=98?= =?UTF-8?q?=20=ED=9A=8C=EC=A0=84=EA=B3=BC=20=EC=9D=B5=EB=AA=85=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B3=B4=ED=98=B8=20=EA=B0=95=ED=99=94=20?= =?UTF-8?q?-=20/api/auth/me=EA=B0=80=20=EC=9C=A0=ED=9A=A8=20AT,=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=BF=A0=ED=82=A4=20=EC=97=86=EC=9D=8C,?= =?UTF-8?q?=20refresh=20=ED=95=84=EC=9A=94=20=EC=83=81=ED=83=9C=EB=A5=BC?= =?UTF-8?q?=20=EB=AA=85=ED=99=95=ED=9E=88=20=EA=B5=AC=EB=B6=84=20-=20Redis?= =?UTF-8?q?=20Lua=EB=A1=9C=20RT=20=EC=86=8C=EB=B9=84=EC=99=80=20rotation?= =?UTF-8?q?=EC=9D=84=20=EC=9B=90=EC=9E=90=ED=99=94=20-=205=EC=B4=88=20?= =?UTF-8?q?=EC=9D=B4=EB=82=B4=20=EC=A4=91=EB=B3=B5=20refresh=EC=97=90=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=20RT=EC=99=80=20=EC=9C=A0=ED=9A=A8=20AT?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=AC=EC=A0=84=EB=8B=AC=20-=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=9D=91=EB=8B=B5=EC=97=90=EC=84=9C=20Redis?= =?UTF-8?q?=EC=99=80=20RT=20=EC=BF=A0=ED=82=A4=EC=9D=98=20=EB=82=A8?= =?UTF-8?q?=EC=9D=80=20TTL=EC=9D=84=20=EB=B3=B4=EC=A1=B4=20-=20grace=20?= =?UTF-8?q?=EC=9D=B4=ED=9B=84=20RT=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EC=8B=9C=20=ED=95=B4=EB=8B=B9=20=EA=B8=B0=EA=B8=B0=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A0=84=EC=B2=B4=20=ED=8F=90=EA=B8=B0=20-=20?= =?UTF-8?q?=EA=B5=AC=EB=B2=84=EC=A0=84=EC=97=90=EC=84=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EB=90=9C=20=EB=8F=99=EC=9D=BC=20sessionId=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20RT=EB=A5=BC=20=EB=8B=A8=EC=9D=BC=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EB=A1=9C=20=EC=88=98=EB=A0=B4=20-=20terminal=20refres?= =?UTF-8?q?h=20=EC=98=A4=EB=A5=98=EC=97=90=EC=84=9C=20=EB=8F=99=EC=9D=BC?= =?UTF-8?q?=20scope=EC=9D=98=20AT=C2=B7RT=20=EC=82=AD=EC=A0=9C=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EB=B0=9C=EA=B8=89=20-=20RT=20=EB=B0=9C=EA=B8=89/?= =?UTF-8?q?=ED=9A=8C=EC=A0=84/=EC=9E=AC=EC=8B=9C=EB=8F=84/=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9/=ED=8F=90=EA=B8=B0=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80=20-=20=EC=9D=B5?= =?UTF-8?q?=EB=AA=85=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EC=A0=9C=EC=B6=9C?= =?UTF-8?q?=EC=9D=84=20=ED=97=88=EC=9A=A9=ED=95=98=EB=A9=B4=EC=84=9C=20Red?= =?UTF-8?q?is=20=EA=B8=B0=EB=B0=98=20rate=20limit=20=EC=A0=81=EC=9A=A9=20-?= =?UTF-8?q?=20=EC=8B=A0=EB=A2=B0=20=ED=94=84=EB=A1=9D=EC=8B=9C=20=EC=B2=B4?= =?UTF-8?q?=EC=9D=B8=EC=97=90=EC=84=9C=EB=A7=8C=20=EC=8B=A4=EC=A0=9C=20IP?= =?UTF-8?q?=EB=A5=BC=20=ED=95=B4=EC=84=9D=ED=95=98=EA=B3=A0=20HMAC=20finge?= =?UTF-8?q?rprint=20=EC=82=AC=EC=9A=A9=20-=20=EC=9D=B8=EC=A6=9D/=EC=BF=A0?= =?UTF-8?q?=ED=82=A4/rotation/=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=20=EA=B3=84=EC=95=BD=20=ED=9A=8C=EA=B7=80=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../nalssilog/auth/api/AuthController.java | 33 +- .../auth/application/AuthSessionService.java | 12 +- .../auth/application/AuthTokenService.java | 189 ++++++++++-- .../nalssilog/auth/application/TokenPair.java | 8 +- .../auth/config/AuthCookieManager.java | 10 +- .../nalssilog/auth/config/AuthProperties.java | 18 +- .../config/OAuth2LoginSuccessHandler.java | 3 +- .../nalssilog/auth/domain/AuthErrorCode.java | 3 + .../auth/repository/RefreshTokenStore.java | 284 +++++++++++++++++- .../auth/api/AuthControllerTest.java | 190 ++++++++++++ .../application/AuthTokenServiceTest.java | 227 ++++++++++++++ .../application/JwtTokenProviderTest.java | 3 +- .../repository/RefreshTokenStoreTest.java | 59 ++++ config | 2 +- member/build.gradle | 1 + .../member/api/FeedbackController.java | 6 +- .../application/FeedbackRateLimiter.java | 90 ++++++ .../member/application/FeedbackService.java | 4 +- .../config/FeedbackRateLimitProperties.java | 31 ++ .../config/TrustedProxyClientIpResolver.java | 104 +++++++ .../member/domain/MemberErrorCode.java | 4 +- .../application/FeedbackRateLimiterTest.java | 77 +++++ .../application/FeedbackServiceTest.java | 36 +++ .../TrustedProxyClientIpResolverTest.java | 46 +++ 24 files changed, 1384 insertions(+), 56 deletions(-) create mode 100644 auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java create mode 100644 member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java create mode 100644 member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java create mode 100644 member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java create mode 100644 member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java create mode 100644 member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java create mode 100644 member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java diff --git a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java b/auth/src/main/java/com/nalssilog/auth/api/AuthController.java index 4d94385..d77e714 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java +++ b/auth/src/main/java/com/nalssilog/auth/api/AuthController.java @@ -75,6 +75,13 @@ public MeResponse me(@AuthenticationPrincipal Long memberId, HttpServletRequest return MeResponse.linkRequired(link.get().provider(), link.get().email(), link.get().existingProviders()); } + // AT 쿠키는 JWT와 같은 시점에 브라우저에서 사라질 수 있다. RT가 남아 있으면 로그아웃(NONE)이 아니라 + // refresh가 필요한 상태이므로 401로 명확히 알린다. NONE은 인증 쿠키가 정말 하나도 없을 때만 반환한다. + if (cookieManager.readAccessToken(request).isPresent() + || cookieManager.readRefreshToken(request).isPresent()) { + throw new NalssiLogException(AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + } + return MeResponse.none(); } @@ -92,7 +99,8 @@ public MeResponse signup(@Valid @RequestBody SignupRequest request, request.agreedTerms()); TokenPair tokens = authTokenService.issue( member.id(), member.status(), ticket.provider(), deviceInfoResolver.resolve(httpRequest)); - cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken()); + cookieManager.addAuthCookies( + response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); ticketStore.deleteSignup(ticketId); cookieManager.clearSignupTicketCookie(response); @@ -101,11 +109,19 @@ public MeResponse signup(@Valid @RequestBody SignupRequest request, @PostMapping("/refresh") public void refresh(HttpServletRequest request, HttpServletResponse response) { - String refreshToken = cookieManager.readRefreshToken(request) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); - TokenPair tokens = authTokenService.refresh(refreshToken, deviceInfoResolver.resolve(request)); - - cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken()); + try { + String refreshToken = cookieManager.readRefreshToken(request) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); + TokenPair tokens = authTokenService.refresh(refreshToken, deviceInfoResolver.resolve(request)); + + cookieManager.addAuthCookies( + response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); + } catch (NalssiLogException exception) { + if (isTerminalRefreshError(exception)) { + cookieManager.clearAuthCookies(response); + } + throw exception; + } } @PostMapping("/logout") @@ -214,4 +230,9 @@ private void validateProvider(String provider) { throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); } } + + private boolean isTerminalRefreshError(NalssiLogException exception) { + return exception.getErrorCode() == AuthErrorCode.AUTH_SESSION_EXPIRED + || exception.getErrorCode() == AuthErrorCode.AUTH_REFRESH_REUSED; + } } diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java b/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java index df60fb8..c4b8c63 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java +++ b/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java @@ -2,6 +2,7 @@ import com.nalssilog.auth.application.dto.SessionData; import com.nalssilog.auth.application.dto.SessionView; +import com.nalssilog.auth.config.AuthProperties; import com.nalssilog.auth.domain.AuthErrorCode; import com.nalssilog.auth.repository.RefreshTokenStore; import com.nalssilog.common.exception.NalssiLogException; @@ -19,6 +20,7 @@ public class AuthSessionService { private final RefreshTokenStore refreshTokenStore; + private final AuthProperties properties; public List listSessions(Long memberId, String currentTokenHash) { return refreshTokenStore.findSessionsByMember(memberId).stream() @@ -37,13 +39,17 @@ public List listSessions(Long memberId, String currentTokenHash) { * 특정 세션(기기) 로그아웃. 대상이 현재 세션이면 true 를 반환해 컨트롤러가 쿠키까지 정리하게 한다. */ public boolean revokeSession(Long memberId, String sessionId, String currentTokenHash) { - SessionData target = refreshTokenStore.findSessionsByMember(memberId).stream() + List sessions = refreshTokenStore.findSessionsByMember(memberId); + SessionData target = sessions.stream() .filter(session -> session.sessionId().equals(sessionId)) .findFirst() .orElseThrow(() -> new NalssiLogException(AuthErrorCode.SESSION_NOT_FOUND)); - refreshTokenStore.delete(target.tokenHash()); + boolean current = currentTokenHash != null && sessions.stream() + .anyMatch(session -> session.sessionId().equals(sessionId) + && session.tokenHash().equals(currentTokenHash)); + refreshTokenStore.revokeSession(memberId, target.sessionId(), properties.jwt().refreshTokenTtl()); - return target.tokenHash().equals(currentTokenHash); + return current; } } diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java b/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java index f7bf7dc..a63e346 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java +++ b/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java @@ -6,6 +6,8 @@ import com.nalssilog.auth.config.AuthProperties; import com.nalssilog.auth.domain.AuthErrorCode; import com.nalssilog.auth.repository.RefreshTokenStore; +import com.nalssilog.auth.repository.RefreshTokenStore.RotationResult; +import com.nalssilog.auth.repository.RefreshTokenStore.RotationStatus; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.MemberStatus; @@ -14,18 +16,23 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; +import java.time.Duration; import java.time.Instant; import java.util.Base64; import java.util.HexFormat; +import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +@Slf4j @Service @RequiredArgsConstructor public class AuthTokenService { private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int FINGERPRINT_LENGTH = 12; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenStore refreshTokenStore; @@ -34,29 +41,63 @@ public class AuthTokenService { /** 새 로그인 세션 발급(새 sessionId + 기기 정보 저장). */ public TokenPair issue(Long memberId, MemberStatus status, Provider provider, DeviceInfo device) { - return issueWithSession( - memberId, status, provider, UUID.randomUUID().toString(), Instant.now(), device); + String sessionId = UUID.randomUUID().toString(); + TokenPair tokens = issueWithSession(memberId, status, provider, sessionId, Instant.now(), device); + log.info("auth.refresh.issued memberId={} sessionId={} provider={}", memberId, sessionId, provider); + return tokens; } - /** rotation: 토큰만 교체하고 기기 정체성(sessionId·loginAt·deviceName)은 유지, lastActiveAt·ip 갱신. */ + /** + * RT rotation. Redis가 기존 RT 소비와 새 RT 저장을 원자적으로 처리한다. + * retry grace 안의 중복 요청은 최초 요청과 같은 새 RT로 수렴하고, grace 이후 재사용은 해당 기기 세션을 폐기한다. + */ public TokenPair refresh(String refreshToken, DeviceInfo device) { String currentHash = hash(refreshToken); - SessionData current = refreshTokenStore.findSession(currentHash) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); - - MemberInfo member = memberClient.findMemberInfo(current.memberId()) - .orElseThrow(() -> new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); - - refreshTokenStore.delete(currentHash); - - DeviceInfo preserved = new DeviceInfo(current.deviceName(), device.ip()); + Optional current = refreshTokenStore.findSession(currentHash); + if (current.isEmpty()) { + return resolveInactiveRefresh(currentHash); + } - return issueWithSession( - member.id(), member.status(), current.provider(), current.sessionId(), current.loginAt(), preserved); + SessionData currentSession = current.get(); + MemberInfo member = activeMember(currentSession.memberId(), currentSession.sessionId(), currentHash); + String replacementToken = generateRefreshToken(); + String replacementHash = hash(replacementToken); + DeviceInfo preserved = new DeviceInfo(currentSession.deviceName(), device.ip()); + SessionData replacement = sessionData( + replacementHash, + member.id(), + currentSession.provider(), + currentSession.sessionId(), + currentSession.loginAt(), + preserved); + + RotationResult result = refreshTokenStore.rotate( + currentHash, + replacementToken, + replacement, + properties.jwt().refreshTokenTtl(), + properties.refresh().retryGrace()); + + return switch (result.status()) { + case ROTATED -> { + log.info("auth.refresh.rotated memberId={} sessionId={} token={}", + member.id(), currentSession.sessionId(), fingerprint(currentHash)); + yield tokenPair( + member, currentSession.provider(), replacementToken, properties.jwt().refreshTokenTtl()); + } + case RETRIED -> retry(result, currentHash); + case REUSED -> rejectReuse(result, currentHash); + case REVOKED, MISSING -> rejectExpired(currentHash, result.status().name()); + }; } public void revoke(String refreshToken) { - refreshTokenStore.delete(hash(refreshToken)); + String tokenHash = hash(refreshToken); + refreshTokenStore.revokeByTokenHash(tokenHash, properties.jwt().refreshTokenTtl()) + .ifPresentOrElse( + session -> log.info("auth.refresh.revoked memberId={} sessionId={} reason=logout token={}", + session.memberId(), session.sessionId(), fingerprint(tokenHash)), + () -> log.info("auth.refresh.revoke_skipped reason=not_found token={}", fingerprint(tokenHash))); } /** refresh 토큰의 세션 키(해시). '현재 세션' 판별용. */ @@ -66,33 +107,137 @@ public String tokenHash(String refreshToken) { /** 해당 회원의 모든 refresh 세션 강제 만료(전 기기). 탈퇴 시 사용. */ public void revokeAllSessions(Long memberId) { - refreshTokenStore.deleteAllByMember(memberId); + long revoked = refreshTokenStore.deleteAllByMember(memberId, properties.jwt().refreshTokenTtl()); + log.info("auth.refresh.revoked_all memberId={} sessions={} reason=withdrawal", memberId, revoked); + } + + private TokenPair resolveInactiveRefresh(String currentHash) { + RefreshTokenStore.UsedToken used = refreshTokenStore.findUsedToken(currentHash).orElse(null); + if (used == null) { + return rejectExpired(currentHash, "not_found_or_expired"); + } + if (refreshTokenStore.isSessionRevoked(used.sessionId())) { + return rejectExpired(currentHash, "session_revoked"); + } + + // retry key의 실제 존재 여부와 replacement 활성 여부는 원자 rotate 스크립트가 최종 판정한다. + SessionData replacement = refreshTokenStore.findSession(used.replacementHash()).orElse(null); + if (replacement == null) { + return rejectReuse( + new RotationResult(RotationStatus.REUSED, "", used.replacementHash(), + used.memberId(), used.sessionId(), 0), + currentHash); + } + + // 후보 세션으로 rotate를 다시 호출하면 old key가 없으므로 Lua가 retry key 유무에 따라 RETRIED/REUSED를 판정한다. + RotationResult result = refreshTokenStore.rotate( + currentHash, + "", + replacement, + properties.jwt().refreshTokenTtl(), + properties.refresh().retryGrace()); + + return switch (result.status()) { + case RETRIED -> retry(result, currentHash); + case REUSED -> rejectReuse(result, currentHash); + case REVOKED, MISSING -> rejectExpired(currentHash, result.status().name()); + case ROTATED -> throw new IllegalStateException("inactive refresh token was unexpectedly rotated"); + }; + } + + private TokenPair retry(RotationResult result, String currentHash) { + SessionData replacement = refreshTokenStore.findSession(result.replacementHash()) + .orElseThrow(() -> expired(currentHash, "replacement_missing")); + MemberInfo member = activeMember(replacement.memberId(), replacement.sessionId(), currentHash); + if (result.refreshTokenTtlMillis() < 1_000) { + throw expired(currentHash, "replacement_expiring"); + } + + log.info("auth.refresh.retry_replayed memberId={} sessionId={} token={}", + member.id(), replacement.sessionId(), fingerprint(currentHash)); + return tokenPair( + member, + replacement.provider(), + result.replacementToken(), + Duration.ofMillis(result.refreshTokenTtlMillis())); + } + + private TokenPair rejectReuse(RotationResult result, String currentHash) { + if (result.memberId() != null && result.sessionId() != null && !result.sessionId().isBlank()) { + long revoked = refreshTokenStore.revokeSession( + result.memberId(), result.sessionId(), properties.jwt().refreshTokenTtl()); + log.warn("auth.refresh.reuse_detected memberId={} sessionId={} revokedTokens={} token={}", + result.memberId(), result.sessionId(), revoked, fingerprint(currentHash)); + } else { + log.warn("auth.refresh.reuse_detected memberId=unknown sessionId=unknown token={}", + fingerprint(currentHash)); + } + + throw new NalssiLogException(AuthErrorCode.AUTH_REFRESH_REUSED); + } + + private TokenPair rejectExpired(String currentHash, String reason) { + throw expired(currentHash, reason); + } + + private NalssiLogException expired(String currentHash, String reason) { + log.info("auth.refresh.rejected reason={} token={}", reason, fingerprint(currentHash)); + return new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED); + } + + private MemberInfo activeMember(Long memberId, String sessionId, String tokenHash) { + return memberClient.findMemberInfo(memberId).orElseThrow(() -> { + refreshTokenStore.revokeSession(memberId, sessionId, properties.jwt().refreshTokenTtl()); + return expired(tokenHash, "member_missing"); + }); } private TokenPair issueWithSession(Long memberId, MemberStatus status, Provider provider, String sessionId, Instant loginAt, DeviceInfo device) { - String accessToken = jwtTokenProvider.createAccessToken(memberId, status, provider); String refreshToken = generateRefreshToken(); String tokenHash = hash(refreshToken); - SessionData session = new SessionData( - tokenHash, sessionId, memberId, provider, device.deviceName(), device.ip(), loginAt, Instant.now()); + SessionData session = sessionData(tokenHash, memberId, provider, sessionId, loginAt, device); refreshTokenStore.save(tokenHash, session, properties.jwt().refreshTokenTtl()); + return new TokenPair( + jwtTokenProvider.createAccessToken(memberId, status, provider), + refreshToken, + properties.jwt().refreshTokenTtl()); + } + + private SessionData sessionData(String tokenHash, Long memberId, Provider provider, String sessionId, + Instant loginAt, DeviceInfo device) { + return new SessionData( + tokenHash, + sessionId, + memberId, + provider, + device.deviceName(), + device.ip(), + loginAt, + Instant.now()); + } - return new TokenPair(accessToken, refreshToken); + private TokenPair tokenPair(MemberInfo member, Provider provider, String refreshToken, Duration maxAge) { + return new TokenPair( + jwtTokenProvider.createAccessToken(member.id(), member.status(), provider), + refreshToken, + maxAge); } private String generateRefreshToken() { byte[] bytes = new byte[32]; SECURE_RANDOM.nextBytes(bytes); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } + private String fingerprint(String tokenHash) { + return tokenHash.substring(0, Math.min(FINGERPRINT_LENGTH, tokenHash.length())); + } + private String hash(String token) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(token.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 not available", e); diff --git a/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java b/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java index 803bb28..86fd45a 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java +++ b/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java @@ -1,4 +1,10 @@ package com.nalssilog.auth.application; -public record TokenPair(String accessToken, String refreshToken) { +import java.time.Duration; + +public record TokenPair(String accessToken, String refreshToken, Duration refreshTokenMaxAge) { + + public TokenPair(String accessToken, String refreshToken) { + this(accessToken, refreshToken, null); + } } diff --git a/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java b/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java index 73c0e0e..53c66ae 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java +++ b/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java @@ -28,8 +28,16 @@ public void addAccessTokenCookie(HttpServletResponse response, String accessToke } public void addAuthCookies(HttpServletResponse response, String accessToken, String refreshToken) { + addAuthCookies(response, accessToken, refreshToken, properties.jwt().refreshTokenTtl()); + } + + public void addAuthCookies(HttpServletResponse response, String accessToken, String refreshToken, + Duration refreshTokenMaxAge) { addAccessTokenCookie(response, accessToken); - addCookie(response, REFRESH_TOKEN_COOKIE, refreshToken, properties.jwt().refreshTokenTtl()); + Duration maxAge = refreshTokenMaxAge == null + ? properties.jwt().refreshTokenTtl() + : refreshTokenMaxAge; + addCookie(response, REFRESH_TOKEN_COOKIE, refreshToken, maxAge); } public void clearAuthCookies(HttpServletResponse response) { diff --git a/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java b/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java index f090575..3a60eaa 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java +++ b/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java @@ -4,7 +4,13 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "nalssilog.auth") -public record AuthProperties(Jwt jwt, Cookie cookie, Ticket ticket, Csrf csrf) { +public record AuthProperties(Jwt jwt, Cookie cookie, Ticket ticket, Csrf csrf, Refresh refresh) { + + public AuthProperties { + if (refresh == null) { + refresh = new Refresh(Duration.ofSeconds(5)); + } + } public record Jwt(String secret, Duration accessTokenTtl, Duration refreshTokenTtl) { } @@ -19,4 +25,14 @@ public record Ticket(Duration ttl) { /** CSRF 쿠키는 프론트 JS 가 서브도메인 넘어 읽어야 해서 env 별 이름 분리 + Domain 지정. */ public record Csrf(String cookieName, String cookieDomain) { } + + /** refresh rotation 중 네트워크 재시도·동시 요청을 같은 결과로 수렴시키는 짧은 멱등 구간. */ + public record Refresh(Duration retryGrace) { + + public Refresh { + if (retryGrace == null || retryGrace.isNegative() || retryGrace.isZero()) { + retryGrace = Duration.ofSeconds(5); + } + } + } } diff --git a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java b/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java index 4ab439b..705a55b 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java @@ -164,7 +164,8 @@ private void loginAndRedirect(HttpServletRequest request, HttpServletResponse re MemberStatus status, Provider provider, String result) throws IOException { TokenPair tokens = authTokenService.issue( memberId, status, provider, deviceInfoResolver.resolve(request)); - cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken()); + cookieManager.addAuthCookies( + response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); response.sendRedirect(callbackUrl(result)); } diff --git a/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java b/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java index f6ada1e..ce43197 100644 --- a/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java +++ b/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java @@ -9,7 +9,10 @@ @RequiredArgsConstructor public enum AuthErrorCode implements ErrorCode { + AUTH_ACCESS_TOKEN_EXPIRED("AUTH_ACCESS_TOKEN_EXPIRED", "액세스 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), AUTH_SESSION_EXPIRED("AUTH_SESSION_EXPIRED", "인증 세션이 만료되었습니다. 다시 로그인해 주세요.", HttpStatus.UNAUTHORIZED), + AUTH_REFRESH_REUSED("AUTH_REFRESH_REUSED", "재사용된 인증 세션을 폐기했습니다. 다시 로그인해 주세요.", + HttpStatus.UNAUTHORIZED), TICKET_NOT_FOUND("AUTH_SESSION_EXPIRED", "인증 세션이 만료되었습니다. 다시 로그인해 주세요.", HttpStatus.BAD_REQUEST), UNSUPPORTED_PROVIDER("OAUTH_FAILED", "지원하지 않는 소셜 로그인입니다.", HttpStatus.BAD_REQUEST), OAUTH_FAILED("OAUTH_FAILED", "소셜 로그인에 실패했습니다.", HttpStatus.UNAUTHORIZED), diff --git a/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java b/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java index 2e4b054..0aac44c 100644 --- a/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java +++ b/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java @@ -5,6 +5,7 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Optional; @@ -12,11 +13,15 @@ import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.stereotype.Repository; /** - * refresh 세션 저장소 (Redis). key=refresh 토큰 SHA-256 해시, value=세션 메타데이터 Hash. TTL 자동만료. - * memberId → 세션 해시 set 역인덱스로 (a)탈퇴 시 전 기기 만료 (b)로그인 기기 목록을 지원. + * refresh 세션 저장소 (Redis). + * + *

활성 RT는 SHA-256 해시만 저장한다. rotation 시 Lua 스크립트가 기존 키 소비·새 키 생성·역인덱스 교체· + * 사용 완료 tombstone 생성을 한 번에 수행한다. 같은 RT가 짧은 retry grace 안에 다시 오면 별도 5초 키에만 + * 보관한 첫 응답의 새 RT를 재전달하고, grace 이후 재사용은 sessionId 단위로 전부 폐기할 수 있게 식별정보만 남긴다. */ @Repository @RequiredArgsConstructor @@ -24,6 +29,9 @@ public class RefreshTokenStore { private static final String KEY_PREFIX = "auth:refresh:"; private static final String MEMBER_SESSIONS_PREFIX = "auth:member-sessions:"; + private static final String USED_PREFIX = "auth:refresh-used:"; + private static final String RETRY_PREFIX = "auth:refresh-retry:"; + private static final String REVOKED_SESSION_PREFIX = "auth:session-revoked:"; private static final String FIELD_MEMBER_ID = "memberId"; private static final String FIELD_SESSION_ID = "sessionId"; @@ -32,6 +40,107 @@ public class RefreshTokenStore { private static final String FIELD_IP = "ip"; private static final String FIELD_LOGIN_AT = "loginAt"; private static final String FIELD_LAST_ACTIVE_AT = "lastActiveAt"; + private static final String FIELD_REPLACEMENT_HASH = "replacementHash"; + private static final String FIELD_USED_AT = "usedAt"; + + @SuppressWarnings("rawtypes") + private static final DefaultRedisScript ROTATE_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('EXISTS', KEYS[6]) == 1 then + return {'REVOKED', '', '', '', ARGV[6], '0'} + end + + if redis.call('EXISTS', KEYS[1]) == 1 then + local actualMemberId = redis.call('HGET', KEYS[1], 'memberId') + local actualSessionId = redis.call('HGET', KEYS[1], 'sessionId') + if actualMemberId ~= ARGV[7] or actualSessionId ~= ARGV[6] then + return {'MISSING', '', '', '', ''} + end + + redis.call('DEL', KEYS[1]) + redis.call('SREM', KEYS[3], ARGV[1]) + + redis.call('HSET', KEYS[2], + 'memberId', ARGV[7], + 'sessionId', ARGV[6], + 'provider', ARGV[8], + 'deviceName', ARGV[9], + 'ip', ARGV[10], + 'loginAt', ARGV[11], + 'lastActiveAt', ARGV[12]) + redis.call('PEXPIRE', KEYS[2], ARGV[4]) + redis.call('SADD', KEYS[3], ARGV[2]) + redis.call('PEXPIRE', KEYS[3], ARGV[4]) + + redis.call('HSET', KEYS[4], + 'memberId', ARGV[7], + 'sessionId', ARGV[6], + 'replacementHash', ARGV[2], + 'usedAt', ARGV[12]) + redis.call('PEXPIRE', KEYS[4], ARGV[4]) + redis.call('SET', KEYS[5], ARGV[3], 'PX', ARGV[5]) + + -- 구버전의 비원자 rotation으로 같은 sessionId에 활성 해시가 여러 개 남았어도 + -- 이번 rotation 결과 하나로 수렴시킨다. + local indexedHashes = redis.call('SMEMBERS', KEYS[3]) + for _, indexedHash in ipairs(indexedHashes) do + if indexedHash ~= ARGV[2] then + local indexedKey = 'auth:refresh:' .. indexedHash + if redis.call('HGET', indexedKey, 'sessionId') == ARGV[6] then + local indexedUsedKey = 'auth:refresh-used:' .. indexedHash + local indexedRetryKey = 'auth:refresh-retry:' .. indexedHash + redis.call('DEL', indexedKey) + redis.call('SREM', KEYS[3], indexedHash) + redis.call('HSET', indexedUsedKey, + 'memberId', ARGV[7], + 'sessionId', ARGV[6], + 'replacementHash', ARGV[2], + 'usedAt', ARGV[12]) + redis.call('PEXPIRE', indexedUsedKey, ARGV[4]) + redis.call('SET', indexedRetryKey, ARGV[3], 'PX', ARGV[5]) + end + end + end + + return {'ROTATED', ARGV[3], ARGV[2], ARGV[7], ARGV[6], ARGV[4]} + end + + if redis.call('EXISTS', KEYS[4]) == 1 then + local memberId = redis.call('HGET', KEYS[4], 'memberId') + local sessionId = redis.call('HGET', KEYS[4], 'sessionId') + local replacementHash = redis.call('HGET', KEYS[4], 'replacementHash') + local revokedKey = 'auth:session-revoked:' .. sessionId + local replacementKey = 'auth:refresh:' .. replacementHash + + if redis.call('EXISTS', revokedKey) == 1 then + return {'REVOKED', '', replacementHash, memberId, sessionId, '0'} + end + + local replacementToken = redis.call('GET', KEYS[5]) + if replacementToken and redis.call('EXISTS', replacementKey) == 1 then + local replacementTtl = redis.call('PTTL', replacementKey) + return {'RETRIED', replacementToken, replacementHash, memberId, sessionId, tostring(replacementTtl)} + end + + return {'REUSED', '', replacementHash, memberId, sessionId, '0'} + end + + return {'MISSING', '', '', '', '', '0'} + """, List.class); + + private static final DefaultRedisScript REVOKE_SESSION_SCRIPT = new DefaultRedisScript<>(""" + local hashes = redis.call('SMEMBERS', KEYS[1]) + local deleted = 0 + for _, hash in ipairs(hashes) do + local refreshKey = ARGV[3] .. hash + if redis.call('HGET', refreshKey, 'sessionId') == ARGV[1] then + redis.call('DEL', refreshKey) + redis.call('SREM', KEYS[1], hash) + deleted = deleted + 1 + end + end + redis.call('SET', KEYS[2], '1', 'PX', ARGV[2]) + return deleted + """, Long.class); private final StringRedisTemplate redisTemplate; @@ -54,10 +163,34 @@ public void save(String tokenHash, SessionData session, Duration ttl) { redisTemplate.expire(memberKey, ttl); } - public Optional findMemberId(String tokenHash) { - Object value = redisTemplate.opsForHash().get(key(tokenHash), FIELD_MEMBER_ID); + public RotationResult rotate(String currentHash, String replacementToken, SessionData replacement, + Duration ttl, Duration retryGrace) { + List keys = List.of( + key(currentHash), + key(replacement.tokenHash()), + memberKey(replacement.memberId()), + usedKey(currentHash), + retryKey(currentHash), + revokedSessionKey(replacement.sessionId())); + + @SuppressWarnings("unchecked") + List raw = redisTemplate.execute( + ROTATE_SCRIPT, + keys, + currentHash, + replacement.tokenHash(), + replacementToken, + String.valueOf(ttl.toMillis()), + String.valueOf(retryGrace.toMillis()), + replacement.sessionId(), + String.valueOf(replacement.memberId()), + replacement.provider().name(), + replacement.deviceName(), + replacement.ip(), + String.valueOf(replacement.loginAt().toEpochMilli()), + String.valueOf(replacement.lastActiveAt().toEpochMilli())); - return Optional.ofNullable(value).map(Object::toString).map(Long::valueOf); + return toRotationResult(raw); } public Optional findSession(String tokenHash) { @@ -75,21 +208,64 @@ public Optional findSession(String tokenHash) { } } - public void delete(String tokenHash) { - findMemberId(tokenHash).ifPresent(memberId -> - redisTemplate.opsForSet().remove(memberKey(memberId), tokenHash)); - redisTemplate.delete(key(tokenHash)); + public Optional findUsedToken(String tokenHash) { + Map raw = redisTemplate.opsForHash().entries(usedKey(tokenHash)); + if (raw.isEmpty()) { + return Optional.empty(); + } + + try { + return Optional.of(new UsedToken( + Long.valueOf(str(raw, FIELD_MEMBER_ID)), + str(raw, FIELD_SESSION_ID), + str(raw, FIELD_REPLACEMENT_HASH), + Instant.ofEpochMilli(Long.parseLong(str(raw, FIELD_USED_AT))))); + } catch (IllegalArgumentException _) { + return Optional.empty(); + } + } + + public boolean isSessionRevoked(String sessionId) { + return Boolean.TRUE.equals(redisTemplate.hasKey(revokedSessionKey(sessionId))); + } + + /** + * 활성 토큰 또는 이미 rotation 된 토큰으로 세션 전체를 폐기한다. + * 로그아웃 응답을 잃은 클라이언트가 직전 RT를 다시 보내도 새 RT가 살아나지 않게 used tombstone도 확인한다. + */ + public Optional revokeByTokenHash(String tokenHash, Duration markerTtl) { + Optional active = findSession(tokenHash) + .map(session -> new SessionRef(session.memberId(), session.sessionId())); + Optional target = active.or(() -> findUsedToken(tokenHash) + .map(used -> new SessionRef(used.memberId(), used.sessionId()))); + + target.ifPresent(session -> revokeSession(session.memberId(), session.sessionId(), markerTtl)); + return target; + } + + /** 같은 sessionId 아래 남아 있는 모든 활성 해시를 지우고 재발급 차단 marker를 남긴다. */ + public long revokeSession(Long memberId, String sessionId, Duration markerTtl) { + Long deleted = redisTemplate.execute( + REVOKE_SESSION_SCRIPT, + List.of(memberKey(memberId), revokedSessionKey(sessionId)), + sessionId, + String.valueOf(markerTtl.toMillis()), + KEY_PREFIX); + return deleted == null ? 0 : deleted; } /** 해당 회원의 모든 refresh 세션 만료(전 기기 로그아웃). 탈퇴 시 호출. */ - public void deleteAllByMember(Long memberId) { - String memberKey = memberKey(memberId); - Set hashes = redisTemplate.opsForSet().members(memberKey); + public long deleteAllByMember(Long memberId, Duration markerTtl) { + Set sessionIds = findSessionsByMember(memberId).stream() + .map(SessionData::sessionId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + long deleted = 0; - if (hashes != null && !hashes.isEmpty()) { - redisTemplate.delete(hashes.stream().map(this::key).collect(Collectors.toSet())); + for (String sessionId : sessionIds) { + deleted += revokeSession(memberId, sessionId, markerTtl); } - redisTemplate.delete(memberKey); + redisTemplate.delete(memberKey(memberId)); + return deleted; } /** 회원의 활성 세션 목록(만료된 해시는 제외). */ @@ -109,6 +285,27 @@ public List findSessionsByMember(Long memberId) { return sessions; } + private RotationResult toRotationResult(List raw) { + if (raw == null || raw.isEmpty()) { + return RotationResult.missing(); + } + + RotationStatus status; + try { + status = RotationStatus.valueOf(value(raw, 0)); + } catch (IllegalArgumentException _) { + return RotationResult.missing(); + } + + return new RotationResult( + status, + value(raw, 1), + value(raw, 2), + longValue(raw, 3), + value(raw, 4), + primitiveLongValue(raw, 5)); + } + private SessionData toSessionData(String tokenHash, Map raw) { return new SessionData( tokenHash, @@ -123,10 +320,26 @@ private SessionData toSessionData(String tokenHash, Map raw) { private String str(Map raw, String field) { Object value = raw.get(field); - return value == null ? "" : value.toString(); } + private String value(List raw, int index) { + if (index >= raw.size() || raw.get(index) == null) { + return ""; + } + return raw.get(index).toString(); + } + + private Long longValue(List raw, int index) { + String value = value(raw, index); + return value.isBlank() ? null : Long.valueOf(value); + } + + private long primitiveLongValue(List raw, int index) { + String value = value(raw, index); + return value.isBlank() ? 0 : Long.parseLong(value); + } + private String key(String tokenHash) { return KEY_PREFIX + tokenHash; } @@ -134,4 +347,43 @@ private String key(String tokenHash) { private String memberKey(Long memberId) { return MEMBER_SESSIONS_PREFIX + memberId; } + + private String usedKey(String tokenHash) { + return USED_PREFIX + tokenHash; + } + + private String retryKey(String tokenHash) { + return RETRY_PREFIX + tokenHash; + } + + private String revokedSessionKey(String sessionId) { + return REVOKED_SESSION_PREFIX + sessionId; + } + + public enum RotationStatus { + ROTATED, + RETRIED, + REUSED, + REVOKED, + MISSING + } + + public record RotationResult( + RotationStatus status, + String replacementToken, + String replacementHash, + Long memberId, + String sessionId, + long refreshTokenTtlMillis + ) { + private static RotationResult missing() { + return new RotationResult(RotationStatus.MISSING, "", "", null, "", 0); + } + } + + public record UsedToken(Long memberId, String sessionId, String replacementHash, Instant usedAt) { + } + + public record SessionRef(Long memberId, String sessionId) { + } } diff --git a/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java b/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java new file mode 100644 index 0000000..d9dc2d1 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java @@ -0,0 +1,190 @@ +package com.nalssilog.auth.api; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.api.dto.AuthResult; +import com.nalssilog.auth.api.dto.MeResponse; +import com.nalssilog.auth.application.AuthSessionService; +import com.nalssilog.auth.application.AuthTokenService; +import com.nalssilog.auth.application.TokenPair; +import com.nalssilog.auth.client.MemberClient; +import com.nalssilog.auth.config.AuthCookieManager; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.config.DeviceInfoResolver; +import com.nalssilog.auth.domain.AuthErrorCode; +import com.nalssilog.auth.repository.AuthTicketStore; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import jakarta.servlet.http.Cookie; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@SuppressWarnings("java:S5960") +class AuthControllerTest { + + private final MemberClient memberClient = mock(MemberClient.class); + private final AuthTokenService tokenService = mock(AuthTokenService.class); + private final AuthSessionService sessionService = mock(AuthSessionService.class); + private final AuthTicketStore ticketStore = mock(AuthTicketStore.class); + private final DeviceInfoResolver deviceInfoResolver = mock(DeviceInfoResolver.class); + private final AuthProperties properties = properties(); + private final AuthCookieManager cookieManager = new AuthCookieManager(properties); + + private AuthController controller; + + @BeforeEach + void setUp() { + controller = new AuthController( + memberClient, + tokenService, + sessionService, + cookieManager, + ticketStore, + properties, + deviceInfoResolver); + } + + @Test + void meReturnsNoneOnlyWhenNoAuthenticationCookieExists() { + MeResponse response = controller.me(null, new MockHttpServletRequest()); + + assertThat(response.authenticated()).isFalse(); + assertThat(response.result()).isEqualTo(AuthResult.NONE); + } + + @Test + void meReturnsSuccessBeforeInspectingCookiesWhenAccessTokenIsValid() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies( + new Cookie(AuthCookieManager.ACCESS_TOKEN_COOKIE, "valid-access"), + new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "valid-refresh")); + when(memberClient.getMemberInfo(1L)).thenReturn(member()); + + MeResponse response = controller.me(1L, request); + + assertThat(response.authenticated()).isTrue(); + assertThat(response.result()).isEqualTo(AuthResult.SUCCESS); + assertThat(response.user().id()).isEqualTo("1"); + } + + @Test + void meRequestsRefreshWhenAccessCookieCouldNotBeAuthenticated() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.ACCESS_TOKEN_COOKIE, "expired-access")); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> controller.me(null, request)); + + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + } + + @Test + void meRequestsRefreshWhenOnlyRefreshCookieRemains() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "refresh-token")); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> controller.me(null, request)); + + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + } + + @Test + void terminalRefreshErrorDeletesBothAuthenticationCookies() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "expired-token")); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(deviceInfoResolver.resolve(request)).thenReturn(null); + when(tokenService.refresh("expired-token", null)) + .thenThrow(new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED)); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> controller.refresh(request, response)); + + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_SESSION_EXPIRED); + assertThat(response.getHeaders(HttpHeaders.SET_COOKIE)) + .anySatisfy(header -> assertThat(header) + .contains("access_token=") + .contains("Path=/") + .contains("Max-Age=0") + .contains("Expires=Thu, 1 Jan 1970 00:00:00 GMT") + .contains("Secure") + .contains("HttpOnly") + .contains("SameSite=Lax") + .doesNotContain("Domain=")) + .anySatisfy(header -> assertThat(header) + .contains("refresh_token=") + .contains("Path=/") + .contains("Max-Age=0") + .contains("Expires=Thu, 1 Jan 1970 00:00:00 GMT") + .contains("Secure") + .contains("HttpOnly") + .contains("SameSite=Lax") + .doesNotContain("Domain=")); + } + + @Test + void refreshUsesServerRemainingTtlForHardenedHostOnlyCookie() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "current-token")); + MockHttpServletResponse response = new MockHttpServletResponse(); + when(deviceInfoResolver.resolve(request)).thenReturn(null); + when(tokenService.refresh("current-token", null)) + .thenReturn(new TokenPair("new-access", "new-refresh", Duration.ofDays(13))); + + controller.refresh(request, response); + + String refreshCookie = response.getHeaders(HttpHeaders.SET_COOKIE).stream() + .filter(header -> header.startsWith("refresh_token=")) + .findFirst() + .orElseThrow(); + assertThat(refreshCookie) + .contains("Max-Age=1123200") + .contains("Path=/") + .contains("Expires=") + .contains("Secure") + .contains("HttpOnly") + .contains("SameSite=Lax") + .doesNotContain("Domain="); + } + + private MemberInfo member() { + return new MemberInfo( + 1L, + "닉네임", + "이름", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.KAKAO)); + } + + private AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(true), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java b/auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java new file mode 100644 index 0000000..29bce94 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java @@ -0,0 +1,227 @@ +package com.nalssilog.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.application.dto.DeviceInfo; +import com.nalssilog.auth.application.dto.SessionData; +import com.nalssilog.auth.client.MemberClient; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.domain.AuthErrorCode; +import com.nalssilog.auth.repository.RefreshTokenStore; +import com.nalssilog.auth.repository.RefreshTokenStore.RotationResult; +import com.nalssilog.auth.repository.RefreshTokenStore.RotationStatus; +import com.nalssilog.auth.repository.RefreshTokenStore.UsedToken; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; +import java.time.Instant; +import java.util.HexFormat; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class AuthTokenServiceTest { + + private static final String CURRENT_TOKEN = "current-refresh-token"; + private static final String CURRENT_HASH = sha256(CURRENT_TOKEN); + private static final String SESSION_ID = "session-1"; + + private final JwtTokenProvider jwtTokenProvider = mock(JwtTokenProvider.class); + private final RefreshTokenStore refreshTokenStore = mock(RefreshTokenStore.class); + private final MemberClient memberClient = mock(MemberClient.class); + private final AuthProperties properties = properties(); + private final AuthTokenService service = new AuthTokenService( + jwtTokenProvider, refreshTokenStore, memberClient, properties); + + private SessionData current; + private MemberInfo member; + + @BeforeEach + void setUp() { + current = new SessionData( + CURRENT_HASH, + SESSION_ID, + 1L, + Provider.KAKAO, + "Chrome · Windows", + "127.0.0.1", + Instant.parse("2026-07-01T00:00:00Z"), + Instant.parse("2026-07-01T00:00:00Z")); + member = new MemberInfo( + 1L, + "닉네임", + "이름", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.KAKAO)); + when(jwtTokenProvider.createAccessToken(1L, MemberStatus.ACTIVE, Provider.KAKAO)) + .thenReturn("access-token"); + } + + @Test + void rotatesCurrentRefreshTokenThroughAtomicStoreOperation() { + when(refreshTokenStore.findSession(CURRENT_HASH)).thenReturn(Optional.of(current)); + when(memberClient.findMemberInfo(1L)).thenReturn(Optional.of(member)); + when(refreshTokenStore.rotate( + eq(CURRENT_HASH), + anyString(), + any(SessionData.class), + eq(Duration.ofDays(14)), + eq(Duration.ofSeconds(5)))) + .thenAnswer(invocation -> { + String token = invocation.getArgument(1); + SessionData replacement = invocation.getArgument(2); + return new RotationResult( + RotationStatus.ROTATED, + token, + replacement.tokenHash(), + 1L, + SESSION_ID, + Duration.ofDays(14).toMillis()); + }); + + TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1")); + + assertThat(tokens.accessToken()).isEqualTo("access-token"); + assertThat(tokens.refreshToken()).isNotBlank().isNotEqualTo(CURRENT_TOKEN); + assertThat(tokens.refreshTokenMaxAge()).isEqualTo(Duration.ofDays(14)); + verify(refreshTokenStore).rotate( + eq(CURRENT_HASH), + eq(tokens.refreshToken()), + any(SessionData.class), + eq(Duration.ofDays(14)), + eq(Duration.ofSeconds(5))); + verify(refreshTokenStore, never()).revokeSession(any(), anyString(), any()); + } + + @Test + void duplicateWithinGraceReplaysTheFirstReplacementToken() { + String replacementHash = sha256("replacement-token"); + SessionData replacement = new SessionData( + replacementHash, + SESSION_ID, + 1L, + Provider.KAKAO, + "Chrome · Windows", + "203.0.113.1", + current.loginAt(), + Instant.now()); + when(refreshTokenStore.findSession(CURRENT_HASH)).thenReturn(Optional.empty()); + when(refreshTokenStore.findUsedToken(CURRENT_HASH)) + .thenReturn(Optional.of(new UsedToken(1L, SESSION_ID, replacementHash, Instant.now()))); + when(refreshTokenStore.findSession(replacementHash)).thenReturn(Optional.of(replacement)); + when(refreshTokenStore.rotate( + eq(CURRENT_HASH), + eq(""), + eq(replacement), + eq(Duration.ofDays(14)), + eq(Duration.ofSeconds(5)))) + .thenReturn(new RotationResult( + RotationStatus.RETRIED, + "replacement-token", + replacementHash, + 1L, + SESSION_ID, + Duration.ofDays(13).toMillis())); + when(memberClient.findMemberInfo(1L)).thenReturn(Optional.of(member)); + + TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1")); + + assertThat(tokens.refreshToken()).isEqualTo("replacement-token"); + assertThat(tokens.accessToken()).isEqualTo("access-token"); + assertThat(tokens.refreshTokenMaxAge()).isEqualTo(Duration.ofDays(13)); + verify(refreshTokenStore, never()).revokeSession(any(), anyString(), any()); + } + + @Test + void reuseOutsideGraceRevokesTheWholeDeviceSession() { + String replacementHash = sha256("replacement-token"); + SessionData replacement = new SessionData( + replacementHash, + SESSION_ID, + 1L, + Provider.KAKAO, + "Chrome · Windows", + "203.0.113.1", + current.loginAt(), + Instant.now()); + when(refreshTokenStore.findSession(CURRENT_HASH)).thenReturn(Optional.empty()); + when(refreshTokenStore.findUsedToken(CURRENT_HASH)) + .thenReturn(Optional.of(new UsedToken(1L, SESSION_ID, replacementHash, Instant.now()))); + when(refreshTokenStore.findSession(replacementHash)).thenReturn(Optional.of(replacement)); + when(refreshTokenStore.rotate( + eq(CURRENT_HASH), + eq(""), + eq(replacement), + eq(Duration.ofDays(14)), + eq(Duration.ofSeconds(5)))) + .thenReturn(new RotationResult( + RotationStatus.REUSED, + "", + replacementHash, + 1L, + SESSION_ID, + 0)); + when(refreshTokenStore.revokeSession(1L, SESSION_ID, Duration.ofDays(14))).thenReturn(1L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1"))); + + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_REFRESH_REUSED); + verify(refreshTokenStore).revokeSession(1L, SESSION_ID, Duration.ofDays(14)); + } + + @Test + void unknownRefreshTokenIsRejectedWithoutRevokingUnrelatedSessions() { + when(refreshTokenStore.findSession(CURRENT_HASH)).thenReturn(Optional.empty()); + when(refreshTokenStore.findUsedToken(CURRENT_HASH)).thenReturn(Optional.empty()); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1"))); + + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_SESSION_EXPIRED); + verify(refreshTokenStore, never()).revokeSession(any(), anyString(), any()); + } + + private static AuthProperties properties() { + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + Duration.ofMinutes(30), + Duration.ofDays(14)), + new AuthProperties.Cookie(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5))); + } + + private static String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java b/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java index 2b566db..511b539 100644 --- a/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java +++ b/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java @@ -30,7 +30,8 @@ private AuthProperties properties() { Duration.ofDays(14)), new AuthProperties.Cookie(false), new AuthProperties.Ticket(Duration.ofMinutes(10)), - new AuthProperties.Csrf("XSRF-TOKEN", null) + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5)) ); } } diff --git a/auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java b/auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java new file mode 100644 index 0000000..c3aaaa8 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java @@ -0,0 +1,59 @@ +package com.nalssilog.auth.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.application.dto.SessionData; +import com.nalssilog.auth.repository.RefreshTokenStore.RotationStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +@SuppressWarnings({"java:S5960", "unchecked"}) +class RefreshTokenStoreTest { + + private final StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + private final RefreshTokenStore store = new RefreshTokenStore(redisTemplate); + + @Test + void mapsAtomicRotationScriptResultToDomainContract() { + when(redisTemplate.execute(any(RedisScript.class), anyList(), any(Object[].class))) + .thenReturn(List.of( + "ROTATED", + "replacement-token", + "replacement-hash", + "1", + "session-1", + String.valueOf(Duration.ofDays(14).toMillis()))); + SessionData replacement = new SessionData( + "replacement-hash", + "session-1", + 1L, + Provider.KAKAO, + "Chrome · Windows", + "203.0.113.1", + Instant.parse("2026-07-01T00:00:00Z"), + Instant.parse("2026-07-24T00:00:00Z")); + + RefreshTokenStore.RotationResult result = store.rotate( + "current-hash", + "replacement-token", + replacement, + Duration.ofDays(14), + Duration.ofSeconds(5)); + + assertThat(result.status()).isEqualTo(RotationStatus.ROTATED); + assertThat(result.replacementToken()).isEqualTo("replacement-token"); + assertThat(result.replacementHash()).isEqualTo("replacement-hash"); + assertThat(result.memberId()).isEqualTo(1L); + assertThat(result.sessionId()).isEqualTo("session-1"); + assertThat(result.refreshTokenTtlMillis()).isEqualTo(Duration.ofDays(14).toMillis()); + } +} diff --git a/config b/config index 30971bb..ba2deb3 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 30971bb44d7ea40f977a89f83df51233d0f09ef9 +Subproject commit ba2deb3a89e98e8309fccbed147666ae70ee48b4 diff --git a/member/build.gradle b/member/build.gradle index f65da65..cc47eee 100644 --- a/member/build.gradle +++ b/member/build.gradle @@ -7,4 +7,5 @@ dependencies { // 컨트롤러에서 인증된 memberId(@AuthenticationPrincipal)를 읽기 위한 최소 의존. // 시큐리티 필터/설정은 auth 모듈이 소유하고, member 는 SecurityContext 만 읽는다. implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' } diff --git a/member/src/main/java/com/nalssilog/member/api/FeedbackController.java b/member/src/main/java/com/nalssilog/member/api/FeedbackController.java index 326ef27..034aebc 100644 --- a/member/src/main/java/com/nalssilog/member/api/FeedbackController.java +++ b/member/src/main/java/com/nalssilog/member/api/FeedbackController.java @@ -3,6 +3,8 @@ import com.nalssilog.member.api.dto.CreateFeedbackRequest; import com.nalssilog.member.api.dto.FeedbackResponse; import com.nalssilog.member.application.FeedbackService; +import com.nalssilog.member.config.TrustedProxyClientIpResolver; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -19,12 +21,14 @@ public class FeedbackController { private final FeedbackService feedbackService; + private final TrustedProxyClientIpResolver clientIpResolver; @PostMapping @ResponseStatus(HttpStatus.CREATED) public FeedbackResponse submit(@AuthenticationPrincipal Long memberId, + HttpServletRequest httpRequest, @Valid @RequestBody CreateFeedbackRequest request) { return FeedbackResponse.from( - feedbackService.submit(memberId, request.content())); + feedbackService.submit(memberId, clientIpResolver.resolve(httpRequest), request.content())); } } diff --git a/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java new file mode 100644 index 0000000..d840c52 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java @@ -0,0 +1,90 @@ +package com.nalssilog.member.application; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.config.FeedbackRateLimitProperties; +import com.nalssilog.member.domain.MemberErrorCode; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.List; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 서비스 피드백 제출 제한. 회원은 memberId, 비회원은 원격 IP의 SHA-256 fingerprint를 Redis key로 사용한다. + * INCR와 최초 TTL 설정은 Lua로 원자 처리한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class FeedbackRateLimiter { + + private static final String KEY_PREFIX = "feedback:rate:"; + private static final String HMAC_ALGORITHM = "HmacSHA256"; + private static final DefaultRedisScript INCREMENT_SCRIPT = new DefaultRedisScript<>(""" + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + end + return count + """, Long.class); + + private final StringRedisTemplate redisTemplate; + private final FeedbackRateLimitProperties properties; + + public void check(Long memberId, String remoteAddress) { + String actor = actor(memberId, remoteAddress); + Long count; + try { + count = redisTemplate.execute( + INCREMENT_SCRIPT, + List.of(KEY_PREFIX + actor), + String.valueOf(properties.window().toMillis())); + } catch (DataAccessException exception) { + // 피드백 저장 자체보다 보조 보호장치 장애의 영향이 커지지 않게 제한기만 fail-open 한다. + log.warn("feedback.rate_limit_unavailable actor={} reason={}", + safeActor(actor), exception.getClass().getSimpleName()); + return; + } + + if (count != null && count > properties.maxSubmissions()) { + log.warn("feedback.rate_limited actor={} count={}", safeActor(actor), count); + throw new NalssiLogException(MemberErrorCode.FEEDBACK_RATE_LIMITED); + } + } + + String actor(Long memberId, String remoteAddress) { + if (memberId != null) { + return "member:" + memberId; + } + + String address = StringUtils.hasText(remoteAddress) ? remoteAddress.strip() : "unknown"; + return "guest:" + hmac(address); + } + + private String safeActor(String actor) { + int separator = actor.indexOf(':'); + String type = separator < 0 ? "unknown" : actor.substring(0, separator); + String identifier = separator < 0 ? actor : actor.substring(separator + 1); + return type + ":" + identifier.substring(0, Math.min(12, identifier.length())); + } + + private String hmac(String value) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + mac.init(new SecretKeySpec(properties.ipHmacSecret().getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("HmacSHA256 not available", e); + } catch (java.security.InvalidKeyException e) { + throw new IllegalStateException("feedback IP HMAC key is invalid", e); + } + } +} diff --git a/member/src/main/java/com/nalssilog/member/application/FeedbackService.java b/member/src/main/java/com/nalssilog/member/application/FeedbackService.java index 7e50d5b..2f77cf5 100644 --- a/member/src/main/java/com/nalssilog/member/application/FeedbackService.java +++ b/member/src/main/java/com/nalssilog/member/application/FeedbackService.java @@ -16,9 +16,11 @@ public class FeedbackService { private final FeedbackRepository feedbackRepository; + private final FeedbackRateLimiter rateLimiter; @Transactional - public FeedbackInfo submit(Long memberId, String content) { + public FeedbackInfo submit(Long memberId, String remoteAddress, String content) { + rateLimiter.check(memberId, remoteAddress); Feedback feedback = Feedback.create(memberId, content.strip()); return feedbackRepository.save(feedback); diff --git a/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java b/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java new file mode 100644 index 0000000..ecf8d8d --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java @@ -0,0 +1,31 @@ +package com.nalssilog.member.config; + +import java.time.Duration; +import java.util.List; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "nalssilog.feedback.rate-limit") +public record FeedbackRateLimitProperties( + int maxSubmissions, + Duration window, + String ipHmacSecret, + List trustedProxies +) { + + public FeedbackRateLimitProperties { + if (maxSubmissions <= 0) { + maxSubmissions = 5; + } + if (window == null || window.isNegative() || window.isZero()) { + window = Duration.ofMinutes(10); + } + if (ipHmacSecret == null || ipHmacSecret.isBlank()) { + ipHmacSecret = "local-feedback-rate-limit-key"; + } + if (trustedProxies == null || trustedProxies.isEmpty()) { + trustedProxies = List.of("127.0.0.0/8", "::1/128", "172.16.0.0/12"); + } else { + trustedProxies = List.copyOf(trustedProxies); + } + } +} diff --git a/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java b/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java new file mode 100644 index 0000000..04f1d24 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java @@ -0,0 +1,104 @@ +package com.nalssilog.member.config; + +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletRequestWrapper; +import jakarta.servlet.http.HttpServletRequest; +import java.util.List; +import org.springframework.security.web.util.matcher.IpAddressMatcher; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +/** + * 실제 socket peer가 명시된 trusted proxy 대역일 때만 X-Forwarded-For를 해석한다. + * 체인은 오른쪽부터 역순으로 따라가 첫 untrusted hop을 실제 클라이언트로 선택하므로, + * 클라이언트가 임의로 앞에 붙인 XFF 값은 rate-limit identity에 영향을 주지 않는다. + */ +@Component +public class TrustedProxyClientIpResolver { + + private static final int MAX_WRAPPER_DEPTH = 16; + + private final List trustedProxies; + + public TrustedProxyClientIpResolver(FeedbackRateLimitProperties properties) { + this.trustedProxies = properties.trustedProxies().stream() + .map(IpAddressMatcher::new) + .toList(); + } + + public String resolve(HttpServletRequest request) { + HttpServletRequest nativeRequest = unwrap(request); + String peer = normalize(nativeRequest.getRemoteAddr()); + if (!isTrusted(peer)) { + return peer; + } + + String forwardedFor = nativeRequest.getHeader("X-Forwarded-For"); + if (!StringUtils.hasText(forwardedFor)) { + return peer; + } + + String current = peer; + String[] hops = forwardedFor.split(","); + for (int index = hops.length - 1; index >= 0 && isTrusted(current); index--) { + String candidate = normalize(hops[index]); + if (!StringUtils.hasText(candidate)) { + break; + } + current = candidate; + } + return current; + } + + private HttpServletRequest unwrap(HttpServletRequest request) { + ServletRequest current = request; + int depth = 0; + while (current instanceof ServletRequestWrapper wrapper && depth++ < MAX_WRAPPER_DEPTH) { + ServletRequest nested = wrapper.getRequest(); + if (nested == current) { + break; + } + current = nested; + } + return current instanceof HttpServletRequest httpRequest ? httpRequest : request; + } + + private boolean isTrusted(String address) { + if (!StringUtils.hasText(address)) { + return false; + } + for (IpAddressMatcher matcher : trustedProxies) { + try { + if (matcher.matches(address)) { + return true; + } + } catch (IllegalArgumentException _) { + return false; + } + } + return false; + } + + private String normalize(String address) { + if (!StringUtils.hasText(address)) { + return "unknown"; + } + + String normalized = address.strip(); + if (normalized.length() >= 2 && normalized.startsWith("\"") && normalized.endsWith("\"")) { + normalized = normalized.substring(1, normalized.length() - 1); + } + if (normalized.startsWith("[")) { + int closingBracket = normalized.indexOf(']'); + if (closingBracket > 1) { + return normalized.substring(1, closingBracket); + } + } + + int colon = normalized.lastIndexOf(':'); + if (colon > 0 && normalized.indexOf(':') == colon && normalized.substring(0, colon).contains(".")) { + return normalized.substring(0, colon); + } + return normalized; + } +} diff --git a/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java b/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java index 0e96eef..d0e3ea1 100644 --- a/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java +++ b/member/src/main/java/com/nalssilog/member/domain/MemberErrorCode.java @@ -26,7 +26,9 @@ public enum MemberErrorCode implements ErrorCode { IMAGE_TOO_LARGE("IMAGE_TOO_LARGE", "이미지는 2MB 이하여야 합니다.", HttpStatus.BAD_REQUEST), INVALID_IMAGE_KEY("INVALID_IMAGE_KEY", "잘못된 이미지 키입니다.", HttpStatus.BAD_REQUEST), IMAGE_NOT_FOUND("IMAGE_NOT_FOUND", "업로드된 이미지를 찾을 수 없습니다.", HttpStatus.BAD_REQUEST), - TERMS_NOT_AGREED("TERMS_NOT_AGREED", "필수 약관에 모두 동의해야 합니다.", HttpStatus.BAD_REQUEST); + TERMS_NOT_AGREED("TERMS_NOT_AGREED", "필수 약관에 모두 동의해야 합니다.", HttpStatus.BAD_REQUEST), + FEEDBACK_RATE_LIMITED("FEEDBACK_RATE_LIMITED", "피드백을 너무 자주 보냈습니다. 잠시 후 다시 시도해 주세요.", + HttpStatus.TOO_MANY_REQUESTS); private final String code; private final String message; diff --git a/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java b/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java new file mode 100644 index 0000000..3559d84 --- /dev/null +++ b/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java @@ -0,0 +1,77 @@ +package com.nalssilog.member.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.config.FeedbackRateLimitProperties; +import com.nalssilog.member.domain.MemberErrorCode; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +@SuppressWarnings({"java:S5960", "unchecked"}) +class FeedbackRateLimiterTest { + + private final StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class); + private final FeedbackRateLimiter limiter = new FeedbackRateLimiter( + redisTemplate, + new FeedbackRateLimitProperties( + 5, + Duration.ofMinutes(10), + "test-feedback-hmac-secret", + List.of("127.0.0.0/8"))); + + @Test + void rejectsSubmissionAboveConfiguredLimit() { + when(redisTemplate.execute(any(RedisScript.class), anyList(), any())) + .thenReturn(6L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> limiter.check(null, "203.0.113.10")); + + assertThat(exception.getErrorCode()).isEqualTo(MemberErrorCode.FEEDBACK_RATE_LIMITED); + } + + @Test + void redisFailureDoesNotBlockFeedbackSubmission() { + when(redisTemplate.execute(any(RedisScript.class), anyList(), any())) + .thenThrow(new RedisConnectionFailureException("test")); + + assertThatCode(() -> limiter.check(null, "203.0.113.10")) + .doesNotThrowAnyException(); + } + + @Test + void guestActorDoesNotExposeRawAddress() { + String actor = limiter.actor(null, "203.0.113.10"); + + assertThat(actor) + .startsWith("guest:") + .doesNotContain("203.0.113.10"); + assertThat(limiter.actor(7L, "203.0.113.10")).isEqualTo("member:7"); + } + + @Test + void guestFingerprintIsKeyedByHmacSecret() { + FeedbackRateLimiter otherSecretLimiter = new FeedbackRateLimiter( + redisTemplate, + new FeedbackRateLimitProperties( + 5, + Duration.ofMinutes(10), + "different-feedback-hmac-secret", + List.of("127.0.0.0/8"))); + + assertThat(otherSecretLimiter.actor(null, "203.0.113.10")) + .isNotEqualTo(limiter.actor(null, "203.0.113.10")); + } +} diff --git a/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java b/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java new file mode 100644 index 0000000..5501de5 --- /dev/null +++ b/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java @@ -0,0 +1,36 @@ +package com.nalssilog.member.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.member.application.dto.FeedbackInfo; +import com.nalssilog.member.domain.Feedback; +import com.nalssilog.member.repository.FeedbackRepository; +import java.time.Instant; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +@SuppressWarnings("java:S5960") +class FeedbackServiceTest { + + private final FeedbackRepository repository = mock(FeedbackRepository.class); + private final FeedbackRateLimiter rateLimiter = mock(FeedbackRateLimiter.class); + private final FeedbackService service = new FeedbackService(repository, rateLimiter); + + @Test + void anonymousFeedbackIsRateCheckedAndStoredWithoutMemberId() { + FeedbackInfo saved = new FeedbackInfo(1L, null, "좋아요", Instant.now()); + when(repository.save(org.mockito.ArgumentMatchers.any(Feedback.class))).thenReturn(saved); + + FeedbackInfo result = service.submit(null, "203.0.113.10", " 좋아요 "); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Feedback.class); + verify(rateLimiter).check(null, "203.0.113.10"); + verify(repository).save(captor.capture()); + assertThat(captor.getValue().getAuthorMemberId()).isNull(); + assertThat(captor.getValue().getContent()).isEqualTo("좋아요"); + assertThat(result).isSameAs(saved); + } +} diff --git a/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java b/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java new file mode 100644 index 0000000..b9e2cd3 --- /dev/null +++ b/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java @@ -0,0 +1,46 @@ +package com.nalssilog.member.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; + +@SuppressWarnings("java:S5960") +class TrustedProxyClientIpResolverTest { + + private final TrustedProxyClientIpResolver resolver = new TrustedProxyClientIpResolver( + new FeedbackRateLimitProperties( + 5, + Duration.ofMinutes(10), + "test-secret", + List.of("127.0.0.0/8", "172.16.0.0/12"))); + + @Test + void ignoresForwardedHeaderFromUntrustedSocketPeer() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("198.51.100.20"); + request.addHeader("X-Forwarded-For", "1.2.3.4"); + + assertThat(resolver.resolve(request)).isEqualTo("198.51.100.20"); + } + + @Test + void selectsRightmostUntrustedHopBehindTrustedProxy() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("172.17.0.1"); + request.addHeader("X-Forwarded-For", "1.2.3.4, 203.0.113.10"); + + assertThat(resolver.resolve(request)).isEqualTo("203.0.113.10"); + } + + @Test + void walksAcrossMultipleTrustedProxies() { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setRemoteAddr("172.17.0.1"); + request.addHeader("X-Forwarded-For", "203.0.113.10, 172.18.0.2"); + + assertThat(resolver.resolve(request)).isEqualTo("203.0.113.10"); + } +}